Merge remote-tracking branch 'origin/main' into brennanb2025/tab-changes

This commit is contained in:
brennanb2025
2026-04-24 18:21:43 -07:00
67 changed files with 4492 additions and 725 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.17-rc.2",
"version": "1.3.17",
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
+4 -3
View File
@@ -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
}
@@ -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/<scriptFileName> 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)
})
})
+144
View File
@@ -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<string, HookDefinition[]>
[key: string]: unknown
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
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 <configPath>.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
}
}
}
}
+706
View File
@@ -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<string, unknown>
}
function buildBody(payload: Record<string, unknown>, overrides: Partial<Body> = {}): 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')
})
})
File diff suppressed because it is too large Load Diff
+222
View File
@@ -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()
+234
View File
@@ -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<string>(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()
@@ -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<typeof vi.fn<() => 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)
})
})
+81
View File
@@ -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<typeof setTimeout> | null = null
private fallbackTimer: ReturnType<typeof setTimeout> | 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
}
}
}
+14 -13
View File
@@ -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', () => {
+17 -2
View File
@@ -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<typeof setTimeout> | null = null
private killTimer: ReturnType<typeof setTimeout> | 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 = []
+8
View File
@@ -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')
})
})
+212
View File
@@ -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()
+66 -29
View File
@@ -1,4 +1,8 @@
import { app, BrowserWindow, nativeImage, nativeTheme, systemPreferences } from 'electron'
/* eslint-disable max-lines -- Why: this is Orca's main-process entry point;
it owns app lifecycle, service wiring, window creation, and hook/daemon
startup. Splitting by line count would fragment tightly coupled startup
logic across files without a cleaner ownership seam. */
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import devIcon from '../../resources/icon-dev.png?asset'
import { Store, initDataPath } from './persistence'
@@ -35,8 +39,12 @@ import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { ClaudeAccountService } from './claude-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
import { openCodeHookService } from './opencode/hook-service'
import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { claudeHookService } from './claude/hook-service'
import { codexHookService } from './codex/hook-service'
import { geminiHookService } from './gemini/hook-service'
import { AGENT_DASHBOARD_ENABLED } from '../shared/constants'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
@@ -58,26 +66,6 @@ let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
let starNag: StarNagService | null = null
function triggerStartupMicrophoneRegistration(): void {
if (process.platform !== 'darwin') {
return
}
try {
const status = systemPreferences.getMediaAccessStatus('microphone')
if (status !== 'not-determined') {
return
}
// Why: terminal child processes (sox/ffmpeg/voice CLIs) cannot reliably
// surface the TCC dialog themselves. Prompt once from the app process after
// the window is visible so mic capture in embedded terminals can work.
void systemPreferences.askForMediaAccess('microphone').catch((error: unknown) => {
console.error('[permissions] Failed to request microphone access:', error)
})
} catch (error) {
console.error('[permissions] Failed to check microphone access status:', error)
}
}
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
// construction in both main (local-pty-provider) and the forked daemon
@@ -178,8 +166,29 @@ function openMainWindow(): BrowserWindow {
if (mainWindow === window) {
mainWindow = null
}
if (AGENT_DASHBOARD_ENABLED) {
// Why: detach the agent hook listener on window close so the server
// never fires into a destroyed webContents during the gap before
// reopen (e.g. macOS dock re-activation). This also ensures the
// replay-loop through lastStatusByPaneKey runs only on deliberate
// window recreations instead of stacking on top of stale listeners.
agentHookServer.setListener(null)
}
})
mainWindow = window
if (AGENT_DASHBOARD_ENABLED) {
agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => {
if (mainWindow?.isDestroyed()) {
return
}
mainWindow?.webContents.send('agentStatus:set', {
paneKey,
tabId,
worktreeId,
...payload
})
})
}
return window
}
@@ -209,6 +218,22 @@ app.whenReady().then(async () => {
starNag.registerIpcHandlers()
runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager))
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
// Why: managed hook installation mutates user-global agent config.
// Startup must fail open so a malformed local config never bricks Orca.
if (AGENT_DASHBOARD_ENABLED) {
for (const installManagedHooks of [
() => claudeHookService.install(),
() => codexHookService.install(),
() => geminiHookService.install()
]) {
try {
installManagedHooks()
} catch (error) {
console.error('[agent-hooks] Failed to install managed hooks:', error)
}
}
}
registerAppMenu({
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
onOpenSettings: () => {
@@ -266,13 +291,24 @@ app.whenReady().then(async () => {
}
setAppRuntimeFlags({ daemonEnabledAtStartup: daemonStarted })
// Why: both server binds are independent and neither blocks window creation.
// Parallelizing them with the window open shaves ~100-200ms off cold start.
if (AGENT_DASHBOARD_ENABLED) {
try {
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state.
// Start the hook server before opening the window so restored/spawned
// terminals never race ahead without hook env on first launch.
await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development' })
} catch (error) {
// Why: Claude/Codex/Gemini/OpenCode hook callbacks are sidebar
// enrichment only. Orca must still boot even if the local loopback
// receiver cannot bind on this launch.
console.error('[agent-hooks] Failed to start local hook server:', error)
}
}
// Why: once the hook server is ready (or has already failed open), window
// creation and runtime RPC startup are independent.
const [win] = await Promise.all([
Promise.resolve(openMainWindow()),
openCodeHookService.start().catch((error) => {
console.error('[opencode] Failed to start local hook server:', error)
}),
runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start local RPC transport:', error)
})
@@ -284,7 +320,6 @@ app.whenReady().then(async () => {
// window, making it impossible for the user to click "Allow".
win.once('show', () => {
triggerStartupNotificationRegistration(store!)
triggerStartupMicrophoneRegistration()
})
app.on('activate', () => {
@@ -313,8 +348,10 @@ app.on('will-quit', () => {
// are still running. killAllPty() does not call runtime.onPtyExit(),
// so without this ordering, running agents would produce orphaned
// agent_start events with no matching stops.
openCodeHookService.stop()
starNag?.stop()
if (AGENT_DASHBOARD_ENABLED) {
agentHookServer.stop()
}
stats?.flush()
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
// holding ports and leaving stale session state on disk.
+66
View File
@@ -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)
}
}
})
}
+5 -1
View File
@@ -440,8 +440,12 @@ export function registerFilesystemHandlers(store: Store): void {
): Promise<string[]> => {
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)
}
+4 -12
View File
@@ -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 }
})
})
+1 -10
View File
@@ -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<PreflightStatus>
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
+29 -1
View File
@@ -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,
+81 -3
View File
@@ -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<string, IPtyProvider>()
// post-spawn operations to the correct provider without the renderer needing
// to track connectionId per-PTY.
const ptyOwnership = new Map<string, string | null>()
// 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<string, string>()
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<string, string> 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)
}
@@ -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)
+2
View File
@@ -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)
+7 -4
View File
@@ -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 {
+21
View File
@@ -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;')
})
})
+179 -161
View File
@@ -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<unknown> {
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<typeof createServer> | null = null
private port = 0
private token = ''
private lastStatusByPtyId = new Map<string, OpenCodeHookStatus>()
async start(): Promise<void> {
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<void>((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<string, string> {
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
}
@@ -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')
}
}))
+11
View File
@@ -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)
+17 -1
View File
@@ -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)
})
})
+39
View File
@@ -750,4 +750,43 @@ describe('updater', () => {
expect((autoUpdaterMock as Record<string, unknown>).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'
})
})
})
+24 -10
View File
@@ -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
+6 -3
View File
@@ -33,7 +33,6 @@ import type {
GitHubIssueUpdate,
NotificationDispatchRequest,
NotificationDispatchResult,
OpenCodeStatusEvent,
OrcaHooks,
PersistedUIState,
PRCheckDetail,
@@ -71,6 +70,7 @@ import type {
} from '../../shared/browser-guest-events'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import type { E2EConfig } from '../../shared/e2e-config'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
import type {
ClaudeUsageBreakdownKind,
@@ -173,7 +173,6 @@ export type DetectedBrowserInfo = {
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
linear: { connected: boolean }
}
export type RefreshAgentsResult = {
@@ -353,7 +352,6 @@ export type PreloadApi = {
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
onData: (callback: (data: { id: string; data: string }) => void) => () => void
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
onOpenCodeStatus: (callback: (event: OpenCodeStatusEvent) => void) => () => void
}
feedback: {
submit: (args: {
@@ -488,6 +486,11 @@ export type PreloadApi = {
install: () => Promise<CliInstallStatus>
remove: () => Promise<CliInstallStatus>
}
agentHooks: {
claudeStatus: () => Promise<AgentHookInstallStatus>
codexStatus: () => Promise<AgentHookInstallStatus>
geminiStatus: () => Promise<AgentHookInstallStatus>
}
preflight: PreflightApi
notifications: {
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
+30 -3
View File
@@ -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 {
+48 -12
View File
@@ -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<CliInstallStatus> => ipcRenderer.invoke('cli:remove')
},
agentHooks: {
claudeStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:claudeStatus'),
codexStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:codexStatus'),
geminiStatus: (): Promise<AgentHookInstallStatus> =>
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)
}
}
}
+82
View File
@@ -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<string[]> {
const files: string[] = []
const deadline = Date.now() + TIMEOUT_MS
async function walk(dir: string): Promise<void> {
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
}
+17 -2
View File
@@ -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<boolean>((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<string, unknown>) {
+143 -139
View File
@@ -2,7 +2,7 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { ChevronLeft, ChevronRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { toast } from 'sonner'
@@ -645,149 +645,153 @@ function App(): React.JSX.Element {
// the sidebar-width left header (workspace view) can share the same
// controls without duplicating the agent badge popover.
const titlebarLeftControls = (
<div ref={titlebarLeftControlsRef} className="flex h-full shrink-0 items-center">
<div className={isMac && !isFullScreen ? 'titlebar-traffic-light-pad' : 'pl-2'} />
{showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={actions.toggleSidebar}
aria-label="Toggle sidebar"
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`}
</TooltipContent>
</Tooltip>
)}
{settings?.showTitlebarAgentActivity !== false ? (
<Popover>
<PopoverTrigger asChild>
<button
className={`titlebar-agent-badge${activeAgentCount === 0 ? ' titlebar-agent-badge-idle' : ''}`}
aria-label={`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
>
<span
className={`titlebar-agent-badge-dot${activeAgentCount === 0 ? ' titlebar-agent-badge-dot-idle' : ''}`}
aria-hidden
/>
<span className="titlebar-agent-badge-count">{activeAgentCount}</span>
</button>
</PopoverTrigger>
<PopoverContent side="bottom" sideOffset={6} className="titlebar-agent-hovercard">
<div
className={`titlebar-agent-hovercard-header${activeAgentCount > 0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`}
>
{activeAgentCount === 0
? 'No agents active'
: `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
</div>
{activeAgentCount > 0 && (
<div className="titlebar-agent-hovercard-list">
{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 (
<div key={worktreeId}>
<button
className="titlebar-agent-hovercard-worktree"
onClick={() => {
// Why: if the worktree is missing from worktreesByRepo
// (transient git error cleared the list), refresh the
// repo's worktrees before navigating so the activation
// lookup succeeds instead of silently failing.
if (!wt) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
void useAppStore
.getState()
.fetchWorktrees(repoId)
.then(() => {
activateAndRevealWorktree(worktreeId)
})
return
}
activateAndRevealWorktree(worktreeId)
}}
>
<span className="titlebar-agent-hovercard-name">
{wt?.displayName ?? fallbackName}
</span>
</button>
{agents.map((agent) => (
<button
key={`${agent.tabId}:${agent.paneId ?? 'none'}:${agent.label}`}
className="titlebar-agent-hovercard-agent"
onClick={() => {
activateAndRevealWorktree(worktreeId)
useAppStore.getState().setActiveTab(agent.tabId)
if (agent.paneId !== null) {
// Why: a split-terminal tab can host multiple
// agents. After selecting the tab, wait one
// frame so the active TerminalPane can mount
// and then focus the specific pane the user
// clicked instead of leaving whichever pane
// was previously active highlighted.
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, {
detail: { tabId: agent.tabId, paneId: agent.paneId }
})
)
})
}
}}
>
<span className="titlebar-agent-hovercard-agent-label">
{agent.label}
</span>
<span className="titlebar-agent-hovercard-agent-dot" />
</button>
))}
</div>
)
})}
</div>
)}
<button
className="titlebar-agent-hovercard-hide"
onClick={() => {
void actions.updateSettings({ showTitlebarAgentActivity: false })
toast('Agent activity badge hidden', {
description: 'You can turn it back on in Settings → Appearance.',
duration: Infinity,
dismissible: true
})
}}
>
Hide from titlebar
</button>
</PopoverContent>
</Popover>
) : 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' && (
<>
<div className="flex h-full w-full shrink-0 items-center">
<div ref={titlebarLeftControlsRef} className="flex h-full items-center">
<div className={isMac && !isFullScreen ? 'titlebar-traffic-light-pad' : 'pl-2'} />
{showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={actions.toggleSidebar}
aria-label="Toggle sidebar"
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`}
</TooltipContent>
</Tooltip>
)}
{settings?.showTitlebarAgentActivity !== false ? (
<Popover>
<PopoverTrigger asChild>
<button
className={`titlebar-agent-badge${activeAgentCount === 0 ? ' titlebar-agent-badge-idle' : ''}`}
aria-label={`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
>
<span
className={`titlebar-agent-badge-dot${activeAgentCount === 0 ? ' titlebar-agent-badge-dot-idle' : ''}`}
aria-hidden
/>
<span className="titlebar-agent-badge-count">{activeAgentCount}</span>
</button>
</PopoverTrigger>
<PopoverContent side="bottom" sideOffset={6} className="titlebar-agent-hovercard">
<div
className={`titlebar-agent-hovercard-header${activeAgentCount > 0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`}
>
{activeAgentCount === 0
? 'No agents active'
: `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
</div>
{activeAgentCount > 0 && (
<div className="titlebar-agent-hovercard-list">
{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 (
<div key={worktreeId}>
<button
className="titlebar-agent-hovercard-worktree"
onClick={() => {
// Why: if the worktree is missing from worktreesByRepo
// (transient git error cleared the list), refresh the
// repo's worktrees before navigating so the activation
// lookup succeeds instead of silently failing.
if (!wt) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
void useAppStore
.getState()
.fetchWorktrees(repoId)
.then(() => {
activateAndRevealWorktree(worktreeId)
})
return
}
activateAndRevealWorktree(worktreeId)
}}
>
<span className="titlebar-agent-hovercard-name">
{wt?.displayName ?? fallbackName}
</span>
</button>
{agents.map((agent) => (
<button
key={`${agent.tabId}:${agent.paneId ?? 'none'}:${agent.label}`}
className="titlebar-agent-hovercard-agent"
onClick={() => {
activateAndRevealWorktree(worktreeId)
useAppStore.getState().setActiveTab(agent.tabId)
if (agent.paneId !== null) {
// Why: a split-terminal tab can host multiple
// agents. After selecting the tab, wait one
// frame so the active TerminalPane can mount
// and then focus the specific pane the user
// clicked instead of leaving whichever pane
// was previously active highlighted.
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, {
detail: { tabId: agent.tabId, paneId: agent.paneId }
})
)
})
}
}}
>
<span className="titlebar-agent-hovercard-agent-label">
{agent.label}
</span>
<span className="titlebar-agent-hovercard-agent-dot" />
</button>
))}
</div>
)
})}
</div>
)}
<button
className="titlebar-agent-hovercard-hide"
onClick={() => {
void actions.updateSettings({ showTitlebarAgentActivity: false })
toast('Agent activity badge hidden', {
description: 'You can turn it back on in Settings → Appearance.',
duration: Infinity,
dismissible: true
})
}}
>
Hide from titlebar
</button>
</PopoverContent>
</Popover>
) : null}
</div>
{/* 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' && (
<div className="ml-auto mr-3 flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goBackWorktree()}
disabled={!canGoBackWorktree}
aria-label="Go back"
>
<ChevronLeft size={16} />
<ArrowLeft size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
@@ -797,19 +801,19 @@ function App(): React.JSX.Element {
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goForwardWorktree()}
disabled={!canGoForwardWorktree}
aria-label="Go forward"
>
<ChevronRight size={16} />
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Go forward (${isMac ? '⌘⌥→' : 'Ctrl+Alt+→'})`}
</TooltipContent>
</Tooltip>
</>
</div>
)}
</div>
)
+7
View File
@@ -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;
@@ -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<void> => {
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 (
<div
@@ -265,12 +246,9 @@ export default function NewWorkspaceComposerCard({
type="button"
variant="ghost"
size="icon-xs"
disabled={isAddingRepo}
onClick={() => 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"
>
<FolderPlus className="size-3" />
</Button>
+12 -2
View File
@@ -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
+26 -27
View File
@@ -1221,34 +1221,33 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : 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. */}
<div className="flex-none flex items-center justify-start px-5 pt-3 pb-1 md:px-8 md:pt-4">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-full"
onClick={closeTaskPage}
aria-label="Close tasks"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close · Esc
</TooltipContent>
</Tooltip>
</div>
<div className="mx-auto flex w-full flex-1 flex-col min-h-0 px-5 pb-5 md:px-8 md:pb-7">
<div className="flex-none flex flex-col gap-5">
<section className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="mx-auto flex w-full flex-1 flex-col min-h-0 px-5 pt-3 pb-5 md:px-8 md:pt-3 md:pb-7">
<div className="flex-none flex flex-col gap-3">
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{/* 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. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full"
onClick={closeTaskPage}
aria-label="Close tasks"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close · Esc
</TooltipContent>
</Tooltip>
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
{SOURCE_OPTIONS.map((source) => {
const active = taskSource === source.id
return (
@@ -1,7 +1,7 @@
/* oxlint-disable max-lines */
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Globe, Plus } from 'lucide-react'
import { Globe, Plus, WifiOff } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
@@ -138,6 +138,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const allWorktrees = useAllWorktrees()
const repos = useAppStore((s) => s.repos)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
// Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a
// working agent in a non-focused pane still surface as 'working' in the
// jump palette. Without this, clicking between panes would desync the
// palette's spinner from the sidebar's spinner.
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@@ -145,6 +150,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
@@ -701,10 +707,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const branch = branchName(worktree.branch)
const status = getWorktreeStatus(
tabsByWorktree[worktree.id] ?? [],
browserTabsByWorktree[worktree.id] ?? []
browserTabsByWorktree[worktree.id] ?? [],
runtimePaneTitlesByTabId
)
const statusLabel = getWorktreeStatusLabel(status)
const isCurrentWorktree = activeWorktreeId === worktree.id
const sshConnectionId = repo?.connectionId ?? null
const sshStatus = sshConnectionId
? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected')
: null
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
return (
<CommandItem
@@ -725,6 +737,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{sshConnectionId && (
<span
aria-label={isSshDisconnected ? 'SSH disconnected' : 'SSH remote'}
className="shrink-0 inline-flex items-center"
>
{isSshDisconnected ? (
<WifiOff className="size-3.5 text-red-400" aria-hidden="true" />
) : (
<Globe
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</span>
)}
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{entry.match.displayNameRange ? (
<HighlightedText
@@ -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])
@@ -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<void> => {
const handleCopySkillInstallCommand = async (command: string): Promise<void> => {
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
<div className="border-t border-border/60 pt-3">
<div className="space-y-0.5">
<Label>Agent skill</Label>
<Label>Agent skills</Label>
<p className="text-xs text-muted-foreground">
Install the Orca skill so agents know to use the{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">orca</code> CLI.
Install skills so agents know how to use Orca and report status.
</p>
</div>
<div className="mt-3 space-y-1">
<p className="text-xs text-muted-foreground">Install command</p>
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{ORCA_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void handleCopySkillInstallCommand()}
aria-label="Copy skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="mt-3 space-y-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">CLI skill</p>
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{ORCA_CLI_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() =>
void handleCopySkillInstallCommand(ORCA_CLI_SKILL_INSTALL_COMMAND)
}
aria-label="Copy CLI skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
@@ -316,6 +316,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setRemoteError(null)
}}
onAdd={handleAddRemoteRepo}
onOpenSshSettings={() => {
closeModal()
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
openSettingsPage()
}}
/>
) : step === 'clone' ? (
<CloneStep
@@ -7,7 +7,7 @@
*/
import React, { useCallback, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Folder, FolderOpen } from 'lucide-react'
import { Folder, FolderOpen, Settings } from 'lucide-react'
import { useAppStore } from '@/store'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -148,6 +148,7 @@ type RemoteStepProps = {
onSelectTarget: (id: string) => 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({
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
{sshTargets.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">
No SSH targets configured. Add one in Settings first.
</p>
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">No SSH targets configured.</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
Add in Settings
</Button>
</div>
) : (
<div className="space-y-1.5">
{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}
/>
<Button
variant="outline"
@@ -75,12 +75,15 @@ const SearchBar = React.memo(function SearchBar() {
return (
<div className="px-2 pb-4">
<div className="relative flex items-center">
<Search className="absolute left-2 size-3.5 text-muted-foreground pointer-events-none" />
<Search
className="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none"
strokeWidth={2.25}
/>
<Input
value={searchQuery}
onChange={(e) => 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"
/>
<div className="absolute right-1 flex items-center gap-0.5">
{searchQuery && (
@@ -104,7 +107,7 @@ const SearchBar = React.memo(function SearchBar() {
: 'size-5 w-5 bg-transparent hover:bg-accent/60'
)}
>
<ListFilter className="size-3 shrink-0" />
<ListFilter className="size-3 shrink-0" strokeWidth={2.25} />
{filterSummary}
</Button>
</DropdownMenuTrigger>
@@ -56,7 +56,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
return (
<div className="flex h-8 items-center justify-between px-4 mt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
Workspaces
</span>
<div className="flex items-center gap-1.5 shrink-0">
@@ -70,7 +70,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
className="text-muted-foreground"
aria-label="View options"
>
<SlidersHorizontal className="size-3.5" />
<SlidersHorizontal className="size-3.5" strokeWidth={2.25} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -153,7 +153,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
aria-label="New workspace"
disabled={!canCreateWorktree}
>
<Plus className="size-3.5" />
<Plus className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
@@ -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'
)}
>
<ListChecks className="size-4 shrink-0" />
<List className="size-4 shrink-0" strokeWidth={2.25} />
<span className="flex-1">Tasks</span>
<span className="flex items-center gap-1">
<span
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
@@ -102,6 +103,24 @@ const WorktreeCard = React.memo(function WorktreeCard({
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS)
// Why: split-pane tabs expose per-pane titles that the aggregate
// `tab.title` does not preserve (onActivePaneChange overwrites it with the
// focused pane's title). getWorktreeStatus needs those pane titles to keep
// the sidebar spinner reflecting *any* working pane, not just the focused
// one. Narrow the subscription to this worktree's tabs via useShallow so
// unrelated pane-title updates do not re-render every sidebar card.
const runtimePaneTitlesForWorktree = useAppStore(
useShallow((s) => {
const out: Record<string, Record<number, string>> = {}
for (const tab of s.tabsByWorktree[worktree.id] ?? []) {
const paneTitles = s.runtimePaneTitlesByTabId[tab.id]
if (paneTitles) {
out[tab.id] = paneTitles
}
}
return out
})
)
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
@@ -123,8 +142,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
// Derive status
const status: WorktreeStatus = useMemo(
() => getWorktreeStatus(tabs, browserTabs),
[tabs, browserTabs]
() => getWorktreeStatus(tabs, browserTabs, runtimePaneTitlesForWorktree),
[tabs, browserTabs, runtimePaneTitlesForWorktree]
)
const showPR = cardProps.includes('pr')
@@ -288,6 +307,23 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Header row: Title and Checks */}
<div className="flex items-center justify-between min-w-0 gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{repo?.connectionId && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center">
{isSshDisconnected ? (
<WifiOff className="size-3 text-red-400" />
) : (
<Globe className="size-3 text-muted-foreground" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'}
</TooltipContent>
</Tooltip>
)}
<div className="text-[12px] font-semibold text-foreground truncate leading-tight">
{worktree.displayName}
</div>
@@ -352,23 +388,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
)}
{repo?.connectionId && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center gap-0.5">
{isSshDisconnected ? (
<WifiOff className="size-3 text-red-400" />
) : (
<Globe className="size-3 text-muted-foreground" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'}
</TooltipContent>
</Tooltip>
)}
{isFolder ? (
<Badge
variant="secondary"
@@ -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)
}
},
@@ -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)
@@ -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)
@@ -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<string, (data: string) => void>()
export const ptyExitHandlers = new Map<string, (code: number) => void>()
export const openCodeStatusHandlers = new Map<string, (event: OpenCodeStatusEvent) => 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
}
@@ -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
@@ -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<typeof setTimeout> | 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()
@@ -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)
}
+16 -3
View File
@@ -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,
+87 -1
View File
@@ -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<typeof useAppStore.getState>,
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 }
}
@@ -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')
})
})
+9 -1
View File
@@ -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.
+26 -2
View File
@@ -5,8 +5,8 @@ describe('worktree-status', () => {
it('prioritizes permission over other live activity states', () => {
const status = getWorktreeStatus(
[
{ ptyId: 'pty-working', title: 'claude [working]' },
{ ptyId: 'pty-permission', title: 'claude [permission]' }
{ id: 'tab-1', ptyId: 'pty-working', title: 'claude [working]' },
{ id: 'tab-2', ptyId: 'pty-permission', title: 'claude [permission]' }
],
[{ id: 'browser-1' }]
)
@@ -24,4 +24,28 @@ describe('worktree-status', () => {
it('returns inactive when neither tabs nor browser state are live', () => {
expect(getWorktreeStatus([], [])).toBe('inactive')
})
it('reports working when any pane in a split-pane tab is working even if tab.title is idle', () => {
// Regression: clicking between split panes rewrites tab.title to the
// focused pane's title (see onActivePaneChange in
// use-terminal-pane-lifecycle.ts). If the focused pane is idle while
// another pane is still working, the sidebar spinner must stay spinning.
const status = getWorktreeStatus(
[{ id: 'tab-1', ptyId: 'pty-1', title: 'claude [done]' }],
[],
{ 'tab-1': { 0: 'codex [working]', 1: 'claude [done]' } }
)
expect(status).toBe('working')
})
it('prefers pane-level permission status over tab.title', () => {
const status = getWorktreeStatus(
[{ id: 'tab-1', ptyId: 'pty-1', title: 'claude [done]' }],
[],
{ 'tab-1': { 0: 'claude [permission]', 1: 'claude [done]' } }
)
expect(status).toBe('permission')
})
})
+34 -4
View File
@@ -11,14 +11,27 @@ const STATUS_LABELS: Record<WorktreeStatus, string> = {
}
export function getWorktreeStatus(
tabs: Pick<TerminalTab, 'ptyId' | 'title'>[],
browserTabs: { id: string }[]
tabs: Pick<TerminalTab, 'id' | 'ptyId' | 'title'>[],
browserTabs: { id: string }[],
runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {}
): WorktreeStatus {
const liveTabs = tabs.filter((tab) => tab.ptyId)
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
// Why: a split-pane tab can host multiple concurrent agents, but `tab.title`
// only reflects the most-recently-focused pane (see onActivePaneChange in
// use-terminal-pane-lifecycle.ts). Reading just `tab.title` causes the
// sidebar spinner to follow the focused pane instead of the aggregate tab
// state — e.g. clicking an idle Claude pane while Codex is still working in
// another pane would collapse the spinner to solid green. Consult per-pane
// titles first (same pattern as countWorkingAgentsForTab) and only fall back
// to `tab.title` for tabs that have no mounted panes yet.
const hasStatus = (status: 'permission' | 'working'): boolean =>
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status))
if (hasStatus('permission')) {
return 'permission'
}
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) {
if (hasStatus('working')) {
return 'working'
}
if (liveTabs.length > 0 || browserTabs.length > 0) {
@@ -31,6 +44,23 @@ export function getWorktreeStatus(
return 'inactive'
}
function tabHasStatus(
tab: Pick<TerminalTab, 'id' | 'title'>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
status: 'permission' | 'working'
): boolean {
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
for (const title of Object.values(paneTitles)) {
if (detectAgentStatusFromTitle(title) === status) {
return true
}
}
return false
}
return detectAgentStatusFromTitle(tab.title) === status
}
export function getWorktreeStatusLabel(status: WorktreeStatus): string {
return STATUS_LABELS[status]
}
+1
View File
@@ -93,6 +93,7 @@ export type UISlice = {
| 'repo'
| 'agents'
| 'experimental'
| 'ssh'
repoId: string | null
sectionId?: string
} | null
+55 -35
View File
@@ -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<string, unknown>
// 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<string, unknown>
// 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
}
-8
View File
@@ -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'