mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/tab-changes
This commit is contained in:
+1
-1
@@ -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,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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+6
-3
@@ -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>
|
||||
|
||||
Vendored
+30
-3
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export type UISlice = {
|
||||
| 'repo'
|
||||
| 'agents'
|
||||
| 'experimental'
|
||||
| 'ssh'
|
||||
repoId: string | null
|
||||
sectionId?: string
|
||||
} | null
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user