From 749ba42cb23b743e6b0a39a075347ee8673cdd13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:15:50 +0000 Subject: [PATCH 01/15] release: v1.3.17-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1f1ee26c3f6..1db53738e49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.17-rc.2", + "version": "1.3.17-rc.3", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f0c96bd2bf79073a44982618fa7dea0a8346df3a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:36:22 -0700 Subject: [PATCH 02/15] fix(updater): keep prerelease users on the RC channel (#1053) Detect when the running build is itself a prerelease (e.g. 1.3.17-rc.1) and auto-enable the RC feed in setupAutoUpdater. Without this, the default generic /releases/latest/download/ feed only advertises stable releases, so "Check for Updates" from a prerelease build could never find the next RC (e.g. 1.3.17-rc.2) unless the user knew to Shift-click. --- src/main/updater-fallback.ts | 11 +++++++++ src/main/updater.fallback.test.ts | 18 +++++++++++++- src/main/updater.test.ts | 39 +++++++++++++++++++++++++++++++ src/main/updater.ts | 34 +++++++++++++++++++-------- 4 files changed, 91 insertions(+), 11 deletions(-) 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 From e6abe45bc7295fac9ad627ca91414ac713b052bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:44:21 -0700 Subject: [PATCH 03/15] Revert "fix(terminal): terminal mic access" (#1057) --- resources/build/entitlements.mac.plist | 2 -- src/main/index.ts | 23 +------------------ .../diff-comments/useDiffCommentDecorator.tsx | 5 ++-- 3 files changed, 3 insertions(+), 27 deletions(-) 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/main/index.ts b/src/main/index.ts index 495ce73f849..e987b92ebdf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, nativeTheme, systemPreferences } from 'electron' +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' @@ -58,26 +58,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 @@ -284,7 +264,6 @@ app.whenReady().then(async () => { // window, making it impossible for the user to click "Allow". win.once('show', () => { triggerStartupNotificationRegistration(store!) - triggerStartupMicrophoneRegistration() }) app.on('activate', () => { diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index b7fcb6bccab..1ffa0cfa152 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -155,7 +155,6 @@ export function useDiffCommentDecorator({ }) disposablesRef.current = [onMouseMove, onMouseLeave, onScroll] - const zones = zonesRef.current return () => { 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]) From fdb7ed66788aa5a61db0519e998672be204ad3a9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:01:44 -0400 Subject: [PATCH 04/15] fix(linear): stop keychain prompt from appearing during app launch (#1058) --- src/main/ipc/preflight.test.ts | 16 ++++------------ src/main/ipc/preflight.ts | 11 +---------- src/preload/api-types.d.ts | 1 - 3 files changed, 5 insertions(+), 23 deletions(-) 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/preload/api-types.d.ts b/src/preload/api-types.d.ts index d56319ea34d..a89cbdaa761 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -173,7 +173,6 @@ export type DetectedBrowserInfo = { export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } - linear: { connected: boolean } } export type RefreshAgentsResult = { From 0a45c5433b43760a7d1e7965a17c9278a183ce53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:11:56 +0000 Subject: [PATCH 05/15] release: 1.3.17-rc.4 [rc-slot:2026-04-24-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1db53738e49..6a8e8bb0a61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.17-rc.3", + "version": "1.3.17-rc.4", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 6675138d80b373029dec12e4a8ad6d0b7d42c4bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:13:58 -0700 Subject: [PATCH 06/15] feat(agent-hooks): shared types, renderer store, local hook server + Claude/Codex/Gemini/OpenCode wiring (gated) (#1019) --- src/cli/runtime-client.ts | 7 +- src/main/agent-hooks/installer-utils.test.ts | 144 +++ src/main/agent-hooks/installer-utils.ts | 144 +++ src/main/agent-hooks/server.test.ts | 706 +++++++++++ src/main/agent-hooks/server.ts | 1088 +++++++++++++++++ src/main/claude/hook-service.ts | 222 ++++ src/main/codex/hook-service.ts | 234 ++++ src/main/gemini/hook-service.ts | 212 ++++ src/main/index.ts | 72 +- src/main/ipc/agent-hooks.ts | 66 + src/main/ipc/pty.test.ts | 30 +- src/main/ipc/pty.ts | 84 +- src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + src/main/opencode/hook-service.test.ts | 21 + src/main/opencode/hook-service.ts | 340 +++--- src/main/providers/provider-dispatch.test.ts | 4 + src/preload/api-types.d.ts | 8 +- src/preload/index.d.ts | 33 +- src/preload/index.ts | 60 +- .../src/components/settings/CliSection.tsx | 63 +- .../components/terminal-pane/TerminalPane.tsx | 3 + .../terminal-pane/pty-connection.test.ts | 3 +- .../terminal-pane/pty-connection.ts | 38 +- .../terminal-pane/pty-dispatcher.ts | 9 +- .../terminal-pane/pty-transport.test.ts | 89 +- .../components/terminal-pane/pty-transport.ts | 178 ++- .../use-terminal-pane-lifecycle.ts | 4 + src/renderer/src/hooks/useIpcEvents.ts | 88 +- src/shared/agent-status-types.ts | 90 +- src/shared/types.ts | 8 - 31 files changed, 3659 insertions(+), 399 deletions(-) create mode 100644 src/main/agent-hooks/installer-utils.test.ts create mode 100644 src/main/agent-hooks/installer-utils.ts create mode 100644 src/main/agent-hooks/server.test.ts create mode 100644 src/main/agent-hooks/server.ts create mode 100644 src/main/claude/hook-service.ts create mode 100644 src/main/codex/hook-service.ts create mode 100644 src/main/gemini/hook-service.ts create mode 100644 src/main/ipc/agent-hooks.ts create mode 100644 src/main/opencode/hook-service.test.ts 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/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 e987b92ebdf..192b433d4f4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,7 @@ +/* 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' @@ -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' @@ -158,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 } @@ -189,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: () => { @@ -246,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) }) @@ -292,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/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/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/preload/api-types.d.ts b/src/preload/api-types.d.ts index a89cbdaa761..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, @@ -352,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: { @@ -487,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/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/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index a1feed80d4e..a5a9b317ec8 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -303,6 +303,9 @@ export default function TerminalPane({ // a single split pane doesn't go through closeTab. useAppStore.getState().setCacheTimerStartedAt(`${tabId}:${paneId}`, null) syncPanePtyLayoutBinding(paneId, null) + // Why: pane teardown can bypass the PTY exit callback ordering, so + // explicit agent status must be cleared on the direct UI close path too. + useAppStore.getState().removeAgentStatus(`${tabId}:${paneId}`) manager.closePane(paneId) } }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1ca0a190502..266d4ee1af3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -180,7 +180,8 @@ describe('connectPanePty', () => { 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/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/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' From 622b85c73eff769dced539ac5d6f9c3086096b1f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:22:21 -0700 Subject: [PATCH 07/15] refactor(ui): polish sidebar typography and compact task page chrome (#1062) Tighten sidebar fonts/icons (Tasks label, WORKSPACES section, search) and collapse the task page close button into the source-filter row so the top chrome forms one compact band. --- src/renderer/src/components/TaskPage.tsx | 53 +++++++++---------- .../src/components/sidebar/SearchBar.tsx | 9 ++-- .../src/components/sidebar/SidebarHeader.tsx | 6 +-- .../src/components/sidebar/SidebarNav.tsx | 6 +-- 4 files changed, 38 insertions(+), 36 deletions(-) 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/sidebar/SearchBar.tsx b/src/renderer/src/components/sidebar/SearchBar.tsx index 86e3bc52cad..f762d47260b 100644 --- a/src/renderer/src/components/sidebar/SearchBar.tsx +++ b/src/renderer/src/components/sidebar/SearchBar.tsx @@ -75,12 +75,15 @@ const SearchBar = React.memo(function SearchBar() { return (
- + setSearchQuery(e.target.value)} placeholder="Search..." - className="h-7 pl-7 pr-20 text-[11px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30" + className="h-7 pl-7 pr-20 text-[12px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30 placeholder:text-muted-foreground/70" />
{searchQuery && ( @@ -104,7 +107,7 @@ const SearchBar = React.memo(function SearchBar() { : 'size-5 w-5 bg-transparent hover:bg-accent/60' )} > - + {filterSummary} 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 Date: Fri, 24 Apr 2026 15:26:50 -0700 Subject: [PATCH 08/15] fix(daemon): defer startup flush past shell raw-mode switch (#1060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(daemon): defer startup-command flush past shell raw-mode switch When launching an agent (e.g. `claude`) through the quick-launch menu, the command name appeared twice in the terminal — once from kernel echo, once from readline's prompt redraw. The daemon session was flushing its pre-ready stdin queue the moment the OSC 777 shell-ready marker arrived, but that marker fires from precmd_functions / PROMPT_COMMAND — before the shell draws its prompt and before zle/readline flips the PTY into raw mode. Writing while ECHO was still on produced the visible duplicate. Mirror the gating already used by the non-daemon path (local-pty-shell-ready.ts::writeStartupCommandWhenShellReady): wait for the next data chunk after the marker (the prompt draw) plus a short 30ms delay, with a 50ms wall-clock fallback for the case where the prompt arrives in the same chunk as the marker. This regression became visible after #1025 made the shell-ready wrappers persist reliably — previously the marker was often missed and the 15s fallback path fired long after the shell was already in raw mode, masking the race. * refactor(daemon): extract PostReadyFlushGate out of Session Factor the post-ready flush gating out of Session into its own class in post-ready-flush-gate.ts. Session's only responsibility is now to arm() the gate on shell-ready, notifyData() on subsequent PTY data, and clear() on teardown. Removes the max-lines oxlint-disable added in the previous commit and puts the timing behavior behind focused unit tests. No behavior change — kept as a separate refactor commit from the behavior fix for reviewability. * fix(daemon): keep queueing writes while post-ready flush gate is pending Codex review caught an ordering regression: once transitionToReady() sets _shellState to 'ready', any Session.write() that arrives during the 30–50ms gate window was being written directly to the subprocess, bypassing the still-unflushed preReadyStdinQueue. That let late input race ahead of the buffered startup command. Expose PostReadyFlushGate.isPending and continue queuing while the gate is armed so queued writes drain in their original order before any fresh input reaches the subprocess. --- src/main/daemon/post-ready-flush-gate.test.ts | 97 +++++++++++++++++++ src/main/daemon/post-ready-flush-gate.ts | 81 ++++++++++++++++ src/main/daemon/session.test.ts | 27 +++--- src/main/daemon/session.ts | 19 +++- src/main/daemon/terminal-host.test.ts | 8 ++ 5 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 src/main/daemon/post-ready-flush-gate.test.ts create mode 100644 src/main/daemon/post-ready-flush-gate.ts 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') }) }) From 9e21423a6e90e211ba9a153d6c42251e6acf76b4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:09:57 -0400 Subject: [PATCH 09/15] fix(ssh): support Cmd+P file search for non-git folders without ripgrep (#1065) --- src/main/ipc/filesystem.ts | 6 +- src/main/ipc/worktrees.ts | 11 +-- src/relay/fs-handler-readdir-fallback.ts | 82 +++++++++++++++++++++++ src/relay/fs-handler.ts | 19 +++++- src/renderer/src/components/QuickOpen.tsx | 14 +++- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/relay/fs-handler-readdir-fallback.ts 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/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/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/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 From 5766defd878e23d93b1f332c2fa3ed11ecd59e7e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:14:06 -0700 Subject: [PATCH 10/15] feat(ssh): surface SSH status in worktree UI and streamline add-repo flow (#1067) - Show SSH icon (Globe/WifiOff) beside worktree titles in jump palette and WorktreeCard so remote state is visible at a glance. - Replace inline add-repo flow in NewWorkspaceComposerCard with the existing add-repo modal for a single entry point. - When no SSH targets are configured, let users jump straight to SSH settings from the add-repo dialog, and disable remote path until a target is selected. --- .../components/NewWorkspaceComposerCard.tsx | 34 ++++--------------- .../src/components/WorktreeJumpPalette.tsx | 23 ++++++++++++- .../src/components/sidebar/AddRepoDialog.tsx | 5 +++ .../src/components/sidebar/AddRepoSteps.tsx | 23 +++++++++---- .../src/components/sidebar/WorktreeCard.tsx | 34 +++++++++---------- src/renderer/src/store/slices/ui.ts | 1 + 6 files changed, 68 insertions(+), 52 deletions(-) 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/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index d9db09828c4..4c8b14920a5 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 { @@ -145,6 +145,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) @@ -705,6 +706,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) 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 ? ( { + 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} /> - - - {`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; From 68ceae531de39a2bda980e651810c3a490f0260d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:39:16 -0700 Subject: [PATCH 15/15] feat(composer): default blank workspace name to a unique marine-creature (#1069) Use the existing getSuggestedCreatureName helper as the fallback seed in getWorkspaceSeedName so blank submissions get a distinct, readable name scoped to the selected repo (respecting nestWorkspaces) instead of colliding on the "workspace" literal. --- src/renderer/src/hooks/useComposerState.ts | 19 ++++++++++++++--- src/renderer/src/lib/new-workspace.test.ts | 24 ++++++++++++++++++++++ src/renderer/src/lib/new-workspace.ts | 10 ++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) 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/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.