diff --git a/00-review-context.md b/00-review-context.md index ab579f6f..10ed03fe 100644 --- a/00-review-context.md +++ b/00-review-context.md @@ -155,6 +155,51 @@ See "Changed Files Summary" above. Categories are: ## Iteration State -Current iteration: 1 -Last completed phase: Setup -Files fixed this iteration: [] +Current iteration: 2 +Last completed phase: Iteration 1 Fix complete, typecheck clean +Files fixed iter 1: +- src/main/agent-hooks/server.ts (C1, C2, M2) +- src/main/ipc/pty.ts (H1) +- src/main/claude/hook-service.ts (H2a) +- src/main/codex/hook-service.ts (H2b) +- src/renderer/src/components/terminal-pane/pty-transport.ts (H3) +- src/renderer/src/hooks/useIpcEvents.ts (H4, M5, M6) +- src/renderer/src/components/terminal-pane/pty-connection.ts (H5) +- src/renderer/src/store/slices/terminals.ts (M1) +- src/preload/index.d.ts (M3) +- src/renderer/src/components/dashboard/useDashboardKeyboard.ts (M4) +- src/renderer/src/components/dashboard/AgentDashboard.tsx (M4 — container ref) + +## Validated Fix Manifest (Iteration 1) + +### Critical +- C1: src/main/agent-hooks/server.ts:20-23 - readJsonBody missing `return` after reject+destroy; no request timeout. + +### High +- H1: src/main/ipc/pty.ts:194 - Hardcoded `:` PATH separator breaks Windows dev. +- H2a: src/main/claude/hook-service.ts:94-98 - getStatus returns `installed` if ANY event has managed command; should require ALL CLAUDE_EVENTS and return `partial` otherwise. +- H2b: src/main/codex/hook-service.ts:83-87 - Same as H2a for CODEX_EVENTS. +- H3: src/renderer/src/components/terminal-pane/pty-transport.ts:107-142 - Unbounded `pending` buffer in OSC processor. +- H4: src/renderer/src/hooks/useIpcEvents.ts:407,424 - Debug console.log leaks payload contents on every event. +- H5: src/renderer/src/components/terminal-pane/pty-connection.ts:162-175 - `onAgentStatus` callback on transport not wired from pty-connection, so OSC 9999 payloads are parsed but never forwarded to store. + +### Medium +- M1: src/renderer/src/store/slices/terminals.ts:344-349,404 - Inline agent-status sweep skips epoch bump; call `removeAgentStatusByTabPrefix` instead. +- M2: src/main/agent-hooks/server.ts:121-168 - Startup `once('error', reject)` never removed, no persistent error listener; runtime errors can crash main process. +- M3: src/preload/index.d.ts:208-228 - Api type references undefined types (HooksApi, CacheApi, SessionApi, UpdaterApi, FsApi, GitApi, UIApi, RuntimeApi) and duplicates PreloadApi fields. +- M4: src/renderer/src/components/dashboard/useDashboardKeyboard.ts - Global keydown listener intercepts arrow keys and digits even when focus is in terminal, can break terminal navigation. +- M5: src/renderer/src/hooks/useIpcEvents.ts:440-452 - findTerminalTitleForPaneKey returns tab-level title not pane-level; wrong for split panes. +- M6: src/renderer/src/hooks/useIpcEvents.ts:406-431 - No paneKey validation; orphan status entries persist forever. + +### Deferred to next iteration (Medium, non-blocking) +- M7: src/renderer/src/components/sidebar/WorktreeCard.tsx:148-150 - Double Object.values filter. +- M8: src/renderer/src/components/sidebar/AgentStatusHover.tsx:334-338 - `now` useMemo staleness. +- M9: src/renderer/src/components/dashboard/useDashboardData.ts:80-82 - O(W*T*P) per render. +- M10: src/cli/runtime-client.ts:386-391 - ORCA_USER_DATA_PATH no trim/validate. + +### Skipped (won't fix) +- L: Dead exports AgentStatusPayload, WellKnownAgentType — they document the wire contract and cost nothing. +- L: `'status'` in isCommandGroup — benign per reviewer; callers consult findCommandSpec first. +- L: normalizeField whitespace collapse scope — matches design intent. +- L: pty-transport dead regex exports (extractAgentStatusOsc, stripAgentStatusOsc) — may be test helpers; low churn. Will revisit in iter 2. +- L: `isExplicitAgentStatusFresh` 3x per entry in smart-sort — trivial, defer. diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b799c980..a39f4c66 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -4,23 +4,49 @@ import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' -type AgentHookSource = 'claude' | 'codex' +type AgentHookSource = 'claude' | 'codex' | 'gemini' type AgentHookEventPayload = { paneKey: string + tabId?: string + worktreeId?: string payload: ParsedAgentStatusPayload } +// Why: only log a given version/env mismatch once per process so a stale hook +// script that fires on every keystroke doesn't flood the logs. +const warnedVersions = new Set() +const warnedEnvs = new Set() + +// Why: Claude documents `prompt` on UserPromptSubmit; other agents may use +// different field names. Probe a small allowlist so we can surface the real +// user prompt in the dashboard regardless of which agent is reporting. +function extractPromptText(hookPayload: Record): string { + const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message'] + for (const key of candidateKeys) { + const value = hookPayload[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return '' +} + function readJsonBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = '' req.on('data', (chunk: Buffer) => { - body += chunk.toString('utf8') - if (body.length > 1_000_000) { + // Why: check size before appending and return immediately after destroy + // so we don't keep accumulating bytes after rejecting, which could let a + // malicious client push memory usage well past the advertised limit. + if (body.length + chunk.length > 1_000_000) { reject(new Error('payload too large')) req.destroy() + return } + body += chunk.toString('utf8') }) req.on('end', () => { try { @@ -33,7 +59,26 @@ function readJsonBody(req: IncomingMessage): Promise { }) } -function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | null { +// Why: only UserPromptSubmit carries the user's prompt. Subsequent events in +// the same turn (PostToolUse, PermissionRequest, Stop, …) arrive with no +// prompt, so we cache the last prompt per pane and reuse it until a new +// prompt arrives. The cache survives across `done` so the user can still see +// what finished; it's reset on the next UserPromptSubmit. +const lastPromptByPaneKey = new Map() + +function resolvePrompt(paneKey: string, promptText: string): string { + if (promptText) { + lastPromptByPaneKey.set(paneKey, promptText) + return promptText + } + return lastPromptByPaneKey.get(paneKey) ?? '' +} + +function normalizeClaudeEvent( + eventName: unknown, + promptText: string, + paneKey: string +): ParsedAgentStatusPayload | null { const state = eventName === 'UserPromptSubmit' || eventName === 'PostToolUse' || @@ -52,18 +97,46 @@ function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | nu return parseAgentStatusPayload( JSON.stringify({ state, - summary: - state === 'waiting' - ? 'Waiting for permission' - : state === 'done' - ? 'Turn complete' - : 'Responding to prompt', + prompt: resolvePrompt(paneKey, promptText), agentType: 'claude' }) ) } -function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | null { +// Why: Gemini CLI exposes BeforeAgent/AfterAgent/AfterTool hooks. BeforeAgent +// fires at turn start and AfterTool resumes the working state after a tool +// call completes; AfterAgent fires when the agent becomes idle. Gemini has no +// permission-prompt hook, so we cannot surface a waiting state for Gemini. +function normalizeGeminiEvent( + eventName: unknown, + promptText: string, + paneKey: string +): ParsedAgentStatusPayload | null { + const state = + eventName === 'BeforeAgent' || eventName === 'AfterTool' + ? 'working' + : eventName === 'AfterAgent' + ? 'done' + : null + + if (!state) { + return null + } + + return parseAgentStatusPayload( + JSON.stringify({ + state, + prompt: resolvePrompt(paneKey, promptText), + agentType: 'gemini' + }) + ) +} + +function normalizeCodexEvent( + eventName: unknown, + promptText: string, + paneKey: string +): ParsedAgentStatusPayload | null { const state = eventName === 'SessionStart' || eventName === 'UserPromptSubmit' ? 'working' @@ -80,20 +153,25 @@ function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | nul return parseAgentStatusPayload( JSON.stringify({ state, - summary: - state === 'waiting' - ? 'Waiting for permission' - : state === 'done' - ? 'Turn complete' - : 'Responding to prompt', + prompt: resolvePrompt(paneKey, promptText), agentType: 'codex' }) ) } +function readStringField(record: Record, key: string): string | undefined { + const value = record[key] + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + function normalizeHookPayload( source: AgentHookSource, - body: unknown + body: unknown, + expectedEnv: string ): AgentHookEventPayload | null { if (typeof body !== 'object' || body === null) { return null @@ -106,28 +184,93 @@ function normalizeHookPayload( return null } - const eventName = (hookPayload as Record).hook_event_name - const payload = - source === 'claude' ? normalizeClaudeEvent(eventName) : normalizeCodexEvent(eventName) + // Why: scripts installed by an older app build may send a different shape. + // We accept the request (fail-open) but log once so stale installs are + // diagnosable instead of silently degrading. + const version = readStringField(record, 'version') + if (version && version !== ORCA_HOOK_PROTOCOL_VERSION && !warnedVersions.has(version)) { + warnedVersions.add(version) + console.warn( + `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + + 'Reinstall agent hooks from Settings to upgrade the managed script.' + ) + } - return payload ? { paneKey, payload } : null + // Why: detects dev-vs-prod cross-talk. A hook installed by a dev build but + // triggered inside a prod terminal (or vice versa) still points at whichever + // loopback port the shell env captured, so the *other* instance may receive + // it. Logging the mismatch lets a user know their terminals are wired to the + // wrong Orca. + const clientEnv = readStringField(record, 'env') + if (clientEnv && clientEnv !== expectedEnv) { + const key = `${clientEnv}->${expectedEnv}` + if (!warnedEnvs.has(key)) { + warnedEnvs.add(key) + console.warn( + `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + + 'Likely a stale terminal from another Orca install.' + ) + } + } + + const tabId = readStringField(record, 'tabId') + const worktreeId = readStringField(record, 'worktreeId') + + const eventName = (hookPayload as Record).hook_event_name + const promptText = extractPromptText(hookPayload as Record) + console.log('[agent-hooks:server] incoming', { + source, + paneKey, + tabId, + worktreeId, + eventName, + promptTextLen: promptText.length, + promptPreview: promptText.slice(0, 80), + cachedPrompt: lastPromptByPaneKey.get(paneKey)?.slice(0, 80) ?? null, + hookPayloadKeys: Object.keys(hookPayload as Record) + }) + const payload = + source === 'claude' + ? normalizeClaudeEvent(eventName, promptText, paneKey) + : source === 'codex' + ? normalizeCodexEvent(eventName, promptText, paneKey) + : normalizeGeminiEvent(eventName, promptText, paneKey) + + console.log('[agent-hooks:server] normalized', { + paneKey, + payload: payload + ? { + state: payload.state, + promptLen: payload.prompt.length, + prompt: payload.prompt.slice(0, 80) + } + : null + }) + return payload ? { paneKey, tabId, worktreeId, payload } : null } export class AgentHookServer { private server: ReturnType | null = null private port = 0 private token = '' + // Why: identifies this Orca instance so hook scripts can stamp requests and + // the server can detect dev vs. prod cross-talk. Set at start() from the + // caller's knowledge of whether this is a packaged build. + private env = 'production' private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener } - async start(): Promise { + async start(options?: { env?: string }): Promise { if (this.server) { return } + if (options?.env) { + this.env = options.env + } this.token = randomUUID() this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { @@ -142,17 +285,30 @@ export class AgentHookServer { return } + // Why: bound request time so a slow/stalled client cannot hold a socket + // open indefinitely (slowloris-style). The hook endpoints are local and + // should complete in well under a second. + req.setTimeout(5000, () => { + req.destroy() + }) + try { const body = await readJsonBody(req) - const source = - req.url === '/hook/claude' ? 'claude' : req.url === '/hook/codex' ? 'codex' : null + const source: AgentHookSource | null = + req.url === '/hook/claude' + ? 'claude' + : req.url === '/hook/codex' + ? 'codex' + : req.url === '/hook/gemini' + ? 'gemini' + : null if (!source) { res.writeHead(404) res.end() return } - const payload = normalizeHookPayload(source, body) + const payload = normalizeHookPayload(source, body, this.env) if (payload) { this.onAgentStatus?.(payload) } @@ -168,14 +324,29 @@ export class AgentHookServer { }) await new Promise((resolve, reject) => { - this.server!.once('error', reject) - this.server!.listen(0, '127.0.0.1', () => { + // Why: the startup error handler must only reject the start() promise for + // errors that happen before 'listening'. Without swapping it out on + // success, any later runtime error (e.g. EADDRINUSE during rebind, + // socket errors) would call reject() on an already-settled promise and, + // more importantly, leaving it as the only 'error' listener means node + // treats runtime errors as unhandled and crashes the main process. + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) const address = this.server!.address() if (address && typeof address === 'object') { this.port = address.port } resolve() - }) + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) }) } @@ -184,6 +355,7 @@ export class AgentHookServer { this.server = null this.port = 0 this.token = '' + this.env = 'production' this.onAgentStatus = null } @@ -194,7 +366,9 @@ export class AgentHookServer { return { ORCA_AGENT_HOOK_PORT: String(this.port), - ORCA_AGENT_HOOK_TOKEN: this.token + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION } } } diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index fff151e1..511b39f1 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,7 +1,7 @@ import { homedir } from 'os' import { join } from 'path' import { app } from 'electron' -import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { readHooksJson, removeManagedCommands, @@ -51,7 +51,7 @@ function getManagedScript(): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/claude') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/claude') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, 'exit /b 0', '' ].join('\r\n') @@ -66,7 +66,11 @@ function getManagedScript(): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', - `body=$(printf '{"paneKey":"%s","payload":%s}' "$ORCA_PANE_KEY" "$payload")`, + // Why: routing/version metadata is included alongside the raw hook payload + // so the receiver can (a) group panes by tab/worktree without round-tripping + // through paneKey parsing, (b) warn on dev/prod cross-talk, and (c) detect + // stale managed scripts installed by an older app build. + `body=$(printf '{"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","payload":%s}' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" "$payload")`, 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', ' -H "Content-Type: application/json" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', @@ -91,19 +95,40 @@ export class ClaudeHookService { } } - const managedHooksPresent = Object.values(config.hooks ?? {}).some((definitions) => - definitions.some((definition) => - (definition.hooks ?? []).some((hook) => hook.command === getManagedCommand(scriptPath)) + // Why: Report `partial` when only some managed events are registered so the + // sidebar surfaces a degraded install rather than a false-positive + // `installed`. Each CLAUDE_EVENTS entry must contain the managed command for + // the integration to function end-to-end. + const command = getManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const event of CLAUDE_EVENTS) { + const definitions = Array.isArray(config.hooks?.[event.eventName]) + ? config.hooks![event.eventName]! + : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) ) - ) - - return { - agent: 'claude', - state: managedHooksPresent ? 'installed' : 'not_installed', - configPath, - managedHooksPresent, - detail: null + if (hasCommand) { + presentCount += 1 + } else { + missing.push(event.eventName) + } } + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'claude', state, configPath, managedHooksPresent, detail } } install(): AgentHookInstallStatus { diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 780be5bb..1fff0941 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,7 +1,7 @@ import { homedir } from 'os' import { join } from 'path' import { app } from 'electron' -import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { readHooksJson, removeManagedCommands, @@ -40,7 +40,7 @@ function getManagedScript(): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/codex') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/codex') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, 'exit /b 0', '' ].join('\r\n') @@ -55,7 +55,11 @@ function getManagedScript(): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', - `body=$(printf '{"paneKey":"%s","payload":%s}' "$ORCA_PANE_KEY" "$payload")`, + // Why: routing/version metadata is included alongside the raw hook payload + // so the receiver can (a) group panes by tab/worktree without round-tripping + // through paneKey parsing, (b) warn on dev/prod cross-talk, and (c) detect + // stale managed scripts installed by an older app build. + `body=$(printf '{"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","payload":%s}' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" "$payload")`, 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', ' -H "Content-Type: application/json" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', @@ -80,19 +84,39 @@ export class CodexHookService { } } - const managedHooksPresent = Object.values(config.hooks ?? {}).some((definitions) => - definitions.some((definition) => - (definition.hooks ?? []).some((hook) => hook.command === getManagedCommand(scriptPath)) + // Why: Report `partial` when only some managed events are registered so the + // sidebar surfaces a degraded install rather than a false-positive + // `installed`. Each CODEX_EVENTS entry must contain the managed command for + // the integration to function end-to-end (e.g. PreToolUse is required for + // permission-prompt detection per the comment above). + const command = getManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const eventName of CODEX_EVENTS) { + const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) ) - ) - - return { - agent: 'codex', - state: managedHooksPresent ? 'installed' : 'not_installed', - configPath, - managedHooksPresent, - detail: null + if (hasCommand) { + presentCount += 1 + } else { + missing.push(eventName) + } } + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'codex', state, configPath, managedHooksPresent, detail } } install(): AgentHookInstallStatus { diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts new file mode 100644 index 00000000..cccfd113 --- /dev/null +++ b/src/main/gemini/hook-service.ts @@ -0,0 +1,191 @@ +import { homedir } from 'os' +import { join } from 'path' +import { app } from 'electron' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + readHooksJson, + removeManagedCommands, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' + +// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when +// it completes. `AfterTool` marks the resumption of model work after a tool +// call, which maps back to `working`. Gemini has no permission-prompt hook +// (approvals flow through inline UI), so Orca cannot surface a waiting state +// for Gemini — that is an upstream limitation, not an Orca bug. +const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool'] as const + +function getConfigPath(): string { + return join(homedir(), '.gemini', 'settings.json') +} + +function getManagedScriptPath(): string { + return join( + app.getPath('userData'), + 'agent-hooks', + process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' + ) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` +} + +function getManagedScript(): string { + if (process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: Gemini expects valid JSON on stdout even when the hook has nothing + // to return. Emit `{}` first so the agent never stalls parsing our + // output, even if the env-var guards below cause an early exit. + 'echo {}', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', + 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/gemini') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + 'exit /b 0', + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: Gemini expects valid JSON on stdout even when the hook has nothing + // to return. Emit `{}` first so the agent never stalls parsing our output, + // even if the env-var guards below cause an early exit. + 'printf "{}\\n"', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + // Why: routing/version metadata is included alongside the raw hook payload + // so the receiver can (a) group panes by tab/worktree without round-tripping + // through paneKey parsing, (b) warn on dev/prod cross-talk, and (c) detect + // stale managed scripts installed by an older app build. + `body=$(printf '{"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","payload":%s}' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" "$payload")`, + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\', + ' -H "Content-Type: application/json" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-binary "$body" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +export class GeminiHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'gemini', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Gemini settings.json' + } + } + + const command = getManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const eventName of GEMINI_EVENTS) { + const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) + ) + if (hasCommand) { + presentCount += 1 + } else { + missing.push(eventName) + } + } + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'gemini', state, configPath, managedHooksPresent, detail } + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'gemini', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Gemini settings.json' + } + } + + const command = getManagedCommand(scriptPath) + const nextHooks = { ...config.hooks } + + for (const eventName of GEMINI_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, (currentCommand) => currentCommand === command) + const definition: HookDefinition = { + hooks: [{ type: 'command', command }] + } + nextHooks[eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, config) + return this.getStatus() + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'gemini', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Gemini settings.json' + } + } + + const command = getManagedCommand(scriptPath) + const nextHooks = { ...config.hooks } + for (const [eventName, definitions] of Object.entries(nextHooks)) { + const cleaned = removeManagedCommands( + definitions, + (currentCommand) => currentCommand === command + ) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const geminiHookService = new GeminiHookService() diff --git a/src/main/index.ts b/src/main/index.ts index 92c0f2f1..d9d835c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,6 +38,7 @@ import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' import { claudeHookService } from './claude/hook-service' import { codexHookService } from './codex/hook-service' +import { geminiHookService } from './gemini/hook-service' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -137,11 +138,25 @@ function openMainWindow(): BrowserWindow { } }) mainWindow = window - agentHookServer.setListener(({ paneKey, payload }) => { + agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => { if (mainWindow?.isDestroyed()) { + console.log('[agent-hooks:main] drop (window destroyed)', { paneKey }) return } - mainWindow?.webContents.send('agentStatus:set', { paneKey, ...payload }) + console.log('[agent-hooks:main] forward to renderer', { + paneKey, + tabId, + worktreeId, + state: payload.state, + promptLen: payload.prompt.length, + promptPreview: payload.prompt.slice(0, 80) + }) + mainWindow?.webContents.send('agentStatus:set', { + paneKey, + tabId, + worktreeId, + ...payload + }) }) return window } @@ -172,7 +187,8 @@ app.whenReady().then(async () => { // Startup must fail open so a malformed local config never bricks Orca. for (const installManagedHooks of [ () => claudeHookService.install(), - () => codexHookService.install() + () => codexHookService.install(), + () => geminiHookService.install() ]) { try { installManagedHooks() @@ -245,7 +261,7 @@ app.whenReady().then(async () => { openCodeHookService.start().catch((error) => { console.error('[opencode] Failed to start local hook server:', error) }), - agentHookServer.start().catch((error) => { + agentHookServer.start({ env: app.isPackaged ? 'production' : 'development' }).catch((error) => { // Why: Claude/Codex hook callbacks are sidebar enrichment only. Orca must // still boot even if the local loopback receiver cannot bind on this launch. console.error('[agent-hooks] Failed to start local hook server:', error) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 5bb67620..6f1e87f2 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' +import { geminiHookService } from '../gemini/hook-service' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a @@ -16,4 +17,8 @@ export function registerAgentHookHandlers(): void { 'agentHooks:codexStatus', (): AgentHookInstallStatus => codexHookService.getStatus() ) + ipcMain.handle( + 'agentHooks:geminiStatus', + (): AgentHookInstallStatus => geminiHookService.getStatus() + ) } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2701ca97..dacb23d6 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3,7 +3,7 @@ main-process module so spawn-time environment scoping, lifecycle cleanup, foreground-process inspection, and renderer IPC stay behind a single audited boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ -import { join } from 'path' +import { join, delimiter } from 'path' import { type BrowserWindow, ipcMain, app } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -192,7 +192,7 @@ export function registerPtyHandlers( const devUserData = app.getPath('userData') baseEnv.ORCA_USER_DATA_PATH ??= devUserData const devCliBin = join(devUserData, 'cli', 'bin') - baseEnv.PATH = `${devCliBin}:${baseEnv.PATH ?? ''}` + baseEnv.PATH = `${devCliBin}${delimiter}${baseEnv.PATH ?? ''}` } return baseEnv @@ -325,11 +325,18 @@ export function registerPtyHandlers( } ) => { const provider = getProvider(args.connectionId) + // Why: agent hook env (ORCA_AGENT_HOOK_PORT/TOKEN) is normally injected by + // the LocalPtyProvider's buildSpawnEnv. When the daemon is active, the + // local provider is replaced by DaemonPtyAdapter and buildSpawnEnv never + // runs — so hook receivers can't find the loopback server. Inject here + // as well so both provider paths get the env. + const hookEnv = agentHookServer.buildPtyEnv() + const effectiveEnv = Object.keys(hookEnv).length > 0 ? { ...args.env, ...hookEnv } : args.env const result = await provider.spawn({ cols: args.cols, rows: args.rows, cwd: args.cwd, - env: args.env, + env: effectiveEnv, command: args.command, worktreeId: args.worktreeId, sessionId: args.sessionId diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index b6de7b99..92a32266 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -156,6 +156,7 @@ type CliApi = { type AgentHooksApi = { claudeStatus: () => Promise codexStatus: () => Promise + geminiStatus: () => Promise } type NotificationsApi = { @@ -202,14 +203,19 @@ type AgentStatusApi = { onSet: ( callback: (data: { paneKey: string + tabId?: string + worktreeId?: string state: string - summary?: string - next?: string + prompt?: string agentType?: string }) => void ) => () => void } +// Why: Only locally-defined *Api types are listed here. Keys like preflight, +// hooks, cache, session, updater, fs, git, ui, and runtime are inherited via +// the PreloadApi intersection (see ./api-types), so re-declaring them would +// reference undefined type names and risk drifting from the canonical surface. type Api = PreloadApi & { repos: ReposApi worktrees: WorktreesApi @@ -219,17 +225,8 @@ type Api = PreloadApi & { settings: SettingsApi cli: CliApi agentHooks: AgentHooksApi - preflight: PreflightApi notifications: NotificationsApi shell: ShellApi - hooks: HooksApi - cache: CacheApi - session: SessionApi - updater: UpdaterApi - fs: FsApi - git: GitApi - ui: UIApi - runtime: RuntimeApi agentStatus: AgentStatusApi } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d9dd2a7..7e359858 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -457,7 +457,10 @@ const api = { agentHooks: { claudeStatus: (): Promise => ipcRenderer.invoke('agentHooks:claudeStatus'), - codexStatus: (): Promise => ipcRenderer.invoke('agentHooks:codexStatus') + codexStatus: (): Promise => + ipcRenderer.invoke('agentHooks:codexStatus'), + geminiStatus: (): Promise => + ipcRenderer.invoke('agentHooks:geminiStatus') }, preflight: { @@ -1344,9 +1347,10 @@ const api = { onSet: ( callback: (data: { paneKey: string + tabId?: string + worktreeId?: string state: string - summary?: string - next?: string + prompt?: string agentType?: string }) => void ): (() => void) => { @@ -1354,9 +1358,10 @@ const api = { _event: Electron.IpcRendererEvent, data: { paneKey: string + tabId?: string + worktreeId?: string state: string - summary?: string - next?: string + prompt?: string agentType?: string } ) => callback(data) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4af24d58..4e13491e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -564,10 +564,10 @@ function App(): React.JSX.Element { return } - // Cmd/Ctrl+Shift+D — toggle right sidebar / agent dashboard tab + // Cmd/Ctrl+Shift+D — open right sidebar (agent dashboard is now + // docked at the sidebar bottom, so only toggle visibility). if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { e.preventDefault() - actions.setRightSidebarTab('dashboard') actions.setRightSidebarOpen(true) } } diff --git a/src/renderer/src/components/AgentStatusBadge.tsx b/src/renderer/src/components/AgentStatusBadge.tsx new file mode 100644 index 00000000..f9b14d59 --- /dev/null +++ b/src/renderer/src/components/AgentStatusBadge.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { AgentIcon } from '@/lib/agent-catalog' +import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import type { AgentType } from '../../../shared/agent-status-types' + +// Why: single shared primitive for rendering an agent in a list. The agent +// icon *is* the status indicator — state is conveyed by a colored ring and +// optional animation around the icon, not by a separate dot. The tooltip +// surfaces both the agent name and its current state so hover gives full +// attribution without consuming horizontal space in the row. + +export type AgentStatusBadgeState = + | 'working' + | 'blocked' + | 'waiting' + | 'done' + | 'idle' + // Why: heuristic rows derived from terminal titles only distinguish "needs + // attention" as a single state; render it the same as `blocked`. + | 'permission' + +function stateLabel(state: AgentStatusBadgeState): string { + switch (state) { + case 'working': + return 'Working' + case 'blocked': + return 'Blocked' + case 'waiting': + return 'Waiting for input' + case 'done': + return 'Done' + case 'idle': + return 'Idle' + case 'permission': + return 'Needs attention' + } +} + +// Why: for non-spinning states the ring is just a solid outline on the +// wrapper. Working is handled separately below (spinning arc overlay) because +// a full solid ring with `animate-spin` would look static — there's no visible +// rotation without an asymmetry in the stroke. +function staticRingClasses(state: AgentStatusBadgeState): string { + switch (state) { + case 'blocked': + case 'waiting': + case 'permission': + return 'ring-2 ring-amber-500 ring-inset animate-pulse' + case 'done': + return 'ring-2 ring-sky-500/70 ring-inset' + case 'idle': + return 'ring-1 ring-zinc-400/30 ring-inset' + case 'working': + return '' // handled by overlay + } +} + +type Props = { + agentType: AgentType | null | undefined + state: AgentStatusBadgeState + size?: number + className?: string +} + +export const AgentStatusBadge = React.memo(function AgentStatusBadge({ + agentType, + state, + size = 12, + className +}: Props): React.JSX.Element { + const iconAgent = agentTypeToIconAgent(agentType) + const agentLabel = formatAgentTypeLabel(agentType) + const tooltip = `${agentLabel} — ${stateLabel(state)}` + + // Why: the ring sits on a padded wrapper (not the icon itself) so the + // visible gap between icon and ring is consistent regardless of icon size. + // Total box is size + 2*padding; the working overlay matches that box. + const padding = Math.max(2, Math.round(size / 6)) + const box = size + padding * 2 + + return ( + + + + + {state === 'working' && ( + // Why: a spinning arc around the icon. The ring has a colored top + // border and transparent bottom so rotation is visible; inset keeps + // the arc aligned with the outline used by the other states. + + )} + + + + {tooltip} + + + ) +}) diff --git a/src/renderer/src/components/dashboard/AgentDashboard.tsx b/src/renderer/src/components/dashboard/AgentDashboard.tsx index e81beee9..565447ea 100644 --- a/src/renderer/src/components/dashboard/AgentDashboard.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboard.tsx @@ -1,12 +1,43 @@ -import React, { useState, useCallback, useMemo } from 'react' +import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react' +import { useAppStore } from '@/store' import { useDashboardData } from './useDashboardData' import { useDashboardFilter } from './useDashboardFilter' import { useDashboardKeyboard } from './useDashboardKeyboard' +import { useRetainedAgents } from './useRetainedAgents' import DashboardFilterBar from './DashboardFilterBar' import DashboardRepoGroup from './DashboardRepoGroup' const AgentDashboard = React.memo(function AgentDashboard() { - const groups = useDashboardData() + const liveGroups = useDashboardData() + // Why: useRetainedAgents keeps a "done" row visible after the terminal/pane + // is closed and the explicit status entry is evicted from the store. Without + // this, a completed agent flips to 'idle' (from the heuristic title scan) or + // vanishes entirely — and the user loses the signal that the agent finished. + // Retained rows are dismissed when the user clicks through to the worktree. + const { + enrichedGroups: groups, + dismissWorktreeAgents, + dismissAgent + } = useRetainedAgents(liveGroups) + const removeAgentStatus = useAppStore((s) => s.removeAgentStatus) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const setActiveTab = useAppStore((s) => s.setActiveTab) + const setActiveView = useAppStore((s) => s.setActiveView) + + // Why: the store's explicit status entry persists after an agent reports + // `done` until the pane actually exits — which may be much later, since the + // user often leaves the Claude/Codex session alive to review output. The + // per-row dismiss removes both the live store entry and any retained entry + // so done agents don't pile up indefinitely in the dashboard. + const handleDismissAgent = useCallback( + (paneKey: string) => { + if (!paneKey.startsWith('heuristic:')) { + removeAgentStatus(paneKey) + } + dismissAgent(paneKey) + }, + [removeAgentStatus, dismissAgent] + ) // Why: checkedWorktreeIds tracks worktrees the user has navigated into. These // are hidden from the 'active' filter so done agents disappear once the user @@ -21,9 +52,36 @@ const AgentDashboard = React.memo(function AgentDashboard() { const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) const [focusedWorktreeId, setFocusedWorktreeId] = useState(null) - const handleCheckWorktree = useCallback((worktreeId: string) => { - setCheckedWorktreeIds((prev) => new Set(prev).add(worktreeId)) - }, []) + // Why: the keyboard hook scopes its listener to this container (not window) + // so dashboard shortcuts (1-5, arrows, Enter, Escape) don't hijack the + // terminal or other focused inputs when the dashboard pane is merely open. + const containerRef = useRef(null) + + const handleCheckWorktree = useCallback( + (worktreeId: string) => { + setCheckedWorktreeIds((prev) => new Set(prev).add(worktreeId)) + // Why: when the user clicks a done worktree, they've acknowledged it. + // Dismiss retained rows so they stop lingering in the list. + dismissWorktreeAgents(worktreeId) + }, + [dismissWorktreeAgents] + ) + + // Why: clicking an agent row takes the user to the specific tab the agent + // ran in, not just the worktree's last-active tab. Retained rows can outlive + // their pane — fall back to worktree-only activation when the tab is no + // longer present so the click still lands somewhere useful. + const handleActivateAgentTab = useCallback( + (worktreeId: string, tabId: string) => { + setActiveWorktree(worktreeId) + setActiveView('terminal') + const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] + if (tabs.some((t) => t.id === tabId)) { + setActiveTab(tabId) + } + }, + [setActiveWorktree, setActiveTab, setActiveView] + ) const toggleCollapse = useCallback((repoId: string) => { setCollapsedRepos((prev) => { @@ -43,9 +101,18 @@ const AgentDashboard = React.memo(function AgentDashboard() { focusedWorktreeId, setFocusedWorktreeId, filter, - setFilter + setFilter, + containerRef }) + // Why: focus the container on mount so keyboard shortcuts work immediately + // without requiring an initial click inside the dashboard. tabIndex={-1} + // on the container makes it programmatically focusable without inserting + // it into the tab order. + useEffect(() => { + containerRef.current?.focus() + }, []) + // Summary stats across all repos (unfiltered) const stats = useMemo(() => { let running = 0 @@ -80,29 +147,35 @@ const AgentDashboard = React.memo(function AgentDashboard() { } return ( -
-
-
- {stats.running > 0 && ( - - {stats.running} running - - )} - {stats.blocked > 0 && ( - - {stats.blocked} blocked - - )} - {stats.done > 0 && ( - - {stats.done} done - - )} - {stats.running === 0 && stats.blocked === 0 && stats.done === 0 && ( - No active agents - )} +
+ {/* Why: hide the stats strip entirely when there's nothing to count — + the empty-state message in the main panel already tells the user + there's no activity, and showing both reads as duplicated chrome. */} + {(stats.running > 0 || stats.blocked > 0 || stats.done > 0) && ( +
+
+ {stats.running > 0 && ( + + {stats.running} running + + )} + {stats.blocked > 0 && ( + + {stats.blocked} blocked + + )} + {stats.done > 0 && ( + + {stats.done} done + + )} +
-
+ )}
@@ -120,12 +193,20 @@ const AgentDashboard = React.memo(function AgentDashboard() { focusedWorktreeId={focusedWorktreeId} onFocusWorktree={setFocusedWorktreeId} onCheckWorktree={handleCheckWorktree} + onDismissAgent={handleDismissAgent} + onActivateAgentTab={handleActivateAgentTab} /> )) ) : (
- {filter === 'active' ? 'All agents are idle.' : 'No worktrees match this filter.'} + {filter === 'active' + ? 'No agents need your attention.' + : filter === 'blocked' + ? 'No agents are blocked.' + : filter === 'done' + ? 'No completed agents to show.' + : 'No agent activity yet.'}
{filter !== 'all' && ( + + + Dismiss + + + )} + + )}
) }) diff --git a/src/renderer/src/components/dashboard/DashboardFilterBar.tsx b/src/renderer/src/components/dashboard/DashboardFilterBar.tsx index 6069b2e7..329d6201 100644 --- a/src/renderer/src/components/dashboard/DashboardFilterBar.tsx +++ b/src/renderer/src/components/dashboard/DashboardFilterBar.tsx @@ -3,9 +3,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import type { DashboardFilter } from './useDashboardFilter' const FILTERS: { value: DashboardFilter; label: string }[] = [ - { value: 'active', label: 'Active' }, { value: 'all', label: 'All' }, - { value: 'working', label: 'Working' }, + { value: 'active', label: 'Active' }, { value: 'blocked', label: 'Blocked' }, { value: 'done', label: 'Done' } ] diff --git a/src/renderer/src/components/dashboard/DashboardRepoGroup.tsx b/src/renderer/src/components/dashboard/DashboardRepoGroup.tsx index d256155e..1b17bea1 100644 --- a/src/renderer/src/components/dashboard/DashboardRepoGroup.tsx +++ b/src/renderer/src/components/dashboard/DashboardRepoGroup.tsx @@ -11,6 +11,8 @@ type Props = { focusedWorktreeId: string | null onFocusWorktree: (worktreeId: string) => void onCheckWorktree: (worktreeId: string) => void + onDismissAgent: (paneKey: string) => void + onActivateAgentTab: (worktreeId: string, tabId: string) => void } const DashboardRepoGroup = React.memo(function DashboardRepoGroup({ @@ -19,13 +21,15 @@ const DashboardRepoGroup = React.memo(function DashboardRepoGroup({ onToggleCollapse, focusedWorktreeId, onFocusWorktree, - onCheckWorktree + onCheckWorktree, + onDismissAgent, + onActivateAgentTab }: Props) { const totalAgents = group.worktrees.reduce((sum, wt) => sum + wt.agents.length, 0) const Icon = isCollapsed ? ChevronRight : ChevronDown return ( -
+
{/* Repo header */} + + Agents + +
+ + {/* Body: full AgentDashboard */} + {!collapsed && ( +
+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 56584bba..7a675ebf 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react' -import { Files, Search, GitBranch, ListChecks, LayoutDashboard, PanelRight } from 'lucide-react' +import { Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' @@ -19,7 +19,7 @@ import FileExplorer from './FileExplorer' import SourceControl from './SourceControl' import SearchPanel from './Search' import ChecksPanel from './ChecksPanel' -import AgentDashboard from '../dashboard/AgentDashboard' +import DashboardBottomPanel from './DashboardBottomPanel' const MIN_WIDTH = 220 // Why: long file names (e.g. construction drawing sheets, multi-part document @@ -112,12 +112,6 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [ title: 'Checks', shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`, gitOnly: true - }, - { - id: 'dashboard', - icon: LayoutDashboard, - title: 'Agent Dashboard', - shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}D` } ] @@ -170,11 +164,17 @@ function RightSidebarInner(): React.JSX.Element { that froze the app for seconds on Windows. Each panel now reacts to activeWorktreeId changes via store subscriptions and reset effects, keeping the component instance alive across switches. */} - {effectiveTab === 'explorer' && } - {effectiveTab === 'search' && } - {effectiveTab === 'source-control' && } - {effectiveTab === 'checks' && } - {effectiveTab === 'dashboard' && } + {/* Why: the active tab content takes the top of the sidebar. The agent + dashboard docks at the bottom regardless of which tab is selected, + so users keep a glanceable view of agent status while they browse + files, search, etc. */} +
+ {effectiveTab === 'explorer' && } + {effectiveTab === 'search' && } + {effectiveTab === 'source-control' && } + {effectiveTab === 'checks' && } +
+
) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 515c14de..8a7ae911 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -53,21 +53,24 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem const [hookStatuses, setHookStatuses] = useState<{ claude: AgentHookInstallStatus | null codex: AgentHookInstallStatus | null + gemini: AgentHookInstallStatus | null loading: boolean }>({ claude: null, codex: null, + gemini: null, loading: true }) const refreshHookStatus = useCallback(async (): Promise => { setHookStatuses((prev) => ({ ...prev, loading: true })) try { - const [claude, codex] = await Promise.all([ + const [claude, codex, gemini] = await Promise.all([ window.api.agentHooks.claudeStatus(), - window.api.agentHooks.codexStatus() + window.api.agentHooks.codexStatus(), + window.api.agentHooks.geminiStatus() ]) - setHookStatuses({ claude, codex, loading: false }) + setHookStatuses({ claude, codex, gemini, loading: false }) } catch { setHookStatuses((prev) => ({ ...prev, loading: false })) } @@ -281,14 +284,15 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem {/* Why: hooks are auto-installed at app startup. Surfacing the result as read-only status avoids a toggle whose "Remove" would be silently reverted on next launch. */} - Orca installs Claude and Codex global hooks at startup so agent lifecycle updates - flow into the sidebar automatically. + Orca installs Claude, Codex, and Gemini global hooks at startup so agent lifecycle + updates flow into the sidebar automatically.

{( [ ['claude', hookStatuses.claude], - ['codex', hookStatuses.codex] + ['codex', hookStatuses.codex], + ['gemini', hookStatuses.gemini] ] as const ).map(([agent, status]) => { const installed = status?.managedHooksPresent === true diff --git a/src/renderer/src/components/sidebar/AgentStatusHover.test.ts b/src/renderer/src/components/sidebar/AgentStatusHover.test.ts index cd525de4..478b380f 100644 --- a/src/renderer/src/components/sidebar/AgentStatusHover.test.ts +++ b/src/renderer/src/components/sidebar/AgentStatusHover.test.ts @@ -21,8 +21,7 @@ function makeTab(overrides: Partial = {}): TerminalTab { function makeEntry(overrides: Partial & { paneKey: string }): AgentStatusEntry { return { state: overrides.state ?? 'working', - summary: overrides.summary ?? '', - next: overrides.next ?? '', + prompt: overrides.prompt ?? '', updatedAt: overrides.updatedAt ?? NOW - 30_000, source: overrides.source ?? 'agent', agentType: overrides.agentType ?? 'codex', @@ -37,11 +36,11 @@ describe('buildAgentStatusHoverRows', () => { const rows = buildAgentStatusHoverRows( [makeTab({ id: 'tab-1', title: 'codex working' })], { - 'tab-1:1': makeEntry({ paneKey: 'tab-1:1', summary: 'Fix login bug' }), + 'tab-1:1': makeEntry({ paneKey: 'tab-1:1', prompt: 'Fix login bug' }), 'tab-1:2': makeEntry({ paneKey: 'tab-1:2', state: 'blocked', - summary: 'Waiting on failing test' + prompt: 'Waiting on failing test' }) }, NOW @@ -58,7 +57,7 @@ describe('buildAgentStatusHoverRows', () => { expect(rows[0]?.kind).toBe('heuristic') }) - it('keeps stale explicit summaries but orders by heuristic urgency', () => { + it('keeps stale explicit prompts but orders by heuristic urgency', () => { const rows = buildAgentStatusHoverRows( [ makeTab({ id: 'tab-a', title: 'codex permission needed' }), diff --git a/src/renderer/src/components/sidebar/AgentStatusHover.tsx b/src/renderer/src/components/sidebar/AgentStatusHover.tsx index bbbca928..46f8e834 100644 --- a/src/renderer/src/components/sidebar/AgentStatusHover.tsx +++ b/src/renderer/src/components/sidebar/AgentStatusHover.tsx @@ -3,16 +3,12 @@ import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/h import { useAppStore } from '@/store' import { detectAgentStatusFromTitle, - formatAgentTypeLabel, - inferAgentTypeFromTitle, + getAgentLabel, isExplicitAgentStatusFresh } from '@/lib/agent-status' import { cn } from '@/lib/utils' -import type { - AgentStatusEntry, - AgentStatusState, - AgentType -} from '../../../../shared/agent-status-types' +import { AgentStatusBadge, type AgentStatusBadgeState } from '@/components/AgentStatusBadge' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import { AGENT_STATUS_STALE_AFTER_MS as STALE_THRESHOLD_MS } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' @@ -41,31 +37,6 @@ type HoverRow = sortTimestamp: number } -function stateLabel(state: AgentStatusState): string { - switch (state) { - case 'working': - return 'Working' - case 'blocked': - return 'Blocked' - case 'waiting': - return 'Waiting for input' - case 'done': - return 'Done' - } -} - -function stateColor(state: AgentStatusState): string { - switch (state) { - case 'working': - return 'text-emerald-500' - case 'blocked': - case 'waiting': - return 'text-red-500' - case 'done': - return 'text-emerald-500' - } -} - function sortKeyForExplicit( explicit: AgentStatusEntry, heuristicState: 'working' | 'permission' | 'idle' | null, @@ -128,14 +99,22 @@ export function buildAgentStatusHoverRows( explicit, heuristicState, tabTitle, - agentType: - explicit.agentType ?? inferAgentTypeFromTitle(explicit.terminalTitle ?? tab.title), + agentType: explicit.agentType ?? 'unknown', sortTimestamp: explicit.updatedAt }) } continue } + // Why: a live PTY tab is not necessarily running an agent — the user may + // have opened a plain shell ("Terminal 1"). Only surface a heuristic row + // when the title actually looks like an agent (detectable status or a + // recognizable agent name); otherwise the hover falsely reports shells as + // "Idle Agent / No task details reported" and inflates "Running agents (N)". + if (heuristicState === null && getAgentLabel(tab.title) === null) { + continue + } + rows.push({ kind: 'heuristic', key: `heuristic:${tab.id}`, @@ -143,7 +122,10 @@ export function buildAgentStatusHoverRows( paneKey: null, heuristicState, tabTitle, - agentType: inferAgentTypeFromTitle(tab.title), + // Why: we no longer guess agent family from titles — the explicit + // agentType from the hook is the source of truth. Heuristic rows + // render with a neutral icon until the hook reports. + agentType: 'unknown', sortTimestamp: tab.createdAt }) } @@ -166,24 +148,19 @@ export function buildAgentStatusHoverRows( return rows } -function StateDot({ state }: { state: AgentStatusState }): React.JSX.Element { +// Why: the heuristic rollup only distinguishes 'working' / 'permission' / +// 'idle'. Map it into the shared badge state vocabulary so both the hover and +// dashboard render identical badges for "agent needs attention". +function heuristicToBadgeState( + state: 'working' | 'permission' | 'idle' | null +): AgentStatusBadgeState { if (state === 'working') { - return ( - - - - ) + return 'working' } - return ( - - - - ) + if (state === 'permission') { + return 'permission' + } + return 'idle' } function formatTimeAgo(updatedAt: number, now: number): string { @@ -203,64 +180,25 @@ function AgentRow({ row, now }: { row: HoverRow; now: number }): React.JSX.Eleme if (row.kind === 'explicit') { const isFresh = isExplicitAgentStatusFresh(row.explicit, now, STALE_THRESHOLD_MS) const shouldUseHeuristic = !isFresh && row.heuristicState !== null + const badgeState: AgentStatusBadgeState = shouldUseHeuristic + ? heuristicToBadgeState(row.heuristicState) + : row.explicit.state return (
- {shouldUseHeuristic ? ( - - - - ) : ( - - )} - - {shouldUseHeuristic - ? row.heuristicState === 'permission' - ? 'Needs attention' - : row.heuristicState === 'working' - ? 'Working' - : 'Idle' - : stateLabel(row.explicit.state)} - - - {formatAgentTypeLabel(row.agentType)} - + {formatTimeAgo(row.explicit.updatedAt, now)}
- {row.explicit.summary && ( -
- {row.explicit.summary} -
- )} - {row.explicit.next && ( -
- Next: {row.explicit.next} + {row.explicit.prompt && ( +
+ {row.explicit.prompt}
)} {!isFresh && ( -
+
Showing last reported task details; live terminal state has taken precedence.
)} @@ -268,35 +206,16 @@ function AgentRow({ row, now }: { row: HoverRow; now: number }): React.JSX.Eleme ) } - const heuristicLabel = - row.heuristicState === 'working' - ? 'Working' - : row.heuristicState === 'permission' - ? 'Needs attention' - : 'Idle' - return (
- - - - {heuristicLabel} - - {formatAgentTypeLabel(row.agentType)} - +
-
{row.tabTitle}
-
+
{row.tabTitle}
+
No task details reported
diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index ec795e8b..a9f0fb52 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -58,8 +58,7 @@ function makeAgentStatusEntry( ): AgentStatusEntry { return { state: overrides.state ?? 'working', - summary: overrides.summary ?? '', - next: overrides.next ?? '', + prompt: overrides.prompt ?? '', updatedAt: overrides.updatedAt ?? NOW - 30_000, source: overrides.source ?? 'agent', agentType: overrides.agentType ?? 'codex', diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0d192467..e8e52a41 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -161,9 +161,13 @@ export function connectPanePty( // Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their // callbacks to the correct Orca pane without resolving worktrees from cwd. // The key matches the `${tabId}:${paneId}` composite used for cacheTimerByKey. + // ORCA_TAB_ID / ORCA_WORKTREE_ID are exposed separately so the receiver has + // routing context without having to split paneKey back into its parts. const paneEnv = { ...paneStartup?.env, - ORCA_PANE_KEY: cacheKey + ORCA_PANE_KEY: cacheKey, + ORCA_TAB_ID: deps.tabId, + ORCA_WORKTREE_ID: deps.worktreeId } // Why: remote repos route PTY spawn through the SSH provider. Resolve the @@ -186,7 +190,14 @@ export function connectPanePty( onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited + onAgentExited, + // Why: forward OSC 9999 payloads from the PTY stream to the agent-status slice. + // Without this, the OSC parser in pty-transport strips sequences from xterm + // output but the status never reaches the store or dashboard/hover UI. + onAgentStatus: (payload) => { + const title = useAppStore.getState().runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + useAppStore.getState().setAgentStatus(cacheKey, payload, title) + } }) const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0 deps.paneTransportsRef.current.set(pane.id, transport) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 5ac2d53e..ecfaa729 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -41,7 +41,7 @@ export { extractLastOscTitle } from '../../../../shared/agent-detection' // ─── OSC 9999: agent status reporting ────────────────────────────────────── // Why OSC 9999: avoids known-used codes (7=cwd, 133=VS Code, 777=Superset, // 1337=iTerm2, 9001=Warp). Agents report structured status by printing -// printf '\x1b]9999;{"state":"working","summary":"...","next":"..."}\x07' +// printf '\x1b]9999;{"state":"working","prompt":"..."}\x07' // eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching const OSC_AGENT_STATUS_RE = /\x1b\]9999;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' @@ -104,6 +104,9 @@ function findAgentStatusTerminator( * escape sequence mid-payload and can leak raw control bytes into xterm. */ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk { + // Why: cap the pending buffer so a malformed or binary stream containing our + // OSC 9999 prefix without a valid terminator cannot grow memory unbounded. + const MAX_PENDING = 64 * 1024 let pending = '' return (data: string): ProcessedAgentStatusChunk => { @@ -126,7 +129,15 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen const terminator = findAgentStatusTerminator(combined, payloadStart) if (terminator === null) { - pending = combined.slice(start) + const candidate = combined.slice(start) + if (candidate.length > MAX_PENDING) { + // Why: drop the partial and treat what we held as plain output so a + // stream that never terminates the escape cannot leak memory. + cleanData += candidate + pending = '' + } else { + pending = candidate + } break } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 71dee441..0e2461a7 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -404,16 +404,22 @@ export function useIpcEvents(): void { // hook callback or an OSC fallback path. unsubs.push( window.api.agentStatus.onSet((data) => { - console.log('[agentStatus:set] Renderer received IPC:', data) + console.log('[agent-hooks:renderer] received IPC', { + paneKey: data.paneKey, + state: data.state, + promptLen: data.prompt?.length ?? 0, + promptPreview: data.prompt?.slice(0, 80) ?? null, + agentType: data.agentType + }) const payload = parseAgentStatusPayload( JSON.stringify({ state: data.state, - summary: data.summary, - next: data.next, + prompt: data.prompt, agentType: data.agentType }) ) if (!payload) { + console.log('[agent-hooks:renderer] drop (parse failed)', data) return } const store = useAppStore.getState() @@ -421,11 +427,29 @@ export function useIpcEvents(): void { // payloads do not include the current title, but the store may already // know it from OSC title tracking. const currentTitle = findTerminalTitleForPaneKey(store, data.paneKey) - console.log('[agentStatus:set] Storing:', { + // Why: a paneKey that no longer resolves to a live tab belongs to a pane + // that has already been torn down. Dropping here prevents orphan entries + // from accumulating in agentStatusByPaneKey. + const [tabId] = data.paneKey.split(':') + if (!tabId) { + console.log('[agent-hooks:renderer] drop (no tabId)', data) + return + } + const tabExists = Object.values(store.tabsByWorktree).some((tabs) => + tabs.some((t) => t.id === tabId) + ) + if (!tabExists) { + console.log('[agent-hooks:renderer] drop (tab not found)', { + paneKey: data.paneKey, + tabId + }) + return + } + console.log('[agent-hooks:renderer] setAgentStatus', { paneKey: data.paneKey, state: payload.state, - summary: payload.summary, - currentTitle + promptLen: payload.prompt.length, + promptPreview: payload.prompt.slice(0, 80) }) store.setAgentStatus(data.paneKey, payload, currentTitle) }) @@ -441,7 +465,18 @@ function findTerminalTitleForPaneKey( store: ReturnType, paneKey: string ): string | undefined { - const tabId = paneKey.split(':')[0] + const [tabId, paneIdRaw] = paneKey.split(':') + if (!tabId) { + return undefined + } + // Why: split panes track per-pane titles in runtimePaneTitlesByTabId; prefer + // the pane's own title over the tab-level (last-winning) title so agent type + // inference attributes status to the correct pane. + const paneTitles = store.runtimePaneTitlesByTabId?.[tabId] + const paneIdNum = paneIdRaw !== undefined ? Number(paneIdRaw) : NaN + if (paneTitles && !Number.isNaN(paneIdNum) && paneTitles[paneIdNum]) { + return paneTitles[paneIdNum] + } for (const tabs of Object.values(store.tabsByWorktree)) { const tab = tabs.find((t) => t.id === tabId) if (tab?.title) { diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 52050699..ee3cb338 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -8,7 +8,6 @@ import { clearWorkingIndicators, createAgentStatusTracker, getAgentLabel, - inferAgentTypeFromTitle, isGeminiTerminalTitle, normalizeTerminalTitle } from './agent-status' @@ -248,21 +247,6 @@ describe('getAgentLabel', () => { }) }) -describe('inferAgentTypeFromTitle', () => { - it('detects known agent types from terminal titles', () => { - expect(inferAgentTypeFromTitle('✦ Gemini CLI')).toBe('gemini') - expect(inferAgentTypeFromTitle('⠂ Claude Code')).toBe('claude') - expect(inferAgentTypeFromTitle('codex - permission needed')).toBe('codex') - expect(inferAgentTypeFromTitle('opencode running tests')).toBe('opencode') - expect(inferAgentTypeFromTitle('aider done')).toBe('aider') - }) - - it('returns unknown for plain shell titles', () => { - expect(inferAgentTypeFromTitle('bash')).toBe('unknown') - expect(inferAgentTypeFromTitle('')).toBe('unknown') - }) -}) - describe('createAgentStatusTracker', () => { // --- Claude Code: real captured OSC title sequence (v2.1.86) --- // CRITICAL: Claude Code changes the title to the TASK DESCRIPTION, diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 25face2b..f51de5d2 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -1,4 +1,4 @@ -import type { TerminalTab, Worktree } from '../../../shared/types' +import type { TerminalTab, TuiAgent, Worktree } from '../../../shared/types' import type { AgentStatusEntry, AgentStatusState, @@ -22,8 +22,6 @@ export { import { type AgentStatus, detectAgentStatusFromTitle, - isGeminiTerminalTitle, - isClaudeAgent, getAgentLabel } from '../../../shared/agent-detection' @@ -94,36 +92,6 @@ export function getWorkingAgentsPerWorktree({ return result } -function includesAgentName(title: string, name: string): boolean { - return title.toLowerCase().includes(name) -} - -// Why: inferAgentTypeFromTitle classifies a terminal title into a known agent -// family so the dashboard can display the correct agent icon/label without -// relying on the explicit hook-based status report (which may not be available -// for all agent types). -export function inferAgentTypeFromTitle(title: string | null | undefined): AgentType { - if (!title) { - return 'unknown' - } - if (isGeminiTerminalTitle(title)) { - return 'gemini' - } - if (isClaudeAgent(title)) { - return 'claude' - } - if (includesAgentName(title, 'codex')) { - return 'codex' - } - if (includesAgentName(title, 'opencode')) { - return 'opencode' - } - if (includesAgentName(title, 'aider')) { - return 'aider' - } - return 'unknown' -} - const WELL_KNOWN_LABELS: Record = { claude: 'Claude', codex: 'Codex', @@ -140,6 +108,18 @@ export function formatAgentTypeLabel(agentType: AgentType | null | undefined): s return WELL_KNOWN_LABELS[agentType] ?? agentType } +// Why: AgentIcon expects a TuiAgent, but AgentType is a broader union that +// includes 'unknown' and arbitrary strings. Normalize here so both the +// dashboard row and the sidebar hover pass the same value through to the icon +// — 'claude' is chosen as the neutral fallback because AgentIcon renders a +// letter glyph for unknown catalog entries anyway. +export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent { + if (!agentType || agentType === 'unknown') { + return 'claude' + } + return agentType as TuiAgent +} + // Why: explicit agent status entries (from hook-based reports) can go stale if // the agent process exits without sending a final update. This helper lets // callers decide whether to trust the entry based on a configurable TTL. diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 7ed6be45..7caaa86d 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -19,9 +19,7 @@ describe('agent status freshness expiry', () => { vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z')) const store = createTestStore() - store - .getState() - .setAgentStatus('tab-1:1', { state: 'working', summary: 'Fix tests', next: '' }, 'codex') + store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'Fix tests' }, 'codex') // setAgentStatus bumps epoch once synchronously expect(store.getState().agentStatusEpoch).toBe(1) @@ -40,9 +38,7 @@ describe('agent status freshness expiry', () => { vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z')) const store = createTestStore() - store - .getState() - .setAgentStatus('tab-1:1', { state: 'working', summary: 'Fix tests', next: '' }, 'codex') + store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'Fix tests' }, 'codex') // set bumps to 1, remove bumps to 2 store.getState().removeAgentStatus('tab-1:1') expect(store.getState().agentStatusEpoch).toBe(2) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 40162787..6dbb83c4 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -7,7 +7,6 @@ import { type AgentStatusEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { inferAgentTypeFromTitle } from '@/lib/agent-status' export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${paneId}` composite. @@ -83,13 +82,13 @@ export const createAgentStatusSlice: StateCreator AGENT_STATE_HISTORY_MAX) { history = history.slice(history.length - AGENT_STATE_HISTORY_MAX) @@ -98,15 +97,10 @@ export const createAgentStatusSlice: StateCreator delete nextCacheTimer[key] } } - // Why: agent status keys use the same `${tabId}:${paneId}` composite. - // Sweep all entries for the closing tab so the hover UI doesn't show - // status for terminals that no longer exist. - const nextAgentStatus = { ...s.agentStatusByPaneKey } - for (const key of Object.keys(nextAgentStatus)) { - if (key.startsWith(`${tabId}:`)) { - delete nextAgentStatus[key] - } - } // Why: keep activeTabIdByWorktree in sync when a tab is closed in a // background worktree. Without this, the remembered tab becomes stale // and restoring it on worktree switch falls back to tabs[0]. @@ -400,10 +391,13 @@ export const createTerminalSlice: StateCreator cacheTimerByKey: nextCacheTimer, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingSnapshotByPtyId: nextSnapshots, - pendingColdRestoreByPtyId: nextColdRestores, - agentStatusByPaneKey: nextAgentStatus + pendingColdRestoreByPtyId: nextColdRestores } }) + // Why: delegate agent-status cleanup to its own slice so the epoch and + // stale-freshness timer bookkeeping stay consistent with other agent-status + // mutations. + get().removeAgentStatusByTabPrefix(tabId) for (const tabs of Object.values(get().unifiedTabsByWorktree)) { const workspaceItem = tabs.find( (entry) => entry.contentType === 'terminal' && entry.entityId === tabId diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index e02816d0..7d4975ab 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -1,4 +1,4 @@ -export type AgentHookTarget = 'claude' | 'codex' +export type AgentHookTarget = 'claude' | 'codex' | 'gemini' export type AgentHookInstallState = 'installed' | 'not_installed' | 'partial' | 'error' @@ -9,3 +9,9 @@ export type AgentHookInstallStatus = { managedHooksPresent: boolean detail: string | null } + +// Why: bumped whenever the managed script's request shape changes. The +// receiver logs a warning when it sees a request from a different version so a +// stale script installed by an older app build is diagnosable instead of +// silently producing partial payloads. +export const ORCA_HOOK_PROTOCOL_VERSION = '1' diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 2e4d173c..92d0d4eb 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -4,12 +4,11 @@ import { parseAgentStatusPayload, AGENT_STATUS_MAX_FIELD_LENGTH } from './agent- describe('parseAgentStatusPayload', () => { it('parses a valid working payload', () => { const result = parseAgentStatusPayload( - '{"state":"working","summary":"Investigating test failures","next":"Fix the flaky assertion","agentType":"codex"}' + '{"state":"working","prompt":"Fix the flaky assertion","agentType":"codex"}' ) expect(result).toEqual({ state: 'working', - summary: 'Investigating test failures', - next: 'Fix the flaky assertion', + prompt: 'Fix the flaky assertion', agentType: 'codex' }) }) @@ -47,52 +46,46 @@ describe('parseAgentStatusPayload', () => { expect(parseAgentStatusPayload('[]')).toBeNull() }) - it('normalizes multiline summary to single line', () => { + it('normalizes multiline prompt to single line', () => { const result = parseAgentStatusPayload( - '{"state":"working","summary":"line one\\nline two\\nline three"}' + '{"state":"working","prompt":"line one\\nline two\\nline three"}' ) - expect(result!.summary).toBe('line one line two line three') + expect(result!.prompt).toBe('line one line two line three') }) it('normalizes Windows-style line endings (\\r\\n) to single line', () => { const result = parseAgentStatusPayload( - '{"state":"working","summary":"line one\\r\\nline two\\r\\nline three"}' + '{"state":"working","prompt":"line one\\r\\nline two\\r\\nline three"}' ) - expect(result!.summary).toBe('line one line two line three') + expect(result!.prompt).toBe('line one line two line three') }) - it('trims whitespace from fields', () => { - const result = parseAgentStatusPayload( - '{"state":"working","summary":" padded ","next":" also padded "}' - ) - expect(result!.summary).toBe('padded') - expect(result!.next).toBe('also padded') + it('trims whitespace from the prompt field', () => { + const result = parseAgentStatusPayload('{"state":"working","prompt":" padded "}') + expect(result!.prompt).toBe('padded') }) - it('truncates fields beyond max length', () => { + it('truncates the prompt beyond max length', () => { const longString = 'x'.repeat(300) - const result = parseAgentStatusPayload(`{"state":"working","summary":"${longString}"}`) - expect(result!.summary).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH) + const result = parseAgentStatusPayload(`{"state":"working","prompt":"${longString}"}`) + expect(result!.prompt).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH) }) - it('defaults missing summary and next to empty string', () => { + it('defaults missing prompt to empty string', () => { const result = parseAgentStatusPayload('{"state":"done"}') - expect(result!.summary).toBe('') - expect(result!.next).toBe('') + expect(result!.prompt).toBe('') }) - it('handles non-string summary/next gracefully', () => { - const result = parseAgentStatusPayload('{"state":"working","summary":42,"next":true}') - expect(result!.summary).toBe('') - expect(result!.next).toBe('') + it('handles non-string prompt gracefully', () => { + const result = parseAgentStatusPayload('{"state":"working","prompt":42}') + expect(result!.prompt).toBe('') }) it('accepts custom non-empty agentType values', () => { const result = parseAgentStatusPayload('{"state":"working","agentType":"cursor"}') expect(result).toEqual({ state: 'working', - summary: '', - next: '', + prompt: '', agentType: 'cursor' }) }) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 019101fb..d41a00f8 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -14,7 +14,7 @@ export type AgentType = WellKnownAgentType | (string & {}) /** A snapshot of a previous agent state, used to render activity blocks. */ export type AgentStateHistoryEntry = { state: AgentStatusState - summary: string + prompt: string /** When this state was first reported. */ startedAt: number } @@ -24,10 +24,11 @@ export const AGENT_STATE_HISTORY_MAX = 20 export type AgentStatusEntry = { state: AgentStatusState - /** Short description of what the agent is currently doing. */ - summary: string - /** Short description of what the agent plans to do next. */ - next: string + /** The user's most recent prompt, when the hook payload carried one. + * Cached across the turn — subsequent tool-use events in the same turn do + * not include the prompt, so the renderer receives the last known value + * until a new prompt arrives or the pane resets. Empty when unknown. */ + prompt: string /** Timestamp (ms) of the last status update. */ updatedAt: number /** Whether this entry was reported explicitly by the agent or inferred from heuristics. */ @@ -48,28 +49,26 @@ export type AgentStatusEntry = { export type AgentStatusPayload = { state: AgentStatusState - summary?: string - next?: string + prompt?: string agentType?: AgentType } /** - * The result of `parseAgentStatusPayload`: summary and next are always - * normalized to strings (empty string when the raw payload omits them), - * so consumers do not need nullish-coalescing on these fields. + * The result of `parseAgentStatusPayload`: prompt is always normalized to a + * string (empty string when the raw payload omits it), so consumers do not + * need nullish-coalescing on the field. */ export type ParsedAgentStatusPayload = { state: AgentStatusState - summary: string - next: string + prompt: string agentType?: AgentType } -/** Maximum character length for summary and next fields. Truncated on parse. */ +/** Maximum character length for the prompt field. Truncated on parse. */ export const AGENT_STATUS_MAX_FIELD_LENGTH = 200 /** * Freshness threshold for explicit agent status. After this point the hover still - * shows the last reported summary, but heuristic state regains precedence for + * shows the last reported prompt, but heuristic state regains precedence for * ordering and coarse status so stale "done" reports do not mask live prompts. */ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000 @@ -112,8 +111,7 @@ export function parseAgentStatusPayload(json: string): ParsedAgentStatusPayload } return { state: state as AgentStatusState, - summary: normalizeField(parsed.summary), - next: normalizeField(parsed.next), + prompt: normalizeField(parsed.prompt), agentType: typeof parsed.agentType === 'string' && parsed.agentType.trim().length > 0 ? parsed.agentType.trim().slice(0, AGENT_TYPE_MAX_LENGTH)