Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/main/agent-hooks/installer-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ export function buildWindowsAgentHookPostCommand(
}

// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both.
export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string {
export function buildWindowsAgentHookCurlPostCommand(
source: AgentHookSource,
extraFormLines: readonly string[] = []
): string {
return [
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
`"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}"`,
Expand All @@ -176,6 +179,7 @@ export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): s
'--data-urlencode "worktreeId=%ORCA_WORKTREE_ID%"',
'--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"',
'--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"',
...extraFormLines,
'--data-urlencode "payload@-"',
'>nul 2>&1'
].join(' ')
Expand Down
1 change: 1 addition & 0 deletions src/main/agent-hooks/server.test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' }
export type Body = {
paneKey: string
launchToken?: string
sessionNonce?: string
tabId?: string
worktreeId?: string
env?: string
Expand Down
4 changes: 4 additions & 0 deletions src/main/agent-hooks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ type PersistedAgentHookEventPayload = Omit<
| 'launchToken'
| 'promptInteractionKey'
| 'restoredUnconfirmed'
// Why: a one-shot resume nonce is meaningless after the process it named exits.
| 'sessionNonce'
// Why: revision counters are in-memory and the authority id is regenerated per process, so
// a stored observation could only rehydrate as a stale ordering claim from a dead authority.
| 'observation'
Expand Down Expand Up @@ -3122,6 +3124,8 @@ export class AgentHookServer {
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
// Why: a one-shot resume nonce is meaningless after the process it named exits.
sessionNonce: _sessionNonce,
// Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload).
observation: _observation,
launchToken,
Expand Down
115 changes: 115 additions & 0 deletions src/main/codex/adopted-codex-tui-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Off-screen readiness proof for a Codex TUI resumed inside a pane Orca already
// owns. Adoption stops the pane's Codex and types a `codex resume` line into the
// bare shell that is left; nothing on screen can tell that shell apart from a
// Codex that has finished starting. The CLI's own SessionStart hook can: it is
// emitted by the resumed process itself, carries the conversation it reopened,
// and reaches Orca over the hook endpoint instead of the terminal buffer.
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'

export type AdoptedCodexReadinessEvent = Pick<
AgentHookEventPayload,
'paneKey' | 'source' | 'hookEventName' | 'isReplay' | 'sessionNonce' | 'providerSession'
>

export type AdoptedCodexReadinessVerdict = 'ignore' | 'ready' | 'wrong-session'

export type AdoptedCodexReadinessTarget = {
paneKey: string
threadId: string
sessionNonce: string
}

export const ADOPTED_CODEX_READINESS_TIMEOUT_MS = 30_000

export function classifyAdoptedCodexReadinessEvent(
event: AdoptedCodexReadinessEvent,
target: AdoptedCodexReadinessTarget
): AdoptedCodexReadinessVerdict {
if (
event.source !== 'codex' ||
event.hookEventName !== 'SessionStart' ||
event.isReplay === true ||
event.paneKey !== target.paneKey
) {
return 'ignore'
}
// A hook script installed by an older Orca omits the nonce entirely. Pane + thread
// still bind it, so degrade rather than strand the return path — but a nonce that
// IS present and disagrees names a different invocation, which is the stale
// SessionStart this gate exists to reject.
if (event.sessionNonce !== undefined && event.sessionNonce !== target.sessionNonce) {
return 'ignore'
}
const observedThreadId = event.providerSession?.id
if (!observedThreadId) {
return 'ignore'
}
if (observedThreadId === target.threadId) {
return 'ready'
}
// Only our own nonce makes a mismatch conclusive; without one this could be any
// Codex sharing the pane's env.
return event.sessionNonce === target.sessionNonce ? 'wrong-session' : 'ignore'
}

export type AdoptedCodexReadinessWatch = {
/** Resolves when the resumed Codex proves itself; rejects on timeout or a wrong thread. */
readonly settled: Promise<void>
dispose(): void
}

/**
* Starts listening BEFORE the resume command is written — a fast Codex can emit
* SessionStart before the write call even returns.
*/
export function beginAdoptedCodexReadinessWatch(input: {
subscribe: (listener: (event: AdoptedCodexReadinessEvent) => void) => () => void
target: AdoptedCodexReadinessTarget
timeoutMs?: number
setTimer?: (callback: () => void, ms: number) => unknown
clearTimer?: (timer: unknown) => void
}): AdoptedCodexReadinessWatch {
const setTimer = input.setTimer ?? ((callback, ms) => setTimeout(callback, ms))
const clearTimer = input.clearTimer ?? ((timer) => clearTimeout(timer as never))
let finish: ((error: Error | null) => void) | null = null
const settled = new Promise<void>((resolve, reject) => {
finish = (error) => {
if (error) {
reject(error)
} else {
resolve()
}
}
})
let unsubscribe: (() => void) | null = null
let timer: unknown = null
const settle = (error: Error | null): void => {
if (!finish) {
return
}
const complete = finish
finish = null
if (timer !== null) {
clearTimer(timer)
timer = null
}
unsubscribe?.()
unsubscribe = null
complete(error)
}
unsubscribe = input.subscribe((event) => {
const verdict = classifyAdoptedCodexReadinessEvent(event, input.target)
if (verdict === 'ready') {
settle(null)
} else if (verdict === 'wrong-session') {
settle(new Error('The agent terminal resumed a different Codex session.'))
}
})
timer = setTimer(() => {
settle(new Error('The agent terminal did not report the resumed Codex session.'))
}, input.timeoutMs ?? ADOPTED_CODEX_READINESS_TIMEOUT_MS)
return {
settled,
dispose: () => settle(new Error('The agent terminal stopped resuming Codex.'))
}
}
17 changes: 17 additions & 0 deletions src/main/codex/codex-app-server-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ function rejection(promise: Promise<unknown>): Promise<Error> {
}

describe('openCodexAppServerConnection', () => {
it('advertises the experimental API required for rollout-path resume', async () => {
const { child, spawnImpl, written } = stubChild()
answerInitialize(child)

const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)

expect(written[0]).toMatchObject({
method: 'initialize',
params: { capabilities: { experimentalApi: true } }
})
await connection.close()
})

it('completes the handshake and keeps the child alive across calls', async () => {
const notifications: { method: string; params: unknown }[] = []
const connection = await openFakeServer({
Expand Down
2 changes: 1 addition & 1 deletion src/main/codex/codex-app-server-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export async function openCodexAppServerConnection(
{
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' },
capabilities: {
experimentalApi: false,
experimentalApi: true,
requestAttestation: false,
mcpServerOpenaiFormElicitation: false,
extensions: {}
Expand Down
95 changes: 95 additions & 0 deletions src/main/codex/codex-pane-launch-home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import type { CodexPaneAccountRecord } from './codex-pane-account-registry'
import {
describeCodexPaneLaunchHomeFailure,
resolveCodexPaneLaunchHome
} from './codex-pane-launch-home'

const SYSTEM_HOME = '/Users/dev/.codex'
const SHARED_HOME = '/Users/dev/Library/Application Support/orca/codex-runtime-home/home'
const ACCOUNT_HOME = '/Users/dev/Library/Application Support/orca/codex-accounts/acct-1/home'

const SETTINGS = {
codexManagedAccounts: [
{
id: 'acct-1',
email: 'dev@example.com',
managedHomePath: ACCOUNT_HOME,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
]
}

function resolve(record: CodexPaneAccountRecord | null) {
return resolveCodexPaneLaunchHome({
record,
settings: SETTINGS,
systemCodexHomePath: SYSTEM_HOME,
sharedRuntimeCodexHomePath: SHARED_HOME
})
}

describe('resolveCodexPaneLaunchHome', () => {
it('names the account home a managed pane launched under', () => {
expect(
resolve({ selectionKey: 'host', accountId: 'acct-1', homeRoute: 'account-home' })
).toEqual({ kind: 'attributed', path: ACCOUNT_HOME })
})

it('routes real-home and shared-home panes to their own homes', () => {
expect(resolve({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' })).toEqual({
kind: 'attributed',
path: SYSTEM_HOME
})
expect(resolve({ selectionKey: 'host', accountId: null, homeRoute: 'shared-home' })).toEqual({
kind: 'attributed',
path: SHARED_HOME
})
})

it('prefers a recorded pane-local override over the route it was classified as', () => {
expect(
resolve({
selectionKey: 'host',
accountId: null,
homeRoute: 'real-home',
environmentHomeOverride: { codexHome: '/tmp/isolated/home' }
})
).toEqual({ kind: 'attributed', path: '/tmp/isolated/home' })
expect(
resolve({
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home',
shellStartupHomeOverride: { home: '/Users/dev', codexHome: '/tmp/shell/home' }
})
).toEqual({ kind: 'attributed', path: '/tmp/shell/home' })
})

it('declines rather than guessing when the pane cannot be attributed', () => {
expect(resolve(null)).toEqual({ kind: 'unrecorded' })
// A WSL lane can never answer for the local pane adoption asks about.
expect(resolve({ selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'wsl-home' })).toEqual(
{ kind: 'unrecorded' }
)
// Recorded before route provenance existed, so the launch home is unknown.
expect(resolve({ selectionKey: 'host', accountId: null })).toEqual({ kind: 'unnameable-home' })
expect(resolve({ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' })).toEqual({
kind: 'unnameable-home'
})
expect(
resolve({ selectionKey: 'host', accountId: 'acct-gone', homeRoute: 'account-home' })
).toEqual({ kind: 'unknown-account', accountId: 'acct-gone' })
})

it('names the account in the failure the user is shown', () => {
expect(
describeCodexPaneLaunchHomeFailure({ kind: 'unknown-account', accountId: 'acct-gone' })
).toContain('acct-gone')
expect(describeCodexPaneLaunchHomeFailure({ kind: 'unrecorded' })).toContain(
'which Codex account'
)
})
})
78 changes: 78 additions & 0 deletions src/main/codex/codex-pane-launch-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { getCodexSelectionTargetForAccount } from '../codex-accounts/runtime-selection'
import type { CodexPaneAccountRecord } from './codex-pane-account-registry'

type CodexPaneLaunchHomeSettings = Pick<GlobalSettings, 'codexManagedAccounts'>

export type CodexPaneLaunchHomeResolution =
| { kind: 'attributed'; path: string }
| { kind: 'unrecorded' }
| { kind: 'unknown-account'; accountId: string }
| { kind: 'unnameable-home' }

/**
* Resolves the CODEX_HOME a live host pane's Codex is ALREADY running under.
*
* Why this is not the selected account's home: `CODEX_HOME` is baked into a
* shell's environment at spawn, and an automatic resume deliberately pins it to
* the home that OWNS the session rather than to the current selection. Only that
* home's `sessions` directory can hold the pane's rollout, so anything reading
* the live pane's transcript must ask which account it launched under — the
* record read here is the one `resolveCodexPaneLaunchAccount` wrote at spawn.
*
* Declines rather than guessing: probing the wrong account's home reports a
* broken session when the real cause is an account mismatch.
*/
export function resolveCodexPaneLaunchHome(args: {
record: CodexPaneAccountRecord | null
settings: CodexPaneLaunchHomeSettings
systemCodexHomePath: string
sharedRuntimeCodexHomePath: string
}): CodexPaneLaunchHomeResolution {
const record = args.record
if (!record || record.selectionKey !== 'host') {
return { kind: 'unrecorded' }
}
// Pane-local overrides carry the literal CODEX_HOME the launch resolved, so
// they answer before the route, which only classifies it.
const overrideHome =
record.environmentHomeOverride?.codexHome ?? record.shellStartupHomeOverride?.codexHome
if (overrideHome) {
return { kind: 'attributed', path: overrideHome }
}
if (record.homeRoute === 'real-home') {
return { kind: 'attributed', path: args.systemCodexHomePath }
}
if (record.homeRoute === 'shared-home') {
return { kind: 'attributed', path: args.sharedRuntimeCodexHomePath }
}
if (record.homeRoute !== 'account-home') {
// `custom-home` recorded a home Orca cannot re-derive; an absent route predates
// provenance; `wsl-home` cannot belong to a host pane.
return { kind: 'unnameable-home' }
}
if (!record.accountId) {
return { kind: 'unnameable-home' }
}
const account = args.settings.codexManagedAccounts?.find(
(candidate) =>
candidate.id === record.accountId &&
getCodexSelectionTargetForAccount(candidate).runtime === 'host'
)
return account
? { kind: 'attributed', path: account.managedHomePath }
: { kind: 'unknown-account', accountId: record.accountId }
}

/** Names the account cause, so a mismatch never reads as a broken session. */
export function describeCodexPaneLaunchHomeFailure(
resolution: Exclude<CodexPaneLaunchHomeResolution, { kind: 'attributed' }>
): string {
if (resolution.kind === 'unknown-account') {
return `This terminal runs Codex under managed account ${resolution.accountId}, which is no longer configured, so its Codex session cannot be located.`
}
if (resolution.kind === 'unnameable-home') {
return 'This terminal runs Codex under a CODEX_HOME Orca cannot identify, so its Codex session cannot be located.'
}
return 'Orca has no record of which Codex account this terminal launched under, so its Codex session cannot be located. Start a new Codex chat instead.'
}
Loading
Loading