From 0271e8015d3a0bc9cfc73cec8645a9aa77ca03f2 Mon Sep 17 00:00:00 2001 From: Max Thomas Date: Mon, 9 Nov 2026 20:47:01 -0600 Subject: [PATCH] fix(status): cover Claude failure, idle and session-switch hooks; add stuck-state fallbacks Once a hook has been seen, a hook-driven session ignores output cadence entirely, so every turn ending Claude does not report left the session "running" until something else corrected it. This closes the endings the hooks can report and adds two bounded fallbacks for the ones they cannot. Hooks now observed for Claude: - StopFailure, which Claude runs instead of Stop when a turn ends on an API error (rate limit, overload, auth). It completes the turn like Stop; only the error type is forwarded. - SessionEnd and SessionStart, installed with a `clear|resume` matcher so the helper never runs on ordinary startup, compaction or exit. When the pane's own conversation ends by /clear or an in-pane /resume, the session start that follows within 30 s with the matching source is adopted: the native session id, usage tracking (local and the remote reader), subagent rows and the id used by restart and workspace restore all move to the new conversation. Without that pending end, a mismatched session id is still dropped, which is what keeps a nested `claude -p` from driving the pane. - The browser-URL elicitation notification, reported as an elicitation dialog. A parent idle_prompt is now authoritative: Claude only sends it once no background agent is running, so it clears subagents whose SubagentStop never arrived instead of deferring to them forever. Fallbacks, Claude only (Codex behaviour is unchanged): - Silence watchdog: a hook-confirmed running turn with no PTY output and no hook event for two minutes drops to cadence mode and shows waiting. Claude's TUI redraws while it works, so that much silence means the end signal was lost. It does not fire while hooks report a running subagent, because a background agent can work behind a still prompt. - Interrupt hint: a lone Esc or Ctrl+C typed into a running hook-driven pane drops it to cadence mode until the next hook. Claude sends no hook on interrupt. Only the single key byte counts; escape sequences do not. Sessions in either fallback rest at waiting rather than decaying to idle, so the hook that later confirms the pause does not notify a second time. The overlay scrub now also removes Tether-managed entries from event keys this version does not install, and leaves keys without one untouched. Nothing here reads or alters PTY output. The helper stays silent on stdout for every new event (SessionStart stdout is injected into Claude's context), which the helper tests assert. This reverses one previously tested behaviour: a confirmed Claude turn no longer stays running through unlimited total silence. The Codex version of that test is kept. Tests: tsc --noEmit clean; eslint clean on touched files; vitest 2334 passed, 1 failed (src/main/usage/remote-command.test.ts real-POSIX-shell test, which fails on this Windows host independently of this change). Co-Authored-By: Claude Fable 5.2 --- cli-tools/tether-cli-hook/index.js | 26 ++- src/docs/sessions.md | 23 +- src/docs/settings.md | 8 +- .../claude-settings-overlay.test.ts | 63 +++++- .../cli-config/claude-settings-overlay.ts | 56 ++++- src/main/cli-config/hook-helper.test.ts | 66 +++++- src/main/session/session-manager.test.ts | 153 +++++++++++++ src/main/session/session-manager.ts | 148 ++++++++++-- src/main/status/status-detector.test.ts | 212 +++++++++++++++++- src/main/status/status-detector.ts | 159 ++++++++++++- 10 files changed, 861 insertions(+), 53 deletions(-) diff --git a/cli-tools/tether-cli-hook/index.js b/cli-tools/tether-cli-hook/index.js index 0a06eddd..57abbf13 100644 --- a/cli-tools/tether-cli-hook/index.js +++ b/cli-tools/tether-cli-hook/index.js @@ -107,6 +107,13 @@ const CLAUDE_NOTIFICATION_TYPES = new Set([ 'elicitation_dialog', 'elicitation_complete', 'elicitation_response', ]); +// Notification types that mean the same thing to the status side channel as +// one we already forward. A browser-URL elicitation blocks Claude on the user +// exactly like a form elicitation does. +const CLAUDE_NOTIFICATION_ALIASES = new Map([ + ['elicitation_url_dialog', 'elicitation_dialog'], +]); + function classifyClaude(payload) { // Claude hook payloads carry `hook_event_name` ('Notification' | 'Stop' | ...) // and, for Notification, `notification_type` ('permission_prompt' | @@ -114,14 +121,23 @@ function classifyClaude(payload) { // flat event enum so the detector doesn't need to know the Claude schema. switch (payload.hook_event_name) { case 'Stop': return 'turn_complete'; + // Claude runs StopFailure INSTEAD of Stop when a turn ends on an API error + // (rate limit, overload, auth). Either way the turn is over. + case 'StopFailure': return 'turn_complete'; case 'UserPromptSubmit': return 'turn_start'; case 'PreToolUse': return 'tool_start'; case 'PostToolUse': case 'PostToolUseFailure': return 'tool_complete'; case 'SubagentStart': return 'subagent_start'; case 'SubagentStop': return 'subagent_stop'; - case 'Notification': - return CLAUDE_NOTIFICATION_TYPES.has(payload.notification_type) ? payload.notification_type : null; + // /clear and an in-pane /resume end one conversation and start another + // under a new session id; Tether follows the pane across that boundary. + case 'SessionStart': return 'session_start'; + case 'SessionEnd': return 'session_end'; + case 'Notification': { + const notificationType = CLAUDE_NOTIFICATION_ALIASES.get(payload.notification_type) || payload.notification_type; + return CLAUDE_NOTIFICATION_TYPES.has(notificationType) ? notificationType : null; + } default: return null; } } @@ -184,6 +200,12 @@ function claudeHookMetadata(payload) { hook_event_name: cappedString(payload.hook_event_name, 128), notification_type: cappedString(payload.notification_type, 128), agent_id: cappedString(payload.agent_id, 256), + // Short enum-like fields: SessionStart `source`, SessionEnd `reason`, + // StopFailure `error` (the error TYPE; never `error_details` or + // `last_assistant_message`, which carry free text). + source: cappedString(payload.source, 64), + reason: cappedString(payload.reason, 64), + error: cappedString(payload.error, 64), }; } diff --git a/src/docs/sessions.md b/src/docs/sessions.md index 112b40c2..e4239265 100644 --- a/src/docs/sessions.md +++ b/src/docs/sessions.md @@ -58,6 +58,8 @@ workspace intact. Restart Tether to retry before relying on workspace saving. Stopping a session cancels its conversation-ID watcher immediately. A transcript read failure ends that watcher without interrupting the running CLI. +With [CLI hooks](settings.md#cli-hooks) on, Tether follows a Claude Code pane when you run `/clear` or switch conversations with `/resume` inside it: status, usage, subagent rows and the conversation that restart or workspace restore reopens all move to the new conversation. Without hooks (including Coder sessions), the pane stays tied to the conversation it was launched with. + For local Claude Code, Codex CLI, GitHub Copilot CLI, and OpenCode sessions, use **Resume conversation** in the launcher to choose history for the working directory. Tether uses `claude --resume `, `codex resume `, `copilot --resume `, or `opencode --session `. The conversation picker is local-only. OpenCode history requires a working OpenCode installation in an absolute directory on your system PATH. Windows native executables and npm `.cmd` installations are supported. Tether asks OpenCode for its session list; if that command is unavailable or cannot finish, the picker stays empty without interrupting running sessions. @@ -66,7 +68,7 @@ For Claude Code and Codex CLI over **SSH or Coder**, **Restart in this pane** an For **OpenCode over SSH or Coder**, restart and workspace restore also pass a known saved native conversation ID to `opencode --session `. Tether does not check that ID against history on your local machine or substitute the latest conversation. Automatic discovery of IDs for fresh remote OpenCode sessions is not available yet, so those sessions cannot automatically resume through Tether unless a native conversation ID is already known and saved. -Recovery requires the same remote host or Coder workspace, CLI user/home, and saved conversation history. If Claude Code has no history for the saved ID (the pane closed before its first message, the conversation was switched with `/resume` or `/clear`, or the history was deleted), the pane opens a fresh Claude conversation under that same ID instead of stopping on a resume error; use `/resume` inside Claude to reopen another conversation. Codex CLI and OpenCode handle missing saved history themselves; Tether does not retry with another conversation. If Tether never captured an ID (for example, remote Codex usage collection was unavailable before disconnection, or a fresh remote OpenCode session has no known ID), restarting opens a fresh session and you can use the CLI's own resume picker to recover older history. Resuming restores saved conversation context; it does not reattach a surviving process or keep a task running through disconnection. +Recovery requires the same remote host or Coder workspace, CLI user/home, and saved conversation history. If Claude Code has no history for the saved ID (the pane closed before its first message, the conversation was switched with `/resume` or `/clear` while hooks were off, or the history was deleted), the pane opens a fresh Claude conversation under that same ID instead of stopping on a resume error; use `/resume` inside Claude to reopen another conversation. Codex CLI and OpenCode handle missing saved history themselves; Tether does not retry with another conversation. If Tether never captured an ID (for example, remote Codex usage collection was unavailable before disconnection, or a fresh remote OpenCode session has no known ID), restarting opens a fresh session and you can use the CLI's own resume picker to recover older history. Resuming restores saved conversation context; it does not reattach a surviving process or keep a task running through disconnection. Claude Code and GitHub Copilot transcript lookups require a full session UUID. Invalid identifiers are treated as unavailable conversations and are not used as filesystem paths. @@ -101,6 +103,25 @@ echo and window resizing. Claude stays running while tracked subagents are active, including when the parent pauses for their results. Answering an elicitation resumes work; it does not mean the turn has finished. +Claude Code does not report every way a turn can end, so hook-driven Claude +sessions keep three safeguards: + +- A turn that ends on an API error (rate limit, overloaded, sign-in problem) + counts as finished, the same as a normal stop. +- Claude sends nothing when you interrupt it. Pressing **Esc** or **Ctrl+C** in + a running Claude pane hands that session back to output-based estimates until + its next hook arrives: if Claude keeps working, it stays running; if output + stops for a few seconds, it shows waiting. Tether only notices the keypress; + it does not change or delay what you type. +- A running session that produces no terminal output and no hook for two + minutes is marked waiting. Claude redraws its spinner and timer for as long as + it works, so that much silence means the turn ended without saying so. This + does not apply while a tracked subagent is still running. + +About a minute after Claude stops, its idle notice confirms the pause. Because +Claude only sends that notice once nothing is running in the background, it also +clears any subagent whose stop was never reported. + When hooks are unavailable, detection estimates activity from output and silence. It cannot reliably distinguish a quiet model or remote subagent from an idle prompt. A user-owned Codex `notify` command is preserved; without Tether's notify diff --git a/src/docs/settings.md b/src/docs/settings.md index a0028889..9db818b8 100644 --- a/src/docs/settings.md +++ b/src/docs/settings.md @@ -238,8 +238,12 @@ When on, Tether installs an additive entry in your `~/.claude/settings.json` and Local CLI hooks require a separate Node.js installation on PATH. Tether checks it at launch and writes its absolute executable path into the hooks. If Node.js is missing or cannot run, Tether removes its old entries, skips installation, and shows one warning; sessions use output-based status detection. Install Node.js and restart Tether to enable hooks. Existing user hooks are preserved. SSH hosts check their own Node.js installation separately. Claude hooks also observe prompt submission, tool activity and subagent -start/stop events so quiet work stays running. The status helper forwards only -event names and identifiers, without prompts, tool arguments or results. +start/stop events so quiet work stays running, turns that end on an API error, +and the conversation changes made by `/clear` and in-pane `/resume` (the helper +does not run on ordinary startup, compaction or exit). The status helper +forwards only event names, short type codes and identifiers, without prompts, +tool arguments, results or error text. See [session status](sessions.md#session-states) +for how Tether covers endings Claude does not report. Existing user hooks are preserved. If Claude disables all hooks, an overlay fails to install, or another integration owns Codex's `notify` command, the affected CLI uses output-cadence estimates unless its other Tether hooks are diff --git a/src/main/cli-config/claude-settings-overlay.test.ts b/src/main/cli-config/claude-settings-overlay.test.ts index 1292b110..5489238d 100644 --- a/src/main/cli-config/claude-settings-overlay.test.ts +++ b/src/main/cli-config/claude-settings-overlay.test.ts @@ -102,11 +102,72 @@ describe('Claude settings overlay', () => { expect(s.hooks.Notification).toHaveLength(1); expect(s.hooks.Stop).toHaveLength(1); const hooks = s.hooks as Record; - for (const event of ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop']) { + for (const event of [ + 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop', + 'StopFailure', 'SessionStart', 'SessionEnd', + ]) { expect(hooks[event]).toHaveLength(1); } }); + it('observes failed turns, and conversation boundaries only for /clear and /resume', async () => { + const { settingsPath } = makeCtx(); + await installClaudeHooks({ helperPath: HELPER, settingsPath }); + const s = readSettings(settingsPath) as { + hooks: Record }>>; + }; + // StopFailure replaces Stop when a turn ends on an API error: every error type counts. + expect(s.hooks.StopFailure[0].matcher).toBeUndefined(); + expect(s.hooks.StopFailure[0].hooks[0].command).toContain('tether-cli-hook'); + // No helper process on ordinary startup, compaction or exit. + expect(s.hooks.SessionStart[0].matcher).toBe('resume|clear'); + expect(s.hooks.SessionEnd[0].matcher).toBe('clear|resume'); + expect(s.hooks.Notification[0].matcher?.split('|')).toContain('elicitation_url_dialog'); + }); + + it('adds its conversation-boundary observers beside the user\'s own and removes only its own', async () => { + const { settingsPath } = makeCtx(); + const original = { + hooks: { + SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'load-context' }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: 'save-notes' }] }], + StopFailure: [{ matcher: 'rate_limit', hooks: [{ type: 'command', command: 'page-me' }] }], + }, + }; + fs.writeFileSync(settingsPath, JSON.stringify(original)); + await installClaudeHooks({ helperPath: HELPER, settingsPath }); + const installed = readSettings(settingsPath) as { hooks: Record }; + for (const event of ['SessionStart', 'SessionEnd', 'StopFailure']) { + expect(installed.hooks[event]).toHaveLength(2); + expect(installed.hooks[event][0]).toEqual(original.hooks[event as keyof typeof original.hooks][0]); + } + await uninstallClaudeHooks({ helperPath: HELPER, settingsPath }); + expect(readSettings(settingsPath)).toEqual(original); + }); + + it('scrubs a Tether entry from an event this version does not install, and nothing else there', async () => { + const { settingsPath } = makeCtx(); + const stale = { hooks: [{ type: 'command', command: 'node /old/tether-cli-hook/index.js --claude' }] }; + const theirs = { hooks: [{ type: 'command', command: 'their-compact-hook' }] }; + fs.writeFileSync(settingsPath, JSON.stringify({ + hooks: { + PreCompact: [stale, theirs], + PostCompact: [stale], + // Keys with nothing of ours in them are left exactly as written. + TeammateIdle: [], + ConfigChange: [{ hooks: [] }, 'malformed'], + }, + })); + await uninstallClaudeHooks({ helperPath: HELPER, settingsPath }); + expect(readSettings(settingsPath)).toEqual({ + hooks: { + PreCompact: [theirs], + TeammateIdle: [], + ConfigChange: [{ hooks: [] }, 'malformed'], + }, + }); + }); + it('reports disabled hooks without changing the user setting', async () => { const { settingsPath } = makeCtx(); fs.writeFileSync(settingsPath, JSON.stringify({ disableAllHooks: true, model: 'sonnet' })); diff --git a/src/main/cli-config/claude-settings-overlay.ts b/src/main/cli-config/claude-settings-overlay.ts index 21040dce..4c49d6e6 100644 --- a/src/main/cli-config/claude-settings-overlay.ts +++ b/src/main/cli-config/claude-settings-overlay.ts @@ -53,6 +53,26 @@ export interface ClaudeOverlayContext { const withMutex = createOverlayMutex(); const ACTIVITY_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop'] as const; +/** + * Turn-ending events, installed without a matcher so every occurrence fires. + * Claude runs `StopFailure` INSTEAD of `Stop` when a turn ends on an API error + * (rate limit, overload, auth), so observing only `Stop` leaves such a turn + * looking like it is still running. + */ +const TURN_END_EVENTS = ['Stop', 'StopFailure'] as const; +/** + * Conversation-boundary events and the matcher each is installed with. Only + * `/clear` and an in-pane `/resume` move a pane to a different session id, so + * the helper is not spawned on ordinary startup, compaction or exit — where it + * would add latency (SessionStart delays the first prompt, SessionEnd delays + * quitting) and report nothing Tether acts on. + */ +const SESSION_BOUNDARY_EVENTS = { + SessionStart: 'resume|clear', + SessionEnd: 'clear|resume', +} as const; +/** Every non-Notification event key Tether installs under `hooks`. */ +const OBSERVED_EVENTS: readonly string[] = [...TURN_END_EVENTS, ...Object.keys(SESSION_BOUNDARY_EVENTS), ...ACTIVITY_EVENTS]; interface CommandHook { type?: string; command?: string; [k: string]: unknown } interface NotificationGroup { matcher?: string; hooks?: CommandHook[]; [k: string]: unknown } @@ -104,6 +124,14 @@ function isTetherManaged(hook: CommandHook | undefined): boolean { return typeof hook?.command === 'string' && hook.command.includes(SENTINEL_TOKEN); } +/** True for a bare `{type, command}` hook or a `{hooks: [...]}` group that holds a Tether-managed command. */ +function hasTetherManagedHook(entry: unknown): boolean { + if (!entry || typeof entry !== 'object') return false; + if (isTetherManaged(entry as CommandHook)) return true; + const hooks = (entry as StopEntryGroup).hooks; + return Array.isArray(hooks) && hooks.some(isTetherManaged); +} + /** * Strip every Tether-managed entry from the observed hook arrays. * Returns true if the structure changed (used to decide whether to write). @@ -138,9 +166,15 @@ function scrubTetherEntries(settings: SettingsShape): boolean { } } - for (const event of ['Stop', ...ACTIVITY_EVENTS]) { + // Events this version installs, then any other event key: an entry left by a + // different Tether version (or a crash) must be removed even when this + // version no longer observes that event. Keys we never installed into are + // only rewritten when one of our entries is actually found in them. + const otherEvents = Object.keys(settings.hooks).filter((event) => event !== 'Notification' && !OBSERVED_EVENTS.includes(event)); + for (const event of [...OBSERVED_EVENTS, ...otherEvents]) { const stop = settings.hooks[event]; if (!Array.isArray(stop)) continue; + if (!OBSERVED_EVENTS.includes(event) && !stop.some(hasTetherManagedHook)) continue; const filtered: StopEntry[] = []; for (const entry of stop) { if (!entry || typeof entry !== 'object') continue; @@ -211,6 +245,7 @@ const NOTIFICATION_MATCHER = [ 'idle_prompt', 'auth_success', 'elicitation_dialog', + 'elicitation_url_dialog', 'elicitation_complete', 'elicitation_response', ].join('|'); @@ -236,28 +271,25 @@ export function mergeClaudeSettings(text: string | null, helperCmd: string): str notif.push({ matcher: NOTIFICATION_MATCHER, hooks: [{ type: 'command', command: helperCmd }] }); settings.hooks.Notification = notif; - const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : []; - stop.push({ hooks: [{ type: 'command', command: helperCmd }] }); - settings.hooks.Stop = stop; - - for (const event of ACTIVITY_EVENTS) { + for (const event of [...TURN_END_EVENTS, ...ACTIVITY_EVENTS]) { const entries = Array.isArray(settings.hooks[event]) ? settings.hooks[event] as StopEntry[] : []; entries.push({ hooks: [{ type: 'command', command: helperCmd }] }); settings.hooks[event] = entries; } + for (const [event, matcher] of Object.entries(SESSION_BOUNDARY_EVENTS)) { + const entries = Array.isArray(settings.hooks[event]) ? settings.hooks[event] as StopEntry[] : []; + entries.push({ matcher, hooks: [{ type: 'command', command: helperCmd }] }); + settings.hooks[event] = entries; + } + return serializeSettings(settings); } export function hasTetherClaudeHooks(text: string): boolean { const settings = parseSettings(text, 'settings.json'); if (settings.disableAllHooks === true) return false; - return Array.isArray(settings.hooks?.Stop) && settings.hooks.Stop.some((entry) => { - if (!entry || typeof entry !== 'object') return false; - if (isTetherManaged(entry as CommandHook)) return true; - const hooks = (entry as StopEntryGroup).hooks; - return Array.isArray(hooks) && hooks.some(isTetherManaged); - }); + return Array.isArray(settings.hooks?.Stop) && settings.hooks.Stop.some(hasTetherManagedHook); } /** diff --git a/src/main/cli-config/hook-helper.test.ts b/src/main/cli-config/hook-helper.test.ts index c2b77b45..c1d84944 100644 --- a/src/main/cli-config/hook-helper.test.ts +++ b/src/main/cli-config/hook-helper.test.ts @@ -20,6 +20,8 @@ afterEach(async () => { interface HelperResult { exitCode: number | null; events: HookEvent[]; + /** Everything the helper printed. Claude reads hook stdout, so it must stay empty. */ + output: string; } /** @@ -50,11 +52,14 @@ function runHelper(args: { } else { child.stdin.end(); } + let output = ''; + child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString('utf8'); }); + child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString('utf8'); }); return new Promise((resolve) => { child.on('exit', (code) => { // Tiny grace period so the bridge handler runs to completion before // we snapshot the captured events array. - setTimeout(() => resolve({ exitCode: code, events: args.events.slice() }), 50); + setTimeout(() => resolve({ exitCode: code, events: args.events.slice(), output }), 50); }); }); } @@ -188,6 +193,65 @@ describe('tether-cli-hook helper (end-to-end against the bridge)', () => { expect(result.events[0].payload).not.toHaveProperty('transcript_path'); }); + it('forwards Claude StopFailure as turn_complete with the error type only', async () => { + const events: HookEvent[] = []; + const bridge = await createHookBridge((e) => events.push(e)); + handles.push(bridge); + const result = await runHelper({ + socket: bridge.socketPath, token: bridge.token, sessionId: 'tether-parent', mode: '--claude', events, + payload: { + session_id: 'claude-parent', hook_event_name: 'StopFailure', error: 'rate_limit', + error_details: 'SECRET 429 for org', last_assistant_message: 'SECRET API Error', transcript_path: '/private/path', + }, + }); + expect(result.exitCode).toBe(0); + expect(result.output).toBe(''); + expect(result.events).toHaveLength(1); + expect(result.events[0]).toMatchObject({ + source: 'claude', type: 'turn_complete', + payload: { session_id: 'claude-parent', hook_event_name: 'StopFailure', error: 'rate_limit' }, + }); + expect(JSON.stringify(result.events)).not.toContain('SECRET'); + expect(result.events[0].payload).not.toHaveProperty('transcript_path'); + }); + + it.each([ + ['SessionStart', 'session_start', { source: 'clear' }], + ['SessionEnd', 'session_end', { reason: 'resume' }], + ] as const)('forwards Claude %s as %s and prints nothing Claude could read', async (hookEventName, type, extra) => { + const events: HookEvent[] = []; + const bridge = await createHookBridge((e) => events.push(e)); + handles.push(bridge); + const result = await runHelper({ + socket: bridge.socketPath, token: bridge.token, sessionId: 'tether-parent', mode: '--claude', events, + payload: { + session_id: 'claude-next', hook_event_name: hookEventName, ...extra, + session_title: 'SECRET title', cwd: '/private/cwd', transcript_path: '/private/path', model: 'claude-x', + }, + }); + expect(result.exitCode).toBe(0); + // A SessionStart hook's stdout is added to Claude's context. + expect(result.output).toBe(''); + expect(result.events).toHaveLength(1); + expect(result.events[0]).toMatchObject({ + source: 'claude', type, payload: { session_id: 'claude-next', hook_event_name: hookEventName, ...extra }, + }); + expect(JSON.stringify(result.events)).not.toContain('SECRET'); + expect(JSON.stringify(result.events)).not.toContain('/private/'); + }); + + it('reports a browser-URL elicitation as an elicitation dialog and ignores unknown notifications', async () => { + const events: HookEvent[] = []; + const bridge = await createHookBridge((e) => events.push(e)); + handles.push(bridge); + const base = { socket: bridge.socketPath, token: bridge.token, sessionId: 'tether-parent', mode: '--claude' as const, events }; + await runHelper({ ...base, payload: { hook_event_name: 'Notification', notification_type: 'elicitation_url_dialog' } }); + const result = await runHelper({ ...base, payload: { hook_event_name: 'Notification', notification_type: 'constructor' } }); + expect(result.exitCode).toBe(0); + expect(result.events).toHaveLength(1); + expect(result.events[0]).toMatchObject({ source: 'claude', type: 'elicitation_dialog' }); + }); + it('classifies Codex lifecycle hooks and redacts non-metadata fields', async () => { const events: HookEvent[] = []; const bridge = await createHookBridge((e) => events.push(e)); diff --git a/src/main/session/session-manager.test.ts b/src/main/session/session-manager.test.ts index e26f8188..1526c6ca 100644 --- a/src/main/session/session-manager.test.ts +++ b/src/main/session/session-manager.test.ts @@ -151,6 +151,7 @@ vi.mock('../helm/integration', () => ({ vi.mock('../usage/usage-service', () => ({ usageService: { trackSession: vi.fn(), + untrackSession: vi.fn(), }, })); vi.mock('../usage/remote-usage-service', () => ({ remoteUsageService: { start: vi.fn(), stop: vi.fn(), dispose: vi.fn() } })); @@ -204,6 +205,7 @@ describe('SessionManager', () => { vi.mocked(detectNewCodexSession).mockClear(); vi.mocked(detectNewCopilotSession).mockClear(); vi.mocked(usageService.trackSession).mockClear(); + vi.mocked(usageService.untrackSession).mockClear(); vi.mocked(resolveAll).mockReset().mockImplementation(async env => env); vi.mocked(resolveSshConfig).mockReset().mockResolvedValue({ host: 'test', port: 22, username: 'user' }); transportHarness.state.instances.length = 0; @@ -978,6 +980,157 @@ describe('SessionManager', () => { manager.handleHookEvent({ tetherSessionId: codex.id, source: 'claude', type: 'turn_complete' }); expect(cb.onStateChange).not.toHaveBeenCalled(); }); + + it('treats a parent idle prompt as proof that no subagent is still running', async () => { + const { session } = await createClaudeSession(); + const onSubagentEvent = vi.fn(); + manager.addLifecycleObserver({ onSubagentEvent }); + const send = (type: 'subagent_start' | 'turn_start' | 'turn_complete' | 'idle_prompt', agentId?: string) => manager.handleHookEvent({ + tetherSessionId: session.id, source: 'claude', type, payload: agentId ? { agent_id: agentId } : undefined, + }); + // Its SubagentStop never arrives (interrupted, or the helper lost its race). + send('subagent_start', 'ghost'); + send('turn_complete'); + expect(session.state).toBe('running'); + onSubagentEvent.mockClear(); + + send('idle_prompt'); + expect(session.state).toBe('waiting'); + expect(session.waitingReason).toBe('idle'); + expect(statusDetector.getActiveSubagentIds(session.id)).toEqual([]); + expect(onSubagentEvent).toHaveBeenCalledWith(session); + + send('turn_start'); + send('turn_complete'); + expect(session.waitingReason).toBe('idle'); + }); + + describe('conversation switches inside a pane', () => { + const OLD_ID = '11111111-1111-4111-8111-111111111111'; + const NEW_ID = '22222222-2222-4222-8222-222222222222'; + type SwitchEvent = 'session_end' | 'session_start' | 'subagent_start' | 'turn_start' | 'turn_complete'; + + async function createPinnedSession() { + const { session, cb } = await createClaudeSession(); + session.toolSessionId = OLD_ID; + session.claudeSessionId = OLD_ID; + const send = (type: SwitchEvent, payload: Record) => manager.handleHookEvent({ + tetherSessionId: session.id, source: 'claude', type, payload, + }); + return { session, cb, send }; + } + + it.each(['clear', 'resume'] as const)('follows the pane to its new conversation after an in-pane %s', async (how) => { + const { session, cb, send } = await createPinnedSession(); + send('subagent_start', { session_id: OLD_ID, agent_id: 'left-behind' }); + expect(session.state).toBe('running'); + vi.mocked(usageService.trackSession).mockClear(); + cb.onUpdate.mockClear(); + + send('session_end', { session_id: OLD_ID, reason: how }); + expect(session.toolSessionId).toBe(OLD_ID); + send('session_start', { session_id: NEW_ID, source: how }); + + expect(session.toolSessionId).toBe(NEW_ID); + expect(session.claudeSessionId).toBe(NEW_ID); + expect(cb.onUpdate).toHaveBeenLastCalledWith(session.id, expect.objectContaining({ toolSessionId: NEW_ID, claudeSessionId: NEW_ID })); + expect(usageService.untrackSession).toHaveBeenCalledWith(OLD_ID); + expect(usageService.trackSession).toHaveBeenCalledWith(NEW_ID, session.workingDir, 'claude', undefined); + // The old conversation's turn and subagents ended with it. + expect(session.state).toBe('waiting'); + expect(session.waitingReason).toBe('idle'); + expect(statusDetector.getActiveSubagentIds(session.id)).toEqual([]); + + // The new conversation's hooks drive status; the old one is now foreign. + send('turn_start', { session_id: NEW_ID }); + expect(session.state).toBe('running'); + send('turn_complete', { session_id: OLD_ID }); + expect(session.state).toBe('running'); + send('turn_complete', { session_id: NEW_ID }); + expect(session.waitingReason).toBe('idle'); + }); + + it('never adopts a session start it was not told to expect', async () => { + vi.useFakeTimers(); + try { + const { session, cb, send } = await createPinnedSession(); + cb.onStateChange.mockClear(); + cb.onUpdate.mockClear(); + + // Nothing pending: a nested `claude -p` or `claude --resume` announcing itself. + send('session_start', { session_id: NEW_ID, source: 'startup' }); + send('session_start', { session_id: NEW_ID, source: 'resume' }); + // Another conversation ending says nothing about this pane. + send('session_end', { session_id: NEW_ID, reason: 'clear' }); + send('session_start', { session_id: NEW_ID, source: 'clear' }); + // Pending, but not the kind of start that end announced, or not a session id. + send('session_end', { session_id: OLD_ID, reason: 'clear' }); + send('session_start', { session_id: NEW_ID, source: 'resume' }); + send('session_start', { session_id: '../../not-a-session-id', source: 'clear' }); + send('session_start', { session_id: NEW_ID, source: 'clear', agent_id: 'agent-a' }); + // Too late. + vi.advanceTimersByTime(30_001); + send('session_start', { session_id: NEW_ID, source: 'clear' }); + // The pane's Claude is exiting, not switching. + send('session_end', { session_id: OLD_ID, reason: 'prompt_input_exit' }); + send('session_start', { session_id: NEW_ID, source: 'clear' }); + + expect(session.toolSessionId).toBe(OLD_ID); + expect(session.claudeSessionId).toBe(OLD_ID); + expect(cb.onStateChange).not.toHaveBeenCalled(); + expect(cb.onUpdate).not.toHaveBeenCalled(); + expect(usageService.untrackSession).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('ignores a session start for the conversation the pane is already in', async () => { + const { session, cb, send } = await createPinnedSession(); + cb.onStateChange.mockClear(); + // Launch, launch with --resume, and compaction: none of them starts a turn. + for (const source of ['startup', 'resume', 'compact']) { + send('session_start', { session_id: OLD_ID, source }); + } + expect(cb.onStateChange).not.toHaveBeenCalled(); + + // Picking the same conversation in /resume closes the window again. + send('session_end', { session_id: OLD_ID, reason: 'resume' }); + send('session_start', { session_id: OLD_ID, source: 'resume' }); + send('session_start', { session_id: NEW_ID, source: 'resume' }); + expect(session.toolSessionId).toBe(OLD_ID); + }); + + it('points the remote usage reader at the new conversation and drops replies for the old one', async () => { + envState.type = 'ssh'; + const cb = callbacks(); + const session = await manager.createSession({ environmentId: 'env', cliTool: 'claude', workingDir: '/work' }, cb); + statusDetector.setHookCapable(session.id, true); + const send = (type: SwitchEvent, payload: Record) => manager.handleHookEvent({ + tetherSessionId: session.id, source: 'claude', type, payload, + }); + const first = vi.mocked(remoteUsageService.start).mock.calls[0][0]; + first.onSource('remote:old', OLD_ID); + expect(session.toolSessionId).toBe(OLD_ID); + + send('session_end', { session_id: OLD_ID, reason: 'clear' }); + send('session_start', { session_id: NEW_ID, source: 'clear' }); + + expect(session.toolSessionId).toBe(NEW_ID); + expect(remoteUsageService.start).toHaveBeenCalledTimes(2); + const second = vi.mocked(remoteUsageService.start).mock.calls[1][0]; + expect(second).toMatchObject({ sessionId: session.id, cli: 'claude', workingDir: '/work', nativeSessionId: NEW_ID }); + expect(session.toInfo().usageSessionId).toBe(`remote-pending:${session.id}`); + expect(usageService.trackSession).not.toHaveBeenCalled(); + expect(usageService.untrackSession).not.toHaveBeenCalled(); + + // A reply still in flight for the old reader must not put the old id back. + first.onSource('remote:old', OLD_ID); + expect(session.toolSessionId).toBe(NEW_ID); + second.onSource('remote:new', NEW_ID); + expect(session.toInfo().usageSessionId).toBe('remote:new'); + }); + }); }); describe('Codex lifecycle hook handling', () => { diff --git a/src/main/session/session-manager.ts b/src/main/session/session-manager.ts index 48edbfd7..098dc1f4 100644 --- a/src/main/session/session-manager.ts +++ b/src/main/session/session-manager.ts @@ -162,6 +162,16 @@ const CODEX_LIFECYCLE_TYPES: ReadonlySet = new Set([ 'session_end', ]); +/** + * `SessionEnd` reasons after which the SAME Claude process carries on in a + * different conversation: `/clear`, and `/resume` used inside a session. + */ +const CLAUDE_SWITCH_REASONS: ReadonlySet = new Set(['clear', 'resume']); +/** How long after such an end the conversation that replaces it may claim the pane. */ +const CLAUDE_SWITCH_WINDOW_MS = 30_000; +/** Claude session ids are UUIDs; an adopted id later becomes a `--resume` argument and a transcript file name. */ +const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const CODEX_METADATA_STRING_LIMITS: Record, number> = { toolSessionId: 256, turnId: 256, @@ -298,6 +308,14 @@ export class Session { remoteUsageStatus?: 'pending' | 'collecting' | 'unavailable'; /** Main-only: the remote host whose resident reader serves this session's side-channel reads. */ remoteProbe?: RemoteProbeTarget; + /** + * Main-only: Claude reported that this pane's conversation ended by `/clear` + * or an in-pane `/resume`, so the session start that follows may move the + * pane to a new native session id. Short-lived and consumed on use. + */ + claudeSwitchPending: { reason: string; expiresAt: number } | null = null; + /** Main-only: point the remote usage reader at the conversation the pane moved to. */ + restartRemoteUsage: ((nativeSessionId: string) => void) | null = null; constructor(id: string, label: string, workingDir: string, options: { environmentId?: string; @@ -581,8 +599,14 @@ export class SessionManager { * mode confirmations and similar input boxes fire elicitation_dialog; * they're the same UX category as a permission prompt — both block * Claude until the user clicks something. - * - turn_complete + idle_prompt → "Claude is paused, your turn", unless - * subagents still have work outstanding. + * - turn_complete → "Claude is paused, your turn", unless subagents still + * have work outstanding. Claude's StopFailure (the turn ended on an API + * error) arrives as turn_complete too. + * - idle_prompt → the same pause, but Claude only raises it once nothing + * is running in the background, so it also retires subagents whose stop + * event never arrived. + * - session_end + session_start (Claude) → `/clear` or an in-pane + * `/resume` moved the pane to another conversation; follow its new id. * - elicitation_complete/response → resume work after the user's answer. * - auth_success → informational, no state change. */ @@ -644,8 +668,18 @@ export class SessionManager { private handleClaudeAgentEvent(session: Session, event: HookEvent): boolean { if (session.cliTool !== 'claude') return true; const nativeId = event.payload?.session_id; - if (typeof nativeId === 'string' && session.toolSessionId && nativeId !== session.toolSessionId) return true; const agentId = boundedMetadataString(event.payload?.agent_id, 256); + if (typeof nativeId === 'string' && session.toolSessionId && nativeId !== session.toolSessionId) { + // Another Claude process that inherited this pane's Tether env (a nested + // `claude -p`, a background fork) must never drive the pane's status. + // The one exception is the conversation the pane itself just moved to. + if (event.type === 'session_start' && !agentId) this.adoptClaudeConversation(session, nativeId, event); + return true; + } + if (event.type === 'session_end' || event.type === 'session_start') { + if (!agentId) this.noteClaudeSessionBoundary(session, event); + return true; + } if (event.type === 'subagent_start' || event.type === 'subagent_stop') { if (agentId) { if (event.type === 'subagent_start') statusDetector.markSubagentStarted(session.id, agentId); @@ -654,7 +688,15 @@ export class SessionManager { } return true; } - if (!agentId) return false; + if (!agentId) { + if (event.type !== 'idle_prompt') return false; + // Claude raises its idle prompt only once no background agent is still + // running, so this is the one completion that need not wait for them. + const hadSubagents = statusDetector.getActiveSubagentIds(session.id).length > 0; + statusDetector.markTurnComplete(session.id, { authoritative: true }); + if (hadSubagents) this.notifyLifecycle(o => o.onSubagentEvent?.(session)); + return true; + } // Subagents inherit the same configured hooks and Tether env. Their // activity and Stop events cannot start or complete the parent turn. switch (event.type) { @@ -676,6 +718,57 @@ export class SessionManager { } } + /** + * A session boundary reported for the conversation this pane is already in. + * Only an end caused by `/clear` or `/resume` opens the window in which a + * different conversation may be adopted; anything else closes it. + */ + private noteClaudeSessionBoundary(session: Session, event: HookEvent): void { + const reason = boundedMetadataString(event.payload?.reason, 64); + if ( + event.type === 'session_end' && + event.payload?.session_id === session.toolSessionId && + reason && CLAUDE_SWITCH_REASONS.has(reason) + ) { + session.claudeSwitchPending = { reason, expiresAt: Date.now() + CLAUDE_SWITCH_WINDOW_MS }; + return; + } + session.claudeSwitchPending = null; + } + + /** + * A session start arrived under a different native id. Follow it only when + * this pane's own conversation just ended the matching way; otherwise it is + * some other Claude process and stays ignored, as every mismatched event is. + */ + private adoptClaudeConversation(session: Session, nativeId: string, event: HookEvent): void { + const pending = session.claudeSwitchPending; + if (!pending || Date.now() > pending.expiresAt) return; + if (boundedMetadataString(event.payload?.source, 64) !== pending.reason) return; + if (!CLAUDE_SESSION_ID_RE.test(nativeId)) return; + session.claudeSwitchPending = null; + + const previousId = session.toolSessionId; + session.toolSessionId = nativeId; + session.claudeSessionId = nativeId; + log.info('Claude conversation switched in pane', { id: session.id, reason: pending.reason }); + + if (session.restartRemoteUsage) { + session.usageSessionId = `remote-pending:${session.id}`; + session.restartRemoteUsage(nativeId); + } else if (!session.usageSessionId) { + // Mirrors the create-time tracking and exit-time untracking in the IPC + // layer, which key off `claudeSessionId`. + if (previousId) usageService.untrackSession(previousId); + usageService.trackSession(nativeId, session.workingDir, 'claude', session.environmentId ?? undefined); + } + + // The previous conversation's turn, subagents and prompts ended with it. + statusDetector.markTurnComplete(session.id, { authoritative: true }); + this.callbacksMap.get(session.id)?.onUpdate?.(session.id, session.toInfo()); + this.notifyLifecycle(o => o.onSubagentEvent?.(session)); + } + private handleCodexLifecycleEvent(session: Session, event: HookEvent): boolean { if (session.cliTool !== 'codex') return false; if (!CODEX_LIFECYCLE_TYPES.has(event.type as CodexLifecycleEventType)) return false; @@ -1088,23 +1181,36 @@ export class SessionManager { claudeHome: resolvedEnv.CLAUDE_CONFIG_DIR, }; session.remoteProbe = target; - remoteUsageService.start({ - sessionId: id, cli: cliTool as 'claude' | 'codex', ...target, - nativeSessionId: toolSessionId, - codexHome: resolvedEnv.CODEX_HOME, - onSource: (key, nativeId) => { - if (!this.sessions.has(id)) return; - session.usageSessionId = key; - session.toolSessionId = nativeId; - if (cliTool === 'claude') session.claudeSessionId = nativeId; - callbacks.onUpdate?.(id, session.toInfo()); - }, - onStatus: status => { - if (!this.sessions.has(id) || session.remoteUsageStatus === status) return; - session.remoteUsageStatus = status; - callbacks.onUpdate?.(id, session.toInfo()); - }, - }); + // Each (re)start gets its own generation so a reply still in flight for + // the conversation the pane left cannot put that id back. + let usageGeneration = 0; + const startRemoteUsage = (nativeSessionId: string | undefined) => { + const generation = ++usageGeneration; + const isCurrentReader = () => generation === usageGeneration && this.sessions.has(id); + remoteUsageService.start({ + sessionId: id, cli: cliTool as 'claude' | 'codex', ...target, + nativeSessionId, + codexHome: resolvedEnv.CODEX_HOME, + onSource: (key, nativeId) => { + if (!isCurrentReader()) return; + session.usageSessionId = key; + session.toolSessionId = nativeId; + if (cliTool === 'claude') session.claudeSessionId = nativeId; + callbacks.onUpdate?.(id, session.toInfo()); + }, + onStatus: status => { + if (!isCurrentReader() || session.remoteUsageStatus === status) return; + session.remoteUsageStatus = status; + callbacks.onUpdate?.(id, session.toInfo()); + }, + }); + }; + startRemoteUsage(toolSessionId); + if (cliTool === 'claude') { + session.restartRemoteUsage = nativeSessionId => { + if (this.sessions.get(id) === session && session.transport) startRemoteUsage(nativeSessionId); + }; + } } // Codex doesn't accept a pre-assigned session id — it mints one and writes diff --git a/src/main/status/status-detector.test.ts b/src/main/status/status-detector.test.ts index 51d73838..ed96971b 100644 --- a/src/main/status/status-detector.test.ts +++ b/src/main/status/status-detector.test.ts @@ -360,8 +360,8 @@ describe('StatusDetector', () => { expect(detector.getState('s1')).toBe('idle'); }); - it('keeps a confirmed model or tool turn running through prolonged silence', () => { - detector.register('s1', 'claude'); + it('keeps a confirmed Codex turn running through prolonged silence', () => { + detector.register('s1', 'codex'); detector.markTurnStarted('s1'); feed('s1', 'tool started'); vi.advanceTimersByTime(30 * 60 * 1000); @@ -370,6 +370,20 @@ describe('StatusDetector', () => { expect(detector.getWaitingReason('s1')).toBe('idle'); }); + it('keeps a confirmed Claude turn running for as long as its TUI keeps drawing', () => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + // Thirty minutes of a long tool run: the spinner redraws, no hook fires. + for (let i = 0; i < 30; i++) { + detector.feedData('s1', 'spinner frame'); + vi.advanceTimersByTime(60_000); + } + expect(detector.getState('s1')).toBe('running'); + expect(detector.isHookDriven('s1')).toBe(true); + detector.markTurnComplete('s1'); + expect(detector.getWaitingReason('s1')).toBe('idle'); + }); + it('restores cadence when hook wiring is lost without requiring more output', () => { detector.register('s1', 'claude'); detector.markTurnStarted('s1'); @@ -486,6 +500,200 @@ describe('StatusDetector', () => { }); }); + describe('authoritative completion', () => { + it('forgets subagents that never reported stopping', () => { + detector.register('s1', 'claude'); + detector.markSubagentStarted('s1', 'ghost'); + detector.markTurnComplete('s1'); + expect(detector.getState('s1')).toBe('running'); + + detector.markTurnComplete('s1', { authoritative: true }); + expect(detector.getState('s1')).toBe('waiting'); + expect(detector.getWaitingReason('s1')).toBe('idle'); + expect(detector.getActiveSubagentIds('s1')).toEqual([]); + + // Nothing is left to wait for, so the next turn completes normally. + detector.markTurnStarted('s1'); + detector.markTurnComplete('s1'); + expect(detector.getWaitingReason('s1')).toBe('idle'); + }); + + it('also clears a permission wait that belonged to a forgotten subagent', () => { + detector.register('s1', 'claude'); + detector.markPermissionWaiting('s1', 'ghost'); + expect(detector.getWaitingReason('s1')).toBe('permission'); + detector.markTurnComplete('s1', { authoritative: true }); + expect(detector.getWaitingReason('s1')).toBe('idle'); + detector.markTurnStarted('s1'); + expect(detector.getState('s1')).toBe('running'); + }); + }); + + describe('Claude silence watchdog', () => { + const WATCHDOG = 2 * 60 * 1000; + + it('hands a hook-confirmed turn back to cadence after two minutes without output or hooks', () => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + vi.advanceTimersByTime(WATCHDOG - 1); + expect(detector.getState('s1')).toBe('running'); + vi.advanceTimersByTime(1); + expect(detector.getState('s1')).toBe('waiting'); + expect(detector.getWaitingReason('s1')).toBe('idle'); + expect(detector.isHookDriven('s1')).toBe(false); + }); + + it('is held off by PTY output and by every kind of hook event', () => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + vi.advanceTimersByTime(100_000); + detector.feedData('s1', 'spinner frame'); + vi.advanceTimersByTime(100_000); + detector.markTurnStarted('s1'); // tool hook + vi.advanceTimersByTime(100_000); + detector.markSubagentStopped('s1', 'never-seen'); + vi.advanceTimersByTime(100_000); + expect(detector.getState('s1')).toBe('running'); + // Measured from the last sign of life, not from the start of the turn. + vi.advanceTimersByTime(19_999); + expect(detector.getState('s1')).toBe('running'); + vi.advanceTimersByTime(1); + expect(detector.getState('s1')).toBe('waiting'); + }); + + it('does not apply to Codex, to a waiting session, or while a subagent is reported running', () => { + detector.register('codex', 'codex'); + detector.markTurnStarted('codex'); + detector.register('prompt', 'claude'); + detector.markTurnStarted('prompt'); + detector.markPermissionWaiting('prompt'); + detector.register('agent', 'claude'); + detector.markTurnStarted('agent'); + detector.markSubagentStarted('agent', 'a'); + detector.markTurnComplete('agent'); + + vi.advanceTimersByTime(10 * WATCHDOG); + expect(detector.getState('codex')).toBe('running'); + expect(detector.getWaitingReason('prompt')).toBe('permission'); + expect(detector.getState('agent')).toBe('running'); + expect(detector.getActiveSubagentIds('agent')).toEqual(['a']); + + // Once the subagent stops with the parent still mid-turn, the watchdog covers it again. + detector.register('resumed', 'claude'); + detector.markTurnStarted('resumed'); + detector.markSubagentStarted('resumed', 'a'); + vi.advanceTimersByTime(10 * WATCHDOG); + detector.markSubagentStopped('resumed', 'a'); + expect(detector.getState('resumed')).toBe('running'); + vi.advanceTimersByTime(WATCHDOG); + expect(detector.getState('resumed')).toBe('waiting'); + }); + + it('rests at waiting afterwards and lets the confirming hook pass silently', () => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + vi.advanceTimersByTime(WATCHDOG); + expect(detector.getState('s1')).toBe('waiting'); + const announced = stateChanges.length; + + // A redraw is cadence-inferred activity; silence then returns it to + // waiting, and it stays there instead of decaying to idle. + feed('s1', 'prompt redraw'); + expect(detector.getState('s1')).toBe('running'); + vi.advanceTimersByTime(3_500); + expect(detector.getState('s1')).toBe('waiting'); + vi.advanceTimersByTime(10 * 60 * 1000); + expect(detector.getState('s1')).toBe('waiting'); + + const beforeHook = stateChanges.length; + expect(beforeHook).toBeGreaterThan(announced); + detector.markTurnComplete('s1', { authoritative: true }); + expect(stateChanges).toHaveLength(beforeHook); + expect(detector.isHookDriven('s1')).toBe(true); + }); + + it('is cancelled by completion, exit and unregister', () => { + for (const id of ['done', 'exited', 'gone']) { + detector.register(id, 'claude'); + detector.markTurnStarted(id); + } + detector.markTurnComplete('done'); + detector.markExited('exited', 0); + detector.unregister('gone'); + const before = stateChanges.length; + vi.advanceTimersByTime(10 * WATCHDOG); + expect(stateChanges).toHaveLength(before); + expect(detector.isHookDriven('done')).toBe(true); + }); + }); + + describe('Claude interrupt keypress', () => { + const ETX = String.fromCharCode(0x03); + + it.each([['Esc', ESC], ['Ctrl+C', ETX]])('%s during a hook-driven turn marks it waiting once the output stops', (_name, key) => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + detector.feedInput('s1', key); + expect(detector.getState('s1')).toBe('running'); + feed('s1', 'Interrupted by user'); + vi.advanceTimersByTime(3_500); + expect(detector.getState('s1')).toBe('waiting'); + expect(detector.getWaitingReason('s1')).toBe('idle'); + // It rests there; the idle prompt that follows a minute later changes nothing. + vi.advanceTimersByTime(60_000); + const before = stateChanges.length; + detector.markTurnComplete('s1', { authoritative: true }); + expect(stateChanges).toHaveLength(before); + }); + + it('keeps the turn running when Claude carries on, and the next hook restores hook mode', () => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + detector.feedInput('s1', ESC); + for (let i = 0; i < 10; i++) { + detector.feedData('s1', 'spinner frame'); + vi.advanceTimersByTime(1_000); + } + expect(detector.getState('s1')).toBe('running'); + detector.markTurnStarted('s1'); // tool hook: the turn was never interrupted + expect(detector.isHookDriven('s1')).toBe(true); + vi.advanceTimersByTime(60_000); + expect(detector.getState('s1')).toBe('running'); + }); + + it('ignores escape sequences, ordinary typing and keys pressed while not running', () => { + detector.register('s1', 'claude'); + detector.markTurnStarted('s1'); + for (const data of [`${ESC}[A`, `${ESC}[I`, `${ESC}[200~pasted${ESC}[201~`, `${ESC}${ESC}`, 'a', '\r']) { + detector.feedInput('s1', data); + } + vi.advanceTimersByTime(60_000); + expect(detector.getState('s1')).toBe('running'); + expect(detector.isHookDriven('s1')).toBe(true); + + detector.markPermissionWaiting('s1'); + detector.feedInput('s1', ESC); + vi.advanceTimersByTime(60_000); + expect(detector.getWaitingReason('s1')).toBe('permission'); + expect(detector.isHookDriven('s1')).toBe(true); + }); + + it('does not apply to Codex or to a Claude session without hooks', () => { + detector.register('codex', 'codex'); + detector.markTurnStarted('codex'); + detector.feedInput('codex', ESC); + vi.advanceTimersByTime(60_000); + expect(detector.getState('codex')).toBe('running'); + expect(detector.isHookDriven('codex')).toBe(true); + + detector.register('plain', 'claude', { hookCapable: false }); + feed('plain', 'output'); + const before = stateChanges.length; + detector.feedInput('plain', ESC); + expect(stateChanges).toHaveLength(before); + }); + }); + describe('per-session hookCapable gating', () => { it('register defaults hook-enabled CLIs to hookCapable (confirmed work suppresses cadence)', () => { // No explicit opts → claude/codex default to hookCapable, so the diff --git a/src/main/status/status-detector.ts b/src/main/status/status-detector.ts index 5c1bde04..7ac446e4 100644 --- a/src/main/status/status-detector.ts +++ b/src/main/status/status-detector.ts @@ -34,6 +34,20 @@ const DEBOUNCE_MS = 500; // Debounce state transitions const BUFFER_MAX = 4096; // Per-session rolling byte buffer for OSC matching const BELL_COALESCE_MS = 2000; // Suppress bell notifications fired in this window const TURN_SAFETY_TIMEOUT = 10 * 60 * 1000; // Fallback for a turn inferred without a start hook +// Claude's TUI animates a spinner and an elapsed-time counter for as long as it +// is working, so a hook-confirmed turn that produces no PTY bytes AND no hook +// event for this long is not working: its end signal never reached us (the +// turn was interrupted, the hook helper lost its one-second race, or Claude is +// parked on a usage limit). Codex is excluded — it can sit silent for 60–90+ +// seconds mid-turn. So is a session whose hooks report a running subagent: a +// background agent can work while the parent's prompt sits still, and calling +// that "waiting" early would swallow the real completion when it arrives. +const CLAUDE_SILENCE_WATCHDOG_MS = 2 * 60 * 1000; + +// The single bytes xterm.js sends for Esc and Ctrl+C. Arrow keys, focus +// reports and bracketed paste are multi-byte sequences that merely START with +// ESC, so only an exact match counts as an interrupt keypress. +const INTERRUPT_KEYS: ReadonlySet = new Set([String.fromCharCode(0x1b), String.fromCharCode(0x03)]); // CLIs that *can* drive hook-based turn detection when hooks are actually // wired. This is a capability ceiling, not a guarantee: a session only @@ -72,6 +86,10 @@ export class StatusDetector { private readonly idleTimers = new Map>(); private readonly debounceTimers = new Map>(); private readonly safetyTimers = new Map>(); + /** Pending silence watchdog per hook-driven Claude session that is running. */ + private readonly watchdogTimers = new Map>(); + /** Last PTY chunk or hook event per watched session; the watchdog re-checks it lazily. */ + private readonly lastActivityAt = new Map(); /** * True until a turn starts or once markTurnComplete has fired. While false * for a hook-enabled CLI, the byte-level idle timeout @@ -82,6 +100,13 @@ export class StatusDetector { private readonly hookSignaledDone = new Map(); /** Once a lifecycle signal arrives, PTY redraws cannot overwrite its state. */ private readonly hookStatusObserved = new Set(); + /** + * Hook-driven sessions temporarily handed back to cadence inference (silence + * watchdog, interrupt keypress). They rest at waiting like any hook-driven + * session instead of decaying to idle, so the hook that eventually confirms + * the pause does not announce it a second time. + */ + private readonly hookFallback = new Set(); private readonly activeSubagents = new Map>(); private readonly completionPending = new Set(); private readonly permissionWaits = new Map>(); @@ -147,9 +172,9 @@ export class StatusDetector { if (!capable) { // A disconnected remote bridge may have no subsequent PTY output. // Re-arm now so it can still leave running without another chunk. - this.hookSignaledDone.set(sessionId, true); - this.hookStatusObserved.delete(sessionId); - this.clearTimer(this.safetyTimers, sessionId); + this.dropToCadence(sessionId); + // Plain cadence from here on, including its idle decay. + this.hookFallback.delete(sessionId); if (this.isLiveSession(sessionId)) this.armSilenceTimers(sessionId); } } @@ -172,6 +197,7 @@ export class StatusDetector { this.lastBellAt.delete(sessionId); this.hookSignaledDone.delete(sessionId); this.hookStatusObserved.delete(sessionId); + this.hookFallback.delete(sessionId); this.activeSubagents.delete(sessionId); this.completionPending.delete(sessionId); this.permissionWaits.delete(sessionId); @@ -179,6 +205,7 @@ export class StatusDetector { this.clearTimer(this.idleTimers, sessionId); this.clearTimer(this.debounceTimers, sessionId); this.clearTimer(this.safetyTimers, sessionId); + this.clearWatchdog(sessionId); } // Called for every chunk of PTY output — this is the passive tap @@ -201,6 +228,7 @@ export class StatusDetector { if (state === 'running' && this.safetyTimers.has(sessionId)) { this.resetSafetyTimer(sessionId); } + this.touchWatchdog(sessionId); return; } @@ -265,6 +293,7 @@ export class StatusDetector { this.idleTimers.set(sessionId, setTimeout(() => { const cur = this.states.get(sessionId); const stillHookActive = this.isHookCapable(sessionId) && !this.hookSignaledDone.get(sessionId); + if (this.hookFallback.has(sessionId)) return; if ((cur === 'waiting' || cur === 'running') && !stillHookActive) { this.transition(sessionId, 'idle'); } @@ -272,10 +301,31 @@ export class StatusDetector { }, WAITING_TIMEOUT)); } - /** Legacy Codex notify only reports completion; submission starts the next turn. */ + /** + * Passive tap on what the user types. It never alters the bytes and never + * looks at PTY output. + * + * Codex: legacy notify only reports completion; submission starts the next turn. + * + * Claude: no hook fires when the user interrupts a turn (`Stop` is skipped on + * interrupt), so an Esc or Ctrl+C keypress during a hook-driven turn hands + * the session back to cadence inference. If Claude keeps working, its output + * keeps the session running and the next hook restores hook mode; if the turn + * really stopped, three seconds of silence marks it waiting. + */ feedInput(sessionId: string, data: string): void { const state = this.states.get(sessionId); - if (this.cliTools.get(sessionId) !== 'codex' || !this.isHookCapable(sessionId)) return; + if (!this.isHookCapable(sessionId)) return; + const cli = this.cliTools.get(sessionId); + if (cli === 'claude') { + if (state === 'running' && this.hookStatusObserved.has(sessionId) && INTERRUPT_KEYS.has(data)) { + this.dropToCadence(sessionId); + this.hookFallback.add(sessionId); + this.armSilenceTimers(sessionId); + } + return; + } + if (cli !== 'codex') return; if (this.isLiveSession(sessionId) && (state !== 'running' || this.hookSignaledDone.get(sessionId)) && /[\r\n]/.test(data)) { this.markTurnStarted(sessionId, { inferred: true }); @@ -286,9 +336,11 @@ export class StatusDetector { markExited(sessionId: string, exitCode: number): void { if (!this.states.has(sessionId)) return; this.clearSessionTimers(sessionId); + this.clearWatchdog(sessionId); this.buffers.delete(sessionId); this.hookSignaledDone.delete(sessionId); this.hookStatusObserved.delete(sessionId); + this.hookFallback.delete(sessionId); this.activeSubagents.delete(sessionId); this.completionPending.delete(sessionId); this.permissionWaits.delete(sessionId); @@ -309,9 +361,10 @@ export class StatusDetector { waits.add(agentId ?? ''); this.permissionWaits.set(sessionId, waits); this.clearSessionTimers(sessionId); - this.hookStatusObserved.add(sessionId); + this.observeHook(sessionId); this.buffers.delete(sessionId); this.setState(sessionId, 'waiting', 'permission'); + this.touchWatchdog(sessionId); } /** @@ -326,7 +379,7 @@ export class StatusDetector { this.clearTimer(this.idleTimers, sessionId); this.clearTimer(this.debounceTimers, sessionId); this.hookSignaledDone.set(sessionId, false); - this.hookStatusObserved.add(sessionId); + this.observeHook(sessionId); this.completionPending.delete(sessionId); this.buffers.delete(sessionId); this.clearTimer(this.safetyTimers, sessionId); @@ -335,6 +388,7 @@ export class StatusDetector { if (opts?.inferred) this.resetSafetyTimer(sessionId); this.clearPermissionWait(sessionId, ''); if (!this.permissionWaits.has(sessionId)) this.setState(sessionId, 'running'); + this.touchWatchdog(sessionId); } /** @@ -342,22 +396,30 @@ export class StatusDetector { * agent-turn-complete, or Notification.idle_prompt). Flip to waiting+idle * — the existing byte-level fallback would have gotten here eventually, we * just don't have to wait for silence. + * + * A completion normally waits for running subagents. `authoritative` is for + * a signal that already proves none are running — Claude's own idle prompt, + * or the pane moving to another conversation — so it also forgets subagents + * whose stop event never arrived instead of waiting on them forever. */ - markTurnComplete(sessionId: string): void { + markTurnComplete(sessionId: string, opts?: { authoritative?: boolean }): void { if (!this.isLiveSession(sessionId)) return; this.clearSessionTimers(sessionId); - this.hookStatusObserved.add(sessionId); + this.observeHook(sessionId); this.buffers.delete(sessionId); + if (opts?.authoritative) this.activeSubagents.delete(sessionId); if (this.activeSubagents.get(sessionId)?.size) { this.completionPending.add(sessionId); this.hookSignaledDone.set(sessionId, false); if (this.waitingReasons.get(sessionId) !== 'permission') this.setState(sessionId, 'running'); + this.touchWatchdog(sessionId); return; } this.completionPending.delete(sessionId); this.permissionWaits.delete(sessionId); this.hookSignaledDone.set(sessionId, true); this.setState(sessionId, 'waiting', 'idle'); + this.touchWatchdog(sessionId); } /** Subagents the hooks currently report as running for this session. */ @@ -386,9 +448,12 @@ export class StatusDetector { this.markTurnStarted(sessionId); if (pending) this.completionPending.add(sessionId); } + this.touchWatchdog(sessionId); } markSubagentStopped(sessionId: string, agentId: string): void { + // Any hook event is proof of life, including a stop for an agent we never saw start. + this.touchWatchdog(sessionId); const agents = this.activeSubagents.get(sessionId); if (!agents?.delete(agentId)) return; const resolvedPermission = this.permissionWaits.get(sessionId)?.has(agentId); @@ -401,17 +466,19 @@ export class StatusDetector { } } if (resolvedPermission && !this.permissionWaits.has(sessionId)) this.setState(sessionId, 'running'); + this.touchWatchdog(sessionId); } markSubagentActivity(sessionId: string, agentId: string): void { if (!this.isLiveSession(sessionId) || !agentId) return; this.markSubagentStarted(sessionId, agentId); this.clearSessionTimers(sessionId); - this.hookStatusObserved.add(sessionId); + this.observeHook(sessionId); this.buffers.delete(sessionId); this.clearPermissionWait(sessionId, agentId); this.hookSignaledDone.set(sessionId, false); if (!this.permissionWaits.has(sessionId)) this.setState(sessionId, 'running'); + this.touchWatchdog(sessionId); } private clearPermissionWait(sessionId: string, agentId: string): void { @@ -474,6 +541,74 @@ export class StatusDetector { } } + /** A hook event arrived: hooks drive this session's status again. */ + private observeHook(sessionId: string): void { + this.hookStatusObserved.add(sessionId); + this.hookFallback.delete(sessionId); + } + + /** + * Hand a hook-driven session back to byte-level cadence inference. The next + * hook event restores hook mode; subagent bookkeeping is left alone so a + * session whose hooks resume picks up where it was. + */ + private dropToCadence(sessionId: string): void { + this.hookSignaledDone.set(sessionId, true); + this.hookStatusObserved.delete(sessionId); + this.clearTimer(this.safetyTimers, sessionId); + this.clearWatchdog(sessionId); + } + + /** + * A hook-confirmed Claude turn is in progress, no subagent is reported + * running, and no inferred-turn safety timer already covers it. + */ + private isWatchdogEligible(sessionId: string): boolean { + return this.cliTools.get(sessionId) === 'claude' + && this.states.get(sessionId) === 'running' + && this.isHookCapable(sessionId) + && this.hookStatusObserved.has(sessionId) + && !this.activeSubagents.get(sessionId)?.size + && !this.safetyTimers.has(sessionId); + } + + /** + * Record PTY or hook activity, and keep exactly one watchdog pending while a + * hook-confirmed Claude turn runs. Called on every PTY chunk, so it only + * stamps a time; the timer re-checks that stamp when it fires rather than + * being rebuilt per chunk. + */ + private touchWatchdog(sessionId: string): void { + if (!this.isWatchdogEligible(sessionId)) { + this.clearWatchdog(sessionId); + return; + } + this.lastActivityAt.set(sessionId, Date.now()); + if (!this.watchdogTimers.has(sessionId)) this.scheduleWatchdog(sessionId, CLAUDE_SILENCE_WATCHDOG_MS); + } + + private scheduleWatchdog(sessionId: string, delayMs: number): void { + this.watchdogTimers.set(sessionId, setTimeout(() => { + this.watchdogTimers.delete(sessionId); + if (!this.isWatchdogEligible(sessionId)) return; + const silentFor = Date.now() - (this.lastActivityAt.get(sessionId) ?? 0); + if (silentFor < CLAUDE_SILENCE_WATCHDOG_MS) { + this.scheduleWatchdog(sessionId, CLAUDE_SILENCE_WATCHDOG_MS - silentFor); + return; + } + // Two minutes with neither output nor hooks: the turn ended without + // telling us. Any later output or hook corrects this immediately. + this.dropToCadence(sessionId); + this.hookFallback.add(sessionId); + this.setState(sessionId, 'waiting', 'idle'); + }, delayMs)); + } + + private clearWatchdog(sessionId: string): void { + this.clearTimer(this.watchdogTimers, sessionId); + this.lastActivityAt.delete(sessionId); + } + private resetSafetyTimer(sessionId: string): void { this.clearTimer(this.safetyTimers, sessionId); this.safetyTimers.set(sessionId, setTimeout(() => { @@ -496,10 +631,11 @@ export class StatusDetector { } dispose(): void { - for (const map of [this.waitingTimers, this.idleTimers, this.debounceTimers, this.safetyTimers]) { + for (const map of [this.waitingTimers, this.idleTimers, this.debounceTimers, this.safetyTimers, this.watchdogTimers]) { for (const timer of map.values()) clearTimeout(timer); map.clear(); } + this.lastActivityAt.clear(); this.states.clear(); this.waitingReasons.clear(); this.buffers.clear(); @@ -508,6 +644,7 @@ export class StatusDetector { this.lastBellAt.clear(); this.hookSignaledDone.clear(); this.hookStatusObserved.clear(); + this.hookFallback.clear(); this.activeSubagents.clear(); this.completionPending.clear(); this.permissionWaits.clear();