diff --git a/package.json b/package.json index 1f1ee26c3f6..45e1513e65f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.17-rc.2", + "version": "1.3.17", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/build/entitlements.mac.plist b/resources/build/entitlements.mac.plist index 0d3e45f0e10..bbe60166c73 100644 --- a/resources/build/entitlements.mac.plist +++ b/resources/build/entitlements.mac.plist @@ -2,8 +2,6 @@ - com.apple.security.device.audio-input - com.apple.security.cs.allow-dyld-environment-variables com.apple.security.cs.allow-jit diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts index 0af231a08bf..3f66235e54d 100644 --- a/src/cli/runtime-client.ts +++ b/src/cli/runtime-client.ts @@ -386,9 +386,10 @@ export function getDefaultUserDataPath( platform: NodeJS.Platform = process.platform, homeDir = homedir() ): string { - // Why: in dev mode, the Electron app writes runtime metadata to `orca-dev` - // instead of `orca` to avoid clobbering the production app's metadata. The - // CLI needs to find the same metadata file, so respect this env var override. + // Why: in dev mode (and for parallel Orca instances), the Electron app writes + // runtime metadata to a separate userData directory (e.g. `orca-dev`) to avoid + // clobbering the production app's metadata. The CLI needs to find the same + // metadata file, so this env var lets the CLI target a specific instance. if (process.env.ORCA_USER_DATA_PATH) { return process.env.ORCA_USER_DATA_PATH } diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts new file mode 100644 index 00000000000..906bceec451 --- /dev/null +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { createManagedCommandMatcher, writeHooksJson, type HooksConfig } from './installer-utils' + +let tmpDir: string +let configPath: string + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-installer-utils-test-')) + configPath = join(tmpDir, 'settings.json') +}) + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('writeHooksJson', () => { + it('writes the config as formatted JSON', () => { + const config: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] } + } + writeHooksJson(configPath, config) + const written = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(written).toEqual(config) + }) + + it('creates the directory if it does not exist', () => { + const nested = join(tmpDir, 'sub', 'dir', 'settings.json') + writeHooksJson(nested, {}) + expect(existsSync(nested)).toBe(true) + }) + + it('creates a .bak file from the previous content before overwriting', () => { + const original: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'original' }] }] } + } + writeFileSync(configPath, `${JSON.stringify(original, null, 2)}\n`, 'utf-8') + + const updated: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'updated' }] }] } + } + writeHooksJson(configPath, updated) + + const bak = JSON.parse(readFileSync(`${configPath}.bak`, 'utf-8')) + expect(bak).toEqual(original) + }) + + it('does not create a .bak file when the config does not yet exist', () => { + writeHooksJson(configPath, {}) + expect(existsSync(`${configPath}.bak`)).toBe(false) + }) + + it('is a no-op (does not rotate .bak) when the serialized content is unchanged', () => { + const config: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] } + } + writeHooksJson(configPath, config) + // First write had no prior file, so no .bak should exist. + expect(existsSync(`${configPath}.bak`)).toBe(false) + + // Writing identical content must not create or rotate the .bak file. + writeHooksJson(configPath, config) + expect(existsSync(`${configPath}.bak`)).toBe(false) + + // A second distinct write must still produce a .bak from the prior content, + // proving the no-op only triggers on byte-identical content. + const updated: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'bar' }] }] } + } + writeHooksJson(configPath, updated) + const bak = JSON.parse(readFileSync(`${configPath}.bak`, 'utf-8')) + expect(bak).toEqual(config) + }) + + it('updates the .bak file to the previous version on each write', () => { + const v1: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] } } + const v2: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] } } + const v3: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] } } + + writeHooksJson(configPath, v1) + writeHooksJson(configPath, v2) + writeHooksJson(configPath, v3) + + // .bak should hold v2 (the version before v3) + const bak = JSON.parse(readFileSync(`${configPath}.bak`, 'utf-8')) + expect(bak).toEqual(v2) + // configPath should hold v3 + const current = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(current).toEqual(v3) + }) + + it('leaves no temp file behind if the rename fails', () => { + // Why: verifies the atomic cleanup — if the rename cannot complete (here, + // because the target is a directory that cannot be overwritten), the finally + // block must remove the temp file so ~/.claude is not littered with orphans. + const blockingDir = configPath + mkdirSync(blockingDir) + + expect(() => writeHooksJson(blockingDir, { hooks: {} })).toThrow() + + const entries = readdirSync(tmpDir) + expect(entries.filter((e) => e.endsWith('.tmp'))).toHaveLength(0) + }) +}) + +describe('createManagedCommandMatcher', () => { + const match = createManagedCommandMatcher('claude-hook.sh') + + it('matches commands containing the agent-hooks/ path', () => { + expect( + match('/bin/sh "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh"') + ).toBe(true) + expect(match('/bin/sh "/some/other/location/agent-hooks/claude-hook.sh"')).toBe(true) + }) + + it('normalizes Windows backslashes so cmd-style paths still match', () => { + expect(match('C:\\Users\\alice\\AppData\\Roaming\\Orca\\agent-hooks\\claude-hook.sh')).toBe( + true + ) + }) + + it('returns false for unrelated commands', () => { + expect(match(undefined)).toBe(false) + expect(match('')).toBe(false) + expect(match('echo "user-authored hook"')).toBe(false) + // Same filename but not under an agent-hooks/ directory — treat as + // user-authored to avoid stomping on someone else's hook. + expect(match('/bin/sh "/home/alice/scripts/claude-hook.sh"')).toBe(false) + }) + + it('does not match hooks for a different agent', () => { + expect(match('/bin/sh "/path/agent-hooks/gemini-hook.sh"')).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts new file mode 100644 index 00000000000..2e92d9faad3 --- /dev/null +++ b/src/main/agent-hooks/installer-utils.ts @@ -0,0 +1,144 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + chmodSync, + copyFileSync, + renameSync, + unlinkSync +} from 'fs' +import { dirname, join } from 'path' +import { randomUUID } from 'crypto' + +export type HookCommandConfig = { + type: 'command' + command: string + timeout?: number + [key: string]: unknown +} + +export type HookDefinition = { + matcher?: string + hooks?: HookCommandConfig[] + [key: string]: unknown +} + +export type HooksConfig = { + hooks?: Record + [key: string]: unknown +} + +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readHooksJson(configPath: string): HooksConfig | null { + if (!existsSync(configPath)) { + return {} + } + + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + +// Why: callers in install/remove need to match not just the exact current +// managed command, but also stale entries pointing at old script paths — e.g. +// from a previous dev build with a different Electron userData dir, or a +// parallel dev/prod install. Matching by the managed script's file name +// (under any `agent-hooks/` directory) lets a fresh install sweep those +// without touching unrelated user-authored hooks. +export function createManagedCommandMatcher( + scriptFileName: string +): (command: string | undefined) => boolean { + const needle = `agent-hooks/${scriptFileName}` + return (command) => { + if (!command) { + return false + } + return command.replaceAll('\\', '/').includes(needle) + } +} + +export function removeManagedCommands( + definitions: HookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): HookDefinition[] { + return definitions.flatMap((definition) => { + if (!Array.isArray(definition.hooks)) { + return [definition] + } + + const filteredHooks = definition.hooks.filter((hook) => !isManagedCommand(hook.command)) + if (filteredHooks.length === 0) { + return [] + } + + return [{ ...definition, hooks: filteredHooks }] + }) +} + +export function writeManagedScript(scriptPath: string, content: string): void { + mkdirSync(dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, content, 'utf-8') + if (process.platform !== 'win32') { + chmodSync(scriptPath, 0o755) + } +} + +export function writeHooksJson(configPath: string, config: HooksConfig): void { + const dir = dirname(configPath) + mkdirSync(dir, { recursive: true }) + + // Why: write to a temp file then rename so a crash or disk-full mid-write + // leaves the original untouched. This is the only safe way to update a + // config file the user may have hand-edited. + // + // Why randomUUID: Date.now() alone collides when two install() calls fire in + // the same millisecond targeting the same dir (e.g. a future caller that + // installs multiple agents sharing a config dir, or rapid reinstalls from + // the settings UI). A collision would corrupt one of the two writes. The + // UUID suffix makes the tmp path unique per call. + const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) + const serialized = `${JSON.stringify(config, null, 2)}\n` + + // Why: skip the write (and therefore the .bak rotation) when the on-disk + // content is already identical. Without this, every install() rewrites the + // file and rolls the backup forward, which can silently destroy the last + // recoverable copy if install() is called repeatedly (e.g. on app start). + if (existsSync(configPath)) { + try { + if (readFileSync(configPath, 'utf-8') === serialized) { + return + } + } catch { + // Fall through to the normal write path — a read error here is not + // worth failing the install for; the atomic write below will either + // succeed or throw loudly. + } + } + + try { + writeFileSync(tmpPath, serialized, 'utf-8') + // Why: single rolling backup — one file, no accumulation in ~/.claude. + // Protects against a merge-logic bug producing bad JSON; the original is + // always recoverable from .bak until the next write. + if (existsSync(configPath)) { + copyFileSync(configPath, `${configPath}.bak`) + } + renameSync(tmpPath, configPath) + } finally { + // Clean up temp file if rename failed. + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort + } + } + } +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts new file mode 100644 index 00000000000..39ab766c114 --- /dev/null +++ b/src/main/agent-hooks/server.test.ts @@ -0,0 +1,706 @@ +/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { AgentHookServer, _internals } from './server' + +const PANE = 'tab-1:0' + +type Body = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + payload: Record +} + +function buildBody(payload: Record, overrides: Partial = {}): Body { + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + payload, + ...overrides + } +} + +beforeEach(() => { + _internals.resetCachesForTests() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AgentHookServer listener replay', () => { + it('replays the latest retained pane status when a listener attaches after windowless events', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'replay me' + }) + ) + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'working', + prompt: 'replay me', + agentType: 'claude' + }) + }) + } finally { + server.stop() + } + }) + + it('does not replay cleared pane state to a newly attached listener', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'clear me' + }) + ) + }) + + server.clearPaneState(PANE) + const listener = vi.fn() + server.setListener(listener) + + expect(listener).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('accepts form-encoded hook posts from Unix managed scripts', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const params = new URLSearchParams({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'repo::/tmp/worktree with "quotes"', + env: 'production', + version: env.ORCA_AGENT_HOOK_VERSION ?? '', + payload: JSON.stringify({ + hook_event_name: 'UserPromptSubmit', + prompt: 'form encoded' + }) + }) + + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: params + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'repo::/tmp/worktree with "quotes"', + payload: expect.objectContaining({ + state: 'working', + prompt: 'form encoded', + agentType: 'claude' + }) + }) + } finally { + server.stop() + } + }) +}) + +describe('Claude hook normalization', () => { + it('PostToolUse for Edit surfaces toolName + file_path preview', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts', old_string: 'a', new_string: 'b' }, + tool_response: {} + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Edit') + expect(result?.payload.toolInput).toBe('/src/config.ts') + }) + + it('PostToolUse for Bash surfaces the command string', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test --run' }, + tool_response: { content: [{ type: 'text', text: 'tests passed' }] } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('Bash') + expect(result?.payload.toolInput).toBe('pnpm test --run') + expect(result?.payload.lastAssistantMessage).toBe('tests passed') + }) + + it('PostToolUse for Grep surfaces the search pattern', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'foo.*bar', path: '/src' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('Grep') + expect(result?.payload.toolInput).toBe('foo.*bar') + }) + + it('PostToolUse for an unknown tool surfaces the name without input', () => { + // Why: we use a per-tool allowlist to decide which field to preview. + // Tools we do not recognize render as name-only rather than guessing at + // a field, which avoids noisy/misleading previews (e.g. an opaque ID). + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'BespokeTool', + tool_input: { irrelevantFlag: true, summary: 'doing the thing' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('BespokeTool') + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => { + // Why: TaskUpdate's tool_input (e.g. { task_id: "3", status: "in_progress" }) + // has no meaningful preview — rendering "3" is actively confusing. The + // allowlist approach leaves toolInput undefined for unlisted tools. + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'TaskUpdate', + tool_input: { task_id: '3', status: 'in_progress' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('TaskUpdate') + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('PostToolUseFailure surfaces the error text as lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUseFailure', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts' }, + error: 'file is read-only' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Edit') + expect(result?.payload.lastAssistantMessage).toBe('file is read-only') + }) + + it('PreToolUse normalizes to working + tool fields', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/src/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Read') + expect(result?.payload.toolInput).toBe('/src/index.ts') + }) + + it('UserPromptSubmit clears the cached tool state from the prior turn', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/stale.ts' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'Do the next thing' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('Do the next thing') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('Stop carries last_assistant_message directly when present', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'what is up my dude' + }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('what is up my dude') + }) + + describe('Stop transcript scan', () => { + let tmpDir: string + let transcriptPath: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-hook-test-')) + transcriptPath = join(tmpDir, 'transcript.jsonl') + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('surfaces the most recent assistant text entry', () => { + const lines = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', message: { role: 'assistant', content: 'earlier reply' } }, + { role: 'user', content: 'do it' }, + { + role: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'final reply' }] } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('final reply') + }) + + it('skips tool_use-only assistant entries to find the previous text reply', () => { + const lines = [ + { role: 'assistant', message: { role: 'assistant', content: 'the answer is 42' } }, + { + role: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('the answer is 42') + }) + + it('finds an assistant reply that sits past the first chunk boundary', () => { + // Why: a turn with many large tool_result entries pushes the final text + // reply well past the first 64 KB chunk; the chunked scan should keep + // reading backward until it finds it. + const filler = 'x'.repeat(70_000) + const lines = [ + { role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } }, + // 70 KB of tool_result content straddling the first chunk boundary. + { + role: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 't1', content: filler }] + } + }, + { + role: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('deeply buried reply') + }) + + it('returns undefined when the transcript has no assistant text at all', () => { + const lines = [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + }) + + it('merges tool fields across consecutive events in the same turn', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls -la' } + }), + 'production' + ) + // Stop event has no tool fields of its own — merged snapshot should still + // carry the earlier PreToolUse values. + const stop = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop' }), + 'production' + ) + expect(stop?.payload.state).toBe('done') + expect(stop?.payload.toolName).toBe('Bash') + expect(stop?.payload.toolInput).toBe('ls -la') + }) +}) + +describe('Codex hook normalization', () => { + it('Stop carries last_assistant_message into lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'Summary of what I did.' + }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('Summary of what I did.') + }) + + it('PreToolUse surfaces tool name + input preview and stays in working state', () => { + // Why: Codex's PreToolUse is NOT an approval prompt — it fires for every + // tool call. We map it to `working` (never `waiting`) and use it only to + // give the dashboard a live readout during the gap between prompt and + // Stop. Real approval signals flow through Codex's `notify` callback. + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'exec_command', + tool_input: { cmd: 'git status', workdir: '/tmp' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('exec_command') + expect(result?.payload.toolInput).toBe('git status') + }) + + it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => { + // Why: UserPromptSubmit is a turn-boundary event; any tool_name on it + // would be leftover noise and should not leak into the working-state + // preview. Tool extraction is gated to PreToolUse/PostToolUse. + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'Hello', + tool_name: 'Edit', + tool_input: { file_path: '/ignored.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('SessionStart clears cached tool state from a prior session', () => { + // Seed a Stop snapshot with an assistant message. + _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'Previous run finished' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SessionStart' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + + it('SessionStart clears the cached prompt from a prior session until a new prompt arrives', () => { + _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'stale prompt' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SessionStart' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('') + }) +}) + +describe('Gemini hook normalization', () => { + it('PreToolUse surfaces toolName + toolInput', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'read_file', + tool_input: { path: '/src/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('read_file') + expect(result?.payload.toolInput).toBe('/src/index.ts') + }) + + it('falls back to args when tool_input is absent', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'run_shell_command', + args: { command: 'git status' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('run_shell_command') + expect(result?.payload.toolInput).toBe('git status') + }) + + it('BeforeAgent clears the cached tool state from a prior turn', () => { + _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'read_file', + tool_input: { path: '/stale.ts' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ hook_event_name: 'BeforeAgent' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('AfterAgent reports done without introducing tool fields on its own', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ hook_event_name: 'AfterAgent' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.toolName).toBeUndefined() + }) + + it('AfterAgent carries prompt_response into lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'AfterAgent', + prompt: 'what did you do', + prompt_response: 'I ran the tests and they passed.', + stop_hook_active: false + }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('I ran the tests and they passed.') + }) +}) + +describe('OpenCode hook normalization', () => { + it('SessionBusy maps to working', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionBusy' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('opencode') + }) + + it('SessionBusy does NOT clear the cached user prompt', () => { + // Why: OpenCode emits the user's MessagePart (message.updated) *before* + // SessionBusy fires — the session goes idle→busy only after OpenCode begins + // processing the prompt. So the cached prompt at SessionBusy is the current + // turn's prompt, not the previous turn's. Clearing on SessionBusy would + // clobber the data the dashboard needs to render for this turn. + _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionBusy' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('new prompt') + }) + + it('SessionIdle maps to done', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionIdle' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.agentType).toBe('opencode') + }) + + it('PermissionRequest maps to waiting', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'PermissionRequest' }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + }) + + it('unknown event name returns null', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SomeOtherEvent' }), + 'production' + ) + expect(result).toBeNull() + }) + + it('MessagePart with role=user surfaces text as the prompt and stays working', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi there' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('hi there') + }) + + it('MessagePart with role=assistant populates lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ + hook_event_name: 'MessagePart', + role: 'assistant', + text: 'Hello! How can I help?' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('Hello! How can I help?') + }) + + it('subsequent SessionIdle preserves cached prompt + assistant message', () => { + _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi' }), + 'production' + ) + _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'assistant', text: 'hello back' }), + 'production' + ) + const done = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionIdle' }), + 'production' + ) + expect(done?.payload.state).toBe('done') + expect(done?.payload.prompt).toBe('hi') + expect(done?.payload.lastAssistantMessage).toBe('hello back') + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts new file mode 100644 index 00000000000..855997278a2 --- /dev/null +++ b/src/main/agent-hooks/server.ts @@ -0,0 +1,1088 @@ +/* eslint-disable max-lines -- Why: the hook server owns the full HTTP ingest surface (routing, body parsing, per-CLI normalization, transcript scan, pane dispatch) in one place so the contract with Claude/Codex/Gemini hooks stays consistent and doesn't drift across files. */ +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { randomUUID } from 'crypto' +import { closeSync, openSync, readSync, statSync } from 'fs' +import { + parseAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' + +// Why: Pi is intentionally absent. Pi has no shell-command hook surface — +// its extensibility is an in-process TypeScript extension API (pi.on(...) +// with events like turn_start/turn_end/tool_execution_start), not a +// settings.json hook block that we could install alongside the Claude/Codex/ +// Gemini ones. Wiring Pi would require shipping a bundled Pi extension +// that POSTs to this server; until we do that, Pi panes fall back to +// terminal-title heuristics like any uninstrumented CLI. +// +// OpenCode rides this server via a bundled plugin (see opencode/hook-service) +// that fetch()es /hook/opencode from inside the OpenCode process. Unlike +// Claude/Codex/Gemini, OpenCode's event names are in-process plugin events +// (session.status, session.idle, permission.asked) rather than settings.json +// hook names, so the plugin pre-maps them to our hook_event_name vocabulary +// before POSTing. See normalizeOpenCodeEvent below for the mapping. +type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' + +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: cap the warning Sets so a buggy or malicious local client that varies +// its `version`/`env` fields per request cannot grow these Sets without bound +// for the process lifetime. Once saturated, we drop further warnings (and skip +// inserting the new key) — the diagnostic value of "warn once" is preserved +// for the common case while memory stays bounded against untrusted input. +const MAX_WARNED_KEYS = 32 +// Why: hook events can arrive while Orca is windowless (common on macOS when +// the user closes the window but leaves the app running). Retain the latest +// normalized status per pane so reopening the window can replay current agent +// state instead of showing nothing until the next hook event happens. +const lastStatusByPaneKey = new Map() + +// 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 + } + } + // Why: OpenCode's plugin sends MessagePart events with { role, text }. When + // role === 'user', the text *is* the prompt — surface it so the dashboard + // shows the user's most recent input even though OpenCode has no dedicated + // UserPromptSubmit event we can hook into. + if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { + const trimmed = hookPayload.text.trim() + if (trimmed.length > 0) { + return hookPayload.text + } + } + return '' +} + +function parseFormEncodedBody(body: string): Record { + const params = new URLSearchParams(body) + const parsed: Record = {} + for (const [key, value] of params.entries()) { + parsed[key] = value + } + return parsed +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let byteLength = 0 + let settled = false + req.on('data', (chunk: Buffer) => { + if (settled) { + return + } + // Why: check size in bytes (not UTF-16 code units) and stop accumulating + // after rejection so a malicious client cannot push memory past the + // advertised 1 MB cap. + if (byteLength + chunk.length > 1_000_000) { + settled = true + reject(new Error('payload too large')) + req.destroy() + return + } + byteLength += chunk.length + chunks.push(chunk) + }) + req.on('end', () => { + if (settled) { + return + } + settled = true + try { + // Why: decode once via Buffer.concat so multi-byte UTF-8 characters that + // straddle a chunk boundary are reassembled correctly. Per-chunk + // `.toString('utf8')` would corrupt emoji or non-ASCII inside assistant + // messages. + const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' + const contentType = req.headers['content-type'] ?? '' + if (typeof contentType === 'string' && contentType.includes('application/json')) { + resolve(body ? JSON.parse(body) : {}) + return + } + if ( + typeof contentType === 'string' && + contentType.includes('application/x-www-form-urlencoded') + ) { + resolve(parseFormEncodedBody(body)) + return + } + // Why: existing managed scripts POST JSON and the updated Unix scripts + // POST form-encoded data. Default to JSON for unknown/missing content + // types so legacy callers that omit the header still behave as before. + resolve(body ? JSON.parse(body) : {}) + } catch (error) { + reject(error) + } + }) + req.on('error', (err) => { + if (settled) { + return + } + settled = true + reject(err) + }) + // Why: req.destroy() (called by the slowloris setTimeout in the route + // handler) emits 'close'/'aborted' but not 'end' or 'error'. Without this + // handler the promise would never settle and the chunk buffers would be + // retained for the process lifetime, letting a slow client that holds a + // valid token accumulate pending closures indefinitely. + req.on('close', () => { + if (settled) { + return + } + settled = true + reject(new Error('aborted')) + }) + }) +} + +// 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, + options?: { resetOnNewTurn?: boolean } +): string { + if (options?.resetOnNewTurn) { + // Why: some turn-boundary events (e.g. Codex SessionStart, OpenCode + // SessionBusy) do not carry the new prompt yet. Clearing here prevents the + // previous turn's prompt from leaking into the new working state until a + // later prompt-bearing event arrives. + lastPromptByPaneKey.delete(paneKey) + } + if (promptText) { + lastPromptByPaneKey.set(paneKey, promptText) + return promptText + } + return lastPromptByPaneKey.get(paneKey) ?? '' +} + +type ToolSnapshot = { + toolName?: string + toolInput?: string + lastAssistantMessage?: string +} + +// Why: mirrors `lastPromptByPaneKey`. Tool + assistant metadata arrives +// piecemeal (PreToolUse gives name+input; PostToolUse gives response; +// Stop gives the final message), and later events typically omit fields +// the earlier ones provided. Caching per-pane lets the renderer show a +// coherent snapshot instead of blinking whenever a field is missing. +const lastToolByPaneKey = new Map() + +function resolveToolState( + paneKey: string, + update: ToolSnapshot, + options: { resetOnNewTurn: boolean } +): ToolSnapshot { + if (options.resetOnNewTurn) { + // Why: a fresh user turn shouldn't inherit the previous turn's + // tool/assistant state — it would look like the agent is still on + // the old step until the first new tool event lands. + lastToolByPaneKey.delete(paneKey) + } + const previous = lastToolByPaneKey.get(paneKey) ?? {} + const merged: ToolSnapshot = { + toolName: update.toolName ?? previous.toolName, + toolInput: update.toolInput ?? previous.toolInput, + lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage + } + lastToolByPaneKey.set(paneKey, merged) + return merged +} + +// Why: per-tool allowlist (noqa style) — explicit mapping from tool name to +// the single input field worth surfacing. Tools that aren't listed render +// name-only. This avoids noisy fallbacks (e.g. "TaskUpdate 3" from the +// task_id field) and keeps the preview honest: if we don't know how to +// describe a tool's input meaningfully, we show nothing rather than guess. +// +// Ordering matters when a tool sends multiple well-known keys (e.g. Grep +// sends both `pattern` and `path`); the first match wins. +const TOOL_INPUT_KEYS_BY_TOOL: Record = { + // Claude tools (PascalCase). + Read: ['file_path', 'filePath', 'path'], + Write: ['file_path', 'filePath', 'path'], + Edit: ['file_path', 'filePath', 'path'], + MultiEdit: ['file_path', 'filePath', 'path'], + NotebookEdit: ['file_path', 'filePath', 'path'], + Bash: ['command'], + Glob: ['pattern'], + Grep: ['pattern'], + WebFetch: ['url'], + WebSearch: ['query'], + // Gemini tools (snake_case). + read_file: ['file_path', 'path'], + write_file: ['file_path', 'path'], + read_many_files: ['file_path', 'paths', 'path'], + edit_file: ['file_path', 'path'], + replace: ['file_path', 'path'], + run_shell_command: ['command'], + glob: ['pattern'], + search_file_content: ['pattern'], + web_fetch: ['url'], + google_web_search: ['query'], + // Codex tools. `exec_command` and `shell_command` both carry their command + // text under `cmd` (the Rust payload) or `command` (some wrappers); list + // both so whichever field is populated wins. `apply_patch` surfaces the + // touched path. `view_image` is path-only. `write_stdin` gets nothing + // meaningful — intentionally omitted so the row stays name-only. + exec_command: ['cmd', 'command'], + shell_command: ['cmd', 'command'], + apply_patch: ['path', 'file_path'], + view_image: ['path', 'file_path'] +} + +function deriveToolInputPreview( + toolName: string | undefined, + toolInput: unknown +): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + if (!toolName) { + return undefined + } + const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] + if (!keys) { + return undefined + } + const record = toolInput as Record + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +// Why: Claude `tool_response` can be a string, or an object with a `content` +// array shaped like `[{type: 'text', text: '...'}]`. Surface the first text +// block so PostToolUse for Task/Agent subagents carries something useful into +// the `lastAssistantMessage` slot. +function extractToolResponseText(toolResponse: unknown): string | undefined { + if (typeof toolResponse === 'string' && toolResponse.length > 0) { + return toolResponse + } + if (typeof toolResponse !== 'object' || toolResponse === null) { + return undefined + } + const record = toolResponse as Record + const content = record.content + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + const text = record.text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + return undefined +} + +// Why: Claude's Stop event carries `transcript_path` to a JSONL transcript. +// Reading the last assistant message gives us the "what did the agent just +// say" preview without needing to buffer tool_response text across PostToolUse +// events. We scan backward from the end of the file in chunks, stopping as +// soon as we find an assistant text entry — bounded work in the common case +// (one chunk) even when transcripts grow to hundreds of MB. +const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 +// Why: ultimate safety cap so a malformed transcript (or a turn with +// pathologically many tool calls and no assistant text) cannot stall the Stop +// handler. 4 MB easily accommodates dozens of tool rounds before the final +// reply; past that, we give up rather than block the hook response. +const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 + +function extractAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = JSON.parse(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + const nestedMessage = record.message as Record | undefined + const role = record.role ?? nestedMessage?.role + if (role !== 'assistant') { + return undefined + } + const content = (nestedMessage ?? record).content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + // Why: assistant entries can be pure tool_use turns with no text parts. + // Return undefined so the caller keeps scanning backward for the most + // recent entry that actually contains assistant text. + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} + +function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + // Why: track unhandled leading bytes as a raw Buffer across iterations + // so multi-byte UTF-8 codepoints that straddle chunk boundaries are not + // corrupted. A previous implementation decoded the combined chunk then + // re-encoded `lines[0]` back to UTF-8 for the carry; when a chunk + // started mid-codepoint the decode produced U+FFFD replacement chars + // and the re-encode baked those replacements into the carry bytes + // permanently, mis-joining every subsequent chunk. Splitting on \n at + // the byte level (0x0a) and only decoding complete-line regions keeps + // the carry byte-exact. + let carryBytes: Buffer = Buffer.alloc(0) + let bytesRead = 0 + while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) + const position = size - bytesRead - chunkSize + const buffer = Buffer.alloc(chunkSize) + // Why: readSync may return fewer bytes than requested (short reads). + // Loop until the full window is read (or EOF) before processing so the + // backward scan windows stay aligned — a bare `bytesRead += n` would + // advance from the last read's tail and either re-read overlapping + // bytes on the next iteration or miss lines entirely if short reads + // accumulate. Rare on local regular files but fs quirks exist. + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + const n = filled + bytesRead += n + if (n === 0) { + break + } + // Why: the newly-read chunk is earlier in the file than `carryBytes` + // (which came from the *previous* iteration's partial first line), + // so concatenation order is chunk first, carry second. + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + const atStart = bytesRead >= size + + // Find the first newline in the raw bytes. Everything before it is + // a (still) potentially-partial line when we haven't reached SOF; + // everything from it onward is a sequence of complete lines we can + // decode safely. + const firstNewline = combined.indexOf(0x0a) + let completeRegion: Buffer + let nextCarry: Buffer + if (atStart) { + // At start-of-file there is no earlier chunk; every line is complete. + completeRegion = combined + nextCarry = Buffer.alloc(0) + } else if (firstNewline === -1) { + // No newline in the combined bytes: the entire region is one + // potentially-partial line — carry all of it forward. + completeRegion = Buffer.alloc(0) + nextCarry = combined + } else { + // Bytes [0, firstNewline) are the partial-line carry; bytes + // [firstNewline+1, end) are the complete-line region. Dropping the + // newline itself avoids an empty leading "" line after split. + nextCarry = combined.subarray(0, firstNewline) + completeRegion = combined.subarray(firstNewline + 1) + } + + if (completeRegion.length > 0) { + const lines = completeRegion.toString('utf8').split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (line.length === 0) { + continue + } + const extracted = extractAssistantTextFromLine(line) + if (extracted !== undefined) { + return extracted + } + } + } + carryBytes = nextCarry + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +function extractClaudeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ) { + const toolName = readString(hookPayload, 'tool_name') + update.toolName = toolName + update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + } + if (eventName === 'PostToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_response) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Stop') { + // Why: newer Claude versions include `last_assistant_message` directly on + // the Stop payload, which is both cheaper and more reliable than reading + // the JSONL transcript. Prefer it when present; fall back to transcript + // scanning for older Claude versions that omit the field. + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } + } + } + return update +} + +function extractCodexToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + // Why: Codex emits tool metadata under `tool_name` + `tool_input` + // (matching Claude's shape). We surface both so the dashboard row can + // show what the agent is currently doing during the otherwise-silent + // gap between UserPromptSubmit and Stop. See TOOL_INPUT_KEYS_BY_TOOL + // for which input field is previewed per Codex tool name. + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + return { toolName, toolInput } + } + if (eventName === 'Stop') { + // Why: Codex documents `last_assistant_message` on Stop. + const message = readString(hookPayload, 'last_assistant_message') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} + +function extractGeminiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) + return { toolName, toolInput } + } + if (eventName === 'AfterAgent') { + // Why: Gemini's AfterAgent payload carries the final reply under + // `prompt_response` (per geminicli.com/docs/hooks/reference). This is + // Gemini's analogue of Claude/Codex's `last_assistant_message` on Stop; + // surfacing it lets the dashboard show the agent's response on done. + const message = readString(hookPayload, 'prompt_response') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} + +function extractOpenCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { + // Why: OpenCode streams the assistant's reply via repeated MessagePart + // events (one per text delta flush). Each event carries the cumulative + // text-so-far for that TextPart, so the latest one we see is the most + // complete snapshot to surface on `done`. We do NOT gate on SessionIdle + // because the plugin emits parts *before* session.idle fires, and gating + // would lose them. + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { + if (source === 'claude') { + return eventName === 'UserPromptSubmit' + } + if (source === 'codex') { + // Why: Codex fires SessionStart at resume AND startup. Both mark the + // boundary of a fresh interactive turn from the hook's perspective, so + // clear the tool cache on either one. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + } + if (source === 'gemini') { + return eventName === 'BeforeAgent' + } + // Why: OpenCode has no UserPromptSubmit analogue, AND the plugin emits the + // user's MessagePart *before* SessionBusy (message.updated fires on prompt + // submission; session.status goes busy only once OpenCode begins processing). + // So resetting on SessionBusy would clobber the user prompt that was just + // cached. The role=user MessagePart itself naturally overwrites the cache + // with each new turn, so no separate reset is needed here. + return false +} + +function extractToolFields( + source: AgentHookSource, + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (source === 'claude') { + return extractClaudeToolFields(eventName, hookPayload) + } + if (source === 'codex') { + return extractCodexToolFields(eventName, hookPayload) + } + if (source === 'gemini') { + return extractGeminiToolFields(eventName, hookPayload) + } + return extractOpenCodeToolFields(eventName, hookPayload) +} + +function normalizeClaudeEvent( + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const state = + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' + ? 'done' + : null + + if (!state) { + return null + } + + const snapshot = resolveToolState(paneKey, extractToolFields('claude', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('claude', eventName) + }) + + // Why: Claude Code's `Stop` hook sets `is_interrupt: true` when the turn + // ended because the user hit ESC / Ctrl+C rather than completing normally. + // This is the authoritative signal (the agent itself reports it), so we + // forward it through only on Stop — other hook events don't carry it. + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state, + prompt: resolvePrompt(paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('claude', eventName) + }), + agentType: 'claude', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + +// 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, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const state = + eventName === 'BeforeAgent' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'AfterAgent' + ? 'done' + : null + + if (!state) { + return null + } + + const snapshot = resolveToolState(paneKey, extractToolFields('gemini', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('gemini', eventName) + }) + + return parseAgentStatusPayload( + JSON.stringify({ + state, + prompt: resolvePrompt(paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('gemini', eventName) + }), + agentType: 'gemini', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +// Why: we deliberately do NOT map Codex `PreToolUse` to `waiting`. That event +// fires for every tool call, not just ones that actually need approval, so +// mapping it would flicker the dashboard. Instead we keep it at `working` +// (same as Claude) and use it only to update tool-name / tool-input previews +// so a running Codex turn has visible progress between UserPromptSubmit and +// Stop. Real approval signals travel through Codex's separate `notify` +// callback (different install surface); wiring that up is deferred. +function normalizeCodexEvent( + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const state = + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'Stop' + ? 'done' + : null + + if (!state) { + return null + } + + const snapshot = resolveToolState(paneKey, extractToolFields('codex', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }) + + return parseAgentStatusPayload( + JSON.stringify({ + state, + prompt: resolvePrompt(paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }), + agentType: 'codex', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +// Why: OpenCode has no declarative hook surface — it exposes in-process plugin +// events (session.status busy/idle, session.idle, permission.asked, +// message.updated, message.part.updated). The bundled plugin (see +// opencode/hook-service) pre-maps those to our stable hook_event_name +// vocabulary before POSTing so this normalizer can share the same switch +// shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle = +// turn finished, PermissionRequest = blocked on user approval, MessagePart = +// incremental text from user prompt or assistant reply (stays in `working` +// because streaming chunks must not flip the row to done mid-turn). +function normalizeOpenCodeEvent( + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const state = + eventName === 'SessionBusy' || eventName === 'MessagePart' + ? 'working' + : eventName === 'SessionIdle' + ? 'done' + : eventName === 'PermissionRequest' + ? 'waiting' + : null + + if (!state) { + return null + } + + const snapshot = resolveToolState( + paneKey, + extractToolFields('opencode', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state, + prompt: resolvePrompt(paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('opencode', eventName) + }), + agentType: 'opencode', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +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, + expectedEnv: string +): AgentHookEventPayload | null { + if (typeof body !== 'object' || body === null) { + return null + } + + const record = body as Record + const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const rawPayload = record.payload + const hookPayload = + typeof rawPayload === 'string' + ? (() => { + try { + return JSON.parse(rawPayload) + } catch { + return null + } + })() + : rawPayload + // Why: paneKey comes from an authenticated-but-potentially-malicious local + // client; bound its size so pathological clients cannot blow up the + // per-pane caches (lastPromptByPaneKey / lastToolByPaneKey) with multi-MB + // keys. 200 chars is well above any legitimate `${tabId}:${paneId}` value. + const MAX_PANE_KEY_LEN = 200 + if ( + !paneKey || + paneKey.length > MAX_PANE_KEY_LEN || + typeof hookPayload !== 'object' || + hookPayload === null + ) { + return null + } + + // 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.size < MAX_WARNED_KEYS + ) { + 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.' + ) + } + + // 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.size < MAX_WARNED_KEYS) { + 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) + const hookPayloadRecord = hookPayload as Record + const payload = + source === 'claude' + ? normalizeClaudeEvent(eventName, promptText, paneKey, hookPayloadRecord) + : source === 'codex' + ? normalizeCodexEvent(eventName, promptText, paneKey, hookPayloadRecord) + : source === 'gemini' + ? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord) + : normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord) + + 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 + if (!listener) { + return + } + // Why: replay is best-effort per pane so one throwing listener call can't + // starve subsequent panes from being replayed. + for (const payload of lastStatusByPaneKey.values()) { + try { + listener(payload) + } catch (err) { + console.error('[agent-hooks] replay listener threw', err) + } + } + } + + 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') { + res.writeHead(404) + res.end() + return + } + + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + 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 readRequestBody(req) + // Why: match on pathname only so a future debugging addition of a + // query string or trailing slash from a hook sender does not silently + // 404 a valid, token-authenticated request. + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const source: AgentHookSource | null = + pathname === '/hook/claude' + ? 'claude' + : pathname === '/hook/codex' + ? 'codex' + : pathname === '/hook/gemini' + ? 'gemini' + : pathname === '/hook/opencode' + ? 'opencode' + : null + if (!source) { + res.writeHead(404) + res.end() + return + } + + const payload = normalizeHookPayload(source, body, this.env) + if (payload) { + lastStatusByPaneKey.set(payload.paneKey, payload) + this.onAgentStatus?.(payload) + } + + res.writeHead(204) + res.end() + } catch { + // Why: agent hooks must fail open. The receiver returns success for + // malformed payloads so a newer or broken hook never blocks the agent. + res.writeHead(204) + res.end() + } + }) + + await new Promise((resolve, reject) => { + // 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) + }) + } + + stop(): void { + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.env = 'production' + this.onAgentStatus = null + // Why: drop all per-pane cache entries on shutdown so a subsequent start() + // in the same process (e.g. during tests or a settings-driven restart) + // does not inherit stale prompt/tool state from the previous run. + lastPromptByPaneKey.clear() + lastToolByPaneKey.clear() + lastStatusByPaneKey.clear() + // Why: across stop()/start() cycles the warn-once Sets would otherwise + // suppress legitimate new warnings after a restart. + warnedVersions.clear() + warnedEnvs.clear() + } + + clearPaneState(paneKey: string): void { + // Why: callers invoke this on PTY teardown so the per-pane caches do not + // accumulate entries for dead panes over the process lifetime. Without + // this, every closed pane leaves its prompt + tool snapshot pinned in + // memory for the life of the main process. + lastPromptByPaneKey.delete(paneKey) + lastToolByPaneKey.delete(paneKey) + lastStatusByPaneKey.delete(paneKey) + } + + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + + return { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + } + } +} + +export const agentHookServer = new AgentHookServer() + +// Why: exported for test coverage of the per-agent field extractors. The +// `normalizeHookPayload` function wraps these with the cache + routing logic +// the tests need to exercise end-to-end; making it test-visible avoids +// having to spin up a real HTTP server just to assert field shaping. +export const _internals = { + normalizeHookPayload, + parseFormEncodedBody, + resetCachesForTests: (): void => { + lastPromptByPaneKey.clear() + lastToolByPaneKey.clear() + lastStatusByPaneKey.clear() + // Why: across test runs the warn-once Sets would otherwise suppress + // legitimate new warnings that a later test expects to observe. + warnedVersions.clear() + warnedEnvs.clear() + } +} diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts new file mode 100644 index 00000000000..6d66aa5f0f0 --- /dev/null +++ b/src/main/claude/hook-service.ts @@ -0,0 +1,222 @@ +import { homedir } from 'os' +import { join } from 'path' +import { app } from 'electron' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + readHooksJson, + removeManagedCommands, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' + +const CLAUDE_EVENTS = [ + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: PreToolUse gives the dashboard a live readout of the in-flight tool + // (name + input preview) before it completes. Without it, a long-running + // Bash/Task step looks like a silent gap between prompt and Stop. + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PermissionRequest', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + } +] as const + +function getConfigPath(): string { + return join(homedir(), '.claude', 'settings.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +} + +function getManagedScriptPath(): string { + return join(app.getPath('userData'), 'agent-hooks', getManagedScriptFileName()) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` +} + +function getManagedScript(): string { + if (process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + '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/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') + } + + return [ + '#!/bin/sh', + '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: worktreeId embeds a filesystem path, so hand-building JSON in POSIX + // shell is not safe once a path contains quotes or newlines. Post the raw + // hook payload plus metadata as form fields and let the receiver parse it. + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +export class ClaudeHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'claude', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Claude settings.json' + } + } + + // 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) + ) + 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 { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'claude', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Claude settings.json' + } + } + + const command = getManagedCommand(scriptPath) + const nextHooks = { ...config.hooks } + + // Why: match by script filename (not exact command string) so a fresh + // install sweeps stale entries left by older builds or a different + // Electron userData path (dev vs. prod). Without this, repeated installs + // accumulate duplicate hook entries pointing at defunct scripts. + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + + for (const event of CLAUDE_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, config) + return this.getStatus() + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'claude', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Claude settings.json' + } + } + + const nextHooks = { ...config.hooks } + // Why: same broad matcher as install(), so remove() also cleans up stale + // entries from older builds even if the current scriptPath has moved. + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + // Why: a malformed settings.json entry (non-array value for an event + // name) would make removeManagedCommands throw via definitions.flatMap. + // Skip — we cannot sweep something we cannot parse, and remove() must + // fail open so a broken user config never blocks uninstall. + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const claudeHookService = new ClaudeHookService() diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts new file mode 100644 index 00000000000..d476814c974 --- /dev/null +++ b/src/main/codex/hook-service.ts @@ -0,0 +1,234 @@ +import { homedir } from 'os' +import { join } from 'path' +import { app } from 'electron' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + readHooksJson, + removeManagedCommands, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' + +// Why: PreToolUse/PostToolUse give the dashboard a live readout of the +// in-flight tool (name + input preview) between UserPromptSubmit and Stop. +// Without them, a long-running Codex turn looks like a silent gap after the +// prompt is submitted. We keep both events mapped to `working` (see +// normalizeCodexEvent); they do NOT mean "approval needed" — Codex's real +// approval signal flows through its separate `notify` callback (different +// install surface, different payload shape) and is deferred as a follow-up. +const CODEX_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop' +] as const + +function getConfigPath(): string { + return join(homedir(), '.codex', 'hooks.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' +} + +function getManagedScriptPath(): string { + return join(app.getPath('userData'), 'agent-hooks', getManagedScriptFileName()) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` +} + +function getManagedScript(): string { + if (process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + '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/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') + } + + return [ + '#!/bin/sh', + '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: worktreeId embeds a filesystem path, so hand-building JSON in POSIX + // shell is not safe once a path contains quotes or newlines. Post the raw + // hook payload plus metadata as form fields and let the receiver parse it. + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +export class CodexHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + // 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) + ) + 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 { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const command = getManagedCommand(scriptPath) + const nextHooks = { ...config.hooks } + const managedEvents = new Set(CODEX_EVENTS) + + // Why: match by script filename (not exact command string) so a fresh + // install sweeps stale entries left by older builds or a different + // Electron userData path (dev vs. prod). Without this, repeated installs + // accumulate duplicate hook entries pointing at defunct scripts. + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + + // Why: sweep managed entries out of events we no longer subscribe to + // (e.g., PreToolUse from a prior install). Without this, users who + // already had PreToolUse registered would keep firing stale hooks on + // every auto-approved tool call after the app upgrade. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName)) { + continue + } + if (!Array.isArray(definitions)) { + // Why: a malformed hooks.json entry (non-array value for an event name) + // would make removeManagedCommands throw. Skip instead — we aren't + // going to sweep something we can't parse, and the install() for + // managed events below still runs. + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const eventName of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + 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 config = readHooksJson(configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const nextHooks = { ...config.hooks } + // Why: same broad matcher as install(), so remove() also cleans up stale + // entries from older builds even if the current scriptPath has moved. + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + // Why: a malformed hooks.json entry (non-array value for an event name) + // would make removeManagedCommands throw. Skip instead — we have no + // managed commands to remove from something we can't parse. + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const codexHookService = new CodexHookService() diff --git a/src/main/daemon/post-ready-flush-gate.test.ts b/src/main/daemon/post-ready-flush-gate.test.ts new file mode 100644 index 00000000000..49379a53bad --- /dev/null +++ b/src/main/daemon/post-ready-flush-gate.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PostReadyFlushGate, + POST_READY_FLUSH_DELAY_MS, + POST_READY_FLUSH_FALLBACK_MS +} from './post-ready-flush-gate' + +describe('PostReadyFlushGate', () => { + let onFlush: ReturnType void>> + let gate: PostReadyFlushGate + + beforeEach(() => { + vi.useFakeTimers() + onFlush = vi.fn<() => void>() + gate = new PostReadyFlushGate(onFlush) + }) + + afterEach(() => { + gate.clear() + vi.useRealTimers() + }) + + it('does not flush immediately when armed', () => { + gate.arm() + expect(onFlush).not.toHaveBeenCalled() + }) + + it('flushes via short delay after notifyData signals the prompt draw', () => { + gate.arm() + gate.notifyData() + expect(onFlush).not.toHaveBeenCalled() + + vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS) + expect(onFlush).toHaveBeenCalledTimes(1) + }) + + it('flushes via wall-clock fallback when no notifyData arrives', () => { + gate.arm() + + vi.advanceTimersByTime(POST_READY_FLUSH_FALLBACK_MS) + expect(onFlush).toHaveBeenCalledTimes(1) + }) + + it('ignores notifyData before arm()', () => { + gate.notifyData() + vi.advanceTimersByTime(1000) + expect(onFlush).not.toHaveBeenCalled() + }) + + it('notifyData after the fallback fired is a no-op', () => { + gate.arm() + vi.advanceTimersByTime(POST_READY_FLUSH_FALLBACK_MS) + expect(onFlush).toHaveBeenCalledTimes(1) + + gate.notifyData() + vi.advanceTimersByTime(1000) + expect(onFlush).toHaveBeenCalledTimes(1) + }) + + it('only the first notifyData schedules the short-delay flush', () => { + gate.arm() + gate.notifyData() + gate.notifyData() + gate.notifyData() + + vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS) + expect(onFlush).toHaveBeenCalledTimes(1) + }) + + it('clear() cancels a pending fallback flush', () => { + gate.arm() + gate.clear() + + vi.advanceTimersByTime(POST_READY_FLUSH_FALLBACK_MS * 2) + expect(onFlush).not.toHaveBeenCalled() + }) + + it('clear() cancels a pending post-data flush', () => { + gate.arm() + gate.notifyData() + gate.clear() + + vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS * 2) + expect(onFlush).not.toHaveBeenCalled() + }) + + it('isPending is true throughout the gate window and false once flush fires', () => { + expect(gate.isPending).toBe(false) + gate.arm() + expect(gate.isPending).toBe(true) + gate.notifyData() + expect(gate.isPending).toBe(true) + vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS) + expect(gate.isPending).toBe(false) + expect(onFlush).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/daemon/post-ready-flush-gate.ts b/src/main/daemon/post-ready-flush-gate.ts new file mode 100644 index 00000000000..2e15b184068 --- /dev/null +++ b/src/main/daemon/post-ready-flush-gate.ts @@ -0,0 +1,81 @@ +/** + * Defers a flush callback until after the shell has drawn its prompt and + * switched the PTY into raw mode. + * + * Why: the OSC 777 shell-ready marker fires from zsh's precmd_functions / + * bash's PROMPT_COMMAND — before the shell draws its prompt and before + * zle/readline flips the PTY into raw mode. Flushing queued input then lets + * the kernel (ECHO still on) echo the command once, and the line editor + * redraws it under the prompt — producing a visible duplicate (e.g. "claude" + * appears twice on agent launch). + * + * Strategy: after arm() is called, wait for the next PTY data chunk (the + * prompt draw) plus a short delay for the tcsetattr() that enables raw mode. + * A wall-clock fallback covers the case where the prompt arrives in the same + * chunk as the marker, so no follow-up notifyData() ever fires. + * + * Mirrors the gate in local-pty-shell-ready.ts::writeStartupCommandWhenShellReady, + * which solves the same race on the non-daemon path. + */ + +export const POST_READY_FLUSH_DELAY_MS = 30 +export const POST_READY_FLUSH_FALLBACK_MS = 50 + +export class PostReadyFlushGate { + private awaitingPromptDraw = false + private postDataTimer: ReturnType | null = null + private fallbackTimer: ReturnType | null = null + + constructor(private readonly onFlush: () => void) {} + + /** True between arm() and the actual flush firing. Callers should treat + * input as still-queued during this window to preserve ordering. */ + get isPending(): boolean { + return this.awaitingPromptDraw || this.postDataTimer !== null || this.fallbackTimer !== null + } + + /** Arm the gate after observing the shell-ready marker. Starts the + * wall-clock fallback; the flush fires when the fallback elapses or when + * notifyData() observes a subsequent PTY data chunk. */ + arm(): void { + this.awaitingPromptDraw = true + this.fallbackTimer = setTimeout(() => { + this.fallbackTimer = null + this.awaitingPromptDraw = false + this.onFlush() + }, POST_READY_FLUSH_FALLBACK_MS) + } + + /** Report a PTY data chunk observed after arm(). The first such call swaps + * the wall-clock fallback for the short post-data delay so readline has + * time to enable raw mode before the flush fires. */ + notifyData(): void { + if (!this.awaitingPromptDraw) { + return + } + this.awaitingPromptDraw = false + if (this.fallbackTimer) { + clearTimeout(this.fallbackTimer) + this.fallbackTimer = null + } + if (this.postDataTimer === null) { + this.postDataTimer = setTimeout(() => { + this.postDataTimer = null + this.onFlush() + }, POST_READY_FLUSH_DELAY_MS) + } + } + + /** Cancel any pending flush. Call on session teardown. */ + clear(): void { + this.awaitingPromptDraw = false + if (this.postDataTimer) { + clearTimeout(this.postDataTimer) + this.postDataTimer = null + } + if (this.fallbackTimer) { + clearTimeout(this.fallbackTimer) + this.fallbackTimer = null + } + } +} diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index c15d1107e1e..5191f77fd6b 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -178,24 +178,25 @@ describe('Session', () => { }) describe('shell readiness gating', () => { - it('buffers writes during pending state', () => { + // Why: regression guard for "claude claude" double-echo. The marker fires + // from precmd before readline switches the PTY into raw mode; flushing + // then lets the kernel re-echo the command under the prompt. Detailed + // timing behavior is covered by post-ready-flush-gate.test.ts. + // Also checks writes that arrive during the gate window keep their order + // — the gate continues to queue even though shellState is already 'ready'. + it('defers flush past the shell-ready marker and preserves write order', () => { createSession({ shellReadySupported: true }) expect(session.shellState).toBe('pending') - session.write('buffered input') - expect(subprocess.written).toEqual([]) - }) - - it('flushes buffered writes when shell marker is detected', () => { - createSession({ shellReadySupported: true }) - - session.write('pre-ready input') - expect(subprocess.written).toEqual([]) - - // Simulate the shell marker arriving in PTY output + session.write('first\n') subprocess.simulateData('\x1b]777;orca-shell-ready\x07') expect(session.shellState).toBe('ready' satisfies ShellReadyState) - expect(subprocess.written).toEqual(['pre-ready input']) + session.write('second\n') + expect(subprocess.written).toEqual([]) + + subprocess.simulateData('\r\nuser@host $ ') + vi.advanceTimersByTime(30) + expect(subprocess.written).toEqual(['first\n', 'second\n']) }) it('transitions to timed_out after 15 seconds', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index e01627ff39a..4c398f5a2e0 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,5 +1,6 @@ import { HeadlessEmulator } from './headless-emulator' import { isValidPtySize, normalizePtySize } from './daemon-pty-size' +import { PostReadyFlushGate } from './post-ready-flush-gate' import type { SessionState, ShellReadyState, TerminalSnapshot } from './types' const SHELL_READY_TIMEOUT_MS = 15_000 @@ -46,6 +47,7 @@ export class Session { private markerBuffer = '' private shellReadyTimer: ReturnType | null = null private killTimer: ReturnType | null = null + private postReadyFlushGate: PostReadyFlushGate constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -70,6 +72,7 @@ export class Session { this._shellState = 'unsupported' } + this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) } @@ -103,7 +106,11 @@ export class Session { return } - if (this._shellState === 'pending') { + // Why: during the post-ready flush gate window (shellState is already + // 'ready' but the queue hasn't flushed yet) we must keep queuing. Writing + // directly would let fresh input race ahead of the buffered startup + // command, changing execution order. + if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) { this.preReadyStdinQueue.push(data) return } @@ -209,6 +216,7 @@ export class Session { clearTimeout(this.killTimer) this.killTimer = null } + this.postReadyFlushGate.clear() this.attachedClients = [] this.preReadyStdinQueue = [] @@ -229,6 +237,8 @@ export class Session { if (this._shellState === 'pending') { this.scanForShellMarker(data) + } else { + this.postReadyFlushGate.notifyData() } // Broadcast to attached clients @@ -253,6 +263,7 @@ export class Session { clearTimeout(this.shellReadyTimer) this.shellReadyTimer = null } + this.postReadyFlushGate.clear() for (const client of this.attachedClients) { client.onExit(code) @@ -282,7 +293,10 @@ export class Session { clearTimeout(this.shellReadyTimer) this.shellReadyTimer = null } - this.flushPreReadyQueue() + if (this.preReadyStdinQueue.length === 0) { + return + } + this.postReadyFlushGate.arm() } private onShellReadyTimeout(): void { @@ -320,6 +334,7 @@ export class Session { clearTimeout(this.killTimer) this.killTimer = null } + this.postReadyFlushGate.clear() const clients = this.attachedClients this.attachedClients = [] diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index d611ce838d3..3dcf673dc35 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -147,7 +147,15 @@ describe('TerminalHost', () => { expect(lastSubprocess.write).not.toHaveBeenCalled() + // Why: the marker alone no longer flushes — the kernel can still have + // ECHO enabled when it arrives. The flush waits for the prompt draw + // plus a short delay so readline has switched the PTY into raw mode + // first. Otherwise the command would be visibly double-echoed. lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready\x07') + expect(lastSubprocess.write).not.toHaveBeenCalled() + + lastSubprocess._onDataCb?.('\r\nuser@host $ ') + await new Promise((r) => setTimeout(r, 40)) expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\n') }) }) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts new file mode 100644 index 00000000000..d613080fcca --- /dev/null +++ b/src/main/gemini/hook-service.ts @@ -0,0 +1,212 @@ +import { homedir } from 'os' +import { join } from 'path' +import { app } from 'electron' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + 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. +// +// PreToolUse surfaces the current tool name + input preview (e.g. +// `read_file: src/foo.ts`) so long-running tool calls aren't a silent gap +// between BeforeAgent and AfterAgent. PostToolUse is intentionally omitted — +// AfterTool already signals "back to working" and the tool name from +// PreToolUse is what we show; PostToolUse would be a redundant fire. +const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse'] as const + +function getConfigPath(): string { + return join(homedir(), '.gemini', 'settings.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' +} + +function getManagedScriptPath(): string { + return join(app.getPath('userData'), 'agent-hooks', getManagedScriptFileName()) +} + +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: worktreeId embeds a filesystem path, so hand-building JSON in POSIX + // shell is not safe once a path contains quotes or newlines. Post the raw + // hook payload plus metadata as form fields and let the receiver parse it. + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/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 } + + // Why: match by script filename (not exact command string) so a fresh + // install sweeps stale entries left by older builds or a different + // Electron userData path (dev vs. prod). Without this, repeated installs + // accumulate duplicate hook entries pointing at defunct scripts. + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + + for (const eventName of GEMINI_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + 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 config = readHooksJson(configPath) + if (!config) { + return { + agent: 'gemini', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Gemini settings.json' + } + } + + const nextHooks = { ...config.hooks } + // Why: same broad matcher as install(), so remove() also cleans up stale + // entries from older builds even if the current scriptPath has moved. + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + // Why: a malformed settings.json entry (non-array value for an event + // name) would make removeManagedCommands throw via definitions.flatMap. + // Skip — remove() must fail open so a broken user config never blocks + // uninstall. + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + 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 495ce73f849..192b433d4f4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,8 @@ -import { app, BrowserWindow, nativeImage, nativeTheme, systemPreferences } from 'electron' +/* eslint-disable max-lines -- Why: this is Orca's main-process entry point; + it owns app lifecycle, service wiring, window creation, and hook/daemon + startup. Splitting by line count would fragment tightly coupled startup + logic across files without a cleaner ownership seam. */ +import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import devIcon from '../../resources/icon-dev.png?asset' import { Store, initDataPath } from './persistence' @@ -35,8 +39,12 @@ import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' -import { openCodeHookService } from './opencode/hook-service' 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' +import { AGENT_DASHBOARD_ENABLED } from '../shared/constants' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' @@ -58,26 +66,6 @@ let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null let starNag: StarNagService | null = null -function triggerStartupMicrophoneRegistration(): void { - if (process.platform !== 'darwin') { - return - } - try { - const status = systemPreferences.getMediaAccessStatus('microphone') - if (status !== 'not-determined') { - return - } - // Why: terminal child processes (sox/ffmpeg/voice CLIs) cannot reliably - // surface the TCC dialog themselves. Prompt once from the app process after - // the window is visible so mic capture in embedded terminals can work. - void systemPreferences.askForMediaAccess('microphone').catch((error: unknown) => { - console.error('[permissions] Failed to request microphone access:', error) - }) - } catch (error) { - console.error('[permissions] Failed to check microphone access status:', error) - } -} - installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env // construction in both main (local-pty-provider) and the forked daemon @@ -178,8 +166,29 @@ function openMainWindow(): BrowserWindow { if (mainWindow === window) { mainWindow = null } + if (AGENT_DASHBOARD_ENABLED) { + // Why: detach the agent hook listener on window close so the server + // never fires into a destroyed webContents during the gap before + // reopen (e.g. macOS dock re-activation). This also ensures the + // replay-loop through lastStatusByPaneKey runs only on deliberate + // window recreations instead of stacking on top of stale listeners. + agentHookServer.setListener(null) + } }) mainWindow = window + if (AGENT_DASHBOARD_ENABLED) { + agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => { + if (mainWindow?.isDestroyed()) { + return + } + mainWindow?.webContents.send('agentStatus:set', { + paneKey, + tabId, + worktreeId, + ...payload + }) + }) + } return window } @@ -209,6 +218,22 @@ app.whenReady().then(async () => { starNag.registerIpcHandlers() runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager)) nativeTheme.themeSource = store.getSettings().theme ?? 'system' + // Why: managed hook installation mutates user-global agent config. + // Startup must fail open so a malformed local config never bricks Orca. + if (AGENT_DASHBOARD_ENABLED) { + for (const installManagedHooks of [ + () => claudeHookService.install(), + () => codexHookService.install(), + () => geminiHookService.install() + ]) { + try { + installManagedHooks() + } catch (error) { + console.error('[agent-hooks] Failed to install managed hooks:', error) + } + } + } + registerAppMenu({ onCheckForUpdates: (options) => checkForUpdatesFromMenu(options), onOpenSettings: () => { @@ -266,13 +291,24 @@ app.whenReady().then(async () => { } setAppRuntimeFlags({ daemonEnabledAtStartup: daemonStarted }) - // Why: both server binds are independent and neither blocks window creation. - // Parallelizing them with the window open shaves ~100-200ms off cold start. + if (AGENT_DASHBOARD_ENABLED) { + try { + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state. + // Start the hook server before opening the window so restored/spawned + // terminals never race ahead without hook env on first launch. + await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development' }) + } catch (error) { + // Why: Claude/Codex/Gemini/OpenCode 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) + } + } + + // Why: once the hook server is ready (or has already failed open), window + // creation and runtime RPC startup are independent. const [win] = await Promise.all([ Promise.resolve(openMainWindow()), - openCodeHookService.start().catch((error) => { - console.error('[opencode] Failed to start local hook server:', error) - }), runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start local RPC transport:', error) }) @@ -284,7 +320,6 @@ app.whenReady().then(async () => { // window, making it impossible for the user to click "Allow". win.once('show', () => { triggerStartupNotificationRegistration(store!) - triggerStartupMicrophoneRegistration() }) app.on('activate', () => { @@ -313,8 +348,10 @@ app.on('will-quit', () => { // are still running. killAllPty() does not call runtime.onPtyExit(), // so without this ordering, running agents would produce orphaned // agent_start events with no matching stops. - openCodeHookService.stop() starNag?.stop() + if (AGENT_DASHBOARD_ENABLED) { + agentHookServer.stop() + } stats?.flush() // Why: agent-browser daemon processes would otherwise linger after Orca quits, // holding ports and leaving stale session state on disk. diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts new file mode 100644 index 00000000000..06f8f6c92e2 --- /dev/null +++ b/src/main/ipc/agent-hooks.ts @@ -0,0 +1,66 @@ +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 +// renderer-triggered remove would be silently reverted on the next launch +// and mislead the user. +export function registerAgentHookHandlers(): void { + // Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration + // never throws "Attempted to register a second handler..." if this function is + // ever invoked more than once (e.g. the macOS app re-activation path that + // recreates the main window). Today the module-level `registered` guard in + // register-core-handlers.ts prevents re-entry, but decoupling from that guard + // future-proofs this file. + ipcMain.removeHandler('agentHooks:claudeStatus') + ipcMain.removeHandler('agentHooks:codexStatus') + ipcMain.removeHandler('agentHooks:geminiStatus') + + // Why: errors from getStatus() (fs permission denied, homedir resolution + // failure, etc.) must be reported inline via state:'error' so the sidebar can + // render a coherent per-agent error row. Letting the exception propagate out + // of the IPC handler surfaces as an unhandled renderer-side rejection, which + // defeats the AgentHookInstallStatus contract the UI relies on. + ipcMain.handle('agentHooks:claudeStatus', (): AgentHookInstallStatus => { + try { + return claudeHookService.getStatus() + } catch (err) { + return { + agent: 'claude', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) + ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => { + try { + return codexHookService.getStatus() + } catch (err) { + return { + agent: 'codex', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) + ipcMain.handle('agentHooks:geminiStatus', (): AgentHookInstallStatus => { + try { + return geminiHookService.getStatus() + } catch (err) { + return { + agent: 'gemini', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) +} diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 475fcc988b4..e59f9126141 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -440,8 +440,12 @@ export function registerFilesystemHandlers(store: Store): void { ): Promise => { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) + // Why: when the SSH connection is not yet established (cold start) or + // temporarily disconnected, return [] so quick-open shows "No matching + // files" instead of an error banner. The file list will repopulate when + // the user re-opens quick-open after the connection is restored. if (!provider) { - throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + return [] } return provider.listFiles(args.rootPath) } diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index cd3b1b5dc8a..3dd925514a6 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -30,10 +30,6 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) -vi.mock('../linear/client', () => ({ - loadToken: vi.fn().mockReturnValue(null) -})) - import { _resetPreflightCache, detectInstalledAgents, @@ -72,8 +68,7 @@ describe('preflight', () => { expect(status).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: true }, - linear: { connected: false } + gh: { installed: true, authenticated: true } }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(3, 'gh', ['auth', 'status'], { encoding: 'utf-8' @@ -131,8 +126,7 @@ describe('preflight', () => { expect(status).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: true }, - linear: { connected: false } + gh: { installed: true, authenticated: true } }) }) @@ -152,13 +146,11 @@ describe('preflight', () => { expect(firstStatus).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: false }, - linear: { connected: false } + gh: { installed: true, authenticated: false } }) expect(refreshedStatus).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: true }, - linear: { connected: false } + gh: { installed: true, authenticated: true } }) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index d183fc0cb87..1fa1dd8c3ce 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -4,7 +4,6 @@ import { promisify } from 'util' import path from 'path' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' -import { loadToken } from '../linear/client' import { getActiveMultiplexer } from './ssh' const execFileAsync = promisify(execFile) @@ -12,7 +11,6 @@ const execFileAsync = promisify(execFile) export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } - linear: { connected: boolean } } // Why: cache the result so repeated Landing mounts don't re-spawn processes. @@ -121,16 +119,9 @@ export async function runPreflightCheck(force = false): Promise const ghAuthenticated = ghInstalled ? await isGhAuthenticated() : false - // Why: the Linear preflight check reads the encrypted token file rather - // than calling the Linear API. Actual API validation happens lazily on - // first use or on linear:connect — this avoids a network round-trip on - // every preflight check. - const linearConnected = loadToken() !== null - cached = { git: { installed: gitInstalled }, - gh: { installed: ghInstalled, authenticated: ghAuthenticated }, - linear: { connected: linearConnected } + gh: { installed: ghInstalled, authenticated: ghAuthenticated } } return cached diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f3b20784a40..4d5f1d121b3 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -18,6 +18,7 @@ const { spawnMock, openCodeBuildPtyEnvMock, openCodeClearPtyMock, + buildAgentHookEnvMock, piBuildPtyEnvMock, piClearPtyMock } = vi.hoisted(() => ({ @@ -35,12 +36,14 @@ const { spawnMock: vi.fn(), openCodeBuildPtyEnvMock: vi.fn(), openCodeClearPtyMock: vi.fn(), + buildAgentHookEnvMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn() })) vi.mock('electron', () => ({ app: { + isPackaged: true, getPath: getPathMock }, ipcMain: { @@ -74,6 +77,12 @@ vi.mock('../opencode/hook-service', () => ({ } })) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + buildPtyEnv: buildAgentHookEnvMock + } +})) + vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: piBuildPtyEnvMock, @@ -114,6 +123,7 @@ describe('registerPtyHandlers', () => { spawnMock.mockReset() openCodeBuildPtyEnvMock.mockReset() openCodeClearPtyMock.mockReset() + buildAgentHookEnvMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() mainWindow.webContents.on.mockReset() @@ -131,6 +141,10 @@ describe('registerPtyHandlers', () => { ORCA_OPENCODE_PTY_ID: 'test-pty', OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config' }) + buildAgentHookEnvMock.mockReturnValue({ + ORCA_AGENT_HOOK_PORT: '5678', + ORCA_AGENT_HOOK_TOKEN: 'agent-token' + }) piBuildPtyEnvMock.mockImplementation((_ptyId: string, existingAgentDir?: string) => ({ PI_CODING_AGENT_DIR: existingAgentDir ? '/tmp/orca-pi-agent-overlay' @@ -284,7 +298,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567') expect(env.ORCA_OPENCODE_HOOK_TOKEN).toBe('opencode-token') expect(env.ORCA_OPENCODE_PTY_ID).toBe('test-pty') - expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') + expect(env.OPENCODE_CONFIG_DIR).toEqual(expect.any(String)) }) it('injects the Pi agent overlay env into Orca terminal PTYs', async () => { @@ -292,6 +306,20 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') }) + + it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => { + const env = await spawnAndGetEnv() + // Why: buildAgentHookEnv runs twice for a local spawn — once inside the + // LocalPtyProvider's buildSpawnEnv closure (pty.ts:166) and once in the + // handler's `!args.connectionId` branch (pty.ts:333). The handler branch + // exists so daemon-adapter providers (which bypass buildSpawnEnv) still + // get the hook env, and is gated off for SSH spawns to avoid leaking + // the loopback token to remote hosts. + expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(2) + expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + }) + it('leaves ambient CODEX_HOME untouched when system default is selected', async () => { const env = await spawnAndGetEnv( undefined, diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 1c754d204e8..76db54831d5 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3,11 +3,13 @@ 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 { type BrowserWindow, ipcMain } from 'electron' +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' import type { GlobalSettings } from '../../shared/types' import { openCodeHookService } from '../opencode/hook-service' +import { agentHookServer } from '../agent-hooks/server' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions } from '../providers/types' @@ -30,6 +32,12 @@ const sshProviders = new Map() // post-spawn operations to the correct provider without the renderer needing // to track connectionId per-PTY. const ptyOwnership = new Map() +// Why: the agent-hooks server caches per-paneKey state (last prompt, last +// tool) that otherwise grows unbounded as panes come and go. Track the +// spawn-time paneKey so clearProviderPtyState can clear that cache on PTY +// teardown — the renderer knows the paneKey but the PTY lifecycle does not +// without this mapping. +const ptyPaneKey = new Map() function getProvider(connectionId: string | null | undefined): IPtyProvider { if (!connectionId) { @@ -109,6 +117,11 @@ export function getPtyIdsForConnection(connectionId: string): string[] { export function clearPtyOwnershipForConnection(connectionId: string): void { for (const [ptyId, connId] of ptyOwnership) { if (connId === connectionId) { + // Why: remote PTYs are gone after the SSH connection closes — their + // paneKey-scoped caches (agent-hooks server, OpenCode, Pi) must be swept + // the same way a local onExit would, otherwise they leak indefinitely + // for the process lifetime. + clearProviderPtyState(ptyId) ptyOwnership.delete(ptyId) } } @@ -122,6 +135,15 @@ export function clearProviderPtyState(id: string): void { // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) + // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would + // otherwise accumulate entries for dead panes over the process lifetime. + // Use the spawn-time paneKey mapping since the server has no other way to + // correlate a ptyId back to its paneKey. + const paneKey = ptyPaneKey.get(id) + if (paneKey) { + agentHookServer.clearPaneState(paneKey) + ptyPaneKey.delete(id) + } } export function deletePtyOwnership(id: string): void { @@ -172,10 +194,17 @@ export function registerPtyHandlers( if (baseEnv.OPENCODE_CONFIG_DIR) { // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a // user-provided directory would silently hide their custom OpenCode - // config, so preserve it and fall back to title-only detection there. + // config, so preserve it. The Orca status plugin will not load, so + // the dashboard falls back to a blank status for that pane until the + // user unsets their override. delete openCodeHookEnv.OPENCODE_CONFIG_DIR } Object.assign(baseEnv, openCodeHookEnv) + // Why: Claude/Codex native hooks run inside the shell process, so Orca + // must inject the loopback receiver coordinates before the agent starts. + // Without these env vars the global hook config cannot map callbacks back + // to the correct Orca pane. + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a // PTY-scoped overlay from the caller's chosen root so Pi sessions keep // their user state without sharing a mutable overlay across terminals. @@ -193,6 +222,24 @@ export function registerPtyHandlers( baseEnv.CODEX_HOME = selectedCodexHomePath } + // Why: in dev mode the `orca` CLI defaults to the production userData + // path, which routes status updates to the packaged Orca instead of + // this dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls + // from agents running inside dev terminals reach the correct runtime. + // We also prepend the dev CLI launcher directory to PATH so `orca` + // resolves to the dev build (which supports ORCA_USER_DATA_PATH) + // instead of the production binary at /usr/local/bin/orca. + if (!app.isPackaged) { + const devUserData = app.getPath('userData') + baseEnv.ORCA_USER_DATA_PATH ??= devUserData + const devCliBin = join(devUserData, 'cli', 'bin') + // Why: avoid a trailing delimiter when PATH is empty — some shells + // treat an empty segment as `.`, which would let commands resolve + // from the current working directory (a foot-gun we don't want to + // create for dev terminals). + baseEnv.PATH = baseEnv.PATH ? `${devCliBin}${delimiter}${baseEnv.PATH}` : devCliBin + } + return baseEnv }, onSpawned: (id) => runtime?.onPtySpawned(id), @@ -339,7 +386,19 @@ export function registerPtyHandlers( 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' ) } - const env = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env + // 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. + // + // Safety: skip the injection entirely when a remote (SSH) connection is + // in play. The hook server is bound to the Orca host's 127.0.0.1, so the + // remote shell cannot reach it; shipping the token across SSH would leak + // a loopback secret to an untrusted machine for no functional benefit. + const hookEnv = args.connectionId ? {} : agentHookServer.buildPtyEnv() + const baseEnv = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env + const env = Object.keys(hookEnv).length > 0 ? { ...baseEnv, ...hookEnv } : baseEnv const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined @@ -366,6 +425,18 @@ export function registerPtyHandlers( if (isClaudeLaunch) { markClaudePtySpawned(result.id) } + // Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned + // spawn (see pty-connection.ts). Recording the mapping here lets + // clearProviderPtyState clear the agent-hooks server's per-paneKey + // caches when the PTY exits. + // Why: args.env arrives as untrusted JSON over IPC — the static + // Record type is not actually enforced at the boundary. + // Narrow to a bounded string so malformed or oversized values cannot + // pollute ptyPaneKey or the downstream clearPaneState call. + const paneKey = args.env?.ORCA_PANE_KEY + if (typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256) { + ptyPaneKey.set(result.id, paneKey) + } return result } ) @@ -408,6 +479,13 @@ export function registerPtyHandlers( } catch { /* session already dead — cleanup below handles the rest */ } finally { + // Why: onExit clears provider state for LocalPtyProvider, but remote + // SSH and daemon shutdown paths do not emit onExit through the local + // provider's listener. Call clearProviderPtyState explicitly here so + // the hook-server paneKey cache and OpenCode/Pi PTY-scoped state are + // cleared on explicit kill. clearProviderPtyState is idempotent — safe + // if onExit already ran. + clearProviderPtyState(args.id) ptyOwnership.delete(args.id) markClaudePtyExited(args.id) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 8217c762681..0ce6b6e737d 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -16,6 +16,7 @@ const { registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerCodexAccountHandlersMock, + registerAgentHookHandlersMock, registerClaudeAccountHandlersMock, registerClipboardHandlersMock, registerUpdaterHandlersMock, @@ -43,6 +44,7 @@ const { registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), + registerAgentHookHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -128,6 +130,10 @@ vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) +vi.mock('./agent-hooks', () => ({ + registerAgentHookHandlers: registerAgentHookHandlersMock +})) + vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -170,6 +176,7 @@ describe('registerCoreHandlers', () => { registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() + registerAgentHookHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -207,6 +214,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) + expect(registerAgentHookHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index fb341bf32e9..262a7d4b974 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -24,6 +24,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' +import { registerAgentHookHandlers } from './agent-hooks' import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' import { @@ -66,6 +67,7 @@ export function registerCoreHandlers( registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) + registerAgentHookHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a96fb70f3d7..134fc4a3e3c 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -99,11 +99,14 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store gitWorktrees = [createFolderWorktree(repo)] } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Throwing here - // makes the renderer's fetchWorktrees catch block preserve its cached - // worktree list instead of replacing it with an empty array. + // Why: when SSH is disconnected the provider is null. Return [] so the + // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) + // preserves its cached worktree list. This avoids a console error on every + // fetchAllWorktrees cycle while the connection is being (re-)established — + // worktrees will be properly populated when the SSH `connected` event fires + // and triggers a re-fetch. if (!provider) { - throw new Error(`SSH connection "${repo.connectionId}" is not active`) + return [] } gitWorktrees = await provider.listWorktrees(repo.path) } else { diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts new file mode 100644 index 00000000000..bc68917c6d4 --- /dev/null +++ b/src/main/opencode/hook-service.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { _internals } from './hook-service' + +describe('OpenCode hook plugin source', () => { + it('filters child sessions via parentID lookup before forwarding events', () => { + const source = _internals.getOpenCodePluginSource() + + expect(source).toContain('async function isChildSession(client, sessionID)') + expect(source).toContain('const sessions = await client.session.list();') + expect(source).toContain('const isChild = !!session?.parentID;') + expect(source).toContain('if (sessionID && (await isChildSession(client, sessionID))) {') + expect(source).toContain('return true;') + }) + + it('still accepts an optional opaque plugin context instead of destructuring', () => { + const source = _internals.getOpenCodePluginSource() + + expect(source).toContain('export const OrcaOpenCodeStatusPlugin = async (_ctx) => {') + expect(source).toContain('const client = _ctx?.client;') + }) +}) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 71e201cfa11..53da57e8f4a 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,195 +1,218 @@ -import { app, BrowserWindow } from 'electron' -import { randomUUID } from 'crypto' -import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { app } from 'electron' import { join } from 'path' import { mkdirSync, writeFileSync, rmSync } from 'fs' -import type { OpenCodeStatusEvent } from '../../shared/types' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -type OpenCodeHookStatus = OpenCodeStatusEvent['status'] - -function readJsonBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = '' - req.on('data', (chunk: Buffer) => { - body += chunk.toString() - if (body.length > 1_000_000) { - reject(new Error('payload too large')) - req.destroy() - } - }) - req.on('end', () => { - try { - resolve(body ? JSON.parse(body) : {}) - } catch (error) { - reject(error) - } - }) - req.on('error', reject) - }) -} - -function normalizeStatus(value: unknown): OpenCodeHookStatus | null { - return value === 'working' || value === 'idle' || value === 'permission' ? value : null +// Why: ptyId today is allocated by Orca (safe UUID-shape), but both entry +// points construct a filesystem path with it and one of them calls +// rmSync(..., recursive) on the result. Reject obviously unsafe IDs as a +// belt-and-braces guard so a future caller (or a bug that forwards an +// external ID) cannot escape userData/opencode-hooks/. +function isSafePtyId(ptyId: string): boolean { + if (!ptyId || ptyId.length === 0 || ptyId.length > 128) { + return false + } + // Allow alphanumeric, dash, underscore, period (but not leading period or + // any slashes/backslashes). + return /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(ptyId) && !ptyId.includes('..') } function getOpenCodePluginSource(): string { + // Why: the plugin runs inside the OpenCode Node process and POSTs to the + // unified agent-hooks server shared with Claude/Codex/Gemini. It reads the + // same ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_AGENT_HOOK_* + // env vars that Orca injects into every PTY, so OpenCode panes flow into + // agentStatusByPaneKey via the same IPC path as every other agent. Event + // mapping is done plugin-side (SessionBusy / SessionIdle / PermissionRequest) + // so the server-side normalizer can keep its one-event-per-case switch shape. return [ - 'const HOOK_PATH = "/hook";', - '', 'function getHookUrl() {', - ' const port = process.env.ORCA_OPENCODE_HOOK_PORT;', - ' return port ? `http://127.0.0.1:${port}${HOOK_PATH}` : null;', + ' const port = process.env.ORCA_AGENT_HOOK_PORT;', + ' return port ? `http://127.0.0.1:${port}/hook/opencode` : null;', '}', '', 'function getStatusType(event) {', ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', '}', '', - 'async function postStatus(status) {', + 'let lastStatus = "idle";', + 'const childSessionById = new Map();', + '', + '// Why: message.part.updated fires for every Part (text, tool, reasoning)', + '// but does not include the message role — that lives on the parent', + '// message.updated event. Cache the role per messageID so the plugin can', + '// tag a TextPart as user vs assistant when POSTing. Capped at 128 entries', + '// so long-running sessions do not grow this map unboundedly.', + 'const messageRoleById = new Map();', + 'function rememberMessageRole(messageID, role) {', + ' if (!messageID || !role) return;', + ' if (messageRoleById.size >= 128) {', + ' const first = messageRoleById.keys().next().value;', + ' if (first !== undefined) messageRoleById.delete(first);', + ' }', + ' messageRoleById.set(messageID, role);', + '}', + '', + '// Why: oh-my-opencode style tools spawn child sessions that emit their', + '// own session.idle / message events. Those child completions must not', + '// flip the root Orca pane to done or overwrite the parent turn preview.', + '// Match Superset by checking `parentID` via client.session.list(), cache', + '// the result per session, and fail closed (assume child) on lookup errors', + '// so a transient SDK failure cannot create false "done" transitions.', + 'async function isChildSession(client, sessionID) {', + ' if (!sessionID) return true;', + ' if (childSessionById.has(sessionID)) return childSessionById.get(sessionID);', + ' if (!client?.session?.list) return true;', + ' try {', + ' const sessions = await client.session.list();', + ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', + ' const session = list.find((entry) => entry?.id === sessionID);', + ' const isChild = !!session?.parentID;', + ' if (childSessionById.size >= 128) {', + ' const first = childSessionById.keys().next().value;', + ' if (first !== undefined) childSessionById.delete(first);', + ' }', + ' childSessionById.set(sessionID, isChild);', + ' return isChild;', + ' } catch {', + ' return true;', + ' }', + '}', + '', + 'async function post(hookEventName, extraProperties) {', ' const url = getHookUrl();', - ' const token = process.env.ORCA_OPENCODE_HOOK_TOKEN;', - ' const ptyId = process.env.ORCA_OPENCODE_PTY_ID;', - ' if (!url || !token || !ptyId) return;', + ' const token = process.env.ORCA_AGENT_HOOK_TOKEN;', + ' const paneKey = process.env.ORCA_PANE_KEY;', + ' if (!url || !token || !paneKey) return;', + ' const body = JSON.stringify({', + ' paneKey,', + ' tabId: process.env.ORCA_TAB_ID || "",', + ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', + ' env: process.env.ORCA_AGENT_HOOK_ENV || "",', + ' version: process.env.ORCA_AGENT_HOOK_VERSION || "",', + ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', + ' });', ' try {', ' await fetch(url, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', - ' "X-Orca-Token": token,', - ' "X-Orca-OpenCode-Pty-Id": ptyId,', + ' "X-Orca-Agent-Hook-Token": token,', ' },', - ' body: JSON.stringify({ status }),', + ' body,', ' });', ' } catch {', - ' // Why: OpenCode session hooks must never fail the agent run just', + ' // Why: OpenCode session events must never fail the agent run just', ' // because Orca is unavailable or the local loopback request failed.', ' }', '}', '', - 'export const OrcaOpenCodeStatusPlugin = async () => ({', + 'async function setStatus(next, extraProperties) {', + ' // Why: dedupe so a flurry of session.status idle events after a turn', + ' // does not spam the dashboard with redundant done transitions.', + ' if (lastStatus === next) return;', + ' lastStatus = next;', + ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', + ' await post(hookEventName, extraProperties);', + '}', + '', + '// Why: accept the factory argument as an optional opaque parameter instead', + '// of destructuring (`async ({ client }) => …`). OpenCode can invoke the', + '// plugin factory with undefined during startup, which makes the', + '// destructuring form throw synchronously and crash OpenCode with an opaque', + '// UnknownError before any event is ever dispatched.', + 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', + ' const client = _ctx?.client;', + ' return {', ' event: async ({ event }) => {', ' if (!event?.type) return;', '', - ' if (event.type === "permission.asked") {', - ' await postStatus("permission");', + ' // Why: cache the message role BEFORE the async isChildSession check.', + ' // OpenCode fires message.updated (user) and message.part.updated (text)', + ' // back-to-back; if we awaited isChildSession first, the part.updated', + ' // handler could reach messageRoleById.get(...) while the user message.updated', + ' // is still suspended on that await — so the part would see an empty cache', + ' // and drop the user prompt. Caching is a cheap Map.set with bounded size,', + ' // safe to run even for child sessions (the part POST still filters them).', + ' if (event.type === "message.updated") {', + ' const info = event.properties && event.properties.info;', + ' rememberMessageRole(info && info.id, info && info.role);', + ' }', + '', + ' const sessionID = event.properties?.sessionID;', + ' if (sessionID && (await isChildSession(client, sessionID))) {', ' return;', ' }', '', - ' if (event.type === "session.idle") {', - ' await postStatus("idle");', + ' if (event.type === "permission.asked") {', + ' // Why: permission asks are not a session state transition — emit', + ' // without mutating lastStatus so the next SessionBusy/SessionIdle', + ' // still fires. The server maps PermissionRequest to `waiting`.', + ' await post("PermissionRequest", event.properties || {});', + ' return;', + ' }', + '', + ' if (event.type === "message.updated") {', + ' // Why: role is already cached above the isChildSession await so the', + ' // back-to-back message.part.updated for the same messageID is not', + ' // racing against this handler. Nothing more to do here — return to', + ' // avoid falling through to the part/session handlers below.', + ' return;', + ' }', + '', + ' if (event.type === "message.part.updated") {', + ' // Why: a TextPart carries the actual user prompt or assistant reply', + ' // text. Skip non-text parts (tool, reasoning, file, …) so we only', + ' // forward what the dashboard renders. Role came from the earlier', + ' // message.updated event; if we never saw one (e.g. plugin loaded', + ' // mid-turn) the role is unknown, and mislabeling the part — a user', + ' // prompt displayed as the assistant reply, or vice versa — is worse', + ' // than silently dropping a single in-flight text chunk. The next', + ' // message.updated event will re-seed the role cache, so subsequent', + ' // parts in the same session flow normally.', + ' const part = event.properties && event.properties.part;', + ' if (!part || part.type !== "text" || !part.text) return;', + ' const role = messageRoleById.get(part.messageID);', + ' if (!role) return;', + ' await post("MessagePart", { role, text: part.text });', + ' return;', + ' }', + '', + ' if (event.type === "session.idle" || event.type === "session.error") {', + ' await setStatus("idle");', ' return;', ' }', '', ' if (event.type === "session.status") {', ' const statusType = getStatusType(event);', ' if (statusType === "busy" || statusType === "retry") {', - ' await postStatus("working");', + ' await setStatus("busy");', ' return;', ' }', ' if (statusType === "idle") {', - ' await postStatus("idle");', + ' await setStatus("idle");', ' }', ' }', ' },', - '});', + ' };', + '};', '' ].join('\n') } +// Why: OpenCode hooks used to run their own loopback HTTP server + IPC +// channel (pty:opencode-status). That pathway produced a synthetic terminal +// title but never entered agentStatusByPaneKey, so the unified dashboard +// never saw OpenCode sessions. The service now only installs the plugin +// file into OPENCODE_CONFIG_DIR — the plugin POSTs directly to the shared +// agent-hooks server (/hook/opencode), so OpenCode rides the same status +// pipeline as Claude/Codex/Gemini. export class OpenCodeHookService { - private server: ReturnType | null = null - private port = 0 - private token = '' - private lastStatusByPtyId = new Map() - - async start(): Promise { - if (this.server) { + clearPty(ptyId: string): void { + if (!isSafePtyId(ptyId)) { return } - - this.token = randomUUID() - this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'POST' || req.url !== '/hook') { - res.writeHead(404) - res.end() - return - } - - if (req.headers['x-orca-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - - const ptyIdHeader = req.headers['x-orca-opencode-pty-id'] - const ptyId = Array.isArray(ptyIdHeader) ? ptyIdHeader[0] : ptyIdHeader - if (!ptyId) { - res.writeHead(400) - res.end() - return - } - - try { - const body = await readJsonBody(req) - const status = normalizeStatus((body as { status?: unknown }).status) - if (!status) { - res.writeHead(400) - res.end() - return - } - - if (this.lastStatusByPtyId.get(ptyId) !== status) { - this.lastStatusByPtyId.set(ptyId, status) - const payload: OpenCodeStatusEvent = { ptyId, status } - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('pty:opencode-status', payload) - } - } - } - - res.writeHead(204) - res.end() - } catch { - res.writeHead(400) - res.end() - } - }) - - await new Promise((resolve, reject) => { - this.server!.once('error', reject) - this.server!.listen(0, '127.0.0.1', () => { - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - resolve() - }) - }) - } - - stop(): void { - this.server?.close() - this.server = null - this.port = 0 - // Why: clean up all remaining PTY config directories before clearing the - // in-memory tracking. Without this, directories from the current session's - // PTYs survive on disk after shutdown. - for (const ptyId of this.lastStatusByPtyId.keys()) { - const configDir = join(app.getPath('userData'), 'opencode-hooks', ptyId) - try { - rmSync(configDir, { recursive: true, force: true }) - } catch { - // best-effort - } - } - this.lastStatusByPtyId.clear() - } - - clearPty(ptyId: string): void { - this.lastStatusByPtyId.delete(ptyId) // Why: writePluginConfig creates a directory per PTY under userData. Without // cleanup these accumulate across sessions since ptyId is a monotonically // increasing counter. Remove the directory when the PTY is torn down. @@ -203,35 +226,26 @@ export class OpenCodeHookService { } buildPtyEnv(ptyId: string): Record { - if (this.port <= 0 || !this.token) { + const configDir = this.writePluginConfig(ptyId) + if (!configDir) { + // Why: plugin config is best-effort. Returning an empty object lets the + // PTY spawn without the OpenCode plugin when the filesystem is locked; + // the agent-hooks env (ORCA_AGENT_HOOK_PORT/TOKEN/ORCA_PANE_KEY) is + // still injected separately by ipc/pty.ts so other agents keep working. return {} } - const configDir = this.writePluginConfig(ptyId) - if (!configDir) { - // Why: plugin config is best-effort — return hook vars without - // OPENCODE_CONFIG_DIR so the PTY still spawns and manually launched - // opencode sessions can still report status via the hook server. - return { - ORCA_OPENCODE_HOOK_PORT: String(this.port), - ORCA_OPENCODE_HOOK_TOKEN: this.token, - ORCA_OPENCODE_PTY_ID: ptyId - } - } - - // Why: OpenCode only reads the extra plugin directory at process startup. - // Inject these vars into every Orca PTY so manually launched `opencode` - // sessions inherit the hook path too, not just sessions started from a - // hardcoded Orca command template. - return { - ORCA_OPENCODE_HOOK_PORT: String(this.port), - ORCA_OPENCODE_HOOK_TOKEN: this.token, - ORCA_OPENCODE_PTY_ID: ptyId, - OPENCODE_CONFIG_DIR: configDir - } + // Why: OPENCODE_CONFIG_DIR points OpenCode at a plugin directory we own. + // Injecting it into every Orca PTY means manually launched `opencode` + // sessions automatically pick up the status plugin too, not just sessions + // started from a hardcoded command template. + return { OPENCODE_CONFIG_DIR: configDir } } private writePluginConfig(ptyId: string): string | null { + if (!isSafePtyId(ptyId)) { + return null + } const configDir = join(app.getPath('userData'), 'opencode-hooks', ptyId) const pluginsDir = join(configDir, 'plugins') try { @@ -248,3 +262,7 @@ export class OpenCodeHookService { } export const openCodeHookService = new OpenCodeHookService() +export const _internals = { + getOpenCodePluginSource, + isSafePtyId +} diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index b2f1acb0878..eac8cefc081 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -12,6 +12,10 @@ vi.mock('electron', () => ({ on: vi.fn(), removeHandler: removeHandlerMock, removeAllListeners: removeAllListenersMock + }, + app: { + isPackaged: true, + getPath: vi.fn().mockReturnValue('/tmp/orca-test-userdata') } })) diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index eab50f393eb..2fc0e94ac3e 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -97,6 +97,17 @@ export function isValidVersion(value: string): boolean { return parseVersion(value) !== null } +// Why: a user running a prerelease build (e.g. 1.3.17-rc.1) must stay on the +// RC channel for "Check for Updates" to resolve the next RC (1.3.17-rc.2). +// The default generic feed only advertises non-prerelease releases, so without +// this detection a prerelease user would be stuck unless they knew to +// Shift-click the menu item — effectively trapping them on the RC they +// installed. +export function isPrereleaseVersion(value: string): boolean { + const parsed = parseVersion(value) + return parsed !== null && parsed.prerelease.length > 0 +} + function compareIdentifiers(left: string, right: string): number { const leftNumeric = /^\d+$/.test(left) const rightNumeric = /^\d+$/.test(right) diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 5b3de44a42b..42d55000797 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { compareVersions } from './updater-fallback' +import { compareVersions, isPrereleaseVersion } from './updater-fallback' describe('compareVersions', () => { it('compares prerelease and build semver strings correctly', () => { @@ -9,3 +9,19 @@ describe('compareVersions', () => { expect(compareVersions('v1.0.70-beta.2', '1.0.70-beta.1')).toBeGreaterThan(0) }) }) + +describe('isPrereleaseVersion', () => { + it('recognises RC, beta, and alpha variants', () => { + expect(isPrereleaseVersion('1.3.17-rc.1')).toBe(true) + expect(isPrereleaseVersion('v1.3.17-rc.2')).toBe(true) + expect(isPrereleaseVersion('1.0.0-beta.5')).toBe(true) + expect(isPrereleaseVersion('2.1.0-alpha')).toBe(true) + }) + + it('returns false for stable releases and unparseable values', () => { + expect(isPrereleaseVersion('1.3.17')).toBe(false) + expect(isPrereleaseVersion('v1.3.17')).toBe(false) + expect(isPrereleaseVersion('1.3.17+build.5')).toBe(false) + expect(isPrereleaseVersion('not-a-version')).toBe(false) + }) +}) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index ec3ceedcc43..995d75ac80d 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -750,4 +750,43 @@ describe('updater', () => { expect((autoUpdaterMock as Record).verifyUpdateCodeSignature).toBeUndefined() }) + + // Why: a user running an RC (e.g. 1.3.17-rc.1) must stay on the RC channel + // so the default "Check for Updates" path can find the next RC. The generic + // /releases/latest/download/ feed only exposes non-prerelease releases. + it('auto-enables the RC channel at setup when running a prerelease build', async () => { + appMock.getVersion.mockReturnValue('1.3.17-rc.1') + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + + const { setupAutoUpdater } = await import('./updater') + + const mainWindow = { webContents: { send: vi.fn() } } + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + expect(autoUpdaterMock.allowPrerelease).toBe(true) + expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({ + provider: 'github', + owner: 'stablyai', + repo: 'orca' + }) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: 'generic' }) + ) + }) + + it('keeps the generic feed at setup when running a stable release', async () => { + appMock.getVersion.mockReturnValue('1.3.17') + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + + const { setupAutoUpdater } = await import('./updater') + + const mainWindow = { webContents: { send: vi.fn() } } + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + expect(autoUpdaterMock.allowPrerelease).not.toBe(true) + expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + }) }) diff --git a/src/main/updater.ts b/src/main/updater.ts index 60a4f9abfc9..21551e6c45a 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -11,7 +11,12 @@ import { markMacQuitAndInstallInFlight } from './updater-mac-install' import { registerAutoUpdaterHandlers } from './updater-events' -import { compareVersions, isBenignCheckFailure, statusesEqual } from './updater-fallback' +import { + compareVersions, + isBenignCheckFailure, + isPrereleaseVersion, + statusesEqual +} from './updater-fallback' import { fetchNudge, shouldApplyNudge } from './updater-nudge' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 @@ -476,15 +481,24 @@ export function setupAutoUpdater( ;(autoUpdater as NsisUpdater).verifyUpdateCodeSignature = () => Promise.resolve(null) } - // Use the generic provider with GitHub's /releases/latest/download/ URL so - // electron-updater always fetches the manifest (latest-mac.yml, latest.yml, - // latest-linux.yml) from the latest non-prerelease release. This sidesteps - // the broken /releases/latest API endpoint (returns 406) and automatically - // excludes RC/prerelease versions without client-side filtering. - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) + // Why: a user already running a prerelease (e.g. 1.3.17-rc.1) must stay on + // the RC channel so "Check for Updates" can find the next RC (1.3.17-rc.2). + // The generic /releases/latest/download/ feed only advertises non-prerelease + // releases, so without this they'd never see the follow-up RC. Users on a + // stable release keep the generic feed and must Shift-click to opt in. + if (isPrereleaseVersion(app.getVersion())) { + enableIncludePrerelease() + } else { + // Use the generic provider with GitHub's /releases/latest/download/ URL so + // electron-updater always fetches the manifest (latest-mac.yml, latest.yml, + // latest-linux.yml) from the latest non-prerelease release. This sidesteps + // the broken /releases/latest API endpoint (returns 406) and automatically + // excludes RC/prerelease versions without client-side filtering. + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } if (autoUpdaterInitialized) { return diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index d56319ea34d..2767f523e6d 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -33,7 +33,6 @@ import type { GitHubIssueUpdate, NotificationDispatchRequest, NotificationDispatchResult, - OpenCodeStatusEvent, OrcaHooks, PersistedUIState, PRCheckDetail, @@ -71,6 +70,7 @@ import type { } from '../../shared/browser-guest-events' import type { CliInstallStatus } from '../../shared/cli-install-types' import type { E2EConfig } from '../../shared/e2e-config' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types' import type { ClaudeUsageBreakdownKind, @@ -173,7 +173,6 @@ export type DetectedBrowserInfo = { export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } - linear: { connected: boolean } } export type RefreshAgentsResult = { @@ -353,7 +352,6 @@ export type PreloadApi = { listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void - onOpenCodeStatus: (callback: (event: OpenCodeStatusEvent) => void) => () => void } feedback: { submit: (args: { @@ -488,6 +486,11 @@ export type PreloadApi = { install: () => Promise remove: () => Promise } + agentHooks: { + claudeStatus: () => Promise + codexStatus: () => Promise + geminiStatus: () => Promise + } preflight: PreflightApi notifications: { dispatch: (args: NotificationDispatchRequest) => Promise diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 68213931828..991100c6ce3 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -6,10 +6,10 @@ import type { GitHubWorkItem, GitHubWorkItemDetails, GitHubViewer, - CreateWorktreeArgs, - OpenCodeStatusEvent + CreateWorktreeArgs } from '../../shared/types' import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' +import type { AgentStatusState } from '../../shared/agent-status-types' import type { PreloadApi } from './api-types' type ReposApi = { @@ -81,7 +81,6 @@ type PtyApi = { listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void - onOpenCodeStatus: (callback: (event: OpenCodeStatusEvent) => void) => () => void } type GhApi = { @@ -192,11 +191,39 @@ type SshApi = { }> } +type AgentStatusApi = { + /** Listen for agent status updates forwarded from native hook receivers. */ + onSet: ( + callback: (data: { + paneKey: string + tabId?: string + worktreeId?: string + state: AgentStatusState + prompt?: string + agentType?: string + toolName?: string + toolInput?: string + lastAssistantMessage?: string + interrupted?: boolean + }) => 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 pty: PtyApi ssh: SshApi + gh: GhApi + settings: SettingsApi + cli: CliApi + notifications: NotificationsApi + shell: ShellApi + agentStatus: AgentStatusApi } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9613b3c0c9f..5c5c7b08d44 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,14 +5,12 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import type { CliInstallStatus } from '../shared/cli-install-types' -import type { - FsChangedPayload, - NotificationDispatchResult, - OpenCodeStatusEvent -} from '../shared/types' +import type { AgentHookInstallStatus } from '../shared/agent-hook-types' +import type { FsChangedPayload, NotificationDispatchResult } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import type { RateLimitState } from '../shared/rate-limit-types' import type { SshConnectionState, SshTarget } from '../shared/ssh-types' +import type { AgentStatusState } from '../shared/agent-status-types' import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, type EditorSaveDirtyFilesDetail @@ -317,13 +315,6 @@ const api = { callback(data) ipcRenderer.on('pty:exit', listener) return () => ipcRenderer.removeListener('pty:exit', listener) - }, - - onOpenCodeStatus: (callback: (event: OpenCodeStatusEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: OpenCodeStatusEvent) => - callback(data) - ipcRenderer.on('pty:opencode-status', listener) - return () => ipcRenderer.removeListener('pty:opencode-status', listener) } }, @@ -539,6 +530,15 @@ const api = { remove: (): Promise => ipcRenderer.invoke('cli:remove') }, + agentHooks: { + claudeStatus: (): Promise => + ipcRenderer.invoke('agentHooks:claudeStatus'), + codexStatus: (): Promise => + ipcRenderer.invoke('agentHooks:codexStatus'), + geminiStatus: (): Promise => + ipcRenderer.invoke('agentHooks:geminiStatus') + }, + preflight: { check: (args?: { force?: boolean @@ -1576,6 +1576,42 @@ const api = { }, e2e: { getConfig: () => preloadE2EConfig + }, + + agentStatus: { + /** Listen for agent status updates forwarded from native hook receivers. */ + onSet: ( + callback: (data: { + paneKey: string + tabId?: string + worktreeId?: string + state: AgentStatusState + prompt?: string + agentType?: string + toolName?: string + toolInput?: string + lastAssistantMessage?: string + interrupted?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + paneKey: string + tabId?: string + worktreeId?: string + state: AgentStatusState + prompt?: string + agentType?: string + toolName?: string + toolInput?: string + lastAssistantMessage?: string + interrupted?: boolean + } + ) => callback(data) + ipcRenderer.on('agentStatus:set', listener) + return () => ipcRenderer.removeListener('agentStatus:set', listener) + } } } diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts new file mode 100644 index 00000000000..2d07a01f1bb --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -0,0 +1,82 @@ +/** + * Plain readdir-based file listing fallback. + * + * Why: when neither ripgrep nor git is available (e.g. a non-git folder on a + * remote machine without rg), we still need to list files for quick-open. + * This walks the directory tree using Node's fs.readdir, respecting the same + * blocklist and timeout constraints as the git-based fallback. + */ +import { readdir } from 'fs/promises' +import { join, relative } from 'path' + +const MAX_FILES = 10_000 +const TIMEOUT_MS = 10_000 + +// Why: mirrors the HIDDEN_DIR_BLOCKLIST in fs-handler-git-fallback.ts — +// tool-generated dirs that clutter quick-open. User-authored dotdirs like +// .github/ and .devcontainer/ are intentionally kept discoverable. +const HIDDEN_DIR_BLOCKLIST = new Set([ + '.git', + '.next', + '.nuxt', + '.cache', + '.stably', + '.vscode', + '.idea', + '.yarn', + '.pnpm-store', + '.terraform', + '.docker', + '.husky' +]) + +function shouldDescend(name: string): boolean { + if (name === 'node_modules' || HIDDEN_DIR_BLOCKLIST.has(name)) { + return false + } + return true +} + +/** + * Recursively list files under `rootPath` using fs.readdir. + * Returns relative POSIX paths, capped at MAX_FILES with a timeout. + */ +export async function listFilesWithReaddir(rootPath: string): Promise { + const files: string[] = [] + const deadline = Date.now() + TIMEOUT_MS + + async function walk(dir: string): Promise { + if (files.length >= MAX_FILES || Date.now() > deadline) { + return + } + + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + // Permission denied, symlink loop, etc. — skip silently. + return + } + + for (const entry of entries) { + if (files.length >= MAX_FILES || Date.now() > deadline) { + return + } + + const name = entry.name + if (entry.isDirectory()) { + if (shouldDescend(name)) { + await walk(join(dir, name)) + } + } else if (entry.isFile()) { + // Why: path.relative() returns backslashes on Windows. The quick-open + // UI assumes POSIX separators for display and fuzzy matching. + const relPath = relative(rootPath, join(dir, name)).replace(/\\/g, '/') + files.push(relPath) + } + } + } + + await walk(rootPath) + return files +} diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index fd65c5e0c3c..bb2ca22974c 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -11,6 +11,7 @@ import { realpath } from 'fs/promises' import { extname } from 'path' +import { execFile } from 'child_process' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' @@ -24,6 +25,7 @@ import { checkRgAvailable } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' +import { listFilesWithReaddir } from './fs-handler-readdir-fallback' type WatchState = { rootPath: string @@ -228,10 +230,23 @@ export class FsHandler { const rootPath = expandTilde(params.rootPath as string) await this.context.validatePathResolved(rootPath) const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { + if (rgAvailable) { + return listFilesWithRg(rootPath) + } + // Why: git ls-files only works inside git repos. Use rev-parse to detect + // git ancestry — unlike checking for a local .git entry, this works from + // subdirectories of a checkout (e.g. /repo/packages/app added as a folder). + // Without this, a git subdirectory would fall through to readdir and + // surface .gitignore'd build artifacts. + const isGitRepo = await new Promise((resolve) => { + execFile('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootPath }, (err) => + resolve(!err) + ) + }) + if (isGitRepo) { return listFilesWithGit(rootPath) } - return listFilesWithRg(rootPath) + return listFilesWithReaddir(rootPath) } private async watch(params: Record) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 82558dc9354..c39a788f1f4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants' -import { ChevronLeft, ChevronRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' +import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { toast } from 'sonner' @@ -645,149 +645,153 @@ function App(): React.JSX.Element { // the sidebar-width left header (workspace view) can share the same // controls without duplicating the agent badge popover. const titlebarLeftControls = ( -
-
- {showSidebar && ( - - - - - - {`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`} - - - )} - {settings?.showTitlebarAgentActivity !== false ? ( - - - - - -
0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`} - > - {activeAgentCount === 0 - ? 'No agents active' - : `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`} -
- {activeAgentCount > 0 && ( -
- {Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => { - const wt = findWorktreeById(worktreesByRepo, worktreeId) - // Why: when a transient git error causes worktreesByRepo to - // lose a worktree, the raw worktreeId (uuid::path) is not - // useful. Extract a cross-platform path basename as a - // readable fallback. - const sepIdx = worktreeId.indexOf('::') - const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId - const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart - return ( -
- - {agents.map((agent) => ( - - ))} -
- ) - })} -
- )} - -
-
- ) : null} - {/* Why: Back/Forward navigate worktree-activation history. Only - meaningful while viewing a worktree (terminal view); hidden in - Settings/Tasks/Landing to keep the titlebar compact and the - semantics unambiguous. */} - {activeView === 'terminal' && ( - <> +
+
+
+ {showSidebar && ( + + + {`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`} + + + )} + {settings?.showTitlebarAgentActivity !== false ? ( + + + + + +
0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`} + > + {activeAgentCount === 0 + ? 'No agents active' + : `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`} +
+ {activeAgentCount > 0 && ( +
+ {Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => { + const wt = findWorktreeById(worktreesByRepo, worktreeId) + // Why: when a transient git error causes worktreesByRepo to + // lose a worktree, the raw worktreeId (uuid::path) is not + // useful. Extract a cross-platform path basename as a + // readable fallback. + const sepIdx = worktreeId.indexOf('::') + const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId + const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart + return ( +
+ + {agents.map((agent) => ( + + ))} +
+ ) + })} +
+ )} + +
+
+ ) : null} +
+ {/* Why: Back/Forward navigate worktree-activation history. Only + meaningful while viewing a worktree (terminal view); hidden in + Settings/Tasks/Landing to keep the titlebar compact and the + semantics unambiguous. The group sits outside the measured inner + container so the sidebar-collapse spacer stays sized to the left + controls only. */} + {activeView === 'terminal' && ( +
+ + + @@ -797,19 +801,19 @@ function App(): React.JSX.Element { {`Go forward (${isMac ? '⌘⌥→' : 'Ctrl+Alt+→'})`} - +
)}
) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b49f9eba549..756b1b2c55c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -369,6 +369,13 @@ color: var(--foreground); } +/* Why: tighter padding for the back/forward pair so the arrows sit + close together as a single navigation group, while the standard + .sidebar-toggle spacing stays unchanged for other titlebar icons. */ +.sidebar-toggle-compact { + padding: 4px 5px; +} + .sidebar-toggle:disabled { cursor: default; opacity: 0.45; diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index ccc31aa4f72..3b27aab1996 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -19,7 +19,6 @@ import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { TuiAgent } from '../../../shared/types' -import { isGitRepoKind } from '../../../shared/repo-kind' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -190,11 +189,9 @@ export default function NewWorkspaceComposerCard({ createError }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() - const addRepo = useAppStore((s) => s.addRepo) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const openModal = useAppStore((s) => s.openModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const updateSettings = useAppStore((s) => s.updateSettings) - const [isAddingRepo, setIsAddingRepo] = React.useState(false) const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null) => { @@ -218,25 +215,9 @@ export default function NewWorkspaceComposerCard({ [detectedAgentIds] ) - const handleAddRepo = React.useCallback(async (): Promise => { - if (isAddingRepo) { - return - } - setIsAddingRepo(true) - try { - const repo = await addRepo() - if (!repo) { - return - } - if (isGitRepoKind(repo)) { - await fetchWorktrees(repo.id) - } - onRepoChange(repo.id) - focusNameInput() - } finally { - setIsAddingRepo(false) - } - }, [addRepo, fetchWorktrees, focusNameInput, isAddingRepo, onRepoChange]) + const handleAddRepo = React.useCallback((): void => { + openModal('add-repo') + }, [openModal]) return (
void handleAddRepo()} + onClick={handleAddRepo} className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground" - aria-label={ - isAddingRepo ? 'Adding folder or repository' : 'Add folder or repository' - } + aria-label="Add folder or repository" > diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ce249e03996..9d2e74b95b9 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -92,9 +92,19 @@ export default function QuickOpen(): React.JSX.Element | null { () => getConnectionId(activeWorktreeId ?? null) ?? undefined, [activeWorktreeId] ) + + // Why: when quick-open opens before the SSH connection is established, + // fs:listFiles returns [] (no provider yet). Watching the active target's + // connection status lets the file-load effect re-fire automatically once + // that specific connection comes up, without being affected by unrelated + // SSH targets reconnecting. + const activeTargetStatus = useAppStore((s) => + connectionId ? s.sshConnectionStates.get(connectionId)?.status : undefined + ) const filesRequestKey = useMemo( - () => `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}`, - [connectionId, excludePathsKey, worktreePath] + () => + `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`, + [connectionId, excludePathsKey, worktreePath, activeTargetStatus] ) // Why: reset input only on open. Keeping this out of the file-load effect diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 67e307d986c..7e0b831e921 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1221,34 +1221,33 @@ export default function TaskPage(): React.JSX.Element {
) : null} - {/* Why: Close sits in its own row below the titlebar strip so it can - never overlap the floating macOS traffic lights. Kept left-aligned - to stay out of the app sidebar on the right edge. */} -
- - - - - - Close · Esc - - -
- -
-
-
-
-
+
+
+
+
+
+ {/* Why: Close is anchored left in the same row as the + source icons so the top chrome is one compact band. + Left-aligned keeps it clear of the app sidebar on the + right edge. */} + + + + + + Close · Esc + + +
{SOURCE_OPTIONS.map((source) => { const active = taskSource === source.id return ( diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index d9db09828c4..bd354d5aa92 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { Globe, Plus } from 'lucide-react' +import { Globe, Plus, WifiOff } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors' import { @@ -138,6 +138,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const allWorktrees = useAllWorktrees() const repos = useAppStore((s) => s.repos) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + // Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a + // working agent in a non-focused pane still surface as 'working' in the + // jump palette. Without this, clicking between panes would desync the + // palette's spinner from the sidebar's spinner. + const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) const prCache = useAppStore((s) => s.prCache) const issueCache = useAppStore((s) => s.issueCache) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -145,6 +150,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) @@ -701,10 +707,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const branch = branchName(worktree.branch) const status = getWorktreeStatus( tabsByWorktree[worktree.id] ?? [], - browserTabsByWorktree[worktree.id] ?? [] + browserTabsByWorktree[worktree.id] ?? [], + runtimePaneTitlesByTabId ) const statusLabel = getWorktreeStatusLabel(status) const isCurrentWorktree = activeWorktreeId === worktree.id + const sshConnectionId = repo?.connectionId ?? null + const sshStatus = sshConnectionId + ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') + : null + const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' return (
+ {sshConnectionId && ( + + {isSshDisconnected ? ( + + )} {entry.match.displayNameRange ? ( { for (const d of disposablesRef.current) { @@ -170,10 +169,10 @@ export function useDiffCommentDecorator({ // stale zone ids from a dead editor. The diff effect below deliberately // has no cleanup so comment-only changes don't cause a full zone // rebuild; this cleanup is the single place we reset zone tracking. - for (const entry of zones.values()) { + for (const entry of zonesRef.current.values()) { entry.root.unmount() } - zones.clear() + zonesRef.current.clear() } }, [editor]) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index b4fac33ada8..b63727d5c51 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -18,7 +18,7 @@ type CliSectionProps = { currentPlatform: string } -const ORCA_SKILL_INSTALL_COMMAND = +const ORCA_CLI_SKILL_INSTALL_COMMAND = 'npx skills add https://github.com/stablyai/orca --skill orca-cli' function getRevealLabel(platform: string): string { @@ -99,10 +99,10 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem } } - const handleCopySkillInstallCommand = async (): Promise => { + const handleCopySkillInstallCommand = async (command: string): Promise => { try { - await window.api.ui.writeClipboardText(ORCA_SKILL_INSTALL_COMMAND) - toast.success('Copied Orca skill install command.') + await window.api.ui.writeClipboardText(command) + toast.success('Copied skill install command.') } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to copy install command.') } @@ -205,36 +205,39 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
- +

- Install the Orca skill so agents know to use the{' '} - orca CLI. + Install skills so agents know how to use Orca and report status.

-
-

Install command

-
- - {ORCA_SKILL_INSTALL_COMMAND} - - - - - - - - Copy - - - +
+
+

CLI skill

+
+ + {ORCA_CLI_SKILL_INSTALL_COMMAND} + + + + + + + + Copy + + + +
diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index d6d80272741..269740aeab1 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -316,6 +316,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setRemoteError(null) }} onAdd={handleAddRemoteRepo} + onOpenSshSettings={() => { + closeModal() + openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsPage() + }} /> ) : step === 'clone' ? ( void onRemotePathChange: (value: string) => void onAdd: () => void + onOpenSshSettings: () => void } export function RemoteStep({ @@ -158,7 +159,8 @@ export function RemoteStep({ isAddingRemote, onSelectTarget, onRemotePathChange, - onAdd + onAdd, + onOpenSshSettings }: RemoteStepProps): React.JSX.Element { const [browsing, setBrowsing] = useState(false) @@ -197,9 +199,18 @@ export function RemoteStep({
{sshTargets.length === 0 ? ( -

- No SSH targets configured. Add one in Settings first. -

+
+

No SSH targets configured.

+ +
) : (
{sshTargets.map((target) => { @@ -252,7 +263,7 @@ export function RemoteStep({ }} placeholder="/home/user/project" className="h-8 text-xs flex-1" - disabled={isAddingRemote} + disabled={isAddingRemote || !selectedTargetId} /> diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 3feab666434..bb0d118ccfc 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -56,7 +56,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { return (
- + Workspaces
@@ -70,7 +70,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { className="text-muted-foreground" aria-label="View options" > - + @@ -153,7 +153,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { aria-label="New workspace" disabled={!canCreateWorktree} > - + diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index adeb411ed61..e15f13497b6 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Github, ListChecks } from 'lucide-react' +import { Github, List } from 'lucide-react' import { useAppStore } from '@/store' import { useRepoMap } from '@/store/selectors' import { cn } from '@/lib/utils' @@ -65,14 +65,14 @@ const SidebarNav = React.memo(function SidebarNav() { disabled={!canBrowseTasks} aria-current={tasksActive ? 'page' : undefined} className={cn( - 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors', tasksActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground', !canBrowseTasks && 'cursor-not-allowed opacity-50 hover:bg-transparent' )} > - + Tasks s.tabsByWorktree[worktree.id] ?? EMPTY_TABS) const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS) + // Why: split-pane tabs expose per-pane titles that the aggregate + // `tab.title` does not preserve (onActivePaneChange overwrites it with the + // focused pane's title). getWorktreeStatus needs those pane titles to keep + // the sidebar spinner reflecting *any* working pane, not just the focused + // one. Narrow the subscription to this worktree's tabs via useShallow so + // unrelated pane-title updates do not re-render every sidebar card. + const runtimePaneTitlesForWorktree = useAppStore( + useShallow((s) => { + const out: Record> = {} + for (const tab of s.tabsByWorktree[worktree.id] ?? []) { + const paneTitles = s.runtimePaneTitlesByTabId[tab.id] + if (paneTitles) { + out[tab.id] = paneTitles + } + } + return out + }) + ) const branch = branchDisplayName(worktree.branch) const isFolder = repo ? isFolderRepo(repo) : false @@ -123,8 +142,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ // Derive status const status: WorktreeStatus = useMemo( - () => getWorktreeStatus(tabs, browserTabs), - [tabs, browserTabs] + () => getWorktreeStatus(tabs, browserTabs, runtimePaneTitlesForWorktree), + [tabs, browserTabs, runtimePaneTitlesForWorktree] ) const showPR = cardProps.includes('pr') @@ -288,6 +307,23 @@ const WorktreeCard = React.memo(function WorktreeCard({ {/* Header row: Title and Checks */}
+ {repo?.connectionId && ( + + + + {isSshDisconnected ? ( + + ) : ( + + )} + + + + {isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'} + + + )} +
{worktree.displayName}
@@ -352,23 +388,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
)} - {repo?.connectionId && ( - - - - {isSshDisconnected ? ( - - ) : ( - - )} - - - - {isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'} - - - )} - {isFolder ? ( { settings: { promptCacheTimerEnabled: true }, codexRestartNoticeByPtyId: {}, consumePendingColdRestore: vi.fn(() => null), - consumePendingSnapshot: vi.fn(() => null) + consumePendingSnapshot: vi.fn(() => null), + removeAgentStatus: vi.fn() } as StoreState globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index af04ebc90fa..3ea5ac486a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -4,6 +4,7 @@ import type { IDisposable } from '@xterm/xterm' import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' +import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' @@ -69,6 +70,9 @@ export function connectPanePty( // first emitting a non-agent title, the cache timer would persist as stale // state. Clear it unconditionally on PTY exit. deps.setCacheTimerStartedAt(cacheKey, null) + // Why: a dead terminal has no running agent — remove its explicit status + // entry so the hover UI only shows what is running *now*. + useAppStore.getState().removeAgentStatus(cacheKey) // The runtime graph is the CLI's source for live terminal bindings, so // we must republish when a pane loses its PTY instead of waiting for a // broader layout change that may never happen. @@ -193,6 +197,20 @@ export function connectPanePty( // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. deps.setCacheTimerStartedAt(cacheKey, null) + // Why: the agent process is gone, so its explicit status is no longer meaningful. + // Remove the entry so the hover UI does not show stale "working" for a dead agent. + useAppStore.getState().removeAgentStatus(cacheKey) + } + // 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_TAB_ID: deps.tabId, + ORCA_WORKTREE_ID: deps.worktreeId } // Why: remote repos route PTY spawn through the SSH provider. Resolve the @@ -205,7 +223,7 @@ export function connectPanePty( const transport = createIpcPtyTransport({ cwd: deps.cwd, - env: paneStartup?.env, + env: paneEnv, command: paneStartup?.command, connectionId, worktreeId: deps.worktreeId, @@ -215,7 +233,23 @@ 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) => { + if (!AGENT_DASHBOARD_ENABLED) { + return + } + // Why: capture the store snapshot once so the title lookup and the + // setAgentStatus call observe the same state. Re-reading getState() + // between the two lines opens a brief window where the title could + // shift (OSC title update landing in between) and the status would be + // stored against a title that was never paired with it. + const state = useAppStore.getState() + const title = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + state.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-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index ea568cadc02..481c1a1702f 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -5,7 +5,7 @@ * co-locating the global handler maps that both the transport factory * and the eager-buffer reconnection logic share. */ -import type { OpenCodeStatusEvent } from '../../../../shared/types' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' // ── Singleton PTY event dispatcher ─────────────────────────────────── // One global IPC listener per channel, routes events to transports by @@ -14,7 +14,6 @@ import type { OpenCodeStatusEvent } from '../../../../shared/types' export const ptyDataHandlers = new Map void>() export const ptyExitHandlers = new Map void>() -export const openCodeStatusHandlers = new Map void>() /** Per-PTY teardown callbacks registered by each transport to clear closure * state (stale-title timer, agent tracker) that would otherwise fire after * the data handler is removed. */ @@ -34,7 +33,6 @@ let ptyDispatcherAttached = false export function unregisterPtyDataHandlers(ptyIds: string[]): void { for (const id of ptyIds) { ptyDataHandlers.delete(id) - openCodeStatusHandlers.delete(id) ptyTeardownHandlers.get(id)?.() ptyTeardownHandlers.delete(id) } @@ -51,9 +49,6 @@ export function ensurePtyDispatcher(): void { window.api.pty.onExit((payload) => { ptyExitHandlers.get(payload.id)?.(payload.code) }) - window.api.pty.onOpenCodeStatus((payload) => { - openCodeStatusHandlers.get(payload.ptyId)?.(payload) - }) } // ─── Eager PTY buffer for reconnection on restart ──────────────────── @@ -211,4 +206,6 @@ export type IpcPtyTransportOptions = { onAgentBecameIdle?: (title: string) => void onAgentBecameWorking?: () => void onAgentExited?: () => void + /** Callback for OSC 9999 agent status payloads parsed from PTY output. */ + onAgentStatus?: (payload: ParsedAgentStatusPayload) => void } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 2dd783508c2..e6e58a0332f 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -5,15 +5,11 @@ describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null let onExit: ((payload: { id: string; code: number }) => void) | null = null - let onOpenCodeStatus: - | ((payload: { ptyId: string; status: 'working' | 'idle' | 'permission' }) => void) - | null = null beforeEach(() => { vi.resetModules() onData = null onExit = null - onOpenCodeStatus = null ;(globalThis as { window: typeof window }).window = { ...originalWindow, @@ -32,18 +28,7 @@ describe('createIpcPtyTransport', () => { onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { onExit = callback return () => {} - }), - onOpenCodeStatus: vi.fn( - ( - callback: (payload: { - ptyId: string - status: 'working' | 'idle' | 'permission' - }) => void - ) => { - onOpenCodeStatus = callback - return () => {} - } - ) + }) } } } as unknown as typeof window @@ -57,36 +42,19 @@ describe('createIpcPtyTransport', () => { } }) - it('maps OpenCode status events into the existing working to idle agent lifecycle', async () => { + it('leaves title tracking to the PTY data stream (no OpenCode IPC channel)', async () => { + // Why: the dedicated OpenCode status IPC channel was replaced by the + // unified agent-hooks server; the transport layer no longer has a + // per-agent status callback. Keep the smoke test so the transport + // still wires up onData/onExit handlers on a basic connect. const { createIpcPtyTransport } = await import('./pty-transport') - const onTitleChange = vi.fn() - const onAgentBecameWorking = vi.fn() - const onAgentBecameIdle = vi.fn() + const transport = createIpcPtyTransport({}) - const transport = createIpcPtyTransport({ - onTitleChange, - onAgentBecameWorking, - onAgentBecameIdle - }) + await transport.connect({ url: '', callbacks: {} }) - await transport.connect({ - url: '', - callbacks: {} - }) - - expect(onOpenCodeStatus).not.toBeNull() - - onOpenCodeStatus?.({ ptyId: 'pty-1', status: 'working' }) - onData?.({ id: 'pty-1', data: ']0;OpenCode' }) - onOpenCodeStatus?.({ ptyId: 'pty-1', status: 'idle' }) - - expect(onAgentBecameWorking).toHaveBeenCalledTimes(1) - expect(onAgentBecameIdle).toHaveBeenCalledWith('OpenCode') - expect(onTitleChange).toHaveBeenNthCalledWith(1, '⠋ OpenCode', '⠋ OpenCode') - expect(onTitleChange).toHaveBeenNthCalledWith(2, '⠋ OpenCode', '⠋ OpenCode') - expect(onTitleChange).toHaveBeenNthCalledWith(3, 'OpenCode', 'OpenCode') expect(onData).not.toBeNull() expect(onExit).not.toBeNull() + transport.disconnect() }) it('suppresses attention side effects when replaying eager-buffered data during attach', async () => { @@ -245,18 +213,7 @@ describe('createIpcPtyTransport', () => { onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { onExit = callback return () => {} - }), - onOpenCodeStatus: vi.fn( - ( - callback: (payload: { - ptyId: string - status: 'working' | 'idle' | 'permission' - }) => void - ) => { - onOpenCodeStatus = callback - return () => {} - } - ) + }) } } } as unknown as typeof window @@ -311,18 +268,7 @@ describe('createIpcPtyTransport', () => { onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { onExit = callback return () => {} - }), - onOpenCodeStatus: vi.fn( - ( - callback: (payload: { - ptyId: string - status: 'working' | 'idle' | 'permission' - }) => void - ) => { - onOpenCodeStatus = callback - return () => {} - } - ) + }) } } } as unknown as typeof window @@ -369,18 +315,7 @@ describe('createIpcPtyTransport', () => { onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { onExit = callback return () => {} - }), - onOpenCodeStatus: vi.fn( - ( - callback: (payload: { - ptyId: string - status: 'working' | 'idle' | 'permission' - }) => void - ) => { - onOpenCodeStatus = callback - return () => {} - } - ) + }) } } } as unknown as typeof window diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 581a86ff407..162e110faf6 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -1,4 +1,7 @@ -/* oxlint-disable max-lines */ +/* oxlint-disable max-lines -- Why: the PTY transport manages lifecycle, data flow, +agent status extraction, and title tracking for terminal panes. Splitting would +scatter the tightly coupled IPC ↔ xterm data pipeline across files with no clear +module boundary, making the data flow harder to trace during debugging. */ import { detectAgentStatusFromTitle, clearWorkingIndicators, @@ -6,17 +9,17 @@ import { normalizeTerminalTitle, extractLastOscTitle } from '../../../../shared/agent-detection' -import type { OpenCodeStatusEvent } from '../../../../shared/types' import { ptyDataHandlers, ptyExitHandlers, - openCodeStatusHandlers, ptyTeardownHandlers, ensurePtyDispatcher, getEagerPtyBufferHandle } from './pty-dispatcher' import type { PtyTransport, IpcPtyTransportOptions, PtyConnectResult } from './pty-dispatcher' import { createBellDetector } from './bell-detector' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { parseAgentStatusPayload } from '../../../../shared/agent-status-types' // Re-export public API so existing consumers keep working. export { @@ -33,6 +36,111 @@ export type { } from './pty-dispatcher' 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","prompt":"..."}\x07' +const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' + +export type ProcessedAgentStatusChunk = { + cleanData: string + payloads: ParsedAgentStatusPayload[] +} + +function findAgentStatusTerminator( + data: string, + searchFrom: number +): { index: number; length: 1 | 2 } | null { + const belIndex = data.indexOf('\x07', searchFrom) + const stIndex = data.indexOf('\x1b\\', searchFrom) + if (belIndex === -1 && stIndex === -1) { + return null + } + if (belIndex === -1) { + return { index: stIndex, length: 2 } + } + if (stIndex === -1 || belIndex < stIndex) { + return { index: belIndex, length: 1 } + } + return { index: stIndex, length: 2 } +} + +/** + * Stateful OSC 9999 parser for PTY streams. + * Why: the design doc explicitly calls out partial reads across chunks. Regexing + * each chunk independently drops valid status updates when the PTY splits the + * 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 => { + const combined = pending + data + pending = '' + + const payloads: ParsedAgentStatusPayload[] = [] + let cleanData = '' + let cursor = 0 + + while (cursor < combined.length) { + const start = combined.indexOf(OSC_AGENT_STATUS_PREFIX, cursor) + if (start === -1) { + // Why: if the stream ends on a partial copy of the prefix (e.g. "\x1b]9999" + // without the trailing ";"), carrying that tail into `pending` lets the + // next chunk complete the prefix. Without this, the tail would be + // emitted as plain output and the next chunk's valid status update + // would be dropped because its prefix is incomplete on its own. + const tail = combined.slice(cursor) + const prefixLen = OSC_AGENT_STATUS_PREFIX.length + let partialPrefixLen = 0 + for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) { + if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) { + partialPrefixLen = k + break + } + } + if (partialPrefixLen > 0) { + cleanData += tail.slice(0, tail.length - partialPrefixLen) + pending = tail.slice(tail.length - partialPrefixLen) + } else { + cleanData += tail + } + break + } + + cleanData += combined.slice(cursor, start) + const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length + const terminator = findAgentStatusTerminator(combined, payloadStart) + + if (terminator === null) { + const candidate = combined.slice(start) + // Why: drop the unterminated OSC entirely when it overflows MAX_PENDING, + // instead of flushing it to xterm. xterm.js would treat a lone + // "\x1b]9999;..." as an open string state and could swallow later + // output until it sees a BEL/ST terminator. Bounding the buffer is the + // goal; leaking corrupt escape sequences would be worse than the + // dropped payload. + pending = candidate.length > MAX_PENDING ? '' : candidate + break + } + + const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index)) + if (parsed) { + payloads.push(parsed) + } + cursor = terminator.index + terminator.length + } + + return { cleanData, payloads } + } +} + +// Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher +// so the OSC 9999 status payloads can be forwarded to the store. + export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { const { cwd, @@ -46,7 +154,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited + onAgentExited, + onAgentStatus } = opts let connected = false let destroyed = false @@ -58,9 +167,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false + const processAgentStatusChunk = createAgentStatusOscProcessor() let lastEmittedTitle: string | null = null - let lastObservedTerminalTitle: string | null = null - let openCodeStatus: OpenCodeStatusEvent['status'] | null = null let staleTitleTimer: ReturnType | null = null const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited @@ -81,53 +189,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra function unregisterPtyHandlers(id: string): void { ptyDataHandlers.delete(id) ptyExitHandlers.delete(id) - openCodeStatusHandlers.delete(id) ptyTeardownHandlers.delete(id) } function unregisterPtyDataAndStatusHandlers(id: string): void { ptyDataHandlers.delete(id) - openCodeStatusHandlers.delete(id) - } - - function getSyntheticOpenCodeTitle(status: OpenCodeStatusEvent['status']): string { - const baseTitle = - lastObservedTerminalTitle && lastObservedTerminalTitle !== 'OpenCode' - ? `OpenCode · ${lastObservedTerminalTitle}` - : 'OpenCode' - - if (status === 'working') { - return `⠋ ${baseTitle}` - } - if (status === 'permission') { - return `${baseTitle} permission needed` - } - return baseTitle - } - - function applyOpenCodeStatus(event: OpenCodeStatusEvent): void { - openCodeStatus = event.status - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - - const rawTitle = getSyntheticOpenCodeTitle(event.status) - const title = normalizeTerminalTitle(rawTitle) - lastEmittedTitle = title - onTitleChange?.(title, rawTitle) - agentTracker?.handleTitle(rawTitle) } function applyObservedTerminalTitle(title: string): void { - lastObservedTerminalTitle = title - // Why: while OpenCode has an explicit non-idle status, that status is the - // source of truth — the observed title is only used as context text. - if (openCodeStatus && openCodeStatus !== 'idle') { - applyOpenCodeStatus({ ptyId: ptyId ?? '', status: openCodeStatus }) - return - } - lastEmittedTitle = normalizeTerminalTitle(title) onTitleChange?.(lastEmittedTitle, title) agentTracker?.handleTitle(title) @@ -143,6 +212,20 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // logic across the two code paths that register a PTY. function registerPtyDataHandler(id: string): void { ptyDataHandlers.set(id, (data) => { + // Why: OSC 9999 is a renderer-only control protocol. Parse it before + // xterm sees the bytes, and keep parser state across chunks so partial + // PTY reads do not drop valid status updates or print escape garbage. + const processed = processAgentStatusChunk(data) + data = processed.cleanData + // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer + // replay we must not surface stale agent-status payloads from a prior app + // session into the live store. The parser still consumes the bytes so they + // do not leak into xterm, we just suppress the callback. + if (onAgentStatus && !suppressAttentionEvents) { + for (const payload of processed.payloads) { + onAgentStatus(payload) + } + } if (replayingBufferedData && storedCallbacks.onReplayData) { storedCallbacks.onReplayData(data) } else { @@ -190,7 +273,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra staleTitleTimer = null } agentTracker?.reset() - openCodeStatus = null bellDetector.reset() } @@ -204,7 +286,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks.onDisconnect?.() onPtyExit?.(id) }) - openCodeStatusHandlers.set(id, applyOpenCodeStatus) // Why: shutdownWorktreeTerminals bypasses the transport layer — it // kills PTYs directly via IPC without calling disconnect()/destroy(). // This teardown callback lets unregisterPtyDataHandlers cancel @@ -298,6 +379,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra registerPtyDataHandler(id) registerPtyExitHandler(id) + // Why: replay buffered data through the real handler so title/bell/agent + // tracking (including OSC 9999 agent status) processes the output — + // otherwise restored tabs keep a default title. const bufferHandle = getEagerPtyBufferHandle(id) if (bufferHandle) { const buffered = bufferHandle.flush() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index ea7f5241994..9b6576a37b0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -491,6 +491,10 @@ export function useTerminalPaneLifecycle({ syncPanePtyLayoutBinding(paneId, null) clearTabPtyId(tabId, ptyId) } + // Why: closing a pane can destroy the transport before its PTY-exit + // callback runs, so clear pane-scoped agent status during teardown + // instead of relying on PTY lifecycle ordering. + useAppStore.getState().removeAgentStatus(`${tabId}:${paneId}`) transport.destroy?.() paneTransportsRef.current.delete(paneId) } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 49a4ba0e983..4d6c1501e7c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -33,6 +33,7 @@ import { renderIssueCommandTemplate, type LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' export type UseComposerStateOptions = { initialRepoId?: string @@ -169,6 +170,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null @@ -320,15 +322,24 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending + // Why: when the user leaves the workspace name blank and provides no other + // seed source (prompt, linked issue/PR), pick a repo-scoped unique marine + // creature name so the workspace gets a distinct, readable identifier + // instead of colliding on a literal "workspace" default. + const fallbackCreatureName = useMemo( + () => getSuggestedCreatureName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? true), + [repoId, worktreesByRepo, settings?.nestWorkspaces] + ) const workspaceSeedName = useMemo( () => getWorkspaceSeedName({ explicitName: name, prompt: agentPrompt, linkedIssueNumber: parsedLinkedIssueNumber, - linkedPR + linkedPR, + fallbackName: fallbackCreatureName }), - [agentPrompt, linkedPR, name, parsedLinkedIssueNumber] + [agentPrompt, fallbackCreatureName, linkedPR, name, parsedLinkedIssueNumber] ) // Why: when the user links an issue/PR but has not typed any prompt text // (attachments don't count), swap the generic "Linked work items:" context @@ -989,7 +1000,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS explicitName: name, prompt: '', linkedIssueNumber: null, - linkedPR: null + linkedPR: null, + fallbackName: fallbackCreatureName }) if ( !repoId || @@ -1057,6 +1069,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS applyWorktreeMeta, clearNewWorkspaceDraft, createWorktree, + fallbackCreatureName, name, note, onCreated, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 634a16a9227..1cdc67a22c9 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,4 +1,4 @@ -/* oxlint-disable max-lines */ +/* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */ import { useEffect } from 'react' import { useAppStore } from '../store' import { applyUIZoom } from '@/lib/ui-zoom' @@ -18,6 +18,8 @@ import { dispatchZoomLevelChanged } from '@/lib/zoom-events' import { resolveZoomTarget } from './resolve-zoom-target' import { handleSwitchTab } from './ipc-tab-switch' import { dispatchClearModifierHints } from './useModifierHint' +import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { AGENT_DASHBOARD_ENABLED } from '../../../shared/constants' import { isGitRepoKind } from '../../../shared/repo-kind' export { resolveZoomTarget } from './resolve-zoom-target' @@ -661,6 +663,90 @@ export function useIpcEvents(): void { }) ) + // Why: agent status arrives from native hook receivers in the main process. + // Re-parse it here so the renderer enforces the same normalization rules + // (state enum, field truncation) regardless of whether the source was a + // hook callback or an OSC fallback path. + if (AGENT_DASHBOARD_ENABLED) { + unsubs.push( + window.api.agentStatus.onSet((data) => { + // Why: the IPC payload is already a structured object — pass it + // straight to the object-input normalizer instead of round-tripping + // through JSON.stringify/JSON.parse. Hook events can fire many times + // per second during a tool-use run, so this avoids the per-event + // serialization cost. + const payload = normalizeAgentStatusPayload({ + state: data.state, + prompt: data.prompt, + agentType: data.agentType, + toolName: data.toolName, + toolInput: data.toolInput, + lastAssistantMessage: data.lastAssistantMessage, + interrupted: data.interrupted + }) + if (!payload) { + return + } + const store = useAppStore.getState() + // Why: resolve tab existence and terminal title in a single pass. + // Previously the code walked store.tabsByWorktree twice — once to + // look up the tab-level title (when the pane-level title was + // missing) and again for the explicit tabExists check. 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 { exists, title } = resolvePaneKey(store, data.paneKey) + if (!exists) { + return + } + store.setAgentStatus(data.paneKey, payload, title) + }) + ) + } + return () => unsubs.forEach((fn) => fn()) }, []) } + +/** Resolve a paneKey (tabId:paneId) to both a liveness check and the current + * terminal title, in a single walk of tabsByWorktree. Used for agent type + * inference when the CLI payload omits agentType, plus to drop status updates + * targeted at panes whose tabs have already been torn down. + * Why combined: callers need both pieces per hook event, and hook events can + * fire many times per second during a tool-use run. Two separate O(N) scans + * over the same map is wasteful; one pass returns both. */ +function resolvePaneKey( + store: ReturnType, + paneKey: string +): { exists: boolean; title: string | undefined } { + const [tabId, paneIdRaw] = paneKey.split(':') + if (!tabId) { + return { exists: false, title: 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 + const rawPaneTitle = paneTitles && !Number.isNaN(paneIdNum) ? paneTitles[paneIdNum] : undefined + // Why: treat an empty-string paneTitle as "no title" so the tab-level + // fallback still fires. `paneTitle ?? tabTitle` alone would short-circuit on + // '' and also erase any previously-cached terminalTitle in the store + // (`terminalTitle ?? existing?.terminalTitle` resolves to ''). + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + let exists = false + let tabTitle: string | undefined + for (const tabs of Object.values(store.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id === tabId) { + exists = true + tabTitle = tab.title + break + } + } + if (exists) { + break + } + } + return { exists, title: paneTitle ?? tabTitle } +} diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 2eecf0c34e3..b9ddee7ff3d 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -88,4 +88,28 @@ describe('getWorkspaceSeedName', () => { }) ).toBe('workspace') }) + + it('uses the fallback name when no other seed source is available', () => { + expect( + getWorkspaceSeedName({ + explicitName: '', + prompt: '', + linkedIssueNumber: null, + linkedPR: null, + fallbackName: 'Nautilus' + }) + ).toBe('Nautilus') + }) + + it('prefers an explicit name over the fallback name', () => { + expect( + getWorkspaceSeedName({ + explicitName: 'my-workspace', + prompt: '', + linkedIssueNumber: null, + linkedPR: null, + fallbackName: 'Nautilus' + }) + ).toBe('my-workspace') + }) }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index e845cbdc430..e9d99b9d8b8 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -153,8 +153,13 @@ export function getWorkspaceSeedName(args: { prompt: string linkedIssueNumber: number | null linkedPR: number | null + /** Why: when none of the other seed sources produce a name, the composer + * supplies a repo-scoped unique marine-creature name so blank submissions + * still get a distinct, readable workspace rather than a collision-prone + * "workspace" literal that git would append numeric suffixes to. */ + fallbackName?: string }): string { - const { explicitName, prompt, linkedIssueNumber, linkedPR } = args + const { explicitName, prompt, linkedIssueNumber, linkedPR, fallbackName } = args if (explicitName.trim()) { return explicitName.trim() } @@ -175,6 +180,9 @@ export function getWorkspaceSeedName(args: { return slug } } + if (fallbackName && fallbackName.trim()) { + return fallbackName.trim() + } // Why: the prompt is optional in this flow. Fall back to a stable default // branch/workspace seed so users can launch an empty draft without first // writing a brief or naming the workspace manually. diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index f4278cb73c9..28c77248bce 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -5,8 +5,8 @@ describe('worktree-status', () => { it('prioritizes permission over other live activity states', () => { const status = getWorktreeStatus( [ - { ptyId: 'pty-working', title: 'claude [working]' }, - { ptyId: 'pty-permission', title: 'claude [permission]' } + { id: 'tab-1', ptyId: 'pty-working', title: 'claude [working]' }, + { id: 'tab-2', ptyId: 'pty-permission', title: 'claude [permission]' } ], [{ id: 'browser-1' }] ) @@ -24,4 +24,28 @@ describe('worktree-status', () => { it('returns inactive when neither tabs nor browser state are live', () => { expect(getWorktreeStatus([], [])).toBe('inactive') }) + + it('reports working when any pane in a split-pane tab is working even if tab.title is idle', () => { + // Regression: clicking between split panes rewrites tab.title to the + // focused pane's title (see onActivePaneChange in + // use-terminal-pane-lifecycle.ts). If the focused pane is idle while + // another pane is still working, the sidebar spinner must stay spinning. + const status = getWorktreeStatus( + [{ id: 'tab-1', ptyId: 'pty-1', title: 'claude [done]' }], + [], + { 'tab-1': { 0: 'codex [working]', 1: 'claude [done]' } } + ) + + expect(status).toBe('working') + }) + + it('prefers pane-level permission status over tab.title', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', ptyId: 'pty-1', title: 'claude [done]' }], + [], + { 'tab-1': { 0: 'claude [permission]', 1: 'claude [done]' } } + ) + + expect(status).toBe('permission') + }) }) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index ac950dfca75..c117f3c61bc 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -11,14 +11,27 @@ const STATUS_LABELS: Record = { } export function getWorktreeStatus( - tabs: Pick[], - browserTabs: { id: string }[] + tabs: Pick[], + browserTabs: { id: string }[], + runtimePaneTitlesByTabId: Record> = {} ): WorktreeStatus { const liveTabs = tabs.filter((tab) => tab.ptyId) - if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) { + + // Why: a split-pane tab can host multiple concurrent agents, but `tab.title` + // only reflects the most-recently-focused pane (see onActivePaneChange in + // use-terminal-pane-lifecycle.ts). Reading just `tab.title` causes the + // sidebar spinner to follow the focused pane instead of the aggregate tab + // state — e.g. clicking an idle Claude pane while Codex is still working in + // another pane would collapse the spinner to solid green. Consult per-pane + // titles first (same pattern as countWorkingAgentsForTab) and only fall back + // to `tab.title` for tabs that have no mounted panes yet. + const hasStatus = (status: 'permission' | 'working'): boolean => + liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status)) + + if (hasStatus('permission')) { return 'permission' } - if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) { + if (hasStatus('working')) { return 'working' } if (liveTabs.length > 0 || browserTabs.length > 0) { @@ -31,6 +44,23 @@ export function getWorktreeStatus( return 'inactive' } +function tabHasStatus( + tab: Pick, + runtimePaneTitlesByTabId: Record>, + status: 'permission' | 'working' +): boolean { + const paneTitles = runtimePaneTitlesByTabId[tab.id] + if (paneTitles && Object.keys(paneTitles).length > 0) { + for (const title of Object.values(paneTitles)) { + if (detectAgentStatusFromTitle(title) === status) { + return true + } + } + return false + } + return detectAgentStatusFromTitle(tab.title) === status +} + export function getWorktreeStatusLabel(status: WorktreeStatus): string { return STATUS_LABELS[status] } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 3c1334c6d40..c85a52a67e0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -93,6 +93,7 @@ export type UISlice = { | 'repo' | 'agents' | 'experimental' + | 'ssh' repoId: string | null sectionId?: string } | null diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 3f52d3f3bfc..cf622cb96e4 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -199,6 +199,60 @@ function normalizeOptionalMultilineField(value: unknown, maxLength: number): str return normalized.length > 0 ? normalized : undefined } +/** + * Normalize and validate an already-parsed agent status object. Shared by the + * JSON string entry point (`parseAgentStatusPayload`) and the object entry + * point (`normalizeAgentStatusPayload`) so both paths enforce identical field + * rules. Returns null when the payload is malformed or the state is invalid. + */ +function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | null { + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const obj = parsed as Record + // Why: explicit typeof guard ensures non-string values (e.g. numbers) + // are rejected rather than relying on Set.has returning false for + // mismatched types. + if (typeof obj.state !== 'string') { + return null + } + const state = obj.state + if (!VALID_STATES.has(state)) { + return null + } + return { + state: state as AgentStatusState, + prompt: normalizeField(obj.prompt), + // Why: route through normalizeOptionalField so agentType gets the same + // trim / collapse-newlines / truncate / empty→undefined treatment as the + // other single-line string fields (toolName, toolInput, prompt). Inline + // trim+slice left embedded newlines intact, which broke single-line UI + // rendering and equality checks when a payload contained e.g. + // `agentType: "claude\nrogue"`. + agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), + toolName: normalizeOptionalField(obj.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH), + toolInput: normalizeOptionalField(obj.toolInput, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH), + lastAssistantMessage: normalizeOptionalMultilineField( + obj.lastAssistantMessage, + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH + ), + // Why: only meaningful on `done`. Coerce to undefined on other states so + // the field doesn't leak stale truth through state transitions. + interrupted: obj.interrupted === true && state === 'done' ? true : undefined + } +} + +/** + * Normalize an already-structured agent status object (e.g. arriving via IPC + * where the payload has already been deserialized by Electron). Skips the + * JSON.stringify → JSON.parse round-trip that `parseAgentStatusPayload` + * requires, which matters because hook events can fire many times per second + * during a tool-use run. + */ +export function normalizeAgentStatusPayload(payload: unknown): ParsedAgentStatusPayload | null { + return normalizeAgentStatusObject(payload) +} + /** * Parse and validate an agent status JSON payload received from explicit * hook integrations or OSC 9999. Returns null if the payload is malformed or @@ -206,41 +260,7 @@ function normalizeOptionalMultilineField(value: unknown, maxLength: number): str */ export function parseAgentStatusPayload(json: string): ParsedAgentStatusPayload | null { try { - const parsed = JSON.parse(json) - if (typeof parsed !== 'object' || parsed === null) { - return null - } - const obj = parsed as Record - // Why: explicit typeof guard ensures non-string values (e.g. numbers) - // are rejected rather than relying on Set.has returning false for - // mismatched types. - if (typeof obj.state !== 'string') { - return null - } - const state = obj.state - if (!VALID_STATES.has(state)) { - return null - } - return { - state: state as AgentStatusState, - prompt: normalizeField(obj.prompt), - // Why: route through normalizeOptionalField so agentType gets the same - // trim / collapse-newlines / truncate / empty→undefined treatment as the - // other single-line string fields (toolName, toolInput, prompt). Inline - // trim+slice left embedded newlines intact, which broke single-line UI - // rendering and equality checks when a payload contained e.g. - // `agentType: "claude\nrogue"`. - agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), - toolName: normalizeOptionalField(obj.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH), - toolInput: normalizeOptionalField(obj.toolInput, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH), - lastAssistantMessage: normalizeOptionalMultilineField( - obj.lastAssistantMessage, - AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH - ), - // Why: only meaningful on `done`. Coerce to undefined on other states so - // the field doesn't leak stale truth through state transitions. - interrupted: obj.interrupted === true && state === 'done' ? true : undefined - } + return normalizeAgentStatusObject(JSON.parse(json)) } catch { return null } diff --git a/src/shared/types.ts b/src/shared/types.ts index 2e44e0a9f60..6f9e6194625 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -883,14 +883,6 @@ export type NotificationDispatchResult = { reason?: 'disabled' | 'source-disabled' | 'suppressed-focus' | 'cooldown' | 'not-supported' } -export type OpenCodeStatusEvent = { - ptyId: string - /** Compatibility shim for OpenCode: Orca's activity surfaces already depend - * on this normalized state machine, so hook payloads collapse into the same - * working/idle/permission categories instead of inventing a parallel model. */ - status: 'working' | 'idle' | 'permission' -} - export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment' export type StatusBarItem = 'claude' | 'codex' | 'ssh' | 'sessions'