diff --git a/CLAUDE.md b/CLAUDE.md index a7f1b37..76db0a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,11 +29,17 @@ Standboy is a VSCode/Cursor extension that auto-expands a Game Boy emulator pane - **ROM byte delivery.** ROM bytes do **not** travel through `postMessage`. A 32MB GBA ROM serialised via `Array.from(uint8)` produced a ~128MB smi array plus a JSON string of similar size and would OOM the extension host on import. Instead, `loadAndPostRom` (`src/extension.ts`) `access()`-checks the ROM file at `library.romFilePath(hash, ext)`, builds a webview-resource URI via `provider.asWebviewFileUri`, and posts only the URI — `` is already in `localResourceRoots` so the webview can `fetch()` the bytes directly into a `Blob` and hand EJS a `blob:` URL. Saves stay inline (≤128KB, harmless). The webview revokes the blob URL inside `EJS_onGameStart` so the ROM-sized Blob is released as soon as EJS has copied it into its Emscripten FS. - **Cover fetcher (`src/covers.ts`).** Tries the canonical name first, then progressively-stripped variants of the user's filename. Network calls happen in the extension host; webview only loads cached files via `asWebviewUri`. CSP stays locked-down. Concurrency 4, `coverUpdate` messages stream back to the grid as art lands. - **Auto-show.** Sidebar `WebviewViewProvider` in primary activity bar with `retainContextWhenHidden: true`. Auto-expand on activity = `vscode.commands.executeCommand("standboy.gameView.focus")`; auto-collapse = focus `workbench.view.explorer`. Gated by the `standboy.autoShow` boolean setting (default `true`); when off, the activity dot still pulses but no focus events fire. The setting is exposed both in VSCode's Settings UI and as a one-click pill in the menu drawer's **Auto-show** section — webview reads the current value from the host's `autoShow` message (sent on `ready` and after every config change), writes via `setAutoShow` webview→host. The host does **not** optimistically echo `setAutoShow`; it lets `onAutoShowChange` deliver the persisted value, so the pill's state is always disk-derived (a failed `writeAutoShow` simply leaves the pill on its prior value rather than fooling the user into thinking the change took). Writes target whichever scope already owns the value (`cfg.inspect()` → workspace folder / workspace / global) so an in-app toggle never silently no-ops against a workspace override. State is driven by `ActivityDetector` (`src/activity.ts`), which OR's two independent signals: - - **Override (authoritative).** A sentinel file at `~/.standboy/agent-active`, written/deleted by the user's agent via lifecycle hooks. `src/agent.ts` watches it via `vscode.workspace.createFileSystemWatcher`. Trusted when present, but **both edges are debounced** (`showDelayMs: 5000`, `hideDelayMs: 5000`) so trivial agent turns never strobe the panel and back-to-back turns hold it open without flicker. Detector exposes a separate `onSchedule` callback that fires when a hide is queued (with `durationMs`) so the webview can render a countdown progress bar and the user isn't surprised by the focus shift. + - **Override (authoritative).** A sentinel file at `~/.standboy/agent-active`, written/deleted by the user's agent via lifecycle hooks. `src/agent.ts` watches it and parses its `:` content (`kind ∈ {prompt, tool}`). Trusted when fresh; **both edges are debounced** (`showDelayMs: 5000`, `hideDelayMs: 5000`) so trivial agent turns never strobe the panel and back-to-back turns hold it open without flicker. The watcher is strictly event-driven — `fs.watch` on the parent dir handles transitions, and a single `setTimeout` (armed when the sentinel is fresh, reset on each fresh write) handles the timestamp-aging check. **No polling**, no `setInterval`. Two failure modes the watcher reconciles past the hooks: + - **Stale-timestamp TTL.** A sentinel whose recorded timestamp is older than `STALE_THRESHOLD_MS` (5 min) is treated as absent. Catches the "Stop hook didn't fire on user interrupt" case — Claude Code's `Stop` doesn't run on interrupt, so the sentinel would otherwise stay pinned until the next activation. The one-shot stale timer (armed inside the watcher's `check()` whenever the sentinel is fresh, cleared when it's absent) fires once after the TTL with no refresh and triggers a re-check; nothing is scheduled while the sentinel is idle. The threshold doubles as the on-activate cleanup window (`cleanupStaleSentinel`); one constant for both is enough since they're the same concept (sentinel age check). + - **Prompt-ping for re-show.** Watcher emits `onPromptPing` when a fresh `prompt`-kind write lands while it was already in the active state — i.e., a new user turn during an ongoing run. Extension wires this to the focus command (gated on `isVisible()`), so a manually-closed panel re-opens on the next prompt. `tool`-kind refreshes deliberately don't fire it — that way mid-run tool activity doesn't fight a user's deliberate close. + + Detector exposes a separate `onSchedule` callback that fires when a hide is queued (with `durationMs`) so the webview can render a countdown progress bar and the user isn't surprised by the focus shift. + - **Burst (heuristic fallback).** Edit-burst detector — multi-character changes within a 1.5s window — for users who haven't connected an agent in the Detection menu, or for agents we don't have specific hook support for. + - **Agent-detection setup (`src/hooks.ts`).** Lives entirely in the menu drawer's **Detection** section — there is no command-palette entry (`contributes.commands` is empty). Host exposes `getAgentStatus()` (returns `{ claude: { detected, connected }, cursor: { detected, connected } }`) and `setExclusiveAgent(agent, enabled)`. **Mutually exclusive by design**: connecting one agent disconnects the other so the two never share the sentinel file at `~/.standboy/agent-active` — a stop hook from agent A can't race a start hook from agent B and prematurely hide the panel. Internal helpers `setClaudeHooks` / `setCursorHooks` are exported for tests; production code goes through `setExclusiveAgent`. The webview pulls status on `ready` (and after every toggle) via the `agentStatus` host→webview message, sends `setAgent` webview→host to flip a single agent. Detection logic: Claude Code present if `~/.claude/settings.json` or `~/.claude/projects/` exists; Cursor present if `vscode.env.appName.toLowerCase().includes("cursor")`. When neither is detected, the section renders an empty-state line; Standboy still works as a manual emulator, auto-show just stays off. - - **Claude Code** → `~/.claude/settings.json`. Events: `UserPromptSubmit` + `PreToolUse` (start), `Stop` (stop). Schema is `{ hooks: { : [{ matcher?, hooks: [{type:"command", command}] }] } }` — we append rather than replace, identifying our entries by `command` containing the absolute marker path. - - **Cursor** → `~/.cursor/hooks/hooks.json`. Events: `beforeSubmitPrompt` (start), `afterAgentResponse` + `sessionEnd` (stop, the second is a safety-net). Schema is `{ version, hooks: { : { command } | [{ command }] } }` — single object becomes an array if a user hook is already present so both fire. + - **Claude Code** → `~/.claude/settings.json`. Events: `UserPromptSubmit` (`marker.cjs prompt`), `PreToolUse` (`marker.cjs tool`), `Stop` (`marker.cjs stop`). Schema is `{ hooks: { : [{ matcher?, hooks: [{type:"command", command}] }] } }`. We identify our entries by `command` containing the absolute marker path. **Install wipes ours-entries before re-adding** so users on the old single-`start`-command schema get migrated to the prompt/tool split on the next reinstall (driven by an activate-time idempotent re-install — see `extension.ts`). + - **Cursor** → `~/.cursor/hooks/hooks.json`. Events: `beforeSubmitPrompt` (`marker.cjs prompt`), `afterAgentResponse` + `sessionEnd` (`marker.cjs stop`, the second is a safety-net). Schema is `{ version, hooks: { : { command } | [{ command }] } }` — single object becomes an array if a user hook is already present so both fire. Install also wipes ours-entries first for the same migration reason as Claude. - The marker script (`~/.standboy/marker.cjs`) is **embedded as a string constant** in `src/agent.ts` and written out at activation. Doing it that way means uninstalling the extension can't leave hook commands pointing at a vanished `extensionPath/...` script. `extension.ts` calls `ensureMarkerInstalled()` at activation regardless of whether the user has connected an agent, so the FileSystemWatcher's parent dir always exists on first install. - Both install and uninstall are **idempotent and no-op-when-nothing-changed** (a `mutated` flag avoids spurious atomic writes). - **No automatic cleanup on extension uninstall.** VSCode's `vscode:uninstall` lifecycle is unreliable (microsoft/vscode#155561, #102260 — extension dir is deleted before the hook script runs; both still open as of 2026), and there is no other API that distinguishes uninstall from quit in `deactivate()`. Every comparable extension (Continue.dev, Cline, etc.) takes the same approach. diff --git a/package.json b/package.json index 35ed549..1afac03 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "standboy", "displayName": "Standboy", "description": "A Game Boy emulator that auto-shows during AI agent activity.", - "version": "0.3.0", + "version": "0.3.1", "publisher": "mfbzme", "license": "MIT", "icon": "media/icon-512.png", diff --git a/src/agent.test.ts b/src/agent.test.ts index 699a845..23cfbfa 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -6,6 +6,7 @@ import * as path from "node:path"; import { STALE_THRESHOLD_MS, cleanupStaleSentinel, + parseSentinelContent, watchSentinel, } from "./agent"; @@ -17,6 +18,39 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } +describe("parseSentinelContent", () => { + it("parses new prompt-kind format", () => { + expect(parseSentinelContent("prompt:1234")).toEqual({ + kind: "prompt", + ts: 1234, + }); + }); + + it("parses new tool-kind format", () => { + expect(parseSentinelContent("tool:5678")).toEqual({ + kind: "tool", + ts: 5678, + }); + }); + + it("falls back to legacy when kind is unrecognized", () => { + expect(parseSentinelContent("other:9999")).toEqual({ + kind: "legacy", + ts: 9999, + }); + }); + + it("parses legacy bare-timestamp format", () => { + expect(parseSentinelContent("9999")).toEqual({ kind: "legacy", ts: 9999 }); + }); + + it("returns null for malformed content", () => { + expect(parseSentinelContent("garbage")).toBeNull(); + expect(parseSentinelContent("prompt:")).toBeNull(); + expect(parseSentinelContent("")).toBeNull(); + }); +}); + describe("cleanupStaleSentinel", () => { let tmp: string; let file: string; @@ -36,18 +70,25 @@ describe("cleanupStaleSentinel", () => { it("removes a sentinel whose recorded timestamp is older than the threshold", async () => { const start = Date.now(); - await fs.writeFile(file, String(start - STALE_THRESHOLD_MS - 1000)); + await fs.writeFile(file, `tool:${start - STALE_THRESHOLD_MS - 1000}`); expect(await cleanupStaleSentinel(start, file)).toBe(true); expect(fsSync.existsSync(file)).toBe(false); }); it("preserves a sentinel that is still fresh", async () => { const start = Date.now(); - await fs.writeFile(file, String(start - 1000)); + await fs.writeFile(file, `prompt:${start - 1000}`); expect(await cleanupStaleSentinel(start, file)).toBe(false); expect(fsSync.existsSync(file)).toBe(true); }); + it("reads legacy bare-timestamp sentinels", async () => { + const start = Date.now(); + await fs.writeFile(file, String(start - STALE_THRESHOLD_MS - 1000)); + expect(await cleanupStaleSentinel(start, file)).toBe(true); + expect(fsSync.existsSync(file)).toBe(false); + }); + it("falls back to mtime when contents are malformed", async () => { await fs.writeFile(file, "not-a-number"); // Just-written file: mtime is now, so it shouldn't be considered stale. @@ -71,39 +112,39 @@ describe("watchSentinel", () => { it("emits the initial absent state once after construction", async () => { const events: boolean[] = []; - const w = watchSentinel((active) => events.push(active), { - dir: tmp, - pollIntervalMs: 200, - }); + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); // Allow the async initial check to flush. - await sleep(50); + await sleep(100); expect(events).toEqual([false]); w.dispose(); }); it("emits initial present state when the sentinel exists at start", async () => { - await fs.writeFile(file, String(Date.now())); + await fs.writeFile(file, `prompt:${Date.now()}`); const events: boolean[] = []; - const w = watchSentinel((active) => events.push(active), { - dir: tmp, - pollIntervalMs: 200, - }); - await sleep(50); + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); + await sleep(100); expect(events).toEqual([true]); w.dispose(); }); it("fires on create and delete transitions, once each", async () => { const events: boolean[] = []; - const w = watchSentinel((active) => events.push(active), { - dir: tmp, - pollIntervalMs: 100, - }); - await sleep(50); + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); + await sleep(100); expect(events).toEqual([false]); - await fs.writeFile(file, String(Date.now())); - // Wait long enough for fs.watch or the poll to pick it up. + await fs.writeFile(file, `prompt:${Date.now()}`); + // Wait long enough for fs.watch to deliver the event. await sleep(400); expect(events).toEqual([false, true]); @@ -116,12 +157,10 @@ describe("watchSentinel", () => { it("does not emit duplicate events for unchanged state", async () => { const events: boolean[] = []; - const w = watchSentinel((active) => events.push(active), { - dir: tmp, - // Aggressive polling — without state-change tracking we'd see - // multiple `false` events here. - pollIntervalMs: 50, - }); + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); await sleep(400); expect(events).toEqual([false]); w.dispose(); @@ -129,33 +168,144 @@ describe("watchSentinel", () => { it("recovers from rewriting the same sentinel file (back-to-back agent turns)", async () => { const events: boolean[] = []; - const w = watchSentinel((active) => events.push(active), { - dir: tmp, - pollIntervalMs: 100, - }); - await sleep(50); + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); + await sleep(100); - await fs.writeFile(file, String(Date.now())); + await fs.writeFile(file, `prompt:${Date.now()}`); await sleep(300); // Marker script writes the file again on the next PreToolUse — // sentinel still exists, state should NOT flap to false-then-true. - await fs.writeFile(file, String(Date.now())); + await fs.writeFile(file, `tool:${Date.now()}`); await sleep(300); expect(events).toEqual([false, true]); w.dispose(); }); + it("treats a stale sentinel as absent (catches interrupted agent runs)", async () => { + // Sentinel exists but its timestamp is well past the TTL — happens + // when the agent's Stop hook didn't fire (user interrupted). + await fs.writeFile(file, `tool:${Date.now() - 60_000}`); + const events: boolean[] = []; + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp, ttlMs: 1000 } + ); + await sleep(150); + // Even though the file exists, age (60s) > ttl (1s) → reported as absent. + expect(events).toEqual([false]); + w.dispose(); + }); + + it("flips to absent when a previously-fresh sentinel ages past the TTL", async () => { + const events: boolean[] = []; + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp, ttlMs: 300 } + ); + await sleep(100); + // Write a fresh sentinel — watcher should report active. + await fs.writeFile(file, `tool:${Date.now()}`); + await sleep(300); + expect(events).toEqual([false, true]); + + // Don't write again — let the one-shot stale timer fire when the + // recorded timestamp ages past the TTL. + await sleep(400); + expect(events).toEqual([false, true, false]); + + w.dispose(); + }); + + it("fires onPromptPing when a fresh prompt write lands during an active run", async () => { + const events: boolean[] = []; + let pings = 0; + const w = watchSentinel( + { + onChange: (active) => events.push(active), + onPromptPing: () => pings++, + }, + { dir: tmp } + ); + await sleep(100); + // Initial prompt — fires onChange(true), but not promptPing (we + // were transitioning idle→active, the existing show path handles this). + await fs.writeFile(file, `prompt:${Date.now()}`); + await sleep(300); + expect(events).toEqual([false, true]); + expect(pings).toBe(0); + + // Tool refresh during active run — no promptPing, no onChange. + await fs.writeFile(file, `tool:${Date.now()}`); + await sleep(300); + expect(events).toEqual([false, true]); + expect(pings).toBe(0); + + // New user prompt arrives during the same active run — promptPing + // fires so the extension can re-show a manually-closed panel. + await fs.writeFile(file, `prompt:${Date.now()}`); + await sleep(300); + expect(events).toEqual([false, true]); + expect(pings).toBe(1); + + w.dispose(); + }); + + it("does not fire onPromptPing when transitioning from absent to prompt", async () => { + // The idle→active edge already drives the show command via onChange; + // firing promptPing too would be a redundant double-trigger. + let pings = 0; + const w = watchSentinel( + { + onChange: () => undefined, + onPromptPing: () => pings++, + }, + { dir: tmp } + ); + await sleep(100); + await fs.writeFile(file, `prompt:${Date.now()}`); + await sleep(300); + expect(pings).toBe(0); + w.dispose(); + }); + + it("recheck() picks up a state change that no event delivered", async () => { + // Simulates a dropped fs.watch event on macOS: write the sentinel + // without giving the watcher time to observe it via fs events, + // dispose the watcher's fs subscription, then verify recheck() + // surfaces the missed transition. (We can't actually force fs.watch + // to drop, but we can verify recheck() is the recovery path.) + const events: boolean[] = []; + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); + await sleep(100); + expect(events).toEqual([false]); + + // Pretend the OS dropped the create event — just check that + // recheck() observes the file we wrote. + await fs.writeFile(file, `prompt:${Date.now()}`); + w.recheck(); + await sleep(100); + expect(events).toEqual([false, true]); + + w.dispose(); + }); + it("stops firing after dispose", async () => { const events: boolean[] = []; - const w = watchSentinel((active) => events.push(active), { - dir: tmp, - pollIntervalMs: 100, - }); - await sleep(50); + const w = watchSentinel( + { onChange: (active) => events.push(active) }, + { dir: tmp } + ); + await sleep(100); w.dispose(); - await fs.writeFile(file, String(Date.now())); + await fs.writeFile(file, `prompt:${Date.now()}`); await sleep(300); expect(events).toEqual([false]); }); diff --git a/src/agent.ts b/src/agent.ts index f777231..cec4c90 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -15,21 +15,36 @@ export const STANDBOY_HOME = path.join(os.homedir(), ".standboy"); export const SENTINEL_PATH = path.join(STANDBOY_HOME, "agent-active"); export const MARKER_SCRIPT_PATH = path.join(STANDBOY_HOME, "marker.cjs"); -// Sentinels older than this on activation are treated as crash debris -// and removed. Threshold is a deliberate trade-off: +// Sentinels with a recorded timestamp older than this are treated as +// stale and ignored — both at activation (crash debris from a previous +// session) and during runtime (no refresh = agent interrupted or +// otherwise stopped without firing its stop hook). The threshold is a +// deliberate trade-off: // - Claude Code refreshes the file on every UserPromptSubmit and // PreToolUse, so its recorded timestamp stays fresh during real // work and we won't false-stale a live run. // - Cursor only writes on `beforeSubmitPrompt`, i.e. once per turn. -// A single Cursor turn that runs longer than this threshold and -// whose second editor activates mid-flight WILL get its sentinel -// deleted out from under it. We accept that edge case in exchange -// for fast recovery from the much more common crash scenario. +// A single Cursor turn that runs longer than this threshold WILL +// get its sentinel ignored. We accept that edge case in exchange +// for catching the much more common "Stop hook didn't fire on +// user interrupt" scenario where the sentinel would otherwise +// stay pinned until the next extension activation. export const STALE_THRESHOLD_MS = 5 * 60 * 1000; // Embedded as a string so we don't ship a separate file and so removing // the extension can't leave the user's hook configs pointing at a vanished -// path-to-extension. Hooks invoke this as `node ~/.standboy/marker.cjs start|stop`. +// path-to-extension. Hooks invoke this as `node ~/.standboy/marker.cjs `. +// +// Actions: +// prompt — user just submitted a prompt (UserPromptSubmit / +// beforeSubmitPrompt). Writes "prompt:". +// tool — agent is using a tool (PreToolUse / PostToolUse). Writes +// "tool:" — refreshes the heartbeat but does NOT trigger +// re-show, so the user can keep the panel closed mid-run. +// stop — agent finished or session ended. Deletes the sentinel. +// start — legacy alias for `tool`, kept so existing hook configs +// written by older builds keep working until they're +// re-installed. export const MARKER_SCRIPT_SOURCE = `#!/usr/bin/env node // Standboy agent-activity marker. Touched by Cursor / Claude Code hooks. // Safe to delete; Standboy recreates it on the next activation. @@ -41,9 +56,12 @@ const SENTINEL = path.join(os.homedir(), ".standboy", "agent-active"); const action = process.argv[2]; try { - if (action === "start") { + if (action === "prompt") { + fs.mkdirSync(path.dirname(SENTINEL), { recursive: true }); + fs.writeFileSync(SENTINEL, "prompt:" + Date.now()); + } else if (action === "tool" || action === "start") { fs.mkdirSync(path.dirname(SENTINEL), { recursive: true }); - fs.writeFileSync(SENTINEL, String(Date.now())); + fs.writeFileSync(SENTINEL, "tool:" + Date.now()); } else if (action === "stop") { try { fs.unlinkSync(SENTINEL); } catch (_) {} } @@ -52,6 +70,32 @@ try { } `; +export interface SentinelContent { + kind: "prompt" | "tool" | "legacy"; + ts: number; +} + +// Accepts both the new ":" format and the legacy bare-timestamp +// format written by older marker scripts. Returns null for malformed +// content so callers can fall back to mtime. +export function parseSentinelContent(raw: string): SentinelContent | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const colonIdx = trimmed.indexOf(":"); + if (colonIdx > 0) { + const kindRaw = trimmed.slice(0, colonIdx); + const tsStr = trimmed.slice(colonIdx + 1); + const ts = Number(tsStr); + if (!Number.isFinite(ts) || ts <= 0) return null; + const kind: SentinelContent["kind"] = + kindRaw === "prompt" ? "prompt" : kindRaw === "tool" ? "tool" : "legacy"; + return { kind, ts }; + } + const ts = Number(trimmed); + if (!Number.isFinite(ts) || ts <= 0) return null; + return { kind: "legacy", ts }; +} + export async function ensureMarkerInstalled(): Promise { await fsp.mkdir(STANDBOY_HOME, { recursive: true }); await fsp.writeFile(MARKER_SCRIPT_PATH, MARKER_SCRIPT_SOURCE, { @@ -80,9 +124,9 @@ export async function cleanupStaleSentinel( // malformed (e.g. truncated write from a previous crash). let recordedAt = stat.mtimeMs; try { - const raw = (await fsp.readFile(sentinelPath, "utf8")).trim(); - const parsed = Number(raw); - if (Number.isFinite(parsed) && parsed > 0) recordedAt = parsed; + const raw = await fsp.readFile(sentinelPath, "utf8"); + const parsed = parseSentinelContent(raw); + if (parsed) recordedAt = parsed.ts; } catch { // keep mtime fallback } @@ -99,68 +143,165 @@ export async function cleanupStaleSentinel( export interface SentinelWatcher { dispose(): void; + // Trigger an immediate re-read of the sentinel and re-emit any state + // change. Used as a recovery hook against the one real failure mode of + // an event-only watcher: fs.watch on macOS can drop events under load. + // The extension calls this when the user opens the panel themselves + // (event-driven, not periodic) so any missed transition gets picked up + // the moment the user interacts with us again. + recheck(): void; +} + +export interface SentinelEvents { + // Fires when the watcher's view of the sentinel transitions between + // "effectively active" (file present + timestamp fresh) and + // "effectively absent" (file missing OR timestamp stale). + onChange: (active: boolean) => void; + // Fires when a fresh prompt-kind write lands WHILE the watcher was + // already in the active state — i.e., the user submitted a new prompt + // during a sustained agent run. Lets the extension re-show a manually + // closed panel. Tool-kind refreshes deliberately do NOT fire this, so + // mid-run tool activity doesn't fight the user's manual close. + onPromptPing?: () => void; } interface WatchOptions { - // Backup poll interval in ms — catches events fs.watch dropped - // (macOS in particular drops them under load). Default 2s is a - // good balance: imperceptible alongside the 5s show/hide delays - // applied downstream, and cheap (a single fs.stat). - pollIntervalMs?: number; + // Sentinels whose recorded timestamp is older than this are treated + // as if the file didn't exist. The fallback for "Stop hook didn't + // fire on user interrupt" — without it the override stays pinned + // active until the next extension activation. Defaults to + // STALE_THRESHOLD_MS. + ttlMs?: number; // Override the watched directory + filename. Defaults to // ~/.standboy/agent-active; tests point this at a tmpdir. dir?: string; filename?: string; } -// Watches ~/.standboy/agent-active and invokes onChange whenever its -// existence flips, exactly once per real transition. Layered for -// reliability: -// 1. Node fs.watch on the parent directory — low-latency, but known -// to drop events on macOS under load and to silently die if the -// watched dir is replaced. -// 2. setInterval poll as a backup — guaranteed to catch any state -// change within pollIntervalMs even if every fs event was lost. +// Watches ~/.standboy/agent-active and reports its effective active +// state. "Effective" means: present AND its recorded timestamp is fresh +// (within ttlMs). A sentinel whose timestamp has gone stale is treated +// as absent — that's how user interrupts (which don't fire Stop) and +// crashes get reconciled at runtime instead of pinning the panel open +// until the next activation. +// +// Strictly event-driven, zero busy work: +// 1. Node fs.watch on the parent directory — kernel notifies us when +// the sentinel is created, modified, or deleted. Costs nothing +// while idle (OS event subscription, not a thread). +// 2. A single setTimeout armed when the sentinel is fresh, fires +// once after ttlMs of silence, then triggers a stale check. Each +// fresh write resets it. No timer is pending when the sentinel +// is absent. This replaces what a periodic poll would do for +// timestamp-aging detection. // 3. Internal state tracking — onChange only fires on real -// transitions, so the polling backup never produces duplicate -// events when fs.watch already delivered them. +// transitions, so any duplicate fs event is harmless. +// +// fs.watch is known to drop events on macOS under heavy I/O load. For +// the sentinel use case (touched a handful of times per agent turn) +// this is rare in practice. Two recovery paths cover it without adding +// any periodic work: +// (a) Most sentinel writes are refreshes during a sustained run, so +// a dropped CREATE is usually followed by several CHANGE events +// within seconds; the next delivered event re-syncs state. +// (b) The extension calls `recheck()` whenever the user opens the +// Standboy panel themselves (view.onDidChangeVisibility → true), +// picking up anything fs.watch never delivered. Event-driven on +// user action, not periodic. export function watchSentinel( - onChange: (active: boolean) => void, + events: SentinelEvents, opts: WatchOptions = {} ): SentinelWatcher { - const pollIntervalMs = opts.pollIntervalMs ?? 2000; + const ttlMs = opts.ttlMs ?? STALE_THRESHOLD_MS; const dir = opts.dir ?? STANDBOY_HOME; const filename = opts.filename ?? "agent-active"; const filePath = path.join(dir, filename); let lastState: boolean | null = null; + // Last seen raw content. Used to detect a fresh prompt-kind write + // (content changed AND new kind is "prompt") so we can ping the + // extension to re-show a manually-closed panel. + let lastContent: string | null = null; let disposed = false; let inFlight = false; let dirWatcher: fs.FSWatcher | null = null; + let staleTimer: ReturnType | null = null; + + const cancelStaleTimer = (): void => { + if (staleTimer) { + clearTimeout(staleTimer); + staleTimer = null; + } + }; + + const armStaleTimer = (recordedAt: number): void => { + cancelStaleTimer(); + const remaining = Math.max(0, ttlMs - (Date.now() - recordedAt)); + staleTimer = setTimeout(() => { + staleTimer = null; + void check(); + }, remaining); + }; const check = async (): Promise => { if (disposed) return; // Coalesce concurrent checks. Multiple fs events can pile up - // (rename + change for a single writeFileSync, plus a poll tick); - // only the latest matters. A transition that lands while a check - // is mid-flight can be dropped here — the next fs event or the - // polling tick (≤pollIntervalMs) will pick it up, and the 5s - // downstream debounce hides the gap. + // (rename + change for a single writeFileSync); only the latest + // matters. A transition that lands while a check is mid-flight + // can be dropped here — the next fs event or the stale-timer + // re-arm will pick it up, and the 5s downstream debounce hides + // any gap. if (inFlight) return; inFlight = true; try { - let exists = false; + let raw: string | null = null; try { - await fsp.access(filePath); - exists = true; + raw = await fsp.readFile(filePath, "utf8"); } catch { - exists = false; + // File missing or unreadable — both treated as absent. } + + let active = false; + let parsed: SentinelContent | null = null; + let recordedAt = 0; + if (raw !== null) { + parsed = parseSentinelContent(raw); + // Malformed content falls through to recordedAt=0 → active=false. + // The marker always writes well-formed content; the only way to + // reach this branch with parsed=null is mid-write corruption or + // hand-edited debris, both of which we should ignore. + recordedAt = parsed?.ts ?? 0; + active = Date.now() - recordedAt < ttlMs; + } + if (disposed) return; - if (exists !== lastState) { - lastState = exists; - onChange(exists); + + const wasActive = lastState === true; + if (lastState !== active) { + lastState = active; + events.onChange(active); + } + + // Sustained active + fresh prompt-kind write = user submitted a + // new prompt during an existing run. Fire promptPing so the + // extension can re-show if the user manually closed the panel. + // Skipped when we just transitioned false→true (the onChange + // branch above already drives the show command). + if ( + active && + wasActive && + parsed?.kind === "prompt" && + raw !== lastContent + ) { + events.onPromptPing?.(); } + + lastContent = raw; + + // Arm/cancel the staleness timer based on the new state. While + // active, one timer is pending; while idle, nothing is scheduled. + if (active) armStaleTimer(recordedAt); + else cancelStaleTimer(); } finally { inFlight = false; } @@ -190,23 +331,21 @@ export function watchSentinel( if (!disposed) setTimeout(startDirWatcher, 1000); }); } catch { - // Directory missing or otherwise unwatchable — polling will - // still detect changes; try again on the next poll tick. + // Directory missing or otherwise unwatchable — fs.watch can't + // attach yet. Retry shortly; ensureMarkerInstalled() runs at + // activate, so this should resolve on its own within a second. dirWatcher = null; + if (!disposed) setTimeout(startDirWatcher, 1000); } }; void check(); startDirWatcher(); - const poll = setInterval(() => { - if (!dirWatcher) startDirWatcher(); - void check(); - }, pollIntervalMs); return { dispose(): void { disposed = true; - clearInterval(poll); + cancelStaleTimer(); try { dirWatcher?.close(); } catch { @@ -214,5 +353,8 @@ export function watchSentinel( } dirWatcher = null; }, + recheck(): void { + void check(); + }, }; } diff --git a/src/extension.ts b/src/extension.ts index b4ee434..90d508e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -21,7 +21,12 @@ import { ensureMarkerInstalled, watchSentinel, } from "./agent"; -import { getAgentStatus, setExclusiveAgent } from "./hooks"; +import { + getAgentStatus, + setClaudeHooks, + setCursorHooks, + setExclusiveAgent, +} from "./hooks"; import type { Agent, AgentStatus, @@ -101,14 +106,49 @@ export async function activate( logError("agent: activation prep failed", err); } - // While ~/.standboy/agent-active exists, the override flag pins activity - // state to "active" and bypasses the burst heuristic — official lifecycle - // signal beats edit-pattern guessing every time. - const sentinelWatcher = watchSentinel((active) => { - log("agent: sentinel", active ? "present" : "absent"); - detector.setOverride(active); + // Migrate hook configs from older builds (single `start` command for + // both prompt and tool events) to the new prompt/tool split. Install + // is idempotent and wipes-then-rewrites our entries, so calling it on + // an already-current config is a no-op effect. Skips work for any + // agent that was never connected. + try { + const status = await getAgentStatus(); + if (status.claude.connected) await setClaudeHooks(true); + if (status.cursor.connected) await setCursorHooks(true); + } catch (err) { + logError("agent: hook migration failed", err); + } + + // While ~/.standboy/agent-active exists with a fresh timestamp, the + // override flag pins activity state to "active" and bypasses the burst + // heuristic — official lifecycle signal beats edit-pattern guessing + // every time. A stale timestamp is treated as absent (catches user + // interrupts, which don't fire the agent's Stop hook). + // + // onPromptPing fires when a new user prompt lands during an existing + // active run — re-shows a panel the user had manually closed. Tool + // refreshes deliberately don't fire it, so the user can keep the + // panel closed mid-run if they want. + const sentinelWatcher = watchSentinel({ + onChange: (active) => { + log("agent: sentinel", active ? "present" : "absent"); + detector.setOverride(active); + }, + onPromptPing: () => { + if (!readAutoShow()) return; + if (provider.isVisible()) return; + log("agent: prompt ping (re-showing panel)"); + void vscode.commands.executeCommand("standboy.gameView.focus"); + }, }); + // Recovery hook for the only real failure mode of an event-only watcher: + // fs.watch on macOS can drop events under heavy I/O load. When the user + // opens the Standboy panel themselves, we recheck the sentinel — picks + // up any transition we may have missed. Event-driven (user action), no + // periodic polling. + provider.setOnBecomeVisible(() => sentinelWatcher.recheck()); + let currentRomHash: string | null = null; const extensionRoot = context.extensionUri.fsPath; diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 5b92f06..efc59f4 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -50,15 +50,61 @@ describe("hooks per-agent API", () => { const { setClaudeHooks } = await import("./hooks"); await setClaudeHooks(true); + const parsed = JSON.parse( + await readFile(path.join(home, ".claude", "settings.json"), "utf8") + ); + expect(parsed.hooks.UserPromptSubmit).toHaveLength(1); + const promptCmd = parsed.hooks.UserPromptSubmit[0].hooks[0].command; + expect(promptCmd).toContain("marker.cjs"); + expect(promptCmd).toContain("prompt"); + expect(parsed.hooks.PreToolUse).toHaveLength(1); + expect(parsed.hooks.PreToolUse[0].hooks[0].command).toContain("tool"); + expect(parsed.hooks.Stop).toHaveLength(1); + expect(parsed.hooks.Stop[0].hooks[0].command).toContain("stop"); + }); + + it("setClaudeHooks(true) migrates legacy `start` commands to prompt/tool split", async () => { + await mkdir(path.join(home, ".claude", "projects"), { recursive: true }); + const markerPath = path.join(home, ".standboy", "marker.cjs"); + // Mimic a hook config written by an older Standboy build — single + // `start` command on both UserPromptSubmit and PreToolUse. + const legacy = { + hooks: { + UserPromptSubmit: [ + { + hooks: [{ type: "command", command: `node "${markerPath}" start` }], + }, + ], + PreToolUse: [ + { + hooks: [{ type: "command", command: `node "${markerPath}" start` }], + }, + ], + Stop: [ + { + hooks: [{ type: "command", command: `node "${markerPath}" stop` }], + }, + ], + }, + }; + await writeFile( + path.join(home, ".claude", "settings.json"), + JSON.stringify(legacy, null, 2) + ); + + const { setClaudeHooks } = await import("./hooks"); + await setClaudeHooks(true); + const parsed = JSON.parse( await readFile(path.join(home, ".claude", "settings.json"), "utf8") ); expect(parsed.hooks.UserPromptSubmit).toHaveLength(1); expect(parsed.hooks.UserPromptSubmit[0].hooks[0].command).toContain( - "marker.cjs" + "prompt" ); expect(parsed.hooks.PreToolUse).toHaveLength(1); - expect(parsed.hooks.Stop).toHaveLength(1); + expect(parsed.hooks.PreToolUse[0].hooks[0].command).toContain("tool"); + expect(parsed.hooks.PreToolUse[0].hooks[0].command).not.toContain("start"); }); it("merges into an existing settings.json without losing user config", async () => { @@ -121,6 +167,40 @@ describe("hooks per-agent API", () => { expect(parsed.hooks.Stop).toHaveLength(1); }); + it("setClaudeHooks(true) re-runs skip the disk write when nothing changed", async () => { + // Auto-migration on activate calls setClaudeHooks(true) on every reload + // when claude is connected. Without the no-op short-circuit, the + // settings.json mtime would churn on every VSCode reload. + await mkdir(path.join(home, ".claude", "projects"), { recursive: true }); + + const { setClaudeHooks } = await import("./hooks"); + await setClaudeHooks(true); + const settingsPath = path.join(home, ".claude", "settings.json"); + const mtimeBefore = (await import("node:fs/promises")).stat(settingsPath); + const before = (await mtimeBefore).mtimeMs; + + // Wait a bit so any new write would be visible in mtime. + await new Promise((r) => setTimeout(r, 20)); + await setClaudeHooks(true); + const after = (await (await import("node:fs/promises")).stat(settingsPath)) + .mtimeMs; + expect(after).toBe(before); + }); + + it("setClaudeHooks(true) refuses to overwrite a corrupt settings.json", async () => { + await mkdir(path.join(home, ".claude", "projects"), { recursive: true }); + const settingsPath = path.join(home, ".claude", "settings.json"); + // Half-written file (e.g. user mid-edit, partial save, merge conflict). + await writeFile(settingsPath, '{"model": "claude-sonnet-4-7"'); + + const { setClaudeHooks } = await import("./hooks"); + await expect(setClaudeHooks(true)).rejects.toThrow(/isn't valid JSON/); + + // Original file is untouched — no data lost. + const raw = await readFile(settingsPath, "utf8"); + expect(raw).toBe('{"model": "claude-sonnet-4-7"'); + }); + it("setClaudeHooks(false) strips only our entries, leaves the user's intact", async () => { await mkdir(path.join(home, ".claude", "projects"), { recursive: true }); const userSettings = { @@ -165,7 +245,7 @@ describe("hooks per-agent API", () => { ); expect(parsed.version).toBe(1); expect(parsed.hooks.beforeSubmitPrompt.command).toContain("marker.cjs"); - expect(parsed.hooks.beforeSubmitPrompt.command).toContain("start"); + expect(parsed.hooks.beforeSubmitPrompt.command).toContain("prompt"); expect(parsed.hooks.afterAgentResponse.command).toContain("stop"); expect(parsed.hooks.sessionEnd.command).toContain("stop"); }); diff --git a/src/hooks.ts b/src/hooks.ts index 64fcfe2..f266f12 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -14,7 +14,15 @@ const CURSOR_HOOKS_FILE = path.join( "hooks.json" ); -const START_COMMAND = `node "${MARKER_SCRIPT_PATH}" start`; +// Distinct commands per event-kind so the marker can record what kind +// of activity refreshed the sentinel: prompt vs tool. The watcher uses +// the kind to decide whether a fresh write should re-show a manually +// closed panel (prompt = yes, tool = no). The legacy `start` action +// (used by older builds) is still accepted by the marker and is treated +// as `tool` — back-compat for users on hook configs we wrote before +// this split. +const PROMPT_COMMAND = `node "${MARKER_SCRIPT_PATH}" prompt`; +const TOOL_COMMAND = `node "${MARKER_SCRIPT_PATH}" tool`; const STOP_COMMAND = `node "${MARKER_SCRIPT_PATH}" stop`; function isCursor(): boolean { @@ -53,10 +61,12 @@ interface ClaudeSettings { [key: string]: unknown; } -const CLAUDE_START_EVENTS = ["UserPromptSubmit", "PreToolUse"] as const; +const CLAUDE_PROMPT_EVENTS = ["UserPromptSubmit"] as const; +const CLAUDE_TOOL_EVENTS = ["PreToolUse"] as const; const CLAUDE_STOP_EVENTS = ["Stop"] as const; const CLAUDE_ALL_EVENTS = [ - ...CLAUDE_START_EVENTS, + ...CLAUDE_PROMPT_EVENTS, + ...CLAUDE_TOOL_EVENTS, ...CLAUDE_STOP_EVENTS, ] as const; @@ -82,13 +92,24 @@ function ensureClaudeEvent( return true; } -async function readJson(p: string): Promise { +// Result is `null` if the file is missing, the special `"corrupt"` token +// if it exists but doesn't parse. Callers MUST distinguish — install paths +// must refuse to overwrite a corrupt config (could be a mid-edit save, a +// merge conflict, etc.); silently overwriting would lose user data on +// every activate now that we auto-reinstall on connected agents. +const CORRUPT = Symbol("corrupt-json"); +async function readJson(p: string): Promise { + let raw: string; try { - const raw = await fs.readFile(p, "utf8"); - return JSON.parse(raw) as T; + raw = await fs.readFile(p, "utf8"); } catch { return null; } + try { + return JSON.parse(raw) as T; + } catch { + return CORRUPT; + } } async function writeJsonAtomic(p: string, value: unknown): Promise { @@ -99,42 +120,80 @@ async function writeJsonAtomic(p: string, value: unknown): Promise { } async function installClaudeHooks(): Promise { - const settings = (await readJson(CLAUDE_SETTINGS)) ?? {}; - for (const event of CLAUDE_START_EVENTS) { - ensureClaudeEvent(settings, event, START_COMMAND); + const result = await readJson(CLAUDE_SETTINGS); + if (result === CORRUPT) { + throw new Error( + "~/.claude/settings.json exists but isn't valid JSON — refusing to overwrite. Fix the file or delete it, then reconnect." + ); + } + const settings = result ?? {}; + // Roundtrip JSON before any mutation so we can short-circuit the write + // when nothing semantically changed. Activate-time auto-migration calls + // this on every reload for already-connected agents; without the + // short-circuit we'd churn the user's settings.json mtime needlessly. + const before = JSON.stringify(settings); + // Wipe any existing ours-entries first so users upgrading from older + // builds (which used a single `start` command for both prompt and + // tool events) get migrated to the new prompt/tool split on reinstall. + if (settings.hooks) { + for (const event of CLAUDE_ALL_EVENTS) { + const list = settings.hooks[event]; + if (!Array.isArray(list)) continue; + const filtered = list.filter((entry) => !isOurClaudeEntry(entry)); + if (filtered.length === 0) delete settings.hooks[event]; + else settings.hooks[event] = filtered; + } + } + for (const event of CLAUDE_PROMPT_EVENTS) { + ensureClaudeEvent(settings, event, PROMPT_COMMAND); + } + for (const event of CLAUDE_TOOL_EVENTS) { + ensureClaudeEvent(settings, event, TOOL_COMMAND); } for (const event of CLAUDE_STOP_EVENTS) { ensureClaudeEvent(settings, event, STOP_COMMAND); } + if (JSON.stringify(settings) === before) return; await writeJsonAtomic(CLAUDE_SETTINGS, settings); } async function uninstallClaudeHooks(): Promise { - const settings = await readJson(CLAUDE_SETTINGS); - if (!settings?.hooks) return; + const result = await readJson(CLAUDE_SETTINGS); + if (result === CORRUPT) { + throw new Error( + "~/.claude/settings.json exists but isn't valid JSON — can't safely modify. Fix the file or delete it, then retry." + ); + } + if (!result?.hooks) return; + const settings = result; + const hooks = settings.hooks; + if (!hooks) return; let mutated = false; for (const event of CLAUDE_ALL_EVENTS) { - const list = settings.hooks[event]; + const list = hooks[event]; if (!Array.isArray(list)) continue; const filtered = list.filter((entry) => !isOurClaudeEntry(entry)); if (filtered.length === list.length) continue; mutated = true; if (filtered.length === 0) { - delete settings.hooks[event]; + delete hooks[event]; } else { - settings.hooks[event] = filtered; + hooks[event] = filtered; } } if (!mutated) return; - if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + if (Object.keys(hooks).length === 0) delete settings.hooks; await writeJsonAtomic(CLAUDE_SETTINGS, settings); } async function isClaudeConnected(): Promise { - const settings = await readJson(CLAUDE_SETTINGS); - if (!settings?.hooks) return false; + const result = await readJson(CLAUDE_SETTINGS); + // Treat a corrupt file as not-connected — the install path will refuse + // to overwrite it, so there's nothing to "connect" to until the user + // resolves the corruption. + if (result === CORRUPT || !result?.hooks) return false; for (const event of CLAUDE_ALL_EVENTS) { - const list = settings.hooks[event]; + const list = result.hooks[event]; if (Array.isArray(list) && list.some(isOurClaudeEntry)) return true; } return false; @@ -156,10 +215,10 @@ interface CursorHooksFile { // sessionStart is NOT a useful start signal — fires when the user merely // opens the Composer pane, before they've typed a prompt. Pinning the // panel open at that moment would be too eager. -const CURSOR_START_EVENTS = ["beforeSubmitPrompt"] as const; +const CURSOR_PROMPT_EVENTS = ["beforeSubmitPrompt"] as const; const CURSOR_STOP_EVENTS = ["afterAgentResponse", "sessionEnd"] as const; const CURSOR_ALL_EVENTS = [ - ...CURSOR_START_EVENTS, + ...CURSOR_PROMPT_EVENTS, ...CURSOR_STOP_EVENTS, ] as const; @@ -190,42 +249,73 @@ function ensureCursorEvent( } async function installCursorHooks(): Promise { - const cfg = (await readJson(CURSOR_HOOKS_FILE)) ?? { - version: 1, - hooks: {}, - }; - for (const event of CURSOR_START_EVENTS) { - ensureCursorEvent(cfg, event, START_COMMAND); + const result = await readJson(CURSOR_HOOKS_FILE); + if (result === CORRUPT) { + throw new Error( + "~/.cursor/hooks/hooks.json exists but isn't valid JSON — refusing to overwrite. Fix the file or delete it, then reconnect." + ); + } + const cfg: CursorHooksFile = result ?? { version: 1, hooks: {} }; + const before = JSON.stringify(cfg); + // Wipe any existing ours-entries first so users upgrading from older + // builds get migrated to the new prompt-command on reinstall. + if (cfg.hooks) { + for (const event of CURSOR_ALL_EVENTS) { + const existing = cfg.hooks[event]; + if (!existing) continue; + if (Array.isArray(existing)) { + const filtered = existing.filter((c) => !isOurCursorCmd(c)); + if (filtered.length === 0) delete cfg.hooks[event]; + else if (filtered.length === 1) cfg.hooks[event] = filtered[0]!; + else cfg.hooks[event] = filtered; + } else if (isOurCursorCmd(existing)) { + delete cfg.hooks[event]; + } + } + } + for (const event of CURSOR_PROMPT_EVENTS) { + ensureCursorEvent(cfg, event, PROMPT_COMMAND); } for (const event of CURSOR_STOP_EVENTS) { ensureCursorEvent(cfg, event, STOP_COMMAND); } cfg.version ??= 1; + if (JSON.stringify(cfg) === before) return; await writeJsonAtomic(CURSOR_HOOKS_FILE, cfg); } async function uninstallCursorHooks(): Promise { - const cfg = await readJson(CURSOR_HOOKS_FILE); - if (!cfg?.hooks) return; + const result = await readJson(CURSOR_HOOKS_FILE); + if (result === CORRUPT) { + throw new Error( + "~/.cursor/hooks/hooks.json exists but isn't valid JSON — can't safely modify. Fix the file or delete it, then retry." + ); + } + if (!result?.hooks) return; + const cfg = result; + // Local non-null re-binding so the loop body doesn't need to assert + // on every access; the early-return above proves it's defined. + const hooks = cfg.hooks; + if (!hooks) return; let mutated = false; for (const event of CURSOR_ALL_EVENTS) { - const existing = cfg.hooks[event]; + const existing = hooks[event]; if (!existing) continue; if (Array.isArray(existing)) { const filtered = existing.filter((c) => !isOurCursorCmd(c)); if (filtered.length === existing.length) continue; mutated = true; if (filtered.length === 0) { - delete cfg.hooks[event]; + delete hooks[event]; } else if (filtered.length === 1) { // Collapse back to object form to match what the user originally had. - cfg.hooks[event] = filtered[0]!; + hooks[event] = filtered[0]!; } else { - cfg.hooks[event] = filtered; + hooks[event] = filtered; } } else if (isOurCursorCmd(existing)) { mutated = true; - delete cfg.hooks[event]; + delete hooks[event]; } } if (!mutated) return; @@ -233,10 +323,10 @@ async function uninstallCursorHooks(): Promise { } async function isCursorConnected(): Promise { - const cfg = await readJson(CURSOR_HOOKS_FILE); - if (!cfg?.hooks) return false; + const result = await readJson(CURSOR_HOOKS_FILE); + if (result === CORRUPT || !result?.hooks) return false; for (const event of CURSOR_ALL_EVENTS) { - const existing = cfg.hooks[event]; + const existing = result.hooks[event]; if (!existing) continue; if ( Array.isArray(existing) diff --git a/src/view.ts b/src/view.ts index 4079383..54abe05 100644 --- a/src/view.ts +++ b/src/view.ts @@ -17,7 +17,9 @@ export class StandboyViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; private messageListener: vscode.Disposable | undefined; + private visibilityListener: vscode.Disposable | undefined; private onMessage: WebviewMessageHandler | undefined; + private onBecomeVisible: (() => void) | undefined; constructor( private readonly extensionUri: vscode.Uri, @@ -28,6 +30,13 @@ export class StandboyViewProvider implements vscode.WebviewViewProvider { this.onMessage = handler; } + // Fires when the view transitions from hidden to visible. Used as a + // recovery hook for any sentinel-watcher event the OS may have dropped + // since the panel was last open — event-driven, no polling. + setOnBecomeVisible(callback: (() => void) | undefined): void { + this.onBecomeVisible = callback; + } + // True only when the Standboy view container is the active item in the // sidebar AND the sidebar itself is visible. Drives focus-shift decisions // upstream so we don't yank the user into a panel that's already on screen @@ -90,6 +99,18 @@ export class StandboyViewProvider implements vscode.WebviewViewProvider { await this.onMessage?.(msg); } ); + + this.visibilityListener?.dispose(); + this.visibilityListener = view.onDidChangeVisibility(() => { + if (view.visible) this.onBecomeVisible?.(); + }); + // onDidChangeVisibility only fires on *transitions*. If the view was + // already visible at resolve time (user had Standboy as their active + // sidebar tab when the extension activated, or the view container was + // re-resolved after a layout change), the callback would otherwise + // never fire for the initial state. Fire it once explicitly so the + // recovery hook is honored on first-open. + if (view.visible) this.onBecomeVisible?.(); } postMessage(message: unknown): void { @@ -114,6 +135,8 @@ export class StandboyViewProvider implements vscode.WebviewViewProvider { dispose(): void { this.messageListener?.dispose(); this.messageListener = undefined; + this.visibilityListener?.dispose(); + this.visibilityListener = undefined; } private getHtml(webview: vscode.Webview): string {