From ef07e0eff63eb64e2a1052156ac18f16105a8c1a Mon Sep 17 00:00:00 2001 From: lCardenas Date: Fri, 18 Sep 2026 06:39:21 -0400 Subject: [PATCH 01/12] fix(shell): show effective repository profile --- docs/gentle-shell.md | 2 +- docs/readme-reference.md | 2 + extensions/gentle-shell.ts | 70 ++++++++++++++------ lib/shell-bar.ts | 13 +++- odd/tasks/effective-profile-status.md | 46 ++++++++++++++ tests/gentle-shell.test.ts | 92 ++++++++++++++++++++++++--- tests/shell-bar.test.ts | 11 +++- 7 files changed, 203 insertions(+), 33 deletions(-) create mode 100644 odd/tasks/effective-profile-status.md diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index dda9f66b9..30276e080 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -25,7 +25,7 @@ In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi The rail reuses its last frame until something it paints changes, so silent frames stay cheap and live session state still lands on the next frame: a model switch, a new thinking level, context growth, session cost, session name and extension statuses all refresh the Status card without a redraw of the rest of the sidebar. -The sidebar Status card also shows `Profile` in its Model section when the profiles store has a valid active marker. It follows profile changes on the next render. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar is unchanged. +The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local or repository declaration pin wins and appends `(pinned)`; otherwise it shows the globally active profile without a suffix. Invalid or stale pins fall back globally. It follows profile and pin changes on the next render, including pin creation, replacement, and removal. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. The status bar replaces pi's three-line footer with a single line of segments: diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 598c8cdd0..bf3e76bdc 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -792,6 +792,8 @@ For a given working directory the winner is the local pin, then the repository d In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The globally active profile and writes made through `/gentle:models` do not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. `enter` follows the same boundary: inside a pinned repository it re-pins that repository instead of writing the global routing, so the panel's main key can never move another repository's routing. The panel states which layer won, names the file that holds it, and marks the profile with `(pinned)`. +The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(pinned)` only when a valid local or repository pin wins, and otherwise shows the global active profile without a suffix. Invalid or stale pins therefore fall back globally. The compact bottom bar remains unchanged and does not show this field. + To share a pin, commit the repository declaration. When `.pi/` is ignored, Git cannot re-include a nested file until its parent directories are visible. The panel therefore prints these ordered root `.gitignore` rules, which keep unrelated `.pi` content ignored while making only the declaration committable: ```gitignore diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index f8e3668d0..822fd4656 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -3,9 +3,10 @@ import type { EditorTheme, TUI } from "@earendil-works/pi-tui"; import { execFile, spawnSync } from "node:child_process"; import { statSync } from "node:fs"; import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts"; +import { localProfilePinPath, repoProfileDeclarationPath, resolveProfilePin } from "../lib/agent-profile-pin.ts"; import * as os from "node:os"; import { join } from "node:path"; -import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts"; +import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme, type ShellProfileState } from "../lib/shell-bar.ts"; import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts"; import { WorktreeChangesView } from "../lib/shell-changes-view.ts"; import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts"; @@ -45,7 +46,7 @@ interface ShellBarComponent { } interface BuildOptions { - profile?: string; + profile?: ShellProfileState; home?: string; dirty?: number; usage?: ProviderUsage; @@ -54,7 +55,7 @@ interface BuildOptions { export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string }; export interface ShellDeps { - activeProfile(): string | undefined; + activeProfile(cwd?: string): ShellProfileState | undefined; fetch: typeof fetch; now(): number; devBinary(): DevBinaryNotice | undefined; @@ -63,27 +64,54 @@ export interface ShellDeps { } // The rail digest runs every frame. Cache parsing by file identity and metadata, -// not just mtime: profile writes replace the store atomically. Keep the cache +// not just mtime: profile and pin writes replace files atomically. Keep the cache // local to this shell instance and recheck on the next frame after panel edits. -export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): () => string | undefined { - const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai")); +export function createActiveProfileReader( + env: NodeJS.ProcessEnv = process.env, + resolveWorktree: WorktreeResolver = resolveSessionWorktree, +): (cwd?: string) => ShellProfileState | undefined { + const configHome = env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"); + const profilesPath = profilesFilePath(configHome); let fingerprint: string | undefined; - let name: string | undefined; - return () => { + let profile: ShellProfileState | undefined; + let identityCwd: string | undefined; + let identity: { root: string; commonDir: string } | undefined; + const fileFingerprint = (path: string): string => { try { const stat = statSync(path, { bigint: true }); - const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; - if (next !== fingerprint) { - const result = readProfilesFileResult(path); - name = result.status === "valid" ? result.file.active : undefined; - fingerprint = next; - } - return name; + return `present:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; } catch { + return "missing"; + } + }; + return (cwd = process.cwd()) => { + if (cwd !== identityCwd) { + identityCwd = cwd; + try { + identity = resolveWorktree(cwd, cwd); + } catch { + identity = undefined; + } fingerprint = undefined; - name = undefined; - return undefined; } + const pinPaths = identity + ? [localProfilePinPath(identity.commonDir), repoProfileDeclarationPath(identity.root)] + : []; + const identityFingerprint = identity ? `${identity.root}:${identity.commonDir}` : "no-worktree"; + const next = `${identityFingerprint}|${[profilesPath, ...pinPaths].map(fileFingerprint).join("|")}`; + if (next !== fingerprint) { + const resolution = resolveProfilePin({ cwd, configHome, resolveWorktree }); + if (resolution) { + profile = { name: resolution.profile, pinned: true }; + } else { + const result = readProfilesFileResult(profilesPath); + profile = result.status === "valid" && result.file.active + ? { name: result.file.active, pinned: false } + : undefined; + } + fingerprint = next; + } + return profile; }; } @@ -485,7 +513,11 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial = {}): void { installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree); if (!shellEnabled(env)) return; - const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides }; + const deps: ShellDeps = { + ...defaultShellDeps, + activeProfile: createActiveProfileReader(env, overrides.resolveWorktree ?? defaultShellDeps.resolveWorktree), + ...overrides, + }; const usage = new UsageStore(); let renderHost: ShellRenderHost | undefined; let usageFetchedAt = 0; @@ -586,7 +618,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // statuses. The digest is what keeps the fullscreen memo honest, and it // rebuilds the model exactly as the narrow bottom bar does every frame. const footerModel = (): ShellBarModel => ({ - ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() }), + ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(ctx.sessionManager.getCwd()) }), changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted, notice: tracker.model.notice }, }); const part = sidebarPart(tui, "footer", bottom, { diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 812ce8de3..56d88eb64 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -10,8 +10,13 @@ export { gaugeTone, renderGauge, type GaugeTone }; // three-line footer. Everything here is pure so the bar can be rendered and // verified without a live TUI. +export interface ShellProfileState { + name: string; + pinned: boolean; +} + export interface ShellBarModel { - profile?: string; + profile?: ShellProfileState; changes?: { files: number; added: number; deleted: number; notice?: string }; cwd: string; branch: string | null; @@ -79,6 +84,10 @@ function sanitizeStatus(text: string): string { return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim(); } +function formatProfile(profile: ShellProfileState): string { + return sanitizeStatus(`${profile.name}${profile.pinned ? " (pinned)" : ""}`); +} + function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] { const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : ""; const location = model.branch @@ -138,7 +147,7 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme ...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []), `${label("Model")} ${value(model.modelId)}`, ...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : []), - ...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []), + ...(model.profile ? [`${label("Profile")} ${value(formatProfile(model.profile))}`] : []), ], }, { diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md new file mode 100644 index 000000000..d4f28e45a --- /dev/null +++ b/odd/tasks/effective-profile-status.md @@ -0,0 +1,46 @@ +# Effective repository profile in Status + +## Objective + +Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that governs subagent launches in the current repository. Append `(pinned)` only when a valid clone-local pin or repository declaration wins; otherwise show the globally active profile without a suffix. + +## Context + +- Upstream issue: https://github.com/Gentleman-Programming/gentle-shell/issues/1176 +- Starting point: `origin/main` at `1170dc84c2198b53807431f6e03d02bf8dcc3444` +- Branch: `fix/effective-profile-status` +- Pre-existing untracked `mise.toml` is outside this feature and must remain untouched. + +## Scope + +- Resolve the effective profile through the existing repository-pin authority. +- Keep profile state structured through the shell model and render `(pinned)` only for a valid winning pin. +- Refresh the Status digest when the global profile or relevant pin state changes. +- Preserve the compact bottom bar behavior: it does not show a profile. +- Add focused regression coverage and update user-facing documentation if the documented semantics require clarification. + +## Tasks + +- [x] T1 — Implement pin-aware Status profile resolution, focused tests, and documentation; run focused and repository checks; commit as one reviewable work unit. + +## Acceptance criteria + +- No valid pin: `Profile `. +- Valid local or repository pin: `Profile (pinned)`. +- Invalid, stale, missing, or unreadable pins fall back to the global active profile without `(pinned)`. +- Local pin precedence over repository declaration remains owned by `resolveProfilePin()`. +- Creating, changing, or removing a pin updates the fullscreen Status digest without restarting Pi. +- The compact bottom bar remains unchanged. +- Focused tests, typecheck/runtime checks, complete test suite, and `git diff --check` pass or any skipped/failed check is reported. + +## Evidence + +- T1 commit: pending commit creation +- TDD RED: the new pin test expected `other (pinned)` but observed the global `team` profile before implementation. +- Focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 60 passed, 0 failed; `node --experimental-strip-types --test tests/profile-pin.test.ts` — 20 passed, 0 failed. +- Full tests: `node --experimental-strip-types --test tests/*.test.ts` — 2643 passed, 0 failed, 38 skipped. +- Type check: `node scripts/check-types.mjs` — passed with 197 recorded baseline diagnostics, no regressions, and 2 diagnostic pairs improved. +- Runtime module check: skipped because `scripts/check-runtime-modules.mjs` does not exist on this branch. +- Diff check: `git diff --check` — passed. +- Verification incident: `pnpm run typecheck` was discarded as a hermetic receipt because pnpm 12 dependency verification triggered install/postinstall side effects in ignored dependency areas. Read-only Git inspection confirmed no new tracked changes; final verification used direct Node commands only. +- Native review: pending. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index d31d1f98d..f5e0ff713 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -10,6 +10,7 @@ import installGentleShell, { buildShellBarModel, createActiveProfileReader, chan import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; import type { ShellBarTheme } from "../lib/shell-bar.ts"; +import { localProfilePinPath, repoProfileDeclarationPath, serializeProfilePin } from "../lib/agent-profile-pin.ts"; import { stripAnsi } from "../lib/terminal-theme.ts"; // The Gentle Shell extension wires the pure bar renderer into pi's footer @@ -206,8 +207,9 @@ test("buildShellBarModel reads session, model, and footer data", () => { getAvailableProviderCount: () => 1, onBranchChange: () => () => {}, }; - const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" }); + const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan", profile: { name: "team", pinned: true } }); assert.equal(built.cwd, "/repo"); + assert.deepEqual(built.profile, { name: "team", pinned: true }); assert.equal(built.branch, "main"); assert.equal(built.sessionName, "Release notes"); assert.equal(built.modelId, "gpt-5.5"); @@ -255,7 +257,7 @@ test("gentleShell installs the footer on session_start when a UI exists", () => test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => { const { pi, handlers } = fakePi(); - let profile: string | undefined = "team"; + let profile: { name: string; pinned: boolean } | undefined = { name: "team", pinned: false }; gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile }); const entries: unknown[] = []; const { ctx, ui } = fakeContext({ entries }); @@ -274,7 +276,7 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforeProfile = live(); - profile = "other"; + profile = { name: "other", pinned: false }; assert.notEqual(live(), beforeProfile); assert.match(rail.render(46).join("\n"), /Profile.*other/); profile = undefined; @@ -305,27 +307,63 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh } }); +test("fullscreen Status digest follows repository pin changes without restarting the shell", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-status-pin-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + const resolveWorktree = () => ({ root: worktreeRoot, commonDir }); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + writeFileSync(join(root, "profiles.json"), JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} }, + })); + const repoPin = repoProfileDeclarationPath(worktreeRoot); + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { resolveWorktree }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + try { + const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + assert.match(rail.render(46).join("\n"), /Profile.*team/); + const beforePin = rail.digest!(); + writeFileSync(repoPin, serializeProfilePin("other")); + assert.notEqual(rail.digest!(), beforePin, "creating a pin must invalidate the Status digest"); + assert.match(rail.render(46).join("\n"), /Profile.*other \(pinned\)/); + const beforeRemoval = rail.digest!(); + rmSync(repoPin); + assert.notEqual(rail.digest!(), beforeRemoval, "removing a pin must invalidate the Status digest"); + assert.match(rail.render(46).join("\n"), /Profile.*team/); + assert.doesNotMatch(rail.render(46).join("\n"), /\(pinned\)/); + } finally { + component.dispose(); + } +}); + test("profile reader follows store changes and rejects missing or invalid active markers", (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-")); t.after(() => rmSync(root, { recursive: true, force: true })); const path = join(root, "profiles.json"); - const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }); + const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }, () => undefined); const save = (active: string | undefined) => writeFileSync(path, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, })); assert.equal(read(), undefined); save("team"); - assert.equal(read(), "team"); - assert.equal(read(), "team"); + assert.deepEqual(read(), { name: "team", pinned: false }); + assert.deepEqual(read(), { name: "team", pinned: false }); save("other"); - assert.equal(read(), "other"); + assert.deepEqual(read(), { name: "other", pinned: false }); const replacement = join(root, "replacement.json"); writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } })); renameSync(replacement, path); - assert.equal(read(), "team", "atomic replacement refreshes the cached profile"); + assert.deepEqual(read(), { name: "team", pinned: false }, "atomic replacement refreshes the cached profile"); const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") }); assert.equal(isolated(), undefined); - assert.equal(read(), "team", "another shell's config home does not alter this cache"); + assert.deepEqual(read(), { name: "team", pinned: false }, "another shell's config home does not alter this cache"); save("missing"); assert.equal(read(), undefined); save(undefined); @@ -333,11 +371,45 @@ test("profile reader follows store changes and rejects missing or invalid active writeFileSync(path, "{broken"); assert.equal(read(), undefined); save("team"); - assert.equal(read(), "team"); + assert.deepEqual(read(), { name: "team", pinned: false }); rmSync(path); assert.equal(read(), undefined); }); +test("profile reader shows the effective pin and refreshes when pins change", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-pin-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + const resolveWorktree = () => ({ root: worktreeRoot, commonDir }); + const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }, resolveWorktree); + const profilesPath = join(root, "profiles.json"); + const save = (active: string) => writeFileSync(profilesPath, JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, + })); + const repoPin = repoProfileDeclarationPath(worktreeRoot); + const localPin = localProfilePinPath(commonDir); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + + save("team"); + assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }); + writeFileSync(repoPin, serializeProfilePin("other")); + assert.deepEqual(read(worktreeRoot), { name: "other", pinned: true }); + writeFileSync(repoPin, serializeProfilePin("team")); + assert.deepEqual(read(worktreeRoot), { name: "team", pinned: true }, "changed pins invalidate the reader cache"); + rmSync(repoPin); + assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }, "removed pins fall back to the global active profile"); + writeFileSync(repoPin, serializeProfilePin("missing")); + assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }, "stale pins fall back globally"); + writeFileSync(repoPin, "{broken"); + assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }, "invalid pins fall back globally"); + writeFileSync(localPin, serializeProfilePin("other")); + assert.deepEqual(read(worktreeRoot), { name: "other", pinned: true }, "a valid local pin wins over the global profile"); + rmSync(localPin); + assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }); +}); + test("gentleShell stays out of the way without a UI or when disabled", () => { const disabled = fakePi(); gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" }); diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 3641221e4..3968760f3 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -200,7 +200,7 @@ test("sidebar unifies project, captured changes, usage and integrations in one f test("sidebar profile wraps long names without changing the compact bar", () => { const profile = "team-" + "x".repeat(59); const base = model(); - const active = model({ profile }); + const active = model({ profile: { name: profile, pinned: false } }); for (const width of [24, 46]) { const lines = renderShellSidebarBar(active, plainTheme, width); assert.ok(lines.every((line) => visibleWidth(line) <= width)); @@ -209,3 +209,12 @@ test("sidebar profile wraps long names without changing the compact bar", () => } assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120)); }); + +test("sidebar formats and sanitizes the pinned profile suffix", () => { + const pinned = model({ profile: { name: "other\x1b[31m", pinned: true } }); + const sidebar = renderShellSidebarBar(pinned, plainTheme, 46).join("\n"); + assert.match(sidebar, /Profile.*other \(pinned\)/); + assert.doesNotMatch(sidebar, /\x1b\[/); + const [compact] = renderShellBar(pinned, plainTheme, 120); + assert.doesNotMatch(compact, /other|pinned/); +}); From 212afaa30a1d8d487af69348dcd3a38631de66d9 Mon Sep 17 00:00:00 2001 From: lCardenas Date: Fri, 18 Sep 2026 06:39:28 -0400 Subject: [PATCH 02/12] docs(odd): record effective profile evidence --- odd/tasks/effective-profile-status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index d4f28e45a..086643372 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -35,7 +35,7 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g ## Evidence -- T1 commit: pending commit creation +- T1 commit: `ef07e0ef` (`fix(shell): show effective repository profile`) - TDD RED: the new pin test expected `other (pinned)` but observed the global `team` profile before implementation. - Focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 60 passed, 0 failed; `node --experimental-strip-types --test tests/profile-pin.test.ts` — 20 passed, 0 failed. - Full tests: `node --experimental-strip-types --test tests/*.test.ts` — 2643 passed, 0 failed, 38 skipped. From 280f3a6b5607a1cc9977306ee32c6479a2466f00 Mon Sep 17 00:00:00 2001 From: lCardenas Date: Fri, 18 Sep 2026 06:40:08 -0400 Subject: [PATCH 03/12] docs(odd): record review preflight failure --- odd/tasks/effective-profile-status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index 086643372..84ab6ef3d 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -43,4 +43,4 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - Runtime module check: skipped because `scripts/check-runtime-modules.mjs` does not exist on this branch. - Diff check: `git diff --check` — passed. - Verification incident: `pnpm run typecheck` was discarded as a hermetic receipt because pnpm 12 dependency verification triggered install/postinstall side effects in ignored dependency areas. Read-only Git inspection confirmed no new tracked changes; final verification used direct Node commands only. -- Native review: pending. +- Native review: unavailable before lineage creation. Two committed-range START attempts against `1170dc84c2198b53807431f6e03d02bf8dcc3444` were rejected with `candidate-target-projection-drift`; both reported `lineage_created: false` and performed no mutation. From 692d140b92d40e52968283d88fb2d5ac320fe0d1 Mon Sep 17 00:00:00 2001 From: Leandro Cardenas Date: Sat, 19 Sep 2026 17:14:49 -0400 Subject: [PATCH 04/12] fix(shell): refresh effective profile outside render path --- docs/gentle-shell.md | 2 +- docs/readme-reference.md | 2 +- extensions/gentle-shell.ts | 198 +++++++++++++++++++++-- odd/tasks/effective-profile-status.md | 19 ++- tests/gentle-shell.test.ts | 219 +++++++++++++++++++++++++- 5 files changed, 423 insertions(+), 17 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index cbbb58fd1..e0956dc2c 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -25,7 +25,7 @@ In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi The rail reuses its last frame until something it paints changes, so silent frames stay cheap and live session state still lands on the next frame: a model switch, a new thinking level, context growth, session cost, session name and extension statuses all refresh the Status card without a redraw of the rest of the sidebar. -The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local or repository declaration pin wins and appends `(pinned)`; otherwise it shows the globally active profile without a suffix. Invalid or stale pins fall back globally. It follows profile and pin changes on the next render, including pin creation, replacement, and removal. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. +The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local or repository declaration pin wins and appends `(pinned)`; otherwise it shows the globally active profile without a suffix. Invalid or stale pins fall back globally. The shell resolves this state initially, on known in-process invalidations, and through debounced parent-directory watchers, so atomic profile and pin replacements appear without per-frame filesystem or Git resolution. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. The status bar replaces pi's three-line footer with a single line of segments: diff --git a/docs/readme-reference.md b/docs/readme-reference.md index bf3e76bdc..0921bfc54 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -792,7 +792,7 @@ For a given working directory the winner is the local pin, then the repository d In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The globally active profile and writes made through `/gentle:models` do not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. `enter` follows the same boundary: inside a pinned repository it re-pins that repository instead of writing the global routing, so the panel's main key can never move another repository's routing. The panel states which layer won, names the file that holds it, and marks the profile with `(pinned)`. -The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(pinned)` only when a valid local or repository pin wins, and otherwise shows the global active profile without a suffix. Invalid or stale pins therefore fall back globally. The compact bottom bar remains unchanged and does not show this field. +The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(pinned)` only when a valid local or repository pin wins, and otherwise shows the global active profile without a suffix. Invalid or stale pins therefore fall back globally. The shell snapshots this state initially and refreshes it on known in-process invalidations or debounced parent-directory watcher events, including atomic replacements; repeated Status/header rendering performs no profile filesystem or Git resolution. The compact bottom bar remains unchanged and does not show this field. To share a pin, commit the repository declaration. When `.pi/` is ignored, Git cannot re-include a nested file until its parent directories are visible. The panel therefore prints these ordered root `.gitignore` rules, which keep unrelated `.pi` content ignored while making only the declaration committable: diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 822fd4656..0e01e0994 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -1,11 +1,11 @@ import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent"; import type { EditorTheme, TUI } from "@earendil-works/pi-tui"; import { execFile, spawnSync } from "node:child_process"; -import { statSync } from "node:fs"; +import { statSync, type FSWatcher, watch } from "node:fs"; import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts"; import { localProfilePinPath, repoProfileDeclarationPath, resolveProfilePin } from "../lib/agent-profile-pin.ts"; import * as os from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme, type ShellProfileState } from "../lib/shell-bar.ts"; import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts"; import { WorktreeChangesView } from "../lib/shell-changes-view.ts"; @@ -63,9 +63,10 @@ export interface ShellDeps { gitRunner(cwd: string): GitRunner; } -// The rail digest runs every frame. Cache parsing by file identity and metadata, -// not just mtime: profile and pin writes replace files atomically. Keep the cache -// local to this shell instance and recheck on the next frame after panel edits. +// Profile resolution is intentionally kept out of the rail digest and render path. +// This reader caches one resolved value and is called by the shell snapshot only at +// startup or after an explicit invalidation/watch event. File identity includes the +// inode because profile and pin writes replace files atomically. export function createActiveProfileReader( env: NodeJS.ProcessEnv = process.env, resolveWorktree: WorktreeResolver = resolveSessionWorktree, @@ -115,6 +116,141 @@ export function createActiveProfileReader( }; } +const PROFILE_REFRESH_DEBOUNCE_MS = 100; + +interface EffectiveProfileSnapshot { + get(): ShellProfileState | undefined; + refresh(): boolean; + dispose(): void; +} + +interface EffectiveProfileSnapshotOptions { + read: (cwd?: string) => ShellProfileState | undefined; + cwd(): string; + env: NodeJS.ProcessEnv; + resolveWorktree: WorktreeResolver; + onChange(): void; +} + +function existingProfileWatchDirectory(path: string, floor: string): string | undefined { + let candidate = dirname(path); + while (true) { + try { + if (statSync(candidate).isDirectory()) return candidate; + } catch { + // Atomic profile writes can create a missing parent after startup. Watch the + // nearest existing ancestor, but never broaden a profile watch to the + // filesystem root or another unrelated repository. + } + if (candidate === floor) return undefined; + const parent = dirname(candidate); + if (parent === candidate) return undefined; + candidate = parent; + } +} + +function effectiveProfileWatchDirectories( + cwd: string, + env: NodeJS.ProcessEnv, + resolveWorktree: WorktreeResolver, +): string[] { + const configHome = env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"); + const paths: Array<{ path: string; floor: string }> = [ + { path: profilesFilePath(configHome), floor: dirname(configHome) }, + ]; + try { + const identity = resolveWorktree(cwd, cwd); + paths.push( + { path: localProfilePinPath(identity.commonDir), floor: identity.commonDir }, + { path: repoProfileDeclarationPath(identity.root), floor: identity.root }, + ); + } catch { + // The global profile remains observable even outside a Git worktree. + } + return [...new Set(paths.map(({ path, floor }) => existingProfileWatchDirectory(path, floor)).filter((path): path is string => path !== undefined))]; +} + +function copyProfileState(profile: ShellProfileState | undefined): ShellProfileState | undefined { + return profile === undefined ? undefined : { name: profile.name, pinned: profile.pinned }; +} + +function sameProfileState(left: ShellProfileState | undefined, right: ShellProfileState | undefined): boolean { + return left?.name === right?.name && left?.pinned === right?.pinned; +} + +function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshotOptions): EffectiveProfileSnapshot { + let disposed = false; + let observedCwd = options.cwd(); + let profile = copyProfileState(options.read(observedCwd)); + let refreshTimer: ReturnType | undefined; + let watchers: FSWatcher[] = []; + let watchedDirectories: string[] = []; + + const closeWatchers = () => { + for (const watcher of watchers) { + try { + watcher.close(); + } catch { + // Best-effort cleanup; the shell component owns every watcher it creates. + } + } + watchers = []; + watchedDirectories = []; + }; + const installWatchers = (cwd: string) => { + const directories = effectiveProfileWatchDirectories(cwd, options.env, options.resolveWorktree); + if (directories.length === watchedDirectories.length && directories.every((directory, index) => directory === watchedDirectories[index]) && watchers.length === directories.length) return; + closeWatchers(); + watchedDirectories = directories; + for (const directory of directories) { + try { + const watcher = watch(directory, () => scheduleRefresh()); + watcher.unref(); + watchers.push(watcher); + } catch { + // Missing or unsupported watch roots are covered by the initial and + // in-process refresh seams; other roots remain best-effort. + } + } + }; + const refresh = (): boolean => { + if (disposed) return false; + const cwd = options.cwd(); + if (cwd !== observedCwd) observedCwd = cwd; + const next = copyProfileState(options.read(cwd)); + // A parent watcher may have observed a directory being created. Recompute + // roots after reading so later atomic writes are watched at their nearest + // parent instead of remaining stranded on the old ancestor. + installWatchers(cwd); + if (sameProfileState(profile, next)) return false; + profile = next; + options.onChange(); + return true; + }; + const scheduleRefresh = () => { + if (disposed) return; + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => { + refreshTimer = undefined; + refresh(); + }, PROFILE_REFRESH_DEBOUNCE_MS); + refreshTimer.unref(); + }; + installWatchers(observedCwd); + + return { + get: () => profile, + refresh, + dispose() { + if (disposed) return; + disposed = true; + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = undefined; + closeWatchers(); + }, + }; +} + function ambientDevBinary(): DevBinaryNotice | undefined { try { const override = resolveGentleAiDevBinaryOverride(); @@ -188,14 +324,20 @@ export function createShellBarComponent( footerData: ShellFooterData, dirty: () => number | undefined = () => undefined, usage: () => ProviderUsage | undefined = () => undefined, + profileRefresh: () => boolean | void = () => false, + profile: () => ShellProfileState | undefined = () => undefined, ): ShellBarComponent { const unsubscribe = footerData.onBranchChange(() => { + // A profile refresh that changed the effective state already invalidates and + // paints the shell through its change seam. Avoid asking the TUI for the same + // render a second time; branch-only changes still use the ordinary path. + if (profileRefresh() === true) return; host.invalidateSidebar?.(); host.requestRender(); }); return { render(width: number) { - return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width); + return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage(), profile: profile() }), theme, width); }, invalidate() {}, dispose() { @@ -568,6 +710,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p let changes: SessionChanges | undefined; let registry: SessionWorktreeRegistry | undefined; let currentContext: ExtensionContext | undefined; + let profileSnapshot: EffectiveProfileSnapshot | undefined; let shown = ""; const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => { const fingerprint = changesFingerprint(model); @@ -612,13 +755,36 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p const tracker = changes; ctx.ui.setFooter((tui, theme, footerData) => { renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) }; - const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? "")); + profileSnapshot?.dispose(); + const snapshot = createEffectiveProfileSnapshot({ + read: deps.activeProfile, + cwd: () => ctx.sessionManager.getCwd(), + env, + resolveWorktree: deps.resolveWorktree, + onChange: () => { + invalidateSidebar(tui); + tui.requestRender(); + }, + }); + profileSnapshot = snapshot; + const bottom = createShellBarComponent( + pi, + ctx, + renderHost, + theme, + footerData, + () => tracker.model.files.length, + () => usage.get(ctx.model?.provider ?? ""), + () => snapshot.refresh(), + () => snapshot.get(), + ); // The Status card paints live session state that no event re-registers a // part for: model, effort, context, cost, session name and extension - // statuses. The digest is what keeps the fullscreen memo honest, and it - // rebuilds the model exactly as the narrow bottom bar does every frame. + // statuses. Profile resolution happens only in the snapshot refresh seam; + // digest and render consume that in-memory value exactly like the other + // structured model fields. const footerModel = (): ShellBarModel => ({ - ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(ctx.sessionManager.getCwd()) }), + ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: snapshot.get() }), changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted, notice: tracker.model.notice }, }); const part = sidebarPart(tui, "footer", bottom, { @@ -627,7 +793,15 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p invalidate() {}, }); const uninstall = installSidebar(tui, theme); - return { ...part, dispose() { uninstall(); part.dispose(); } }; + return { + ...part, + dispose() { + snapshot.dispose(); + if (profileSnapshot === snapshot) profileSnapshot = undefined; + uninstall(); + part.dispose(); + }, + }; }); void refreshUsage(ctx, true); const ownsPrompt = installPrompt(ctx, (created) => { @@ -657,6 +831,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p } registry?.close(); registry = undefined; + profileSnapshot?.dispose(); + profileSnapshot = undefined; changes = undefined; currentContext = undefined; unsubscribeWorktrees(); diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index 84ab6ef3d..a03fab74b 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -8,8 +8,11 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - Upstream issue: https://github.com/Gentleman-Programming/gentle-shell/issues/1176 - Starting point: `origin/main` at `1170dc84c2198b53807431f6e03d02bf8dcc3444` +- Re-investigation baseline: `main` at `43269de359c5052d2cadb72ab4cf2d57ca0211b0` - Branch: `fix/effective-profile-status` - Pre-existing untracked `mise.toml` is outside this feature and must remain untouched. +- Current main routes the profile through both the fullscreen Status card and live header; profile + resolution must stay outside `digest()` and `render()`. ## Scope @@ -22,6 +25,9 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g ## Tasks - [x] T1 — Implement pin-aware Status profile resolution, focused tests, and documentation; run focused and repository checks; commit as one reviewable work unit. +- [x] T2 — Rebase the design onto current main: refresh one cached effective-profile snapshot through + invalidation/watch events, use it in Status and the live header, and prove repeated renders perform + no profile filesystem or Git resolution. ## Acceptance criteria @@ -29,7 +35,12 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - Valid local or repository pin: `Profile (pinned)`. - Invalid, stale, missing, or unreadable pins fall back to the global active profile without `(pinned)`. - Local pin precedence over repository declaration remains owned by `resolveProfilePin()`. -- Creating, changing, or removing a pin updates the fullscreen Status digest without restarting Pi. +- Creating, changing, or removing a pin updates the fullscreen Status digest and live header without + restarting Pi. +- Repeated Status/header `digest()` and `render()` calls perform no profile filesystem or Git + resolution. +- External atomic replacements are observed through debounced parent-directory watchers, which are + disposed with the shell component. - The compact bottom bar remains unchanged. - Focused tests, typecheck/runtime checks, complete test suite, and `git diff --check` pass or any skipped/failed check is reported. @@ -44,3 +55,9 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - Diff check: `git diff --check` — passed. - Verification incident: `pnpm run typecheck` was discarded as a hermetic receipt because pnpm 12 dependency verification triggered install/postinstall side effects in ignored dependency areas. Read-only Git inspection confirmed no new tracked changes; final verification used direct Node commands only. - Native review: unavailable before lineage creation. Two committed-range START attempts against `1170dc84c2198b53807431f6e03d02bf8dcc3444` were rejected with `candidate-target-projection-drift`; both reported `lineage_created: false` and performed no mutation. +- T2 focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 82 passed, 0 failed. +- T2 full tests: `node --experimental-strip-types --test tests/*.test.ts` — 2639 passed, 0 failed, 47 skipped. +- T2 type check: `node scripts/check-types.mjs` — passed with 197 baseline diagnostics and no regressions. +- T2 diff check: `git diff --check` — passed. +- T2 independent verification: passed with no findings; confirmed cached Status/header parity, compact-bar stability, watcher debounce/disposal, atomic replacement handling, and no profile I/O from repeated digest/render calls. +- T2 commit: pending explicit user authorization; no commit was created in this session. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index f5e0ff713..2dcf6c37a 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -6,11 +6,12 @@ import { join } from "node:path"; import test from "node:test"; import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; -import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts"; +import installGentleShell, { buildShellBarModel, createActiveProfileReader, createShellBarComponent, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts"; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; -import type { ShellBarTheme } from "../lib/shell-bar.ts"; +import type { ShellBarTheme, ShellProfileState } from "../lib/shell-bar.ts"; import { localProfilePinPath, repoProfileDeclarationPath, serializeProfilePin } from "../lib/agent-profile-pin.ts"; +import { profilesFilePath } from "../lib/agent-profiles.ts"; import { stripAnsi } from "../lib/terminal-theme.ts"; // The Gentle Shell extension wires the pure bar renderer into pi's footer @@ -237,6 +238,66 @@ test("buildShellBarModel shortens the home directory and hides effort for non-re assert.equal(built.branch, null); }); +test("the live shell header consumes the structured profile snapshot without painting it in the compact bar", () => { + const { pi } = fakePi(); + const { ctx } = fakeContext(); + let reads = 0; + const component = createShellBarComponent( + pi, + ctx, + { requestRender() {}, invalidateSidebar() {} }, + plainTheme, + { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }, + () => undefined, + () => undefined, + () => false, + () => { + reads++; + return { name: "team", pinned: true }; + }, + ); + assert.equal(reads, 0, "the in-memory profile is read by the header, not mounted from disk"); + const [line] = component.render(160); + assert.equal(reads, 1); + assert.doesNotMatch(line, /team|pinned/, "the compact bar's visual contract remains unchanged"); + component.dispose(); +}); + +test("branch invalidation does not duplicate a render already requested by profile refresh", () => { + const { pi } = fakePi(); + const { ctx } = fakeContext(); + let branchChanged: (() => void) | undefined; + let requests = 0; + let invalidations = 0; + const component = createShellBarComponent( + pi, + ctx, + { requestRender() { requests++; }, invalidateSidebar() { invalidations++; } }, + plainTheme, + { + getGitBranch: () => "main", + getExtensionStatuses: () => new Map(), + getAvailableProviderCount: () => 1, + onBranchChange: (callback) => { + branchChanged = callback; + return () => { branchChanged = undefined; }; + }, + }, + () => undefined, + () => undefined, + () => { + invalidations++; + requests++; + return true; + }, + () => ({ name: "other", pinned: true }), + ); + branchChanged!(); + assert.equal(invalidations, 1); + assert.equal(requests, 1, "the profile refresh already requested the render"); + component.dispose(); +}); + test("gentleShell installs the footer on session_start when a UI exists", () => { const { pi, handlers } = fakePi(); gentleShell(pi, {}); @@ -264,7 +325,16 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh await fire(handlers, "session_start", ctx); const statuses = new Map(); - const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => statuses, getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }; + let branchChanged: (() => void) | undefined; + const liveFooterData = { + getGitBranch: () => "main", + getExtensionStatuses: () => statuses, + getAvailableProviderCount: () => 1, + onBranchChange: (callback: () => void) => { + branchChanged = callback; + return () => { branchChanged = undefined; }; + }, + }; const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; const component = factory(tui, plainTheme, liveFooterData); @@ -277,9 +347,11 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforeProfile = live(); profile = { name: "other", pinned: false }; + branchChanged!(); assert.notEqual(live(), beforeProfile); assert.match(rail.render(46).join("\n"), /Profile.*other/); profile = undefined; + branchChanged!(); assert.doesNotMatch(rail.render(46).join("\n"), /Profile/); const beforeModel = live(); @@ -328,13 +400,27 @@ test("fullscreen Status digest follows repository pin changes without restarting const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); try { const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + const waitForProfile = async (text: string) => { + for (let attempt = 0; attempt < 20; attempt++) { + if (rail.digest!().includes(text)) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`timed out waiting for profile ${text}`); + }; assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforePin = rail.digest!(); writeFileSync(repoPin, serializeProfilePin("other")); + await waitForProfile('"name":"other"'); assert.notEqual(rail.digest!(), beforePin, "creating a pin must invalidate the Status digest"); assert.match(rail.render(46).join("\n"), /Profile.*other \(pinned\)/); + const replacement = join(root, "profile-replacement.json"); + writeFileSync(replacement, serializeProfilePin("team")); + renameSync(replacement, repoPin); + await waitForProfile('"name":"team"'); + assert.match(rail.render(46).join("\n"), /Profile.*team \(pinned\)/); const beforeRemoval = rail.digest!(); rmSync(repoPin); + await waitForProfile('"pinned":false'); assert.notEqual(rail.digest!(), beforeRemoval, "removing a pin must invalidate the Status digest"); assert.match(rail.render(46).join("\n"), /Profile.*team/); assert.doesNotMatch(rail.render(46).join("\n"), /\(pinned\)/); @@ -343,6 +429,133 @@ test("fullscreen Status digest follows repository pin changes without restarting } }); +test("fullscreen Status rebinds parent watchers when a profile store appears after startup", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-root-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "created", "gentle-ai"); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + mkdirSync(worktreeRoot, { recursive: true }); + mkdirSync(commonDir, { recursive: true }); + mkdirSync(join(root, "created"), { recursive: true }); + const profileFile = (active: string) => JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, + }); + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + try { + const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + const waitForDigest = async (text: string) => { + for (let attempt = 0; attempt < 30; attempt++) { + if (rail.digest!().includes(text)) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`timed out waiting for profile ${text}`); + }; + assert.doesNotMatch(rail.render(46).join("\n"), /Profile/); + mkdirSync(configHome, { recursive: true }); + writeFileSync(profilesFilePath(configHome), profileFile("team")); + await waitForDigest('"name":"team"'); + const replacement = join(root, "profile-replacement.json"); + writeFileSync(replacement, profileFile("other")); + renameSync(replacement, profilesFilePath(configHome)); + await waitForDigest('"name":"other"'); + assert.match(rail.render(46).join("\n"), /Profile.*other/); + } finally { + component.dispose(); + } +}); + +test("fullscreen Status keeps the effective profile in a snapshot between known invalidations", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-snapshot-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + let profile: ShellProfileState | undefined = { name: "team", pinned: false }; + let reads = 0; + let branchChanged: (() => void) | undefined; + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + activeProfile: () => { + reads++; + return profile; + }, + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { + getGitBranch: () => "main", + getExtensionStatuses: () => new Map(), + getAvailableProviderCount: () => 1, + onBranchChange: (callback: () => void) => { + branchChanged = callback; + return () => { branchChanged = undefined; }; + }, + }); + try { + const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + assert.equal(reads, 1, "the initial footer mount resolves the effective profile once"); + const before = rail.digest!(); + for (let i = 0; i < 3; i++) { + rail.digest!(); + rail.render(46); + } + assert.equal(reads, 1, "repeated Status digest/render calls use the in-memory snapshot"); + profile = { name: "other", pinned: true }; + branchChanged!(); + assert.equal(reads, 2, "a known in-process invalidation refreshes the snapshot"); + assert.notEqual(rail.digest!(), before); + assert.match(rail.render(46).join("\n"), /Profile.*other \(pinned\)/); + } finally { + component.dispose(); + } +}); + +test("fullscreen Status disposes profile watchers and pending refreshes with the footer", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-lifecycle-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + writeFileSync(profilesFilePath(root), JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} }, + })); + let reads = 0; + const repoPin = repoProfileDeclarationPath(worktreeRoot); + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + activeProfile: () => { + reads++; + return { name: "team", pinned: false }; + }, + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + assert.equal(reads, 1); + component.dispose(); + component.dispose(); + writeFileSync(repoPin, serializeProfilePin("other")); + await new Promise((resolve) => setTimeout(resolve, 180)); + assert.equal(reads, 1, "disposed Status components must close watchers and pending refresh timers"); +}); + test("profile reader follows store changes and rejects missing or invalid active markers", (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-")); t.after(() => rmSync(root, { recursive: true, force: true })); From 20cda00eea8d431443a4b9f48a259d9f874b488d Mon Sep 17 00:00:00 2001 From: Leandro Cardenas Date: Sat, 19 Sep 2026 17:55:01 -0400 Subject: [PATCH 05/12] docs(odd): record current main integration --- odd/tasks/effective-profile-status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index 258581d9f..4b5b8fea9 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -69,4 +69,4 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g absent; the remaining suite ran with the normal environment. - T3 type check: passed with 196 baseline diagnostics and no regressions. - T3 diff check: `git diff --check` — passed. -- T3 merge commit: pending. +- T3 merge commit: `e32c62ce` (`chore(branch): merge current main into effective profile fix`). From d6fb5a5d929dd9c0490f644b303a484d7cdb7efc Mon Sep 17 00:00:00 2001 From: Leandro Cardenas Date: Sat, 19 Sep 2026 19:35:45 -0400 Subject: [PATCH 06/12] fix(shell): show effective profile source --- docs/gentle-shell.md | 2 +- docs/readme-reference.md | 2 +- extensions/gentle-shell.ts | 8 +-- lib/shell-bar.ts | 7 ++- odd/tasks/effective-profile-status.md | 19 +++++-- tests/gentle-shell.test.ts | 80 ++++++++++++++++----------- tests/shell-bar.test.ts | 24 ++++---- 7 files changed, 85 insertions(+), 57 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index f2467b73c..b17ee996b 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -29,7 +29,7 @@ At 140 columns or wider, fullscreen splits into a live header row over a transcr ✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ``` -The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local or repository declaration pin wins and appends `(pinned)`; otherwise it shows the globally active profile without a suffix. Invalid or stale pins fall back globally. The shell resolves this state initially, on known in-process invalidations, and through debounced parent-directory watchers, so atomic profile and pin replacements appear without per-frame filesystem or Git resolution. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. +The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local pin wins and appends `(local)`, a valid repository declaration wins and appends `(repo)`, and the globally active profile has no suffix. Invalid or stale pins fall back globally. The shell resolves this state initially, on known in-process invalidations, and through debounced parent-directory watchers, so atomic profile and pin replacements appear without per-frame filesystem or Git resolution. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. - The header is one row, always visible, and carries only what changes every frame: session identity on the left (brand, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). It never shows the working/thinking state or extension statuses — those stay in the prompt title and the compact bar. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand survives, and below that the header renders nothing. - The right rail scrolls **Status → Changes → TODO**, each an event-driven card that only repaints when its own state changes: a model switch or a cost tick refreshes the header, not the rail. Every card (sidebar or not) paints the same rose frame — the rounded border in the theme's plain border role, the title in the accent role — the look every `CARD_TONE.INFO` card in Gentle Shell uses (warning/error/success cards keep their own tone colors). diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 196da939c..d2ca701ef 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -794,7 +794,7 @@ For a given working directory the winner is the local pin, then the repository d In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The globally active profile and writes made through `/gentle:models` do not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. `enter` follows the same boundary: inside a pinned repository it re-pins that repository instead of writing the global routing, so the panel's main key can never move another repository's routing. The panel states which layer won, names the file that holds it, and marks the profile with `(pinned)`. -The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(pinned)` only when a valid local or repository pin wins, and otherwise shows the global active profile without a suffix. Invalid or stale pins therefore fall back globally. The shell snapshots this state initially and refreshes it on known in-process invalidations or debounced parent-directory watcher events, including atomic replacements; repeated Status/header rendering performs no profile filesystem or Git resolution. The compact bottom bar remains unchanged and does not show this field. +The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(local)` when a valid clone-local pin wins, `(repo)` when a valid repository declaration wins, and no suffix for the global active profile. Invalid or stale pins therefore fall back globally. The shell snapshots this state initially and refreshes it on known in-process invalidations or debounced parent-directory watcher events, including atomic replacements; repeated Status/header rendering performs no profile filesystem or Git resolution. The compact bottom bar remains unchanged and does not show this field. To share a pin, commit the repository declaration. When `.pi/` is ignored, Git cannot re-include a nested file until its parent directories are visible. The panel therefore prints these ordered root `.gitignore` rules, which keep unrelated `.pi` content ignored while making only the declaration committable: diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 496bddbc2..c2c3088ac 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -112,11 +112,11 @@ export function createActiveProfileReader( if (next !== fingerprint) { const resolution = resolveProfilePin({ cwd, configHome, resolveWorktree }); if (resolution) { - profile = { name: resolution.profile, pinned: true }; + profile = { name: resolution.profile, source: resolution.source }; } else { const result = readProfilesFileResult(profilesPath); profile = result.status === "valid" && result.file.active - ? { name: result.file.active, pinned: false } + ? { name: result.file.active, source: "global" } : undefined; } fingerprint = next; @@ -180,11 +180,11 @@ function effectiveProfileWatchDirectories( } function copyProfileState(profile: ShellProfileState | undefined): ShellProfileState | undefined { - return profile === undefined ? undefined : { name: profile.name, pinned: profile.pinned }; + return profile === undefined ? undefined : { name: profile.name, source: profile.source }; } function sameProfileState(left: ShellProfileState | undefined, right: ShellProfileState | undefined): boolean { - return left?.name === right?.name && left?.pinned === right?.pinned; + return left?.name === right?.name && left?.source === right?.source; } function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshotOptions): EffectiveProfileSnapshot { diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 9abb78021..494ab61d4 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -10,9 +10,11 @@ export { gaugeTone, renderGauge, type GaugeTone }; // three-line footer. Everything here is pure so the bar can be rendered and // verified without a live TUI. +export type ShellProfileSource = "global" | "local" | "repo"; + export interface ShellProfileState { name: string; - pinned: boolean; + source: ShellProfileSource; } export interface ShellBarModel { @@ -122,7 +124,8 @@ function sanitizeStatus(text: string): string { } function formatProfile(profile: ShellProfileState): string { - return sanitizeStatus(`${profile.name}${profile.pinned ? " (pinned)" : ""}`); + const suffix = profile.source === "global" ? "" : ` (${profile.source})`; + return sanitizeStatus(`${profile.name}${suffix}`); } // Shared by the compact bar, the sidebar Status card, and the fullscreen diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index 4b5b8fea9..2596d9811 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -2,7 +2,7 @@ ## Objective -Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that governs subagent launches in the current repository. Append `(pinned)` only when a valid clone-local pin or repository declaration wins; otherwise show the globally active profile without a suffix. +Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that governs subagent launches in the current repository. Preserve the winning source returned by `resolveProfilePin()` and display `(local)` for a clone-local pin or `(repo)` for a repository declaration; otherwise show the globally active profile without a suffix. ## Context @@ -17,10 +17,11 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g ## Scope - Resolve the effective profile through the existing repository-pin authority. -- Keep profile state structured through the shell model and render `(pinned)` only for a valid winning pin. +- Keep profile state structured through the shell model and render the valid winning pin source as `(local)` or `(repo)`. - Refresh the Status digest when the global profile or relevant pin state changes. - Preserve the compact bottom bar behavior: it does not show a profile. - Add focused regression coverage and update user-facing documentation if the documented semantics require clarification. +- Keep this follow-up below 400 changed production/documentation lines and below 400 changed test lines. ## Tasks @@ -30,12 +31,15 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g no profile filesystem or Git resolution. - [x] T3 — Merge current `main` at `43269de3`, reconcile the feature with the live header and current shell behavior, and verify the merged candidate. +- [x] T4 — Replace the lossy `pinned` boolean with the effective source (`global`, `local`, or `repo`), render the winning pin scope, cover same-profile source transitions, update documentation, and verify within the user-specified line budgets. Route: delegated writer because the change spans multiple non-trivial files. ## Acceptance criteria - No valid pin: `Profile `. -- Valid local or repository pin: `Profile (pinned)`. -- Invalid, stale, missing, or unreadable pins fall back to the global active profile without `(pinned)`. +- Valid clone-local pin: `Profile (local)`. +- Valid repository declaration: `Profile (repo)`. +- Invalid, stale, missing, or unreadable pins fall back to the global active profile without a suffix. +- A same-name transition between local and repository sources refreshes the displayed scope. - Local pin precedence over repository declaration remains owned by `resolveProfilePin()`. - Creating, changing, or removing a pin updates the fullscreen Status digest and live header without restarting Pi. @@ -70,3 +74,10 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - T3 type check: passed with 196 baseline diagnostics and no regressions. - T3 diff check: `git diff --check` — passed. - T3 merge commit: `e32c62ce` (`chore(branch): merge current main into effective profile fix`). +- T4 TDD RED: focused tests observed 129 passed and 5 failed after expectations changed to exact global/local/repo sources, including missing local suffixes and old reader state. +- T4 TDD GREEN: focused tests passed with 134 passed and 0 failed after replacing `pinned` with `source` and comparing source during snapshot refresh. +- T4 focused verification: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 160 passed, 0 failed. +- T4 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics, no regressions, and 3 file/code pairs improved. +- T4 full suite: the required command reported 2846 passed, 1 failed, and 47 skipped only in `tests/review-host-relay-routing.test.ts`; the mandated override passed 31 tests, and the remaining suite passed 2816 tests with 47 skipped. +- T4 diff check: `git diff --check` — passed after removing one test trailing-whitespace line. +- T4 line budgets: 19 changed production/documentation lines and 112 changed test lines (additions plus deletions; task-artifact bookkeeping excluded). diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 4900b7f8e..2cfbc1ef5 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -210,9 +210,9 @@ test("buildShellBarModel reads session, model, and footer data", () => { getAvailableProviderCount: () => 1, onBranchChange: () => () => {}, }; - const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan", profile: { name: "team", pinned: true } }); + const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan", profile: { name: "team", source: "local" } }); assert.equal(built.cwd, "/repo"); - assert.deepEqual(built.profile, { name: "team", pinned: true }); + assert.deepEqual(built.profile, { name: "team", source: "local" }); assert.equal(built.branch, "main"); assert.equal(built.sessionName, "Release notes"); assert.equal(built.modelId, "gpt-5.5"); @@ -255,13 +255,13 @@ test("the live shell header consumes the structured profile snapshot without pai () => false, () => { reads++; - return { name: "team", pinned: true }; + return { name: "team", source: "local" }; }, ); assert.equal(reads, 0, "the in-memory profile is read by the header, not mounted from disk"); const [line] = component.render(160); assert.equal(reads, 1); - assert.doesNotMatch(line, /team|pinned/, "the compact bar's visual contract remains unchanged"); + assert.doesNotMatch(line, /team|\((local|repo)\)/, "the compact bar's visual contract remains unchanged"); component.dispose(); }); @@ -292,7 +292,7 @@ test("branch invalidation does not duplicate a render already requested by profi requests++; return true; }, - () => ({ name: "other", pinned: true }), + () => ({ name: "other", source: "local" }), ); branchChanged!(); assert.equal(invalidations, 1); @@ -320,7 +320,7 @@ test("gentleShell installs the footer on session_start when a UI exists", () => test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => { const { pi, handlers } = fakePi(); - let profile: { name: string; pinned: boolean } | undefined = { name: "team", pinned: false }; + let profile: ShellProfileState | undefined = { name: "team", source: "global" }; gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile }); const { ctx, ui } = fakeContext(); await fire(handlers, "session_start", ctx); @@ -347,7 +347,7 @@ test("the fullscreen Status rail carries a live digest so a profile switch refre assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforeProfile = live(); - profile = { name: "other", pinned: false }; + profile = { name: "other", source: "global" }; branchChanged!(); assert.notEqual(live(), beforeProfile); assert.match(rail.render(46).join("\n"), /Profile.*other/); @@ -365,7 +365,7 @@ test("the fullscreen Status rail carries a live digest so a profile switch refre } }); -test("fullscreen Status digest follows repository pin changes without restarting the shell", async (t) => { +test("fullscreen Status digest follows effective pin source changes without restarting the shell", async (t) => { const root = mkdtempSync(join(tmpdir(), "shell-status-pin-")); t.after(() => rmSync(root, { recursive: true, force: true })); const worktreeRoot = join(root, "repo"); @@ -377,6 +377,7 @@ test("fullscreen Status digest follows repository pin changes without restarting kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} }, })); const repoPin = repoProfileDeclarationPath(worktreeRoot); + const localPin = localProfilePinPath(commonDir); const { pi, handlers } = fakePi(); gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { resolveWorktree }); const { ctx, ui } = fakeContext(); @@ -396,20 +397,29 @@ test("fullscreen Status digest follows repository pin changes without restarting assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforePin = rail.digest!(); writeFileSync(repoPin, serializeProfilePin("other")); - await waitForProfile('"name":"other"'); + await waitForProfile('"name":"other","source":"repo"'); assert.notEqual(rail.digest!(), beforePin, "creating a pin must invalidate the Status digest"); - assert.match(rail.render(46).join("\n"), /Profile.*other \(pinned\)/); + assert.match(rail.render(46).join("\n"), /Profile.*other \(repo\)/); const replacement = join(root, "profile-replacement.json"); writeFileSync(replacement, serializeProfilePin("team")); renameSync(replacement, repoPin); - await waitForProfile('"name":"team"'); - assert.match(rail.render(46).join("\n"), /Profile.*team \(pinned\)/); + await waitForProfile('"name":"team","source":"repo"'); + assert.match(rail.render(46).join("\n"), /Profile.*team \(repo\)/); + const beforeLocal = rail.digest!(); + writeFileSync(localPin, serializeProfilePin("team")); + await waitForProfile('"name":"team","source":"local"'); + assert.notEqual(rail.digest!(), beforeLocal, "same-profile local precedence must invalidate the Status digest"); + assert.match(rail.render(46).join("\n"), /Profile.*team \(local\)/); + const beforeRepo = rail.digest!(); + rmSync(localPin); + await waitForProfile('"name":"team","source":"repo"'); + assert.notEqual(rail.digest!(), beforeRepo, "returning to the repository source must invalidate the Status digest"); const beforeRemoval = rail.digest!(); rmSync(repoPin); - await waitForProfile('"pinned":false'); + await waitForProfile('"name":"team","source":"global"'); assert.notEqual(rail.digest!(), beforeRemoval, "removing a pin must invalidate the Status digest"); assert.match(rail.render(46).join("\n"), /Profile.*team/); - assert.doesNotMatch(rail.render(46).join("\n"), /\(pinned\)/); + assert.doesNotMatch(rail.render(46).join("\n"), /\((local|repo)\)/); } finally { component.dispose(); } @@ -534,7 +544,7 @@ test("fullscreen Status keeps the effective profile in a snapshot between known const commonDir = join(root, "clone"); mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); - let profile: ShellProfileState | undefined = { name: "team", pinned: false }; + let profile: ShellProfileState | undefined = { name: "team", source: "global" }; let reads = 0; let branchChanged: (() => void) | undefined; const { pi, handlers } = fakePi(); @@ -567,11 +577,11 @@ test("fullscreen Status keeps the effective profile in a snapshot between known rail.render(46); } assert.equal(reads, 1, "repeated Status digest/render calls use the in-memory snapshot"); - profile = { name: "other", pinned: true }; + profile = { name: "other", source: "local" }; branchChanged!(); assert.equal(reads, 2, "a known in-process invalidation refreshes the snapshot"); assert.notEqual(rail.digest!(), before); - assert.match(rail.render(46).join("\n"), /Profile.*other \(pinned\)/); + assert.match(rail.render(46).join("\n"), /Profile.*other \(local\)/); } finally { component.dispose(); } @@ -594,7 +604,7 @@ test("fullscreen Status disposes profile watchers and pending refreshes with the resolveWorktree: () => ({ root: worktreeRoot, commonDir }), activeProfile: () => { reads++; - return { name: "team", pinned: false }; + return { name: "team", source: "global" }; }, }); const { ctx, ui } = fakeContext(); @@ -620,17 +630,17 @@ test("profile reader follows store changes and rejects missing or invalid active })); assert.equal(read(), undefined); save("team"); - assert.deepEqual(read(), { name: "team", pinned: false }); - assert.deepEqual(read(), { name: "team", pinned: false }); + assert.deepEqual(read(), { name: "team", source: "global" }); + assert.deepEqual(read(), { name: "team", source: "global" }); save("other"); - assert.deepEqual(read(), { name: "other", pinned: false }); + assert.deepEqual(read(), { name: "other", source: "global" }); const replacement = join(root, "replacement.json"); writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } })); renameSync(replacement, path); - assert.deepEqual(read(), { name: "team", pinned: false }, "atomic replacement refreshes the cached profile"); + assert.deepEqual(read(), { name: "team", source: "global" }, "atomic replacement refreshes the cached profile"); const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") }); assert.equal(isolated(), undefined); - assert.deepEqual(read(), { name: "team", pinned: false }, "another shell's config home does not alter this cache"); + assert.deepEqual(read(), { name: "team", source: "global" }, "another shell's config home does not alter this cache"); save("missing"); assert.equal(read(), undefined); save(undefined); @@ -638,12 +648,12 @@ test("profile reader follows store changes and rejects missing or invalid active writeFileSync(path, "{broken"); assert.equal(read(), undefined); save("team"); - assert.deepEqual(read(), { name: "team", pinned: false }); + assert.deepEqual(read(), { name: "team", source: "global" }); rmSync(path); assert.equal(read(), undefined); }); -test("profile reader shows the effective pin and refreshes when pins change", (t) => { +test("profile reader shows the effective pin source and refreshes when pins change", (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-pin-")); t.after(() => rmSync(root, { recursive: true, force: true })); const worktreeRoot = join(root, "repo"); @@ -660,21 +670,25 @@ test("profile reader shows the effective pin and refreshes when pins change", (t mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); save("team"); - assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }); writeFileSync(repoPin, serializeProfilePin("other")); - assert.deepEqual(read(worktreeRoot), { name: "other", pinned: true }); + assert.deepEqual(read(worktreeRoot), { name: "other", source: "repo" }); writeFileSync(repoPin, serializeProfilePin("team")); - assert.deepEqual(read(worktreeRoot), { name: "team", pinned: true }, "changed pins invalidate the reader cache"); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "repo" }, "changed pins invalidate the reader cache"); + writeFileSync(localPin, serializeProfilePin("team")); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "local" }, "same-profile local precedence changes the effective source"); + rmSync(localPin); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "repo" }, "removing the local pin restores the repository source"); rmSync(repoPin); - assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }, "removed pins fall back to the global active profile"); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }, "removed pins fall back to the global active profile"); writeFileSync(repoPin, serializeProfilePin("missing")); - assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }, "stale pins fall back globally"); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }, "stale pins fall back globally"); writeFileSync(repoPin, "{broken"); - assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }, "invalid pins fall back globally"); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }, "invalid pins fall back globally"); writeFileSync(localPin, serializeProfilePin("other")); - assert.deepEqual(read(worktreeRoot), { name: "other", pinned: true }, "a valid local pin wins over the global profile"); + assert.deepEqual(read(worktreeRoot), { name: "other", source: "local" }, "a valid local pin wins over the global profile"); rmSync(localPin); - assert.deepEqual(read(worktreeRoot), { name: "team", pinned: false }); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }); }); test("gentleShell stays out of the way without a UI or when disabled", () => { diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index d16ff8ded..1cffa89c6 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -239,7 +239,7 @@ test("sidebar unifies project, captured changes and integrations in one frame", test("sidebar profile wraps long names without changing the compact bar", () => { const profile = "team-" + "x".repeat(59); const base = model(); - const active = model({ profile: { name: profile, pinned: false } }); + const active = model({ profile: { name: profile, source: "global" } }); for (const width of [24, 46]) { const lines = renderShellSidebarBar(active, plainTheme, width); assert.ok(lines.every((line) => visibleWidth(line) <= width)); @@ -249,13 +249,13 @@ test("sidebar profile wraps long names without changing the compact bar", () => assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120)); }); -test("sidebar formats and sanitizes the pinned profile suffix", () => { - const pinned = model({ profile: { name: "other\x1b[31m", pinned: true } }); - const sidebar = renderShellSidebarBar(pinned, plainTheme, 46).join("\n"); - assert.match(sidebar, /Profile.*other \(pinned\)/); +test("sidebar formats and sanitizes the effective pin source suffix", () => { + const local = model({ profile: { name: "other\x1b[31m", source: "local" } }); + const sidebar = renderShellSidebarBar(local, plainTheme, 46).join("\n"); + assert.match(sidebar, /Profile.*other \(local\)/); assert.doesNotMatch(sidebar, /\x1b\[/); - const [compact] = renderShellBar(pinned, plainTheme, 120); - assert.doesNotMatch(compact, /other|pinned/); + const [compact] = renderShellBar(local, plainTheme, 120); + assert.doesNotMatch(compact, /other|local/); }); test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping Project, Changes and Integrations", () => { @@ -265,7 +265,7 @@ test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping fetchedAt: 0, limits: [{ name: "codex", limitReached: false, windows: [{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null }] }], }; - const data = model({ profile: { name: "team", pinned: false }, sessionName: "session", usage, changes: { files: 1, added: 2, deleted: 1 }, statuses: ["MCP connected"] }); + const data = model({ profile: { name: "team", source: "global" }, sessionName: "session", usage, changes: { files: 1, added: 2, deleted: 1 }, statuses: ["MCP connected"] }); const text = renderShellSidebarBar(data, plainTheme, 60).join("\n"); assert.doesNotMatch(text, /Usage/); assert.doesNotMatch(text, /Model/); @@ -286,14 +286,14 @@ test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping // statuses — those stay in the prompt and the Status card. test("buildShellHeaderModel keeps only the header's fields from the bar model", () => { - const header = buildShellHeaderModel(model({ profile: { name: "team", pinned: false }, statuses: ["MCP: 3 servers"] })); + const header = buildShellHeaderModel(model({ profile: { name: "team", source: "global" }, statuses: ["MCP: 3 servers"] })); assert.deepEqual(header, { cwd: "~/work/gentle-pi", branch: "main", dirty: undefined, modelId: "gpt-5.5", effort: "medium", - profile: { name: "team", pinned: false }, + profile: { name: "team", source: "global" }, contextPercent: 45, costTotal: 9.49, subscription: true, @@ -303,7 +303,7 @@ test("buildShellHeaderModel keeps only the header's fields from the bar model", }); test("renderShellHeaderBar draws the brand, identity, and right-aligned counters (plus the standing usage segment) in one line", () => { - const header = buildShellHeaderModel(model({ profile: { name: "team", pinned: false } })); + const header = buildShellHeaderModel(model({ profile: { name: "team", source: "global" } })); const { text: line } = renderShellHeaderBar(header, plainTheme, 120); const left = "✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team"; const right = "ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ usage"; @@ -326,7 +326,7 @@ test("renderShellHeaderBar colors the brand bold and by role", () => { }); test("renderShellHeaderBar drops the profile, then the effort, then the whole location before the right group", () => { - const withProfile = buildShellHeaderModel(model({ profile: { name: "team", pinned: false } })); + const withProfile = buildShellHeaderModel(model({ profile: { name: "team", source: "global" } })); const { text: wide } = renderShellHeaderBar(withProfile, plainTheme, 120); assert.match(wide, /gpt-5\.5 · medium · team/); assert.match(wide, /~\/work\/gentle-pi main/); From d55b210f5492d534dde595fa66159bda6a9ffee8 Mon Sep 17 00:00:00 2001 From: Leandro Cardenas Date: Mon, 21 Sep 2026 05:36:00 -0400 Subject: [PATCH 07/12] docs(odd): record main integration evidence --- odd/tasks/effective-profile-status.md | 1 + 1 file changed, 1 insertion(+) diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index e75139b90..f56506ad1 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -87,3 +87,4 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - T5 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics, no regressions, and 3 file/code pairs improved. - T5 full suite: the required command reported 2923 passed, 1 failed, and 47 skipped only in `tests/review-host-relay-routing.test.ts`; the established native-runtime override passed that file's 31 tests, and the remaining suite passed 2893 tests with 47 skipped. - T5 diff check: `git diff --check --cached` — passed. +- T5 merge commit: `c1bd0148` (`chore(branch): merge current main into effective profile fix`). From ec2e3c562379775d90a4ee7b3a429160d7faad37 Mon Sep 17 00:00:00 2001 From: Leandro Cardenas Date: Mon, 21 Sep 2026 06:12:59 -0400 Subject: [PATCH 08/12] fix(shell): handle profile watcher errors --- extensions/gentle-shell.ts | 20 +++++++++++++++++--- odd/tasks/effective-profile-status.md | 6 ++++++ tests/gentle-shell.test.ts | 24 +++++++++++++++++++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 94dce4206..65a64c83e 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -139,6 +139,7 @@ interface EffectiveProfileSnapshotOptions { env: NodeJS.ProcessEnv; resolveWorktree: WorktreeResolver; onChange(): void; + watch?: typeof watch; } function existingProfileWatchDirectory(path: string, floor: string): string | undefined { @@ -187,13 +188,14 @@ function sameProfileState(left: ShellProfileState | undefined, right: ShellProfi return left?.name === right?.name && left?.source === right?.source; } -function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshotOptions): EffectiveProfileSnapshot { +export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshotOptions): EffectiveProfileSnapshot { let disposed = false; let observedCwd = options.cwd(); let profile = copyProfileState(options.read(observedCwd)); let refreshTimer: ReturnType | undefined; let watchers: FSWatcher[] = []; let watchedDirectories: string[] = []; + const failedWatchDirectories = new Set(); const closeWatchers = () => { for (const watcher of watchers) { @@ -207,13 +209,25 @@ function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshotOptions watchedDirectories = []; }; const installWatchers = (cwd: string) => { - const directories = effectiveProfileWatchDirectories(cwd, options.env, options.resolveWorktree); + const availableDirectories = effectiveProfileWatchDirectories(cwd, options.env, options.resolveWorktree); + for (const directory of failedWatchDirectories) if (!availableDirectories.includes(directory)) failedWatchDirectories.delete(directory); + const directories = availableDirectories.filter((directory) => !failedWatchDirectories.has(directory)); if (directories.length === watchedDirectories.length && directories.every((directory, index) => directory === watchedDirectories[index]) && watchers.length === directories.length) return; closeWatchers(); watchedDirectories = directories; for (const directory of directories) { try { - const watcher = watch(directory, () => scheduleRefresh()); + const watcher = (options.watch ?? watch)(directory, () => scheduleRefresh()); + watcher.on("error", () => { + if (disposed) return; + failedWatchDirectories.add(directory); + const index = watchers.indexOf(watcher); + if (index >= 0) watchers.splice(index, 1); + const directoryIndex = watchedDirectories.indexOf(directory); + if (directoryIndex >= 0) watchedDirectories.splice(directoryIndex, 1); + try { watcher.close(); } catch { /* Best-effort cleanup after an asynchronous watch failure. */ } + scheduleRefresh(); + }); watcher.unref(); watchers.push(watcher); } catch { diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index f56506ad1..d73ebd022 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -33,6 +33,7 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g shell behavior, and verify the merged candidate. - [x] T4 — Replace the lossy `pinned` boolean with the effective source (`global`, `local`, or `repo`), render the winning pin scope, cover same-profile source transitions, update documentation, and verify within the user-specified line budgets. Route: delegated writer because the change spans multiple non-trivial files. - [x] T5 — Integrate current `main` at `cf1fdb65`, resolve the `extensions/gentle-shell.ts` import conflict while preserving both effective-profile state and the fullscreen header rule, run focused and repository verification, and commit the integration. +- [x] T6 — Correct review finding `R4-watch-runtime-error`: handle asynchronous `FSWatcher` errors without terminating the Pi host, add focused regression coverage, validate within the native correction budget, and commit the fix. ## Acceptance criteria @@ -88,3 +89,8 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - T5 full suite: the required command reported 2923 passed, 1 failed, and 47 skipped only in `tests/review-host-relay-routing.test.ts`; the established native-runtime override passed that file's 31 tests, and the remaining suite passed 2893 tests with 47 skipped. - T5 diff check: `git diff --check --cached` — passed. - T5 merge commit: `c1bd0148` (`chore(branch): merge current main into effective profile fix`). +- T6 correction: asynchronous watcher errors close and retire the failed watcher, suppress retry loops, and remain safe after disposal; focused regression coverage exercises the error lifecycle. +- T6 correction size: 44 diff lines across production and test code, within the authorized 80-line plan. +- T6 focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 167 passed, 0 failed. +- T6 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics, no regressions, and 3 file/code pairs improved. +- T6 diff check: `git diff --check` — passed. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 70fb985e1..91cb65d78 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import test from "node:test"; import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent"; import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui"; -import installGentleShell, { buildShellBarModel, createActiveProfileReader, createShellBarComponent, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts"; +import installGentleShell, { buildShellBarModel, createActiveProfileReader, createEffectiveProfileSnapshot, createShellBarComponent, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts"; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; import type { ShellBarTheme, ShellProfileState } from "../lib/shell-bar.ts"; @@ -623,6 +623,28 @@ test("fullscreen Status disposes profile watchers and pending refreshes with the assert.equal(reads, 1, "disposed Status components must close watchers and pending refresh timers"); }); +test("profile watcher errors close the failed watcher without retrying it", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-error-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const errors: Array<(error: Error) => void> = []; + let closed = 0; + let created = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: root }, resolveWorktree: () => ({ root, commonDir: root }), onChange() {}, read: () => undefined, + watch: () => { + created++; + return { on(event: string, callback: (error: Error) => void) { if (event === "error") errors.push(callback); }, unref() {}, close() { closed++; } } as never; + }, + }); + assert.doesNotThrow(() => errors[0]!(new Error("watched directory vanished"))); + assert.equal(closed, 1, "the failed watcher is closed immediately"); + await new Promise((resolve) => setTimeout(resolve, 130)); + assert.equal(created, 1, "a persistent watcher failure does not create a retry loop"); + snapshot.dispose(); + assert.equal(closed, 1, "disposal does not close an already failed watcher again"); + assert.doesNotThrow(() => errors[0]!(new Error("late error after disposal"))); +}); + test("profile reader follows store changes and rejects missing or invalid active markers", (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-")); t.after(() => rmSync(root, { recursive: true, force: true })); From 729bfa62d4ed5401095d1b93922237c74d380c7e Mon Sep 17 00:00:00 2001 From: lCardenas Date: Wed, 23 Sep 2026 04:01:55 -0400 Subject: [PATCH 09/12] fix(shell): avoid unrelated profile watcher refreshes Filter watcher events by their next target path component and cache the worktree identity per cwd. Keep unknown filenames and ancestor rebinding observable with focused regression coverage. --- extensions/gentle-shell.ts | 51 ++++++++--- odd/tasks/effective-profile-status.md | 28 +++++- tests/gentle-shell.test.ts | 124 ++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 462b41ca7..c29a0b019 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -5,7 +5,7 @@ import { statSync, type FSWatcher, watch } from "node:fs"; import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts"; import { localProfilePinPath, repoProfileDeclarationPath, resolveProfilePin } from "../lib/agent-profile-pin.ts"; import * as os from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, sep } from "node:path"; import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme, type ShellProfileState } from "../lib/shell-bar.ts"; import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts"; import { WorktreeChangesView } from "../lib/shell-changes-view.ts"; @@ -159,25 +159,31 @@ function existingProfileWatchDirectory(path: string, floor: string): string | un } } -function effectiveProfileWatchDirectories( - cwd: string, +function effectiveProfileWatchTargets( env: NodeJS.ProcessEnv, - resolveWorktree: WorktreeResolver, -): string[] { + identity: ReturnType | undefined, +): Map> { const configHome = env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"); const paths: Array<{ path: string; floor: string }> = [ { path: profilesFilePath(configHome), floor: dirname(configHome) }, ]; - try { - const identity = resolveWorktree(cwd, cwd); + if (identity) { paths.push( { path: localProfilePinPath(identity.commonDir), floor: identity.commonDir }, { path: repoProfileDeclarationPath(identity.root), floor: identity.root }, ); - } catch { - // The global profile remains observable even outside a Git worktree. } - return [...new Set(paths.map(({ path, floor }) => existingProfileWatchDirectory(path, floor)).filter((path): path is string => path !== undefined))]; + const targets = new Map>(); + for (const { path, floor } of paths) { + const directory = existingProfileWatchDirectory(path, floor); + if (!directory) continue; + const firstPathComponent = relative(directory, path).split(sep)[0]; + if (!firstPathComponent || firstPathComponent === "..") continue; + const names = targets.get(directory) ?? new Set(); + names.add(firstPathComponent); + targets.set(directory, names); + } + return targets; } function copyProfileState(profile: ShellProfileState | undefined): ShellProfileState | undefined { @@ -192,10 +198,27 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot let disposed = false; let observedCwd = options.cwd(); let profile = copyProfileState(options.read(observedCwd)); + let identityCwd: string | undefined; + let worktreeIdentity: ReturnType | undefined; + let identityResolved = false; let refreshTimer: ReturnType | undefined; let watchers: FSWatcher[] = []; let watchedDirectories: string[] = []; const failedWatchDirectories = new Set(); + // Worktree identity is stable for this snapshot's cwd; a cwd transition is the + // explicit invalidation event. Profile events reuse the cached result instead of Git. + const resolveIdentity = (cwd: string): ReturnType | undefined => { + if (!identityResolved || cwd !== identityCwd) { + identityCwd = cwd; + identityResolved = true; + try { + worktreeIdentity = options.resolveWorktree(cwd, cwd); + } catch { + worktreeIdentity = undefined; + } + } + return worktreeIdentity; + }; const closeWatchers = () => { for (const watcher of watchers) { @@ -209,7 +232,8 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot watchedDirectories = []; }; const installWatchers = (cwd: string) => { - const availableDirectories = effectiveProfileWatchDirectories(cwd, options.env, options.resolveWorktree); + const targets = effectiveProfileWatchTargets(options.env, resolveIdentity(cwd)); + const availableDirectories = [...targets.keys()]; for (const directory of failedWatchDirectories) if (!availableDirectories.includes(directory)) failedWatchDirectories.delete(directory); const directories = availableDirectories.filter((directory) => !failedWatchDirectories.has(directory)); if (directories.length === watchedDirectories.length && directories.every((directory, index) => directory === watchedDirectories[index]) && watchers.length === directories.length) return; @@ -217,7 +241,10 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot watchedDirectories = directories; for (const directory of directories) { try { - const watcher = (options.watch ?? watch)(directory, () => scheduleRefresh()); + const watcher = (options.watch ?? watch)(directory, (_eventType, filename) => { + const names = targets.get(directory); + if (filename === null || filename === undefined || names?.has(filename.toString())) scheduleRefresh(); + }); watcher.on("error", () => { if (disposed) return; failedWatchDirectories.add(directory); diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index 141b85f38..5b3c4c378 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -21,7 +21,12 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - Refresh the Status digest when the global profile or relevant pin state changes. - Preserve the compact bottom bar behavior: it does not show a profile. - Add focused regression coverage and update user-facing documentation if the documented semantics require clarification. -- Keep this follow-up below 400 changed production/documentation lines and below 400 changed test lines. +- Keep the correction reviewable without shrinking necessary tests or documentation to meet a line target. +- Review follow-up plan: `work-items/active/fix/1176-effective-profile-status/review-follow-up-implementation-plan.md`. +- Baseline for this follow-up: PR head `cfbdf1be`; local feature branch `8e3578c9` contains the later main merge, with the same watcher/test behavior and a changed documentation sentence. +- TDD mode: enabled for these tasks by the user-accepted follow-up plan (RED/GREEN/TRIANGULATE/REFACTOR); exact focused runner: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts`. +- RDD switch: off (clone-local; observed before implementation). Native review is not enabled for this candidate. +- Review workload: forecast approximately 250–400 authored diff lines for T8–T10; the existing PR already exceeds 400 lines and requests `size:exception`. Delivery strategy: `exception-ok` for PR #1252, explicitly selected by the user despite its existing size-exception request. Keep task-scoped work-unit commits on this feature branch; no push/PR creation is authorized here. ## Tasks @@ -35,13 +40,18 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - [x] T5 — Integrate current `main` at `cf1fdb65`, resolve the `extensions/gentle-shell.ts` import conflict while preserving both effective-profile state and the fullscreen header rule, run focused and repository verification, and commit the integration. - [x] T6 — Correct review finding `R4-watch-runtime-error`: handle asynchronous `FSWatcher` errors without terminating the Pi host, add focused regression coverage, validate within the native correction budget, and commit the fix. - [x] T7 — Integrate updated `main` at `b6188bef`, resolve the test import conflict while preserving both subscription-usage and effective-profile coverage, verify the merged candidate, push the feature branch, then integrate it into `downstream/main`. +- [x] T8 — Filter irrelevant profile watcher events and reuse the resolved worktree identity; preserve null-filename, ancestor creation, atomic replacement, and non-Git global behavior. Route: delegated writer (production and regression tests); checks: observed RED/GREEN focused tests, resolver/read counters, typecheck, diff check, work-unit commit. +- [ ] T9 — Remove real OS watcher/timer dependence from both fullscreen Status refresh regressions while preserving integration assertions and rebind coverage. Route: delegated writer (integration tests and narrow test seam); checks: observed RED/GREEN, focused repeat, suite, work-unit commit. +- [ ] T10 — Clarify global-store and invalid/stale-pin fallback in the fullscreen documentation, verify consistency with reference docs, and commit the documentation work unit. Route: inline direct unless additional non-trivial files become necessary; checks: readback, markdown/diff check, work-unit commit. ## Acceptance criteria - No valid pin: `Profile `. - Valid clone-local pin: `Profile (local)`. - Valid repository declaration: `Profile (repo)`. -- Invalid, stale, missing, or unreadable pins fall back to the global active profile without a suffix. +- Invalid, stale, missing, or unreadable pin layers are skipped; a valid lower-priority repository declaration wins before falling back to the global active profile without a suffix. +- Irrelevant watched-directory changes do not schedule profile refresh or synchronous Git resolution; relevant and unknown-filename changes still refresh the snapshot. +- The two fullscreen refresh regressions use controlled watcher events and debounce timing, not OS delivery or short wall-clock deadlines. - A same-name transition between local and repository sources refreshes the displayed scope. - Local pin precedence over repository declaration remains owned by `resolveProfilePin()`. - Creating, changing, or removing a pin updates the fullscreen Status digest and live header without @@ -53,6 +63,20 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - The compact bottom bar remains unchanged. - Focused tests, typecheck/runtime checks, complete test suite, and `git diff --check` pass or any skipped/failed check is reported. +## Follow-up progress + +- T8 implemented and independently verified; recording its work-unit commit. T9 and T10 pending. The two new T8 tests retain 130 ms wall-clock waits, to be removed with the T9 timer refactor. +- Current branch is ahead of `origin/fix/effective-profile-status` because of an earlier local main merge; do not silently reset, rebase, push, or use the remote PR head as the checked-out source. +- Scoped mapping complete; next: commit T8 as one work unit, then determinize all profile watcher timing tests in T9. + +## Follow-up evidence + +- T8 RED: injected watcher tests failed before implementation: unrelated `index` event caused an extra read (`2 !== 1`); outside-Git global refresh repeated worktree lookup (`2 !== 1`). +- T8 GREEN: two targeted tests passed after filtering by next path component and caching the watcher-side worktree identity per `cwd`. +- T8 writer and independent verifier: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 148 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 baseline diagnostics, no regression; `git diff --check` — passed. +- T8 independent finding: the two new tests use 130 ms real waits against a 100 ms debounce; T9 must replace those waits too. Relevant-event Git from the separate profile reader is not measured by watcher-side counters and was intentionally not changed. +- T8 parent spot check: `git diff --check` passed; no untracked source changes. RDD-off native assessment reported high risk; independent verifier completed with no confirmed production defect. + ## Evidence - T1 commit: `ef07e0ef` (`fix(shell): show effective repository profile`) diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 181434c37..96def5857 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -624,6 +624,130 @@ test("fullscreen Status disposes profile watchers and pending refreshes with the assert.equal(reads, 1, "disposed Status components must close watchers and pending refresh timers"); }); +test("profile watchers filter filenames, rebind ancestors, and cache worktree identity by cwd", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-filter-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + const commonDir = join(root, "clone"); + const localWatchDirectory = join(commonDir, "gentle-ai"); + mkdirSync(localWatchDirectory, { recursive: true }); + const nextRoot = join(root, "next-repo"); + const nextCommonDir = join(root, "next-clone"); + mkdirSync(nextRoot, { recursive: true }); + mkdirSync(join(nextCommonDir, "gentle-ai"), { recursive: true }); + let cwd = root; + const resolutions: string[] = []; + const reads: string[] = []; + const records: Array<{ + directory: string; + callback(eventType: string, filename: string | Buffer | null): void; + closed: boolean; + }> = []; + let profile: ShellProfileState | undefined = { name: "team", source: "global" }; + let changes = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => cwd, + env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: (path: string) => { + resolutions.push(path); + return path === root + ? { root, commonDir } + : { root: nextRoot, commonDir: nextCommonDir }; + }, + read: (path) => { reads.push(path!); return profile; }, + onChange: () => { changes++; }, + watch: ((directory: string, callback: (eventType: string, filename: string | Buffer | null) => void) => { + const record = { directory, callback, closed: false }; + records.push(record); + return { on() { return this; }, unref() {}, close() { record.closed = true; } }; + }) as never, + }); + const activeWatcher = (directory: string) => { + const record = records.find((candidate) => candidate.directory === directory && !candidate.closed); + assert.ok(record, `expected an active watcher for ${directory}`); + return record; + }; + const emit = (directory: string, filename: string | Buffer | null) => activeWatcher(directory).callback("change", filename); + const settle = () => new Promise((resolve) => setTimeout(resolve, 130)); + try { + assert.deepEqual(reads, [root]); + assert.deepEqual(resolutions, [root], "the worktree identity is resolved once for the initial cwd"); + for (const filename of ["index", "HEAD", ".git", "profile.json"]) emit(root, filename); + emit(localWatchDirectory, "index"); + await settle(); + assert.equal(reads.length, 1, "named unrelated files, including a matching basename in another parent, do not refresh"); + assert.deepEqual(resolutions, [root], "unrelated watcher events do not resolve Git"); + + mkdirSync(configHome, { recursive: true }); + emit(root, "config"); + await settle(); + assert.equal(reads.length, 2, "a shared watch directory retains the global store's next path component"); + assert.ok(activeWatcher(configHome), "the global watcher rebinds after its ancestor is created"); + + mkdirSync(join(root, ".pi", "gentle-ai"), { recursive: true }); + profile = { name: "other", source: "repo" }; + emit(root, Buffer.from(".pi")); + await settle(); + assert.equal(reads.length, 3, "creating a watched ancestor refreshes the profile"); + assert.deepEqual(snapshot.get(), { name: "other", source: "repo" }, "the refreshed snapshot preserves the effective pin source"); + assert.equal(changes, 1); + assert.ok(activeWatcher(join(root, ".pi", "gentle-ai")), "the watcher rebinds to the nearest newly created profile parent"); + assert.deepEqual(resolutions, [root], "refreshing the same cwd reuses its worktree identity"); + + emit(configHome, "profile.json"); + await settle(); + assert.equal(reads.length, 3, "the same filename in a different parent is unrelated"); + emit(join(root, ".pi", "gentle-ai"), "profile.json"); + await settle(); + assert.equal(reads.length, 4, "a watched target filename refreshes the profile"); + assert.equal(changes, 1, "an unchanged resolved state does not notify the shell"); + emit(localWatchDirectory, null); + await settle(); + assert.equal(reads.length, 5, "an unknown filename preserves conservative refresh behavior"); + + cwd = nextRoot; + snapshot.refresh(); + assert.deepEqual(reads, [root, root, root, root, root, nextRoot]); + assert.deepEqual(resolutions, [root, nextRoot], "a cwd transition resolves and caches the new worktree identity"); + assert.ok(records.some((record) => record.directory === nextRoot && !record.closed), "watchers rebind to the new worktree"); + assert.ok(records.some((record) => record.directory === join(root, ".pi", "gentle-ai") && record.closed), "watchers from the old worktree are closed"); + } finally { + snapshot.dispose(); + } +}); + +test("profile watchers keep the global store observable outside Git", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-global-watch-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome, { recursive: true }); + let reads = 0; + let resolutions = 0; + let callback: ((eventType: string, filename: string | Buffer | null) => void) | undefined; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, + env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not a Git worktree"); }, + read: () => { reads++; return { name: "team", source: "global" }; }, + onChange() {}, + watch: ((directory: string, onEvent: (eventType: string, filename: string | Buffer | null) => void) => { + assert.equal(directory, configHome); + callback = onEvent; + return { on() { return this; }, unref() {}, close() {} }; + }) as never, + }); + try { + assert.equal(reads, 1); + assert.equal(resolutions, 1); + callback!("change", "profiles.json"); + await new Promise((resolve) => setTimeout(resolve, 130)); + assert.equal(reads, 2, "global profile changes remain observable outside Git"); + assert.equal(resolutions, 1, "the failed worktree lookup is cached for this cwd"); + } finally { + snapshot.dispose(); + } +}); + test("profile watcher errors close the failed watcher without retrying it", async (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-error-")); t.after(() => rmSync(root, { recursive: true, force: true })); From e4bcd254076ba2f8f9bddfbb90a4b84af51315b8 Mon Sep 17 00:00:00 2001 From: lCardenas Date: Wed, 23 Sep 2026 04:22:42 -0400 Subject: [PATCH 10/12] test(shell): control profile watcher events and debounce Inject watcher and per-snapshot clock dependencies for deterministic fullscreen profile refresh tests. Preserve real production defaults and assert debounce resets, source transitions, rebinding, and atomic replacement. --- extensions/gentle-shell.ts | 22 ++- odd/tasks/effective-profile-status.md | 12 +- tests/gentle-shell.test.ts | 244 +++++++++++++++++--------- 3 files changed, 192 insertions(+), 86 deletions(-) diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index c29a0b019..c129e6d9c 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -63,6 +63,11 @@ interface BuildOptions { export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string }; +export interface ProfileRefreshClock { + setTimeout(callback: () => void, delay: number): ReturnType; + clearTimeout(timer: ReturnType): void; +} + export interface ShellDeps { activeProfile(cwd?: string): ShellProfileState | undefined; fetch: typeof fetch; @@ -70,6 +75,8 @@ export interface ShellDeps { devBinary(): DevBinaryNotice | undefined; resolveWorktree: WorktreeResolver; gitRunner(cwd: string): GitRunner; + watchProfile?: typeof watch; + profileRefreshClock?: ProfileRefreshClock; } // Profile resolution is intentionally kept out of the rail digest and render path. @@ -127,6 +134,11 @@ export function createActiveProfileReader( const PROFILE_REFRESH_DEBOUNCE_MS = 100; +const defaultProfileRefreshClock: ProfileRefreshClock = { + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (timer) => clearTimeout(timer), +}; + interface EffectiveProfileSnapshot { get(): ShellProfileState | undefined; refresh(): boolean; @@ -140,6 +152,7 @@ interface EffectiveProfileSnapshotOptions { resolveWorktree: WorktreeResolver; onChange(): void; watch?: typeof watch; + profileRefreshClock?: ProfileRefreshClock; } function existingProfileWatchDirectory(path: string, floor: string): string | undefined { @@ -202,6 +215,7 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot let worktreeIdentity: ReturnType | undefined; let identityResolved = false; let refreshTimer: ReturnType | undefined; + const clock = options.profileRefreshClock ?? defaultProfileRefreshClock; let watchers: FSWatcher[] = []; let watchedDirectories: string[] = []; const failedWatchDirectories = new Set(); @@ -279,8 +293,8 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot }; const scheduleRefresh = () => { if (disposed) return; - if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => { + if (refreshTimer) clock.clearTimeout(refreshTimer); + refreshTimer = clock.setTimeout(() => { refreshTimer = undefined; refresh(); }, PROFILE_REFRESH_DEBOUNCE_MS); @@ -294,7 +308,7 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot dispose() { if (disposed) return; disposed = true; - if (refreshTimer) clearTimeout(refreshTimer); + if (refreshTimer) clock.clearTimeout(refreshTimer); refreshTimer = undefined; closeWatchers(); }, @@ -1148,6 +1162,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p cwd: () => ctx.sessionManager.getCwd(), env, resolveWorktree: deps.resolveWorktree, + watch: deps.watchProfile, + profileRefreshClock: deps.profileRefreshClock, onChange: () => { invalidateSidebar(tui); tui.requestRender(); diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index 5b3c4c378..cb479e37a 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -41,7 +41,7 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - [x] T6 — Correct review finding `R4-watch-runtime-error`: handle asynchronous `FSWatcher` errors without terminating the Pi host, add focused regression coverage, validate within the native correction budget, and commit the fix. - [x] T7 — Integrate updated `main` at `b6188bef`, resolve the test import conflict while preserving both subscription-usage and effective-profile coverage, verify the merged candidate, push the feature branch, then integrate it into `downstream/main`. - [x] T8 — Filter irrelevant profile watcher events and reuse the resolved worktree identity; preserve null-filename, ancestor creation, atomic replacement, and non-Git global behavior. Route: delegated writer (production and regression tests); checks: observed RED/GREEN focused tests, resolver/read counters, typecheck, diff check, work-unit commit. -- [ ] T9 — Remove real OS watcher/timer dependence from both fullscreen Status refresh regressions while preserving integration assertions and rebind coverage. Route: delegated writer (integration tests and narrow test seam); checks: observed RED/GREEN, focused repeat, suite, work-unit commit. +- [x] T9 — Remove real OS watcher/timer dependence from both fullscreen Status refresh regressions while preserving integration assertions and rebind coverage. Route: delegated writer (integration tests and narrow test seam); checks: observed RED/GREEN, focused repeat, suite, work-unit commit. - [ ] T10 — Clarify global-store and invalid/stale-pin fallback in the fullscreen documentation, verify consistency with reference docs, and commit the documentation work unit. Route: inline direct unless additional non-trivial files become necessary; checks: readback, markdown/diff check, work-unit commit. ## Acceptance criteria @@ -65,9 +65,9 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g ## Follow-up progress -- T8 implemented and independently verified; recording its work-unit commit. T9 and T10 pending. The two new T8 tests retain 130 ms wall-clock waits, to be removed with the T9 timer refactor. +- T8 complete in local commit `729bfa62`; T9 implemented and independently verified, recording its work-unit commit. T10 pending. The two new T8 tests retain 130 ms wall-clock waits, to be removed with the T9 timer refactor. - Current branch is ahead of `origin/fix/effective-profile-status` because of an earlier local main merge; do not silently reset, rebase, push, or use the remote PR head as the checked-out source. -- Scoped mapping complete; next: commit T8 as one work unit, then determinize all profile watcher timing tests in T9. +- Next: record the deterministic test work unit, clarify the fallback documentation, then run the complete suite. ## Follow-up evidence @@ -76,6 +76,12 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - T8 writer and independent verifier: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 148 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 baseline diagnostics, no regression; `git diff --check` — passed. - T8 independent finding: the two new tests use 130 ms real waits against a 100 ms debounce; T9 must replace those waits too. Relevant-event Git from the separate profile reader is not measured by watcher-side counters and was intentionally not changed. - T8 parent spot check: `git diff --check` passed; no untracked source changes. RDD-off native assessment reported high risk; independent verifier completed with no confirmed production defect. +- T8 commit: `729bfa62` (`fix(shell): avoid unrelated profile watcher refreshes`), local only; user approved work-unit commits but not push. +- T9 RED: before seam wiring six targeted watcher tests failed because injected watchers were not installed and fake-clock advances did not refresh the snapshots. +- T9 GREEN: six targeted tests passed; original source transitions, late store creation, atomic replacement, null filenames, watcher errors, and disposal now use injected events and a per-snapshot clock. Production defaults retain Node watchers and timers. +- T9 writer and independent verifier: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 148 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 baseline diagnostics, no regressions; `git diff --check` passed. A new test-helper type error was corrected before the final run. +- T9 follow-up verifier: later relevant event at t=60 resets the debounce from t=100 to t=160; focused suite 148 passed, diff check passed. Throwing watcher `close()` remains a low-priority pre-existing coverage gap outside the three review observations. +- T9 parent spot check: `git diff --check` passed. RDD-off assessment reported high risk; separate verification found no production defect. ## Evidence diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 96def5857..c52bae6b8 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -2,11 +2,11 @@ import assert from "node:assert/strict"; import { execFileSync, execFile } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import test from "node:test"; import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent"; import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui"; -import installGentleShell, { buildShellBarModel, createActiveProfileReader, createEffectiveProfileSnapshot, createShellBarComponent, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts"; +import installGentleShell, { buildShellBarModel, createActiveProfileReader, createEffectiveProfileSnapshot, createShellBarComponent, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor, type ProfileRefreshClock, type ShellDeps } from "../extensions/gentle-shell.ts"; import { USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts"; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; @@ -132,6 +132,64 @@ async function fire(handlers: Map void; + closed: boolean; + closeCalls: number; +} + +function createProfileWatchHarness() { + let now = 0; + let nextTimerId = 0; + const timers = new Map, { due: number; callback: () => void }>(); + const watchers: ProfileWatchRecord[] = []; + const profileRefreshClock: ProfileRefreshClock = { + setTimeout(callback, delay) { + const timer = { id: ++nextTimerId, unref() {} } as unknown as ReturnType; + timers.set(timer, { due: now + delay, callback }); + return timer; + }, + clearTimeout(timer) { + timers.delete(timer); + }, + }; + const watchProfile: NonNullable = ((directory: string, callback: ProfileWatchRecord["callback"]) => { + const record: ProfileWatchRecord = { directory, callback, closed: false, closeCalls: 0 }; + watchers.push(record); + return { + on(event: string, listener: (error: Error) => void) { + if (event === "error") record.onError = listener; + return this; + }, + unref() {}, + close() { + record.closed = true; + record.closeCalls++; + }, + }; + }) as unknown as NonNullable; + const activeWatcher = (directory: string) => { + const record = watchers.find((candidate) => candidate.directory === directory && !candidate.closed); + assert.ok(record, `expected an active watcher for ${directory}`); + return record; + }; + const emit = (directory: string, filename: string | Buffer | null) => activeWatcher(directory).callback("change", filename); + const advance = (milliseconds: number) => { + now += milliseconds; + while (true) { + const next = [...timers.entries()] + .filter(([, timer]) => timer.due <= now) + .sort((left, right) => left[1].due - right[1].due)[0]; + if (!next) return; + timers.delete(next[0]); + next[1].callback(); + } + }; + return { watchers, profileRefreshClock, watchProfile, activeWatcher, emit, advance, pendingTimers: () => timers.size }; +} + function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise } { const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined }; let resolveOverlay: () => void; @@ -383,7 +441,12 @@ test("fullscreen Status digest follows effective pin source changes without rest const repoPin = repoProfileDeclarationPath(worktreeRoot); const localPin = localProfilePinPath(commonDir); const { pi, handlers } = fakePi(); - gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { resolveWorktree }); + const profileWatch = createProfileWatchHarness(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree, + watchProfile: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); const { ctx, ui } = fakeContext(); await fire(handlers, "session_start", ctx); const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; @@ -391,39 +454,40 @@ test("fullscreen Status digest follows effective pin source changes without rest const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); try { const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; - const waitForProfile = async (text: string) => { - for (let attempt = 0; attempt < 20; attempt++) { - if (rail.digest!().includes(text)) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.fail(`timed out waiting for profile ${text}`); + const refreshAfterEvent = (directory: string, filename: string, expectedDigest: string, rendered: RegExp) => { + const before = rail.digest!(); + profileWatch.emit(directory, filename); + profileWatch.advance(99); + assert.equal(rail.digest!(), before, "the Status snapshot stays unchanged before the debounce expires"); + profileWatch.advance(1); + assert.notEqual(rail.digest!(), expectedDigest, "the watcher event refreshes the Status digest after debounce"); + assert.match(rail.render(46).join("\n"), rendered); }; assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforePin = rail.digest!(); writeFileSync(repoPin, serializeProfilePin("other")); - await waitForProfile('"name":"other","source":"repo"'); + refreshAfterEvent(dirname(repoPin), basename(repoPin), beforePin, /Profile.*other \(repo\)/); assert.notEqual(rail.digest!(), beforePin, "creating a pin must invalidate the Status digest"); - assert.match(rail.render(46).join("\n"), /Profile.*other \(repo\)/); const replacement = join(root, "profile-replacement.json"); writeFileSync(replacement, serializeProfilePin("team")); renameSync(replacement, repoPin); - await waitForProfile('"name":"team","source":"repo"'); - assert.match(rail.render(46).join("\n"), /Profile.*team \(repo\)/); + const beforeReplacement = rail.digest!(); + refreshAfterEvent(dirname(repoPin), basename(repoPin), beforeReplacement, /Profile.*team \(repo\)/); const beforeLocal = rail.digest!(); writeFileSync(localPin, serializeProfilePin("team")); - await waitForProfile('"name":"team","source":"local"'); + refreshAfterEvent(dirname(localPin), basename(localPin), beforeLocal, /Profile.*team \(local\)/); assert.notEqual(rail.digest!(), beforeLocal, "same-profile local precedence must invalidate the Status digest"); - assert.match(rail.render(46).join("\n"), /Profile.*team \(local\)/); const beforeRepo = rail.digest!(); rmSync(localPin); - await waitForProfile('"name":"team","source":"repo"'); + refreshAfterEvent(dirname(localPin), basename(localPin), beforeRepo, /Profile.*team \(repo\)/); assert.notEqual(rail.digest!(), beforeRepo, "returning to the repository source must invalidate the Status digest"); const beforeRemoval = rail.digest!(); rmSync(repoPin); - await waitForProfile('"name":"team","source":"global"'); + refreshAfterEvent(dirname(repoPin), basename(repoPin), beforeRemoval, /Profile.*team/); assert.notEqual(rail.digest!(), beforeRemoval, "removing a pin must invalidate the Status digest"); assert.match(rail.render(46).join("\n"), /Profile.*team/); assert.doesNotMatch(rail.render(46).join("\n"), /\((local|repo)\)/); + assert.ok(profileWatch.watchers.length > 0, "the fullscreen Status factory installs the injected watchers"); } finally { component.dispose(); } @@ -482,8 +546,11 @@ test("fullscreen Status rebinds parent watchers when a profile store appears aft kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, }); const { pi, handlers } = fakePi(); + const profileWatch = createProfileWatchHarness(); gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + watchProfile: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, }); const { ctx, ui } = fakeContext(); await fire(handlers, "session_start", ctx); @@ -492,21 +559,22 @@ test("fullscreen Status rebinds parent watchers when a profile store appears aft const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); try { const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; - const waitForDigest = async (text: string) => { - for (let attempt = 0; attempt < 30; attempt++) { - if (rail.digest!().includes(text)) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.fail(`timed out waiting for profile ${text}`); - }; assert.doesNotMatch(rail.render(46).join("\n"), /Profile/); + const oldAncestor = profileWatch.activeWatcher(join(root, "created")); mkdirSync(configHome, { recursive: true }); writeFileSync(profilesFilePath(configHome), profileFile("team")); - await waitForDigest('"name":"team"'); + profileWatch.emit(join(root, "created"), basename(configHome)); + profileWatch.advance(99); + assert.doesNotMatch(rail.render(46).join("\n"), /Profile/, "the late store is not visible before the debounce expires"); + profileWatch.advance(1); + assert.match(rail.render(46).join("\n"), /Profile.*team/); + assert.equal(oldAncestor.closed, true, "the parent watcher is retired after the store directory appears"); + profileWatch.activeWatcher(configHome); const replacement = join(root, "profile-replacement.json"); writeFileSync(replacement, profileFile("other")); renameSync(replacement, profilesFilePath(configHome)); - await waitForDigest('"name":"other"'); + profileWatch.emit(configHome, basename(profilesFilePath(configHome))); + profileWatch.advance(100); assert.match(rail.render(46).join("\n"), /Profile.*other/); } finally { component.dispose(); @@ -603,6 +671,7 @@ test("fullscreen Status disposes profile watchers and pending refreshes with the })); let reads = 0; const repoPin = repoProfileDeclarationPath(worktreeRoot); + const profileWatch = createProfileWatchHarness(); const { pi, handlers } = fakePi(); gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { resolveWorktree: () => ({ root: worktreeRoot, commonDir }), @@ -610,6 +679,8 @@ test("fullscreen Status disposes profile watchers and pending refreshes with the reads++; return { name: "team", source: "global" }; }, + watchProfile: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, }); const { ctx, ui } = fakeContext(); await fire(handlers, "session_start", ctx); @@ -617,10 +688,13 @@ test("fullscreen Status disposes profile watchers and pending refreshes with the const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); assert.equal(reads, 1); + profileWatch.emit(dirname(repoPin), basename(repoPin)); + assert.equal(profileWatch.pendingTimers(), 1, "a relevant event has a pending debounced refresh"); component.dispose(); component.dispose(); + assert.ok(profileWatch.watchers.every((watcher) => watcher.closed && watcher.closeCalls === 1), "footer disposal closes each watcher exactly once"); writeFileSync(repoPin, serializeProfilePin("other")); - await new Promise((resolve) => setTimeout(resolve, 180)); + profileWatch.advance(100); assert.equal(reads, 1, "disposed Status components must close watchers and pending refresh timers"); }); @@ -638,11 +712,7 @@ test("profile watchers filter filenames, rebind ancestors, and cache worktree id let cwd = root; const resolutions: string[] = []; const reads: string[] = []; - const records: Array<{ - directory: string; - callback(eventType: string, filename: string | Buffer | null): void; - closed: boolean; - }> = []; + const profileWatch = createProfileWatchHarness(); let profile: ShellProfileState | undefined = { name: "team", source: "global" }; let changes = 0; const snapshot = createEffectiveProfileSnapshot({ @@ -656,61 +726,70 @@ test("profile watchers filter filenames, rebind ancestors, and cache worktree id }, read: (path) => { reads.push(path!); return profile; }, onChange: () => { changes++; }, - watch: ((directory: string, callback: (eventType: string, filename: string | Buffer | null) => void) => { - const record = { directory, callback, closed: false }; - records.push(record); - return { on() { return this; }, unref() {}, close() { record.closed = true; } }; - }) as never, + watch: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, }); - const activeWatcher = (directory: string) => { - const record = records.find((candidate) => candidate.directory === directory && !candidate.closed); - assert.ok(record, `expected an active watcher for ${directory}`); - return record; - }; - const emit = (directory: string, filename: string | Buffer | null) => activeWatcher(directory).callback("change", filename); - const settle = () => new Promise((resolve) => setTimeout(resolve, 130)); + const emit = profileWatch.emit; try { assert.deepEqual(reads, [root]); assert.deepEqual(resolutions, [root], "the worktree identity is resolved once for the initial cwd"); for (const filename of ["index", "HEAD", ".git", "profile.json"]) emit(root, filename); emit(localWatchDirectory, "index"); - await settle(); + profileWatch.advance(100); assert.equal(reads.length, 1, "named unrelated files, including a matching basename in another parent, do not refresh"); assert.deepEqual(resolutions, [root], "unrelated watcher events do not resolve Git"); mkdirSync(configHome, { recursive: true }); emit(root, "config"); - await settle(); + profileWatch.advance(99); + assert.equal(reads.length, 1, "a relevant event remains debounced until the full interval"); + profileWatch.advance(1); assert.equal(reads.length, 2, "a shared watch directory retains the global store's next path component"); - assert.ok(activeWatcher(configHome), "the global watcher rebinds after its ancestor is created"); + profileWatch.activeWatcher(configHome); mkdirSync(join(root, ".pi", "gentle-ai"), { recursive: true }); profile = { name: "other", source: "repo" }; emit(root, Buffer.from(".pi")); - await settle(); + profileWatch.advance(100); assert.equal(reads.length, 3, "creating a watched ancestor refreshes the profile"); assert.deepEqual(snapshot.get(), { name: "other", source: "repo" }, "the refreshed snapshot preserves the effective pin source"); assert.equal(changes, 1); - assert.ok(activeWatcher(join(root, ".pi", "gentle-ai")), "the watcher rebinds to the nearest newly created profile parent"); + profileWatch.activeWatcher(join(root, ".pi", "gentle-ai")); assert.deepEqual(resolutions, [root], "refreshing the same cwd reuses its worktree identity"); emit(configHome, "profile.json"); - await settle(); + profileWatch.advance(100); assert.equal(reads.length, 3, "the same filename in a different parent is unrelated"); - emit(join(root, ".pi", "gentle-ai"), "profile.json"); - await settle(); - assert.equal(reads.length, 4, "a watched target filename refreshes the profile"); + const repoProfileDirectory = join(root, ".pi", "gentle-ai"); + emit(repoProfileDirectory, "profile.json"); + emit(repoProfileDirectory, "profile.json"); + profileWatch.advance(99); + assert.equal(reads.length, 3, "repeated relevant events remain grouped before the debounce expires"); + profileWatch.advance(1); + assert.equal(reads.length, 4, "a watched target filename refreshes the profile once"); assert.equal(changes, 1, "an unchanged resolved state does not notify the shell"); + + emit(repoProfileDirectory, "profile.json"); + profileWatch.advance(60); + emit(repoProfileDirectory, "profile.json"); + profileWatch.advance(40); + assert.equal(reads.length, 4, "a later relevant event resets the deadline, so there is no refresh at t=100"); + profileWatch.advance(60); + assert.equal(reads.length, 5, "the coalesced refresh runs at t=160 from the first event"); + assert.equal(changes, 1, "the reset-deadline refresh preserves unchanged-state notification behavior"); + emit(localWatchDirectory, null); - await settle(); - assert.equal(reads.length, 5, "an unknown filename preserves conservative refresh behavior"); + profileWatch.advance(99); + assert.equal(reads.length, 5, "unknown-filename refreshes remain debounced"); + profileWatch.advance(1); + assert.equal(reads.length, 6, "an unknown filename preserves conservative refresh behavior"); cwd = nextRoot; snapshot.refresh(); - assert.deepEqual(reads, [root, root, root, root, root, nextRoot]); + assert.deepEqual(reads, [root, root, root, root, root, root, nextRoot]); assert.deepEqual(resolutions, [root, nextRoot], "a cwd transition resolves and caches the new worktree identity"); - assert.ok(records.some((record) => record.directory === nextRoot && !record.closed), "watchers rebind to the new worktree"); - assert.ok(records.some((record) => record.directory === join(root, ".pi", "gentle-ai") && record.closed), "watchers from the old worktree are closed"); + assert.ok(profileWatch.watchers.some((record) => record.directory === nextRoot && !record.closed), "watchers rebind to the new worktree"); + assert.ok(profileWatch.watchers.some((record) => record.directory === join(root, ".pi", "gentle-ai") && record.closed), "watchers from the old worktree are closed"); } finally { snapshot.dispose(); } @@ -723,24 +802,24 @@ test("profile watchers keep the global store observable outside Git", async (t) mkdirSync(configHome, { recursive: true }); let reads = 0; let resolutions = 0; - let callback: ((eventType: string, filename: string | Buffer | null) => void) | undefined; + const profileWatch = createProfileWatchHarness(); const snapshot = createEffectiveProfileSnapshot({ cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, resolveWorktree: () => { resolutions++; throw new Error("not a Git worktree"); }, read: () => { reads++; return { name: "team", source: "global" }; }, onChange() {}, - watch: ((directory: string, onEvent: (eventType: string, filename: string | Buffer | null) => void) => { - assert.equal(directory, configHome); - callback = onEvent; - return { on() { return this; }, unref() {}, close() {} }; - }) as never, + watch: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, }); try { assert.equal(reads, 1); assert.equal(resolutions, 1); - callback!("change", "profiles.json"); - await new Promise((resolve) => setTimeout(resolve, 130)); + assert.equal(profileWatch.watchers[0]!.directory, configHome); + profileWatch.emit(configHome, basename(profilesFilePath(configHome))); + profileWatch.advance(99); + assert.equal(reads, 1, "global profile changes remain debounced"); + profileWatch.advance(1); assert.equal(reads, 2, "global profile changes remain observable outside Git"); assert.equal(resolutions, 1, "the failed worktree lookup is cached for this cwd"); } finally { @@ -751,23 +830,28 @@ test("profile watchers keep the global store observable outside Git", async (t) test("profile watcher errors close the failed watcher without retrying it", async (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-error-")); t.after(() => rmSync(root, { recursive: true, force: true })); - const errors: Array<(error: Error) => void> = []; - let closed = 0; - let created = 0; + const profileWatch = createProfileWatchHarness(); + let reads = 0; const snapshot = createEffectiveProfileSnapshot({ - cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: root }, resolveWorktree: () => ({ root, commonDir: root }), onChange() {}, read: () => undefined, - watch: () => { - created++; - return { on(event: string, callback: (error: Error) => void) { if (event === "error") errors.push(callback); }, unref() {}, close() { closed++; } } as never; - }, + cwd: () => root, + env: { GENTLE_PI_CONFIG_HOME: root }, + resolveWorktree: () => ({ root, commonDir: root }), + onChange() {}, + read: () => { reads++; return undefined; }, + watch: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, }); - assert.doesNotThrow(() => errors[0]!(new Error("watched directory vanished"))); - assert.equal(closed, 1, "the failed watcher is closed immediately"); - await new Promise((resolve) => setTimeout(resolve, 130)); - assert.equal(created, 1, "a persistent watcher failure does not create a retry loop"); + const failedWatcher = profileWatch.watchers[0]!; + assert.doesNotThrow(() => failedWatcher.onError!(new Error("watched directory vanished"))); + assert.equal(failedWatcher.closeCalls, 1, "the failed watcher is closed immediately"); + assert.equal(profileWatch.pendingTimers(), 1, "watch errors schedule one debounced refresh"); + profileWatch.advance(100); + assert.equal(reads, 2, "the scheduled error refresh still runs"); + assert.equal(profileWatch.watchers.length, 1, "a persistent watcher failure does not create a retry loop"); snapshot.dispose(); - assert.equal(closed, 1, "disposal does not close an already failed watcher again"); - assert.doesNotThrow(() => errors[0]!(new Error("late error after disposal"))); + assert.equal(failedWatcher.closeCalls, 1, "disposal does not close an already failed watcher again"); + assert.doesNotThrow(() => failedWatcher.onError!(new Error("late error after disposal"))); + assert.equal(profileWatch.pendingTimers(), 0, "late errors after disposal schedule no work"); }); test("profile reader follows store changes and rejects missing or invalid active markers", (t) => { From fb9ad2a21d3d72cf3f5728d1750cff5538bbd0cf Mon Sep 17 00:00:00 2001 From: lCardenas Date: Wed, 23 Sep 2026 04:27:15 -0400 Subject: [PATCH 11/12] docs(shell): distinguish invalid pins from profile store failures Document layer-by-layer pin fallback and distinguish it from an unavailable global profile store. Record focused and full-suite verification for the review follow-up. --- docs/gentle-shell.md | 2 +- docs/readme-reference.md | 2 +- odd/tasks/effective-profile-status.md | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 0c4cd7128..6d5e984f9 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -29,7 +29,7 @@ At 140 columns or wider, fullscreen splits into a live header row over a transcr ✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ``` -The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local pin wins and appends `(local)`, a valid repository declaration wins and appends `(repo)`, and the globally active profile has no suffix. Invalid or stale pins fall back globally. The shell resolves this state initially, on known in-process invalidations, and through debounced parent-directory watchers, so atomic profile and pin replacements appear without per-frame filesystem or Git resolution. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. +The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local pin wins and appends `(local)`, a valid repository declaration wins and appends `(repo)`, and the globally active profile has no suffix. Invalid, stale, or unreadable pin files are skipped in precedence order: a valid repository declaration may still win after an unusable local pin, and the global profile is used only when neither pin resolves. The shell resolves this state initially, on known in-process invalidations, and through debounced parent-directory watchers, so atomic profile and pin replacements appear without per-frame filesystem or Git resolution. A missing, unreadable, or invalid global profile store leaves the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. - The header is one row, always visible, and carries only what changes every frame: session identity on the left (brand, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). It never shows the working/thinking state or extension statuses — those stay in the prompt title and the compact bar. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand survives, and below that the header renders nothing. - The right rail scrolls **Status → Changes → TODO**, each an event-driven card that only repaints when its own state changes: a model switch or a cost tick refreshes the header, not the rail. Every card (sidebar or not) paints the same rose frame — the rounded border in the theme's plain border role, the title in the accent role — the look every `CARD_TONE.INFO` card in Gentle Shell uses (warning/error/success cards keep their own tone colors). diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 7e6b30c3c..0f56cad56 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -950,7 +950,7 @@ For a given working directory the winner is the local pin, then the repository d In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The globally active profile and writes made through `/gentle:models` do not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. `enter` follows the same boundary: inside a pinned repository it re-pins that repository instead of writing the global routing, so the panel's main key can never move another repository's routing. The panel states which layer won, names the file that holds it, and marks the profile with `(pinned)`. -The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(local)` when a valid clone-local pin wins, `(repo)` when a valid repository declaration wins, and no suffix for the global active profile. Invalid or stale pins therefore fall back globally. The shell snapshots this state initially and refreshes it on known in-process invalidations or debounced parent-directory watcher events, including atomic replacements; repeated Status/header rendering performs no profile filesystem or Git resolution. The compact bottom bar remains unchanged and does not show this field. +The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(local)` when a valid clone-local pin wins, `(repo)` when a valid repository declaration wins, and no suffix for the global active profile. Invalid, stale, or unreadable pins are skipped in precedence order; a valid repository declaration can win after an unusable local pin, and the global profile is used only when neither resolves. An unavailable or invalid global profile store leaves the field hidden. The shell snapshots this state initially and refreshes it on known in-process invalidations or debounced parent-directory watcher events, including atomic replacements; repeated Status/header rendering performs no profile filesystem or Git resolution. The compact bottom bar remains unchanged and does not show this field. To share a pin, commit the repository declaration. When `.pi/` is ignored, Git cannot re-include a nested file until its parent directories are visible. The panel therefore prints these ordered root `.gitignore` rules, which keep unrelated `.pi` content ignored while making only the declaration committable: diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md index cb479e37a..8b625dc11 100644 --- a/odd/tasks/effective-profile-status.md +++ b/odd/tasks/effective-profile-status.md @@ -42,7 +42,7 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - [x] T7 — Integrate updated `main` at `b6188bef`, resolve the test import conflict while preserving both subscription-usage and effective-profile coverage, verify the merged candidate, push the feature branch, then integrate it into `downstream/main`. - [x] T8 — Filter irrelevant profile watcher events and reuse the resolved worktree identity; preserve null-filename, ancestor creation, atomic replacement, and non-Git global behavior. Route: delegated writer (production and regression tests); checks: observed RED/GREEN focused tests, resolver/read counters, typecheck, diff check, work-unit commit. - [x] T9 — Remove real OS watcher/timer dependence from both fullscreen Status refresh regressions while preserving integration assertions and rebind coverage. Route: delegated writer (integration tests and narrow test seam); checks: observed RED/GREEN, focused repeat, suite, work-unit commit. -- [ ] T10 — Clarify global-store and invalid/stale-pin fallback in the fullscreen documentation, verify consistency with reference docs, and commit the documentation work unit. Route: inline direct unless additional non-trivial files become necessary; checks: readback, markdown/diff check, work-unit commit. +- [x] T10 — Clarify global-store and invalid/stale-pin fallback in the fullscreen documentation, verify consistency with reference docs, and commit the documentation work unit. Route: inline direct unless additional non-trivial files become necessary; checks: readback, markdown/diff check, work-unit commit. ## Acceptance criteria @@ -65,9 +65,9 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g ## Follow-up progress -- T8 complete in local commit `729bfa62`; T9 implemented and independently verified, recording its work-unit commit. T10 pending. The two new T8 tests retain 130 ms wall-clock waits, to be removed with the T9 timer refactor. +- T8 complete in local commit `729bfa62`; T9 complete in local commit `e4bcd254`; T10 complete in the local documentation work unit. The four profile-watcher refresh tests now use injected events and a controlled clock instead of wall-clock debounce waits. - Current branch is ahead of `origin/fix/effective-profile-status` because of an earlier local main merge; do not silently reset, rebase, push, or use the remote PR head as the checked-out source. -- Next: record the deterministic test work unit, clarify the fallback documentation, then run the complete suite. +- Next: report the macOS-specific verification limit without claiming it passed; no push was performed. ## Follow-up evidence @@ -82,6 +82,9 @@ Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that g - T9 writer and independent verifier: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 148 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 baseline diagnostics, no regressions; `git diff --check` passed. A new test-helper type error was corrected before the final run. - T9 follow-up verifier: later relevant event at t=60 resets the debounce from t=100 to t=160; focused suite 148 passed, diff check passed. Throwing watcher `close()` remains a low-priority pre-existing coverage gap outside the three review observations. - T9 parent spot check: `git diff --check` passed. RDD-off assessment reported high risk; separate verification found no production defect. +- T9 commit: `e4bcd254` (`test(shell): control profile watcher events and debounce`), local only. +- T10 docs: `docs/gentle-shell.md` and `docs/readme-reference.md` now state that an unusable local pin can reveal a valid repository declaration, and an unavailable/invalid global store hides the field. The local ignored work-item index labels its earlier draft as historical and updates its next step. +- T10 independent full verification: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 178 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 recorded diagnostics, no regressions, 4 improved pairs; `node --experimental-strip-types --test tests/*.test.ts` — 3355 passed, 0 failed, 38 skipped; `node scripts/check-provider-contract.mjs` — passed contract 1.2.0; `node --experimental-strip-types tests/runtime-harness.mjs` — exit 0; `git diff --check` — passed. Native assessment for docs was unassessable, so the separate verifier was required and completed. macOS-specific execution remains unavailable in this Linux checkout. ## Evidence From 5c27fd76b5796e7895ded2d80f6bb82aa878368f Mon Sep 17 00:00:00 2001 From: lCardenas Date: Wed, 23 Sep 2026 08:47:35 -0400 Subject: [PATCH 12/12] fix(shell): refresh profiles from atomic temp watch events Recognize watched profile and pin temporary filenames when fs.watch omits the final rename event. Retry within a bounded window and cover event coalescing, delayed replacement, and cancellation with deterministic tests. --- extensions/gentle-shell.ts | 56 +++- ...profile-sidebar-atomic-watch-regression.md | 41 +++ tests/gentle-shell.test.ts | 243 ++++++++++++++++++ 3 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 odd/tasks/profile-sidebar-atomic-watch-regression.md diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index c129e6d9c..acf29c7ef 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -133,6 +133,17 @@ export function createActiveProfileReader( } const PROFILE_REFRESH_DEBOUNCE_MS = 100; +const ATOMIC_REFRESH_RETRY_DELAYS_MS = [250, 500, 1000] as const; +const ATOMIC_SIBLING_SUFFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i; + +function isAtomicProfileSibling(filename: string, names: Set | undefined): boolean { + if (!names) return false; + for (const name of names) { + if (name !== "profiles.json" && name !== "profile-pin.json" && name !== "profile.json") continue; + if (filename.startsWith(`.${name}.`) && ATOMIC_SIBLING_SUFFIX.test(filename.slice(name.length + 2))) return true; + } + return false; +} const defaultProfileRefreshClock: ProfileRefreshClock = { setTimeout: (callback, delay) => setTimeout(callback, delay), @@ -215,6 +226,10 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot let worktreeIdentity: ReturnType | undefined; let identityResolved = false; let refreshTimer: ReturnType | undefined; + let atomicRetryTimer: ReturnType | undefined; + let atomicRetryIndex = 0; + let atomicRetryFilename: string | undefined; + let atomicRefreshPending = false; const clock = options.profileRefreshClock ?? defaultProfileRefreshClock; let watchers: FSWatcher[] = []; let watchedDirectories: string[] = []; @@ -257,7 +272,21 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot try { const watcher = (options.watch ?? watch)(directory, (_eventType, filename) => { const names = targets.get(directory); - if (filename === null || filename === undefined || names?.has(filename.toString())) scheduleRefresh(); + const named = filename?.toString(); + if (named === undefined || names?.has(named)) scheduleRefresh(); + else if (isAtomicProfileSibling(named, names)) { + // A temp event may precede rename with no subsequent target event. + // Coalesce bursts and sample a finite window after the first read. + if (named !== atomicRetryFilename) { + // Each writer uses a fresh UUID; duplicate events for one temp file + // coalesce, while a new write receives its own bounded retry window. + atomicRetryFilename = named; + atomicRetryIndex = 0; + if (atomicRetryTimer) clock.clearTimeout(atomicRetryTimer); + atomicRetryTimer = undefined; + } + scheduleRefresh(true); + } }); watcher.on("error", () => { if (disposed) return; @@ -287,16 +316,35 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot // parent instead of remaining stranded on the old ancestor. installWatchers(cwd); if (sameProfileState(profile, next)) return false; + if (atomicRetryTimer) clock.clearTimeout(atomicRetryTimer); + atomicRetryTimer = undefined; + atomicRetryIndex = 0; + atomicRefreshPending = false; profile = next; options.onChange(); return true; }; - const scheduleRefresh = () => { + const scheduleAtomicRetry = () => { + if (disposed || !atomicRefreshPending || atomicRetryTimer) return; + if (atomicRetryIndex >= ATOMIC_REFRESH_RETRY_DELAYS_MS.length) { + atomicRefreshPending = false; + return; + } + const delay = ATOMIC_REFRESH_RETRY_DELAYS_MS[atomicRetryIndex++]!; + atomicRetryTimer = clock.setTimeout(() => { + atomicRetryTimer = undefined; + if (!refresh()) scheduleAtomicRetry(); + }, delay); + atomicRetryTimer.unref(); + }; + const scheduleRefresh = (atomic = false) => { if (disposed) return; + if (atomic) atomicRefreshPending = true; if (refreshTimer) clock.clearTimeout(refreshTimer); refreshTimer = clock.setTimeout(() => { refreshTimer = undefined; - refresh(); + const changed = refresh(); + if (!changed && atomicRefreshPending) scheduleAtomicRetry(); }, PROFILE_REFRESH_DEBOUNCE_MS); refreshTimer.unref(); }; @@ -309,7 +357,9 @@ export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshot if (disposed) return; disposed = true; if (refreshTimer) clock.clearTimeout(refreshTimer); + if (atomicRetryTimer) clock.clearTimeout(atomicRetryTimer); refreshTimer = undefined; + atomicRetryTimer = undefined; closeWatchers(); }, }; diff --git a/odd/tasks/profile-sidebar-atomic-watch-regression.md b/odd/tasks/profile-sidebar-atomic-watch-regression.md new file mode 100644 index 000000000..999087264 --- /dev/null +++ b/odd/tasks/profile-sidebar-atomic-watch-regression.md @@ -0,0 +1,41 @@ +# Profile sidebar atomic-watch regression + +## Objective + +Restore live fullscreen profile updates when the platform reports only the temporary filename from an atomic profile or pin replacement and omits the final target filename. + +## Evidence and boundaries + +- Live Pi trace on 2026-09-23: pin replacement from `MediumWork (local)` to `HeavyWork (local)` emitted `rename` for `.profile-pin.json..tmp`, rejected by `extensions/gentle-shell.ts`; no target-basename event or snapshot refresh followed. A fresh disk read returned `HeavyWork (local)`, while the sidebar remained `MediumWork (local)` until `/reload`. +- An earlier event naming `profile-pin.json` caused the expected snapshot change and fullscreen render. The existing deterministic tests and temporary real-watcher fixture observed target basenames, so they missed this event-only case. +- Previous feature history: `odd/tasks/effective-profile-status.md`, especially review follow-up T8–T10. +- Current branch: `fix/effective-profile-status` at `fb9ad2a2`; starting working tree clean. No push, PR, real profile-store mutation by the agent, or live-process instrumentation is in scope. + +## Scope and approach + +- Preserve filtering of unrelated watched-directory events; recognize only the atomic sibling temporary filenames produced for watched `profiles.json`, `profile-pin.json`, or `profile.json` targets where applicable. +- Schedule a bounded refresh after the atomic replacement is complete; cover the case where the only event arrives before the rename and no final-target event follows. Avoid repeated Git resolution or unbounded polling. +- Keep existing null-filename, direct-target, late-directory, source-transition, and watcher-disposal behavior. +- Use one work unit for implementation and its regression tests. Forecast: roughly 100–220 authored changed lines, under the ~400-line review planning heuristic; delivery strategy `ask-on-risk`. The task has two non-trivial edit surfaces, so delegate one writer. +- TDD: enabled by the user-approved plan for this regression (write a deterministic failing reproduction first, then implement); focused runner `node --experimental-strip-types --test tests/gentle-shell.test.ts`. Full runner: `node --experimental-strip-types --test tests/*.test.ts`. Typecheck: `node scripts/check-types.mjs`. RDD switch: off, confirmed by `gentle-ai review mode status` on this branch. +- Delivery: the user explicitly authorized one local work-unit commit for this fix, tests, and task record; do not push or open a PR. + +## Tasks + +- [x] T1 — Add deterministic temp-only and pre-rename-event regressions, implement a target-scoped bounded refresh, and pass focused tests, typecheck, and diff check. Route: delegated writer for `extensions/gentle-shell.ts` and `tests/gentle-shell.test.ts`; final RED/GREEN also covers a second distinct-UUID write near retry exhaustion. Included with this task record in the local work-unit commit; SHA recorded in the Engram mirror. +- [x] T2 — Run the complete repository test suite and validate the updated fullscreen sidebar in a fresh `/reload` with a user-initiated profile change. Route: independent delegated command verification and parent-coordinated live check; user confirmed the sidebar now follows profile changes without another reload. + +## Acceptance criteria + +- An atomic pin or global-profile write updates the effective profile and fullscreen sidebar even if `fs.watch` reports only the matching temporary basename. +- An event before the rename still produces an update after the completed replacement, within a bounded retry policy. +- Unrelated temporary files and names do not refresh the profile; direct target and null events continue to work. +- No profile resolution is added to sidebar digest/render; timers and watchers are disposed safely; no unbounded retry or unrelated filesystem activity. +- Focused tests, typecheck, full suite, and `git diff --check` pass; live Pi verification is reported distinctly from simulated tests. + +## Progress + +- T1 complete: deterministic regressions cover temp-only pin/global writes, delayed replacement, irrelevant temp names, null/direct target interleavings before debounce and during active retry, bounded retry reads, exhaustion, change cancellation, disposal, and a second distinct-UUID write near exhaustion. Each distinct temp filename receives a finite retry window; same-name bursts coalesce with one cancellable timer. A rename completing after its bounded window still needs another event or explicit refresh. +- T1 RED/GREEN sequence: delayed rename 114 passed/1 failed → 115 passed; stale retry after direct event 115 passed/1 failed → 116 passed; pre-debounce direct/null 116 passed/2 failed → 118 passed; duplicate active retry 118 passed/2 failed → 120 passed; second distinct-UUID write 120 passed/1 failed → final 121 passed, 0 failed. `node scripts/check-types.mjs` reported 195 recorded diagnostics with no regressions; `git diff --check` passed. The user subsequently approved a local work-unit commit without push. +- T2 final independent verification: `node --experimental-strip-types --test tests/*.test.ts` — 3362 passed, 0 failed, 38 skipped; `node scripts/check-types.mjs` — 195 recorded diagnostics, no regressions (4 improved pairs); `git diff --check` passed. The independent verifier confirmed all earlier findings fixed and found no other material defect in scoped code. Parent spot check passed. Source/test authored diff: 299 lines (additions plus deletions), below the ~400-line delivery review heuristic. +- T2 live Pi check: after `/reload`, the user changed the profile and confirmed the fullscreen sidebar now updates correctly without another reload. This is user-observed live behavior, distinct from the deterministic automated tests. macOS-specific execution was not available; the 38 suite skips remain. The user approved a local commit of this work unit; its exact SHA is recorded in the Engram recovery mirror. No push is authorized. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index c52bae6b8..7569dd2fa 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -827,6 +827,249 @@ test("profile watchers keep the global store observable outside Git", async (t) } }); +test("atomic sibling events refresh pin and global profiles even when rename completes late", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-atomic-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + const commonDir = join(root, "clone"); + mkdirSync(configHome); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + const harness = createProfileWatchHarness(); + let disk: ShellProfileState = { name: "old", source: "local" }; + let reads = 0; + let resolutions = 0; + let changes = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; return { root, commonDir }; }, + read: () => { reads++; return disk; }, + onChange: () => { changes++; }, + watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + const localDir = join(commonDir, "gentle-ai"); + const uuid = "12345678-1234-1234-1234-123456789abc"; + try { + for (const name of [".unrelated." + uuid + ".tmp", ".profile-pin.json.bad.tmp", ".profiles.json." + uuid + ".tmp"]) harness.emit(localDir, name); + assert.equal(harness.pendingTimers(), 0, "unrelated temp names do not schedule reads"); + harness.emit(localDir, `.profile-pin.json.${uuid}.tmp`); + harness.advance(100); + assert.equal(snapshot.get()?.name, "old", "event can precede replacement"); + disk = { name: "new pin", source: "local" }; + harness.advance(1000); + assert.deepEqual(snapshot.get(), disk, "bounded follow-up observes a late rename"); + assert.equal(harness.pendingTimers(), 0); + harness.emit(configHome, `.profiles.json.${uuid}.tmp`); + disk = { name: "new global", source: "global" }; + harness.advance(1000); + assert.deepEqual(snapshot.get(), disk); + assert.equal(changes, 2); + assert.equal(resolutions, 1); + assert.equal(harness.pendingTimers(), 0); + assert.ok(reads <= 7, "atomic retries remain bounded"); + harness.emit(localDir, `.profile-pin.json.${uuid}.tmp`); + assert.ok(harness.pendingTimers() > 0); + snapshot.dispose(); + assert.equal(harness.pendingTimers(), 0, "disposal cancels delayed work"); + } finally { snapshot.dispose(); } +}); + +test("a direct or null event that observes the replacement cancels stale atomic retries", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-atomic-target-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + let resolutions = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not Git"); }, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + try { + for (const event of ["profiles.json", null]) { + harness.emit(configHome, ".profiles.json.12345678-1234-1234-1234-123456789abc.tmp"); + harness.advance(100); + assert.equal(harness.pendingTimers(), 1, "unchanged first read schedules one retry"); + disk = event === null ? "after null" : "after target"; + harness.emit(configHome, event); + harness.advance(100); + assert.equal(snapshot.get()?.name, disk); + assert.equal(harness.pendingTimers(), 0, "observed change cancels stale retry"); + const settledReads = reads; + harness.advance(2000); + assert.equal(reads, settledReads, "no stale retry reads after the direct refresh"); + } + assert.equal(resolutions, 1); + } finally { snapshot.dispose(); } +}); + +for (const interveningEvent of ["profiles.json", null] as const) { + test(`atomic retry survives a pre-debounce ${interveningEvent === null ? "null" : "direct-target"} event`, (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-atomic-coalesced-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + let resolutions = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not Git"); }, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + try { + harness.emit(configHome, ".profiles.json.12345678-1234-1234-1234-123456789abc.tmp"); + harness.advance(50); + harness.emit(configHome, interveningEvent); + harness.advance(100); + assert.equal(snapshot.get()?.name, "old", "the first read precedes the delayed rename"); + assert.equal(harness.pendingTimers(), 1, "coalescing preserves the atomic retry"); + disk = "new after rename"; + harness.advance(250); + assert.equal(snapshot.get()?.name, disk, "the retry observes the replacement without another event"); + assert.equal(harness.pendingTimers(), 0); + const settledReads = reads; + harness.advance(2000); + assert.equal(reads, settledReads, "the completed change leaves no retry sequence"); + assert.equal(resolutions, 1, "profile refreshes reuse cached worktree identity"); + } finally { snapshot.dispose(); } + }); +} + +for (const interveningEvent of ["profiles.json", null] as const) { + test(`atomic retry timer stays singular through ${interveningEvent === null ? "null" : "direct-target"} refreshes`, (t) => { + const uuid = "12345678-1234-1234-1234-123456789abc"; + const createScenario = () => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-retry-timer-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + let resolutions = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not Git"); }, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + return { + harness, snapshot, + setDisk: (name: string) => { disk = name; }, + reads: () => reads, + resolutions: () => resolutions, + }; + }; + const startRetryWithUnchangedEvent = (scenario: ReturnType) => { + scenario.harness.emit(scenario.harness.watchers[0]!.directory, `.profiles.json.${uuid}.tmp`); + scenario.harness.advance(100); + scenario.harness.emit(scenario.harness.watchers[0]!.directory, interveningEvent); + scenario.harness.advance(100); + }; + + const changed = createScenario(); + startRetryWithUnchangedEvent(changed); + assert.equal(changed.harness.pendingTimers(), 1, "an unchanged event must not overwrite the active retry handle"); + changed.setDisk("changed"); + assert.equal(changed.snapshot.refresh(), true, "an explicit refresh observes the later change"); + assert.equal(changed.harness.pendingTimers(), 0, "observing a change cancels the sole retry timer"); + const changedReads = changed.reads(); + changed.harness.advance(2000); + assert.equal(changed.reads(), changedReads, "no stale timer reads after the change"); + assert.equal(changed.resolutions(), 1); + changed.snapshot.dispose(); + + const disposed = createScenario(); + startRetryWithUnchangedEvent(disposed); + assert.equal(disposed.harness.pendingTimers(), 1); + disposed.snapshot.dispose(); + assert.equal(disposed.harness.pendingTimers(), 0, "disposal cancels every outstanding retry"); + const disposedReads = disposed.reads(); + disposed.harness.advance(2000); + assert.equal(disposed.reads(), disposedReads, "disposed snapshots never read from orphaned timers"); + + const exhausted = createScenario(); + exhausted.harness.emit(exhausted.harness.watchers[0]!.directory, `.profiles.json.${uuid}.tmp`); + exhausted.harness.advance(100); + exhausted.harness.emit(exhausted.harness.watchers[0]!.directory, interveningEvent); + exhausted.harness.advance(100); + assert.equal(exhausted.harness.pendingTimers(), 1); + for (const delay of [250, 500]) { + exhausted.harness.advance(delay); + assert.equal(exhausted.harness.pendingTimers(), 1, "each retry schedules only its single successor"); + } + exhausted.harness.advance(1000); + assert.equal(exhausted.harness.pendingTimers(), 0, "retry exhaustion leaves no timer handle or callback"); + assert.ok(exhausted.reads() <= 6, "coalesced retries remain bounded"); + exhausted.setDisk("after exhaustion"); + exhausted.harness.emit(exhausted.harness.watchers[0]!.directory, "profiles.json"); + exhausted.harness.advance(100); + assert.equal(exhausted.snapshot.get()?.name, "after exhaustion"); + const exhaustedReads = exhausted.reads(); + exhausted.harness.advance(2000); + assert.equal(exhausted.reads(), exhaustedReads, "exhaustion does not leave a duplicate retry"); + exhausted.snapshot.dispose(); + }); +} + +test("a new atomic temp filename gets a fresh bounded retry window near exhaustion", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-new-atomic-write-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => undefined, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + const firstTemp = ".profiles.json.11111111-1111-1111-1111-111111111111.tmp"; + const secondTemp = ".profiles.json.22222222-2222-2222-2222-222222222222.tmp"; + try { + harness.emit(configHome, firstTemp); + harness.emit(configHome, firstTemp); + assert.equal(harness.pendingTimers(), 1, "same-write events coalesce into one debounce"); + harness.advance(100); + harness.advance(250); + harness.advance(500); + harness.advance(950); + assert.equal(harness.pendingTimers(), 1, "the first write has one final retry pending at t=1800"); + + harness.emit(configHome, secondTemp); + assert.equal(harness.pendingTimers(), 1, "the new write replaces the old retry with one debounce"); + harness.advance(100); + assert.equal(harness.pendingTimers(), 1, "the second write starts a fresh retry sequence"); + disk = "second write completed"; + harness.advance(250); + assert.equal(snapshot.get()?.name, disk, "the delayed second rename is observed without a final-name event"); + assert.equal(harness.pendingTimers(), 0, "observed change cancels the fresh sequence"); + const settledReads = reads; + harness.advance(2000); + assert.equal(reads, settledReads, "no retries remain after the profile changes"); + + harness.emit(configHome, ".profiles.json.33333333-3333-3333-3333-333333333333.tmp"); + harness.advance(100); + harness.emit(configHome, null); + harness.advance(100); + assert.equal(harness.pendingTimers(), 1, "a null event does not duplicate the active retry"); + snapshot.dispose(); + assert.equal(harness.pendingTimers(), 0, "disposal cancels the active retry after a null event"); + const disposedReads = reads; + harness.advance(2000); + assert.equal(reads, disposedReads, "no callback reads after disposal"); + assert.ok(reads <= 8, "the separate writes retain finite retry budgets"); + } finally { snapshot.dispose(); } +}); + test("profile watcher errors close the failed watcher without retrying it", async (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-error-")); t.after(() => rmSync(root, { recursive: true, force: true }));