From 786c97fe277f0973324a66c431f215defe1d2385 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 05:25:23 -0700
Subject: [PATCH 01/11] fix(source-control): generate clean OpenCode answers on
local and SSH hosts
Restack the original focused change onto current main, preserving every owned source and test blob and the merged CI contract and journal cleanup fixes.
Original-commit: 64bb15e3f20799a21f108c849b0bdff83c692712
fix(source-control): generate clean OpenCode answers on local and SSH hosts
Use configured models and JSON answer/error events, preserve run-first arguments, and handle the precise v2 variant rejection. Hydrate SSH execution-host PATH through the existing bounded login environment resolver before direct spawning.
Credits: andy-murr (PR #5197 SSH environment intent) and coelho-doti (PR #13065 argument-order intent).
Original-commit: 1b60ec5d11132a5fed0695faea5de782a228a8e5
fix(source-control): retry inline OpenCode model and variant options
Original-commit: 98fdecc7a33ef1571dbf2854f309e4bfa1ec4300
Preserve OpenCode named errors without a data message
Restacked-from: 98fdecc7a33ef1571dbf2854f309e4bfa1ec4300
Restacked-onto: f7b1f9d8be14d12244957ee0b710a3482ec62383
---
.../ssh-git-noninteractive-provider.ts | 4 +-
.../ssh-git-provider-commit-message.test.ts | 9 +-
.../command-environment-process.test.ts | 5 +-
.../commit-message-agent-environment.ts | 4 +-
.../commit-message-model-discovery-policy.ts | 6 ++
.../opencode-generation-events.test.ts | 96 +++++++++++++++++++
.../source-control-agent-failure.ts | 23 ++++-
.../source-control-local-process.ts | 1 +
.../source-control-remote-generation.ts | 1 +
...source-control-text-generation-requests.ts | 39 +++++---
src/relay/agent-exec-handler.ts | 34 +++++--
.../agent-exec-shell-environment.test.ts | 64 +++++++++++++
src/shared/command-option-occurrence.ts | 38 ++++++++
src/shared/commit-message-agent-spec.test.ts | 8 +-
.../commit-message-agent-specs-primary.ts | 26 ++---
src/shared/commit-message-plan.test.ts | 24 ++---
src/shared/commit-message-plan.ts | 45 +++------
.../opencode-generation-command.test.ts | 94 ++++++++++++++++++
src/shared/opencode-generation-command.ts | 44 +++++++++
src/shared/opencode-generation-output.test.ts | 84 ++++++++++++++++
src/shared/opencode-generation-output.ts | 53 ++++++++++
21 files changed, 606 insertions(+), 96 deletions(-)
create mode 100644 src/main/text-generation/opencode-generation-events.test.ts
create mode 100644 src/relay/agent-exec-shell-environment.test.ts
create mode 100644 src/shared/command-option-occurrence.ts
create mode 100644 src/shared/opencode-generation-command.test.ts
create mode 100644 src/shared/opencode-generation-command.ts
create mode 100644 src/shared/opencode-generation-output.test.ts
create mode 100644 src/shared/opencode-generation-output.ts
diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts
index 0550b1bba390..c0d8df0573f3 100644
--- a/src/main/providers/ssh-git-noninteractive-provider.ts
+++ b/src/main/providers/ssh-git-noninteractive-provider.ts
@@ -29,7 +29,8 @@ export class SshGitNoninteractiveProvider extends SshGitReadProvider {
stdin: plan.stdinPayload,
...(plan.env ? { env: plan.env } : {}),
timeoutMs,
- operation
+ operation,
+ shell: true
},
undefined,
operation
@@ -100,6 +101,7 @@ export class SshGitNoninteractiveProvider extends SshGitReadProvider {
timeoutMs: number
env?: Record
operation?: string
+ shell?: boolean
},
signal?: AbortSignal,
operation?: string
diff --git a/src/main/providers/ssh-git-provider-commit-message.test.ts b/src/main/providers/ssh-git-provider-commit-message.test.ts
index be024b643c81..70d31d6a4f2d 100644
--- a/src/main/providers/ssh-git-provider-commit-message.test.ts
+++ b/src/main/providers/ssh-git-provider-commit-message.test.ts
@@ -179,7 +179,8 @@ describe('SshGitProvider', () => {
stdin: null,
env: { FLAG: 'literal $HOME' },
timeoutMs: 60_000,
- operation: 'commit-message'
+ operation: 'commit-message',
+ shell: true
},
{ timeoutMs: 65_000 }
)
@@ -228,7 +229,8 @@ describe('SshGitProvider', () => {
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 60_000,
- operation: 'commit-message'
+ operation: 'commit-message',
+ shell: true
},
{ timeoutMs: 65_000 }
)
@@ -241,7 +243,8 @@ describe('SshGitProvider', () => {
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 60_000,
- operation: 'pull-request-fields'
+ operation: 'pull-request-fields',
+ shell: true
},
{ timeoutMs: 65_000 }
)
diff --git a/src/main/text-generation/command-environment-process.test.ts b/src/main/text-generation/command-environment-process.test.ts
index bdc04daca3f6..3ae17c6e60d0 100644
--- a/src/main/text-generation/command-environment-process.test.ts
+++ b/src/main/text-generation/command-environment-process.test.ts
@@ -155,6 +155,9 @@ describe('environment-prefixed commands with real child processes', () => {
it('discovers models through the same override environment', async () => {
await expect(
discoverCommitMessageModelsLocal('opencode', target.env, override, { cwd: folder })
- ).resolves.toMatchObject({ success: true, models: [{ id: 'anthropic/claude-sonnet-4' }] })
+ ).resolves.toMatchObject({
+ success: true,
+ models: [{ id: 'default' }, { id: 'anthropic/claude-sonnet-4' }]
+ })
})
})
diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts
index 3c5523bc4200..588a56058e13 100644
--- a/src/main/text-generation/commit-message-agent-environment.ts
+++ b/src/main/text-generation/commit-message-agent-environment.ts
@@ -51,7 +51,7 @@ function readInheritedOrShellEnvVar(name: string, sourceName?: string): string |
function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null {
const configVar =
- agentId === 'opencode'
+ agentId === 'opencode' || agentId === 'opencode2'
? 'OPENCODE_CONFIG_DIR'
: agentId === 'pi' || agentId === 'omp'
? 'PI_CODING_AGENT_DIR'
@@ -66,7 +66,7 @@ function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.Pro
// the Pi one (and vice versa). PI_CODING_AGENT_DIR is the binary-facing var
// both kinds consume — see src/main/pi/titlebar-extension-service.ts.
const sourceVar =
- agentId === 'opencode'
+ agentId === 'opencode' || agentId === 'opencode2'
? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
: agentId === 'pi'
? 'ORCA_PI_SOURCE_AGENT_DIR'
diff --git a/src/main/text-generation/commit-message-model-discovery-policy.ts b/src/main/text-generation/commit-message-model-discovery-policy.ts
index fa2b8869a37a..41bef79fb184 100644
--- a/src/main/text-generation/commit-message-model-discovery-policy.ts
+++ b/src/main/text-generation/commit-message-model-discovery-policy.ts
@@ -57,6 +57,12 @@ export function finalizeModelDiscoveryOutput(
}
return { success: false, error: `${spec.label} returned no available models.` }
}
+ if (spec.id === 'opencode' || spec.id === 'opencode2') {
+ const configuredDefault = spec.models.find((model) => model.id === 'default')
+ if (configuredDefault && !models.some((model) => model.id === 'default')) {
+ models = [configuredDefault, ...models]
+ }
+ }
// A sentinel model in the static spec (for example `default`) means the CLI
// should keep its configured provider even when discovery lists concrete models.
const defaultModelId =
diff --git a/src/main/text-generation/opencode-generation-events.test.ts b/src/main/text-generation/opencode-generation-events.test.ts
new file mode 100644
index 000000000000..894f70e679f8
--- /dev/null
+++ b/src/main/text-generation/opencode-generation-events.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import {
+ generateCommitMessageFromContext,
+ generateBranchNameFromContext
+} from './commit-message-text-generation'
+import { finalizeModelDiscoveryOutput } from './commit-message-model-discovery-policy'
+import { getCommitMessageAgentSpec } from '../../shared/commit-message-agent-spec'
+
+const text = (answer: string): string =>
+ JSON.stringify({ type: 'text', part: { id: 'answer', text: answer } })
+const context = { branch: 'main', stagedSummary: 'M\tfile.ts', stagedPatch: '+new' }
+const params = { agentId: 'opencode', model: 'default' } as const
+
+describe('OpenCode generation event handling across remote execution', () => {
+ it('extracts only the answer from a JSON stream', async () => {
+ const result = await generateCommitMessageFromContext(context, params, {
+ kind: 'remote',
+ cwd: '/repo',
+ missingBinaryLocation: 'remote PATH',
+ execute: async () => ({
+ stdout: `${JSON.stringify({ type: 'tool_use', part: { text: 'tool output' } })}\n${text('fix: output only the answer')}`,
+ stderr: '',
+ exitCode: 0,
+ timedOut: false
+ })
+ })
+ expect(result).toEqual({
+ success: true,
+ message: 'fix: output only the answer',
+ agentLabel: 'OpenCode'
+ })
+ })
+
+ it.each([0, 1])('does not accept an error event with exit code %s', async (exitCode) => {
+ const result = await generateCommitMessageFromContext(context, params, {
+ kind: 'remote',
+ cwd: '/repo',
+ missingBinaryLocation: 'remote PATH',
+ execute: async () => ({
+ stdout: JSON.stringify({
+ type: 'error',
+ error: { type: 'provider.no-route', message: 'Model unavailable' }
+ }),
+ stderr: '',
+ exitCode,
+ timedOut: false
+ })
+ })
+ expect(result).toMatchObject({
+ success: false,
+ error: expect.stringContaining('Model unavailable')
+ })
+ })
+
+ it('retries the v2 flag rejection on the same execution host', async () => {
+ const argv: string[][] = []
+ const result = await generateBranchNameFromContext(
+ { firstPrompt: 'Fix generation' },
+ { ...params, model: 'fixture/chat', thinkingLevel: 'high' },
+ {
+ kind: 'remote',
+ cwd: '/repo',
+ missingBinaryLocation: 'remote PATH',
+ execute: async (plan) => {
+ argv.push(plan.args)
+ return argv.length === 1
+ ? {
+ stdout: 'Help',
+ stderr: 'ERROR\nUnrecognized flag: --variant in command opencode run',
+ exitCode: 1,
+ timedOut: false
+ }
+ : { stdout: text('fix-generation'), stderr: '', exitCode: 0, timedOut: false }
+ }
+ }
+ )
+ expect(result).toMatchObject({ success: true, slug: 'fix-generation' })
+ expect(argv[1]).toContain('fixture/chat#high')
+ expect(argv[1]).not.toContain('--variant')
+ })
+
+ it.each(['opencode', 'opencode2'] as const)(
+ 'retains Config default when %s discovery lists explicit models',
+ (agent) => {
+ const spec = getCommitMessageAgentSpec(agent)
+ if (!spec) {
+ throw new Error('missing spec')
+ }
+ expect(finalizeModelDiscoveryOutput(spec, 'fixture/chat\n', '', 0)).toMatchObject({
+ success: true,
+ defaultModelId: 'default',
+ models: [{ id: 'default' }, { id: 'fixture/chat' }]
+ })
+ }
+ )
+})
diff --git a/src/main/text-generation/source-control-agent-failure.ts b/src/main/text-generation/source-control-agent-failure.ts
index 3bf549f663a1..c924343e94da 100644
--- a/src/main/text-generation/source-control-agent-failure.ts
+++ b/src/main/text-generation/source-control-agent-failure.ts
@@ -9,6 +9,7 @@ import {
type AgentGenerationFailureOutput
} from './agent-failure-output'
import type { InternalTextGenerationResult } from './source-control-text-generation-types'
+import { parseOpenCodeGenerationOutput } from '../../shared/opencode-generation-output'
export function formatAgentCliFailureMessage(
label: string,
@@ -45,17 +46,35 @@ export function finalizeFromAgentOutput(args: {
emptyResultName: string
includeLocalMacDnsHint?: boolean
includeStdoutDetail?: boolean
+ outputFormat?: 'opencode-json'
}): InternalTextGenerationResult {
const { code, stdout, stderr, label, emptyResultName } = args
+ const parsed =
+ args.outputFormat === 'opencode-json' ? parseOpenCodeGenerationOutput(stdout) : null
if (code !== 0) {
console.error('[commit-message] Generator failed:', { label, exitCode: code, stdout, stderr })
return {
success: false,
- error: formatAgentCliFailureMessage(label, stdout, stderr, code, args),
+ error: formatAgentCliFailureMessage(
+ label,
+ stdout,
+ parsed && !parsed.ok && parsed.error !== 'OpenCode returned invalid JSON events.'
+ ? parsed.error
+ : stderr,
+ code,
+ args
+ ),
failureOutput: captureFailureOutput(label, code, stdout, stderr)
}
}
- const cleaned = cleanGeneratedCommitMessage(stdout)
+ if (parsed && !parsed.ok) {
+ return {
+ success: false,
+ error: sanitizeAgentFailureDetail(parsed.error) ?? 'OpenCode reported an error.',
+ failureOutput: captureFailureOutput(label, code, stdout, stderr)
+ }
+ }
+ const cleaned = cleanGeneratedCommitMessage(parsed?.ok ? parsed.text : stdout)
if (cleaned) {
return { success: true, rawOutput: cleaned, agentLabel: label }
}
diff --git a/src/main/text-generation/source-control-local-process.ts b/src/main/text-generation/source-control-local-process.ts
index da1a2f9a139d..4800868d068f 100644
--- a/src/main/text-generation/source-control-local-process.ts
+++ b/src/main/text-generation/source-control-local-process.ts
@@ -186,6 +186,7 @@ export function runLocalSourceControlPlan(input: {
stdout,
stderr,
label: plan.label,
+ outputFormat: plan.outputFormat,
emptyResultName: input.emptyResultName,
includeStdoutDetail: operation !== 'branch-name'
})
diff --git a/src/main/text-generation/source-control-remote-generation.ts b/src/main/text-generation/source-control-remote-generation.ts
index ece1a6de1d89..871345787a94 100644
--- a/src/main/text-generation/source-control-remote-generation.ts
+++ b/src/main/text-generation/source-control-remote-generation.ts
@@ -66,6 +66,7 @@ export async function runRemoteSourceControlPlan(input: {
stdout: result.stdout,
stderr: result.stderr,
label: plan.label,
+ outputFormat: plan.outputFormat,
emptyResultName: input.emptyResultName,
includeLocalMacDnsHint: false,
includeStdoutDetail: operation !== 'branch-name'
diff --git a/src/main/text-generation/source-control-text-generation-requests.ts b/src/main/text-generation/source-control-text-generation-requests.ts
index a061201ac8c6..298d2ac2e399 100644
--- a/src/main/text-generation/source-control-text-generation-requests.ts
+++ b/src/main/text-generation/source-control-text-generation-requests.ts
@@ -28,6 +28,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../../shared/sourc
import { formatLinkedIssueTemplateValue } from '../../shared/source-control-ai-action-variables'
import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions'
import { captureAgentGenerationFailureOutput } from './agent-failure-output'
+import { openCodeVariantRetryPlan } from '../../shared/opencode-generation-command'
import { runLocalPlanForAgent } from './source-control-local-generation'
import { runRemoteSourceControlPlan } from './source-control-remote-generation'
import type {
@@ -61,21 +62,29 @@ async function executeGenerationPlan(input: {
operation: TextGenerationOperation
spawnAgent: SpawnSourceControlAgent
}): Promise {
- const result = await (input.target.kind === 'remote'
- ? runRemoteSourceControlPlan({
- plan: input.plan,
- target: input.target,
- emptyResultName: input.emptyResultName,
- operation: input.operation
- })
- : runLocalPlanForAgent({
- agentId: input.params.agentId,
- plan: input.plan,
- target: input.target,
- emptyResultName: input.emptyResultName,
- operation: input.operation,
- spawnAgent: input.spawnAgent
- }))
+ const execute = (plan: CommitMessagePlan): Promise =>
+ input.target.kind === 'remote'
+ ? runRemoteSourceControlPlan({
+ plan,
+ target: input.target,
+ emptyResultName: input.emptyResultName,
+ operation: input.operation
+ })
+ : runLocalPlanForAgent({
+ agentId: input.params.agentId,
+ plan,
+ target: input.target,
+ emptyResultName: input.emptyResultName,
+ operation: input.operation,
+ spawnAgent: input.spawnAgent
+ })
+ let result = await execute(input.plan)
+ if (!result.success && input.params.agentId === 'opencode') {
+ const retry = openCodeVariantRetryPlan(input.plan, result.failureOutput?.stderr ?? '')
+ if (retry) {
+ result = await execute(retry)
+ }
+ }
// Why: only a custom command runs a raw model whose chat template can swallow
// the opening think tag; a built-in agent's message may just mention the tag.
// PR fields are JSON, so they strip only when parsing fails instead.
diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts
index 152e8bd72fbe..b8d5e69f3303 100644
--- a/src/relay/agent-exec-handler.ts
+++ b/src/relay/agent-exec-handler.ts
@@ -6,6 +6,7 @@ import type { RelayDispatcher, RequestContext } from './dispatcher'
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
import { terminateRelaySubprocessTree } from './subprocess-tree-termination'
+import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
const DEFAULT_TIMEOUT_MS = 60_000
const MAX_TIMEOUT_MS = 5 * 60 * 1000
@@ -76,6 +77,7 @@ type ExecParams = {
timeoutMs: unknown
env: unknown
operation: unknown
+ shell: unknown
}
type CancelParams = {
@@ -88,7 +90,7 @@ function laneKeyFor(cwd: string, operation: unknown): string {
return JSON.stringify([op, cwd])
}
-type InFlightExec = { child: ChildProcess; cancel: () => void }
+type InFlightExec = { child?: ChildProcess; cancel: () => void }
type ExecResult = {
stdout: string
@@ -149,14 +151,32 @@ export class AgentExecHandler {
params.env && typeof params.env === 'object' && !Array.isArray(params.env)
? (params.env as Record)
: null
- const baseEnv = mergeCommandEnvironment(
- process.env,
- extraEnv ? {} : undefined,
- process.platform
- )
+ let hostEnv = process.env
+ if (params.shell === true) {
+ let canceled = false
+ const key = this.laneKey(cwd ?? '', params.operation)
+ const pending = {
+ cancel: (): void => {
+ canceled = true
+ }
+ }
+ this.inFlightByLane.get(key)?.cancel()
+ this.inFlightByLane.set(key, pending)
+ try {
+ hostEnv = await resolveLoginShellEnvironment({ env: process.env })
+ } finally {
+ if (this.inFlightByLane.get(key) === pending) {
+ this.inFlightByLane.delete(key)
+ }
+ }
+ if (canceled || context?.signal?.aborted) {
+ return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
+ }
+ }
+ const baseEnv = mergeCommandEnvironment(hostEnv, extraEnv ? {} : undefined, process.platform)
const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform)
const spawnEnv = Object.fromEntries(
- Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? process.env, overrides)).filter(
+ Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? hostEnv, overrides)).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
diff --git a/src/relay/agent-exec-shell-environment.test.ts b/src/relay/agent-exec-shell-environment.test.ts
new file mode 100644
index 000000000000..2f64c21e145b
--- /dev/null
+++ b/src/relay/agent-exec-shell-environment.test.ts
@@ -0,0 +1,64 @@
+import { spawn } from 'node:child_process'
+import type * as ChildProcess from 'node:child_process'
+import { describe, expect, it, vi } from 'vitest'
+import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
+import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
+
+vi.mock('node:child_process', async (importOriginal) => ({
+ ...(await importOriginal()),
+ spawn: vi.fn()
+}))
+vi.mock('../main/startup/login-shell-environment', () => ({
+ resolveLoginShellEnvironment: vi.fn()
+}))
+
+describe('relay headless generation shell environment', () => {
+ it('uses the execution host profile PATH and retains explicit command overrides', async () => {
+ vi.mocked(resolveLoginShellEnvironment).mockResolvedValue({
+ PATH: '/profile/bin:/usr/bin',
+ MODEL: 'profile'
+ })
+ const child = createFakeChild()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake provides the streams and lifecycle used by the relay.
+ vi.mocked(spawn).mockReturnValue(child as never)
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, env: { MODEL: 'override' } },
+ requestContext()
+ )
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalled())
+ child.emit('close', 0)
+ await expect(pending).resolves.toMatchObject({ exitCode: 0 })
+ expect(spawn).toHaveBeenLastCalledWith(
+ 'opencode',
+ ['run'],
+ expect.objectContaining({
+ env: expect.objectContaining({ PATH: '/profile/bin:/usr/bin', MODEL: 'override' })
+ })
+ )
+ })
+
+ it('does not start generation after cancellation during profile resolution', async () => {
+ vi.mocked(spawn).mockClear()
+ let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
+ vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
+ new Promise((resolve) => {
+ resolveProfile = resolve
+ })
+ )
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true },
+ requestContext()
+ )
+ await expect(
+ handlers.get('agent.cancelExec')?.(
+ { cwd: '/repo', operation: 'commit-message' },
+ requestContext()
+ )
+ ).resolves.toEqual({ canceled: true })
+ resolveProfile({ PATH: '/profile/bin' })
+ await expect(pending).resolves.toMatchObject({ canceled: true })
+ expect(spawn).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/shared/command-option-occurrence.ts b/src/shared/command-option-occurrence.ts
new file mode 100644
index 000000000000..6dee668829be
--- /dev/null
+++ b/src/shared/command-option-occurrence.ts
@@ -0,0 +1,38 @@
+function matchesOption(token: string, aliases: readonly string[]): boolean {
+ return aliases.some(
+ (alias) =>
+ token === alias ||
+ token.startsWith(`${alias}=`) ||
+ (alias.startsWith('-') &&
+ !alias.startsWith('--') &&
+ token.startsWith(alias) &&
+ token.length > alias.length)
+ )
+}
+
+export function findOptionOccurrence(
+ tokens: string[],
+ aliases: readonly string[],
+ stopAtTerminator: boolean
+): { index: number; consumed: number; value?: string } | null {
+ for (let index = 0; index < tokens.length; index += 1) {
+ const token = tokens[index]
+ if (stopAtTerminator && token === '--') {
+ break
+ }
+ if (!matchesOption(token, aliases)) {
+ continue
+ }
+ const nextToken = tokens[index + 1]
+ const consumesNext =
+ aliases.includes(token) && nextToken !== undefined && !nextToken.startsWith('-')
+ const alias = aliases.find((name) => matchesOption(token, [name]))
+ const value = consumesNext
+ ? nextToken
+ : alias && token !== alias
+ ? token.slice(alias.length + (token[alias.length] === '=' ? 1 : 0))
+ : undefined
+ return { index, consumed: consumesNext ? 2 : 1, value }
+ }
+ return null
+}
diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts
index 553f734a9719..a4e52b604f2b 100644
--- a/src/shared/commit-message-agent-spec.test.ts
+++ b/src/shared/commit-message-agent-spec.test.ts
@@ -550,7 +550,7 @@ describe('buildArgs (OpenCode)', () => {
'--agent',
'build',
'--format',
- 'default'
+ 'json'
])
expect(args).not.toContain(prompt)
expect(args).not.toContain('')
@@ -571,7 +571,7 @@ describe('buildArgs (OpenCode)', () => {
'--agent',
'build',
'--format',
- 'default',
+ 'json',
'--variant',
'high'
])
@@ -604,7 +604,7 @@ describe('buildArgs (OpenCode 2)', () => {
'--agent',
'build',
'--format',
- 'default'
+ 'json'
])
expect(args).not.toContain(prompt)
expect(args).not.toContain('')
@@ -625,7 +625,7 @@ describe('buildArgs (OpenCode 2)', () => {
'--agent',
'build',
'--format',
- 'default'
+ 'json'
])
expect(args).not.toContain('--variant')
})
diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts
index 729228824bf1..ce7b35afcfb2 100644
--- a/src/shared/commit-message-agent-specs-primary.ts
+++ b/src/shared/commit-message-agent-specs-primary.ts
@@ -161,32 +161,25 @@ export function buildPrimaryCommitMessageAgentSpecs({
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'run',
- '--model',
- model,
+ ...(model && model !== 'default' ? ['--model', model] : []),
'--agent',
'build',
'--format',
- 'default',
+ 'json',
...(thinkingLevel ? ['--variant', thinkingLevel] : [])
],
singletonOptions: [['--model', '-m'], ['--agent'], ['--format'], ['--variant']],
modelSource: 'dynamic',
modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels },
models: [
- {
- // Why: OpenCode's hosted GPT models can require workspace billing even
- // when `opencode models` lists them. This free model is available in
- // discovery and works as a usable out-of-the-box default.
- id: 'opencode/deepseek-v4-flash-free',
- label: 'OpenCode DeepSeek V4 Flash Free'
- },
+ { id: 'default', label: 'Config default' },
{
id: 'opencode/gpt-5.4-mini',
label: 'OpenCode GPT 5.4 Mini',
...withOpenAiThinking('gpt-5.4-mini')
}
],
- defaultModelId: 'opencode/deepseek-v4-flash-free'
+ defaultModelId: 'default'
},
opencode2: {
id: 'opencode2',
@@ -195,25 +188,26 @@ export function buildPrimaryCommitMessageAgentSpecs({
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'run',
- '--model',
- thinkingLevel ? `${model}#${thinkingLevel}` : model,
+ ...(model && model !== 'default'
+ ? ['--model', thinkingLevel ? `${model}#${thinkingLevel}` : model]
+ : []),
'--agent',
'build',
'--format',
- 'default'
+ 'json'
],
singletonOptions: [['--model', '-m'], ['--agent'], ['--format']],
modelSource: 'dynamic',
modelDiscovery: { binary: 'opencode2', args: ['models'], parse: parseLineModels },
models: [
- { id: 'opencode/deepseek-v4-flash-free', label: 'OpenCode DeepSeek V4 Flash Free' },
+ { id: 'default', label: 'Config default' },
{
id: 'opencode/gpt-5.4-mini',
label: 'OpenCode GPT 5.4 Mini',
...withOpenAiThinking('gpt-5.4-mini')
}
],
- defaultModelId: 'opencode/deepseek-v4-flash-free'
+ defaultModelId: 'default'
},
pi: {
id: 'pi',
diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts
index 9e499ae4a952..ba6ab2b888d7 100644
--- a/src/shared/commit-message-plan.test.ts
+++ b/src/shared/commit-message-plan.test.ts
@@ -77,12 +77,13 @@ describe('planCommitMessageGeneration', () => {
'--agent',
'build',
'--format',
- 'default',
+ 'json',
'--variant',
'high'
],
stdinPayload: 'PROMPT',
- label: 'OpenCode'
+ label: 'OpenCode',
+ outputFormat: 'opencode-json'
}
})
})
@@ -109,10 +110,11 @@ describe('planCommitMessageGeneration', () => {
'--agent',
'build',
'--format',
- 'default'
+ 'json'
],
stdinPayload: 'PROMPT',
- label: 'OpenCode'
+ label: 'OpenCode',
+ outputFormat: 'opencode-json'
}
})
})
@@ -481,7 +483,7 @@ describe('planCommitMessageGeneration', () => {
expect(result).toMatchObject({
ok: true,
plan: {
- args: ['run', '--model', 'opencode/gpt-5.5', '--agent', 'build', '--format', 'default'],
+ args: ['run', '--model', 'opencode/gpt-5.5', '--agent', 'build', '--format', 'json'],
stdinPayload: 'PROMPT'
}
})
@@ -496,7 +498,7 @@ describe('planCommitMessageGeneration', () => {
expect(result).toMatchObject({
ok: true,
plan: {
- args: ['run', '-m', 'opencode/gpt-5.5', '--agent', 'build', '--format', 'default']
+ args: ['run', '-m', 'opencode/gpt-5.5', '--agent', 'build', '--format', 'json']
}
})
})
@@ -546,7 +548,7 @@ describe('planCommitMessageGeneration', () => {
'--agent',
'build',
'--format',
- 'default',
+ 'json',
'--share'
],
stdinPayload: 'PROMPT'
@@ -567,7 +569,7 @@ describe('planCommitMessageGeneration', () => {
expect(result).toMatchObject({
ok: true,
plan: {
- args: ['run', '--model', 'opencode/first', '--agent', 'build', '--format', 'default']
+ args: ['run', '--model', 'opencode/first', '--agent', 'build', '--format', 'json']
}
})
})
@@ -610,7 +612,7 @@ describe('planCommitMessageGeneration', () => {
'--agent',
'build',
'--format',
- 'default'
+ 'json'
]
}
})
@@ -641,7 +643,7 @@ describe('planCommitMessageGeneration', () => {
'--agent',
'build',
'--format',
- 'default'
+ 'json'
]
}
})
@@ -662,7 +664,7 @@ describe('planCommitMessageGeneration', () => {
ok: true,
plan: {
binary: 'opencode',
- args: ['run', '--model', 'opencode/from-recipe', '--agent', 'build', '--format', 'default']
+ args: ['run', '--model', 'opencode/from-recipe', '--agent', 'build', '--format', 'json']
}
})
})
diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts
index 1d0bf1e02de9..f00158eb879c 100644
--- a/src/shared/commit-message-plan.ts
+++ b/src/shared/commit-message-plan.ts
@@ -1,3 +1,4 @@
+import { findOptionOccurrence } from './command-option-occurrence'
import { planAgentBinary } from './agent-command-plan'
export { planAgentBinary } from './agent-command-plan'
import type { CommandTemplateBackslash } from './commit-message-prompt'
@@ -8,6 +9,7 @@ import {
} from './commit-message-agent-spec'
import { planCustomCommand, tokenizeCustomCommandTemplate } from './commit-message-prompt'
import type { TuiAgent } from './tui-agent'
+import { mergeOpenCodeGenerationArgs } from './opencode-generation-command'
// Why: planning is a pure transformation from "user request + prompt text"
// into "spawn-ready binary + argv". Keeping it in shared lets both the local
@@ -37,6 +39,7 @@ export type CommitMessagePlan = {
label: string
/** Leading command assignments, applied on the execution host. */
env?: Record
+ outputFormat?: 'opencode-json'
}
export type CommitMessagePlanResult =
@@ -60,39 +63,6 @@ function planAdditionalAgentArgs(
const DEFAULT_SINGLETON_OPTIONS: readonly (readonly string[])[] = [['--model']]
-function matchesOption(token: string, aliases: readonly string[]): boolean {
- return aliases.some(
- (alias) =>
- token === alias ||
- token.startsWith(`${alias}=`) ||
- (alias.startsWith('-') &&
- !alias.startsWith('--') &&
- token.startsWith(alias) &&
- token.length > alias.length)
- )
-}
-
-function findOptionOccurrence(
- tokens: string[],
- aliases: readonly string[],
- stopAtTerminator: boolean
-): { index: number; consumed: number } | null {
- for (let index = 0; index < tokens.length; index += 1) {
- const token = tokens[index]
- if (stopAtTerminator && token === '--') {
- break
- }
- if (!matchesOption(token, aliases)) {
- continue
- }
- const nextToken = tokens[index + 1]
- const consumesNext =
- aliases.includes(token) && nextToken !== undefined && !nextToken.startsWith('-')
- return { index, consumed: consumesNext ? 2 : 1 }
- }
- return null
-}
-
function applyRecipeOptionOverride(args: {
generatedArgs: string[]
recipeArgs: string[]
@@ -311,9 +281,16 @@ export function planCommitMessageGeneration(
ok: true,
plan: {
binary: command.binary,
- args: [...merged.prefixArgs, ...args],
+ args: mergeOpenCodeGenerationArgs(input.agentId, command.binary, merged.prefixArgs, args),
stdinPayload: spec.promptDelivery === 'stdin' ? prompt : null,
label: spec.label,
+ ...((input.agentId === 'opencode' || input.agentId === 'opencode2') &&
+ [...merged.prefixArgs, ...args].some(
+ (value, index, values) =>
+ value === '--format=json' || (value === '--format' && values[index + 1] === 'json')
+ )
+ ? { outputFormat: 'opencode-json' as const }
+ : {}),
...(command.env ? { env: command.env } : {})
}
}
diff --git a/src/shared/opencode-generation-command.test.ts b/src/shared/opencode-generation-command.test.ts
new file mode 100644
index 000000000000..945300a1c128
--- /dev/null
+++ b/src/shared/opencode-generation-command.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest'
+import { planCommitMessageGeneration } from './commit-message-plan'
+import { openCodeVariantRetryPlan } from './opencode-generation-command'
+
+const rejection = 'Unrecognized flag: --variant in command opencode run'
+describe('OpenCode generation commands', () => {
+ it.each(['opencode', 'opencode.exe', 'opencode.cmd', 'opencode2'])(
+ 'keeps run before launch flags for %s',
+ (binary) => {
+ const result = planCommitMessageGeneration(
+ { agentId: 'opencode', model: 'default', agentCommandOverride: `${binary} --auto` },
+ 'PROMPT'
+ )
+ expect(result).toMatchObject({
+ ok: true,
+ plan: {
+ args: ['run', '--auto', '--agent', 'build', '--format', 'json'],
+ stdinPayload: 'PROMPT',
+ outputFormat: 'opencode-json'
+ }
+ })
+ }
+ )
+
+ it('keeps explicit formatted output recipes on their requested output format', () => {
+ const result = planCommitMessageGeneration(
+ { agentId: 'opencode', model: 'default', agentArgs: '--format default' },
+ 'PROMPT'
+ )
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ expect(result.plan.outputFormat).toBeUndefined()
+ })
+
+ it('retries only the precise v2 argv rejection, retaining the prompt and selected model', () => {
+ const result = planCommitMessageGeneration(
+ { agentId: 'opencode', model: 'fixture/chat', thinkingLevel: 'high' },
+ 'PROMPT'
+ )
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ expect(openCodeVariantRetryPlan(result.plan, rejection)).toMatchObject({
+ args: ['run', '--model', 'fixture/chat#high', '--agent', 'build', '--format', 'json'],
+ stdinPayload: 'PROMPT'
+ })
+ expect(openCodeVariantRetryPlan(result.plan, 'provider rejected the request')).toBeNull()
+ expect(openCodeVariantRetryPlan(result.plan, 'Unrecognized flag: --other')).toBeNull()
+ })
+ it.each([
+ '--model=fixture/chat --variant=high',
+ '-mfixture/chat --variant=high',
+ '--variant high --model=fixture/chat',
+ '--variant=high -m fixture/chat'
+ ])('retries the chosen recipe model and variant in %s', (agentArgs) => {
+ const result = planCommitMessageGeneration(
+ { agentId: 'opencode', model: 'other/model', thinkingLevel: 'low', agentArgs },
+ 'PROMPT'
+ )
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ const retry = openCodeVariantRetryPlan(result.plan, rejection)
+ expect(retry?.args.join(' ')).toContain('fixture/chat#high')
+ expect(retry?.args.join(' ')).not.toContain('--variant')
+ expect(retry?.stdinPayload).toBe('PROMPT')
+ })
+
+ it('does not treat prompt tokens after the terminator as options', () => {
+ expect(
+ openCodeVariantRetryPlan(
+ {
+ binary: 'opencode',
+ label: 'OpenCode',
+ stdinPayload: null,
+ args: ['run', '--model=fixture/chat', '--', '--variant=high']
+ },
+ rejection
+ )
+ ).toBeNull()
+ expect(
+ openCodeVariantRetryPlan(
+ {
+ binary: 'opencode',
+ label: 'OpenCode',
+ stdinPayload: null,
+ args: ['run', '--variant=high', '--', '--model=fixture/chat']
+ },
+ rejection
+ )
+ ).toBeNull()
+ })
+})
diff --git a/src/shared/opencode-generation-command.ts b/src/shared/opencode-generation-command.ts
new file mode 100644
index 000000000000..524cbe5bdbed
--- /dev/null
+++ b/src/shared/opencode-generation-command.ts
@@ -0,0 +1,44 @@
+import { findOptionOccurrence } from './command-option-occurrence'
+import type { CommitMessagePlan } from './commit-message-plan'
+
+export function mergeOpenCodeGenerationArgs(
+ agentId: string,
+ binary: string,
+ prefixArgs: string[],
+ args: string[]
+): string[] {
+ if (
+ (agentId === 'opencode' || agentId === 'opencode2') &&
+ /(?:^|[\\/])opencode2?(?:\.(?:cmd|exe))?$/i.test(binary) &&
+ args[0] === 'run' &&
+ !prefixArgs.includes('--')
+ ) {
+ return ['run', ...prefixArgs, ...args.slice(1)]
+ }
+ return [...prefixArgs, ...args]
+}
+
+export function openCodeVariantRetryPlan(
+ plan: CommitMessagePlan,
+ stderr: string
+): CommitMessagePlan | null {
+ // Retry only an argv rejection, which happens before a model turn starts.
+ if (!stderr.includes('Unrecognized flag: --variant in command opencode run')) {
+ return null
+ }
+ const variant = findOptionOccurrence(plan.args, ['--variant'], true)
+ const model = findOptionOccurrence(plan.args, ['--model', '-m'], true)
+ if (!variant?.value || !model?.value) {
+ return null
+ }
+ const args = [...plan.args]
+ const replacement = `${model.value.split('#')[0]}#${variant.value}`
+ if (model.consumed === 2) {
+ args[model.index + 1] = replacement
+ } else {
+ const token = args[model.index]
+ args[model.index] = `${token.slice(0, token.length - model.value.length)}${replacement}`
+ }
+ args.splice(variant.index, variant.consumed)
+ return { ...plan, args }
+}
diff --git a/src/shared/opencode-generation-output.test.ts b/src/shared/opencode-generation-output.test.ts
new file mode 100644
index 000000000000..1d0e8c4d9ce1
--- /dev/null
+++ b/src/shared/opencode-generation-output.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest'
+import { parseOpenCodeGenerationOutput } from './opencode-generation-output'
+
+const frame = (type: string, text: string, id = 'answer'): string =>
+ JSON.stringify({ type, part: { id, text } })
+
+describe('OpenCode generation event output', () => {
+ it('uses the final answer after a tool step', () => {
+ const output = [
+ frame('step_start', ''),
+ frame('text', 'I will inspect the staged diff.'),
+ frame('tool_use', 'git diff'),
+ frame('step_finish', 'tool-calls'),
+ frame('step_start', ''),
+ frame('text', 'fix: parse the final answer')
+ ].join('\n')
+ expect(parseOpenCodeGenerationOutput(output)).toEqual({
+ ok: true,
+ text: 'fix: parse the final answer'
+ })
+ })
+ it('keeps assistant text and ignores tool, reasoning, progress and warning events', () => {
+ const output = [
+ frame('step_start', 'starting'),
+ frame('reasoning', 'reasoning'),
+ frame('tool_use', 'git diff output'),
+ JSON.stringify({ type: 'warning', message: 'notice' }),
+ frame('text', 'fix: generate messages'),
+ frame('step_finish', 'done')
+ ].join('\n')
+ expect(parseOpenCodeGenerationOutput(output)).toEqual({
+ ok: true,
+ text: 'fix: generate messages'
+ })
+ })
+
+ it('replaces repeated parts and preserves multiple answer parts', () => {
+ expect(
+ parseOpenCodeGenerationOutput(
+ [frame('text', 'partial'), frame('text', 'subject'), frame('text', 'body', 'body')].join(
+ '\r\n'
+ )
+ )
+ ).toEqual({ ok: true, text: 'subject\nbody' })
+ })
+
+ it.each([
+ { name: 'UnknownError', data: { message: 'Model unavailable' } },
+ { type: 'provider.no-route', message: 'Unsupported package' }
+ ])('reports errors instead of accepting a partial answer', (error) => {
+ const output = `${frame('text', 'partial')}\n${JSON.stringify({ type: 'error', error })}`
+ expect(parseOpenCodeGenerationOutput(output)).toEqual({
+ ok: false,
+ error: error.message ?? error.data?.message
+ })
+ })
+
+ it.each([
+ { error: { name: 'MessageOutputLengthError', data: {} }, expected: 'MessageOutputLengthError' },
+ {
+ error: { name: 'ProviderError', data: { retryable: false } },
+ expected: 'ProviderError'
+ },
+ {
+ error: { name: 'ProviderError', message: 'Provider rejected the request', data: {} },
+ expected: 'Provider rejected the request'
+ }
+ ])('reports named errors without a data message', ({ error, expected }) => {
+ const output = `${frame('text', 'partial')}\n${JSON.stringify({ type: 'error', error })}`
+ expect(parseOpenCodeGenerationOutput(output)).toEqual({ ok: false, error: expected })
+ })
+
+ it.each([
+ '{broken',
+ '{"title":"not an event"}',
+ 'null',
+ JSON.stringify({ type: 'error', error: { name: 'ProviderError', data: { message: 42 } } })
+ ])('rejects malformed events %s', (output) => {
+ expect(parseOpenCodeGenerationOutput(output)).toEqual({
+ ok: false,
+ error: 'OpenCode returned invalid JSON events.'
+ })
+ })
+})
diff --git a/src/shared/opencode-generation-output.ts b/src/shared/opencode-generation-output.ts
new file mode 100644
index 000000000000..8a3ba24e4949
--- /dev/null
+++ b/src/shared/opencode-generation-output.ts
@@ -0,0 +1,53 @@
+import { z } from 'zod'
+
+const eventSchema = z.object({
+ type: z.string(),
+ part: z.object({ id: z.string().optional(), text: z.string().optional() }).optional(),
+ error: z
+ .object({
+ name: z.string().optional(),
+ message: z.string().optional(),
+ data: z.object({ message: z.string().optional() }).optional()
+ })
+ .optional()
+})
+
+export function parseOpenCodeGenerationOutput(
+ stdout: string
+): { ok: true; text: string } | { ok: false; error: string } {
+ const parts = new Map()
+ let anonymousPart = 0
+ for (const line of stdout.split(/\r?\n/)) {
+ if (!line.trim()) {
+ continue
+ }
+ let value: unknown
+ try {
+ value = JSON.parse(line)
+ } catch {
+ return { ok: false, error: 'OpenCode returned invalid JSON events.' }
+ }
+ const parsed = eventSchema.safeParse(value)
+ if (!parsed.success) {
+ return { ok: false, error: 'OpenCode returned invalid JSON events.' }
+ }
+ const event = parsed.data
+ if (event.type === 'step_start') {
+ parts.clear()
+ }
+ if (event.type === 'error') {
+ return {
+ ok: false,
+ error:
+ event.error?.data?.message ??
+ event.error?.message ??
+ event.error?.name ??
+ 'OpenCode reported an error.'
+ }
+ }
+ if (event.type === 'text' && event.part?.text !== undefined) {
+ parts.set(event.part.id ?? `anonymous-${anonymousPart++}`, event.part.text)
+ }
+ }
+ return { ok: true, text: [...parts.values()].join('\n') }
+}
From 77ad467ebb83f16e8754abe7cfab7cb6d8078d5d Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:52:36 -0700
Subject: [PATCH 02/11] fix(ci): prevent concurrent pnpm refresh during mobile
typechecks (#24776)
* fix(ci): run mobile typechecks without concurrent dependency refresh
* test(ci): check effective Linux E2E package list
* test(ci): preserve the mobile production compiler barrier
---------
Co-authored-by: Orca Integration Recovery
---
.github/workflows/mobile.yml | 6 +--
.../mobile-typecheck-workflow.test.mjs | 42 +++++++++++++++++++
config/scripts/pr-e2e-gate-contract.test.mjs | 6 ++-
.../pr-e2e-linux-packages.test-fixture.mjs | 10 +++++
4 files changed, 59 insertions(+), 5 deletions(-)
create mode 100644 config/scripts/mobile-typecheck-workflow.test.mjs
create mode 100644 config/scripts/pr-e2e-linux-packages.test-fixture.mjs
diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml
index f17c7e8f1d73..ae695b8e3eb6 100644
--- a/.github/workflows/mobile.yml
+++ b/.github/workflows/mobile.yml
@@ -114,11 +114,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- # pnpm entrypoints can auto-install; finish each before the next mutates node_modules.
+ # Call installed tools so pnpm's dependency refresh cannot race between checks.
- name: Typecheck
id: production-types
background: true
- run: pnpm typecheck
+ run: node node_modules/typescript/bin/tsc --noEmit
- wait: production-types
@@ -127,7 +127,7 @@ jobs:
# drifted. This fails when a test file that checks today stops checking, when a test leaves
# the program, and on @ts-nocheck; the baseline may only shrink.
- name: Typecheck tests (ratchet)
- run: pnpm run check:tests-typecheck
+ run: node scripts/check-tests-typecheck-ratchet.mjs
# This includes the bridged replay of the whole recording corpus, which used to be a second
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
diff --git a/config/scripts/mobile-typecheck-workflow.test.mjs b/config/scripts/mobile-typecheck-workflow.test.mjs
new file mode 100644
index 000000000000..0ab57ad858f7
--- /dev/null
+++ b/config/scripts/mobile-typecheck-workflow.test.mjs
@@ -0,0 +1,42 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { parse } from 'yaml'
+
+const workflow = parse(readFileSync('.github/workflows/mobile.yml', 'utf8'))
+const packageJson = JSON.parse(readFileSync('mobile/package.json', 'utf8'))
+const job = workflow.jobs.verify
+const steps = job.steps
+
+describe('mobile verification command ownership', () => {
+ it('runs the declared checks through installed tools without a script-time install', () => {
+ const production = steps.find((step) => step.name === 'Typecheck')
+ const tests = steps.find((step) => step.name === 'Typecheck tests (ratchet)')
+
+ expect(packageJson.scripts.typecheck).toMatch(/^tsc\b/)
+ expect(production.run).toBe(`node node_modules/typescript/bin/${packageJson.scripts.typecheck}`)
+ expect(tests.run).toBe(packageJson.scripts['check:tests-typecheck'])
+ expect(tests.run).toMatch(/^node\s/)
+ expect(job.defaults.run['working-directory']).toBe('mobile')
+ for (const name of ['typecheck', 'check:tests-typecheck']) {
+ expect(packageJson.scripts[`pre${name}`]).toBeUndefined()
+ expect(packageJson.scripts[`post${name}`]).toBeUndefined()
+ }
+ })
+
+ it('finishes installation and joins production types before checking test types', () => {
+ const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
+ const productionIndex = steps.findIndex((step) => step.name === 'Typecheck')
+ const ratchetIndex = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)')
+ const waitIndex = steps.findIndex((step) => step.wait === steps[productionIndex].id)
+ const testIndex = steps.findIndex((step) => step.name === 'Test')
+
+ expect(steps[installIndex].run).toBe('pnpm install --frozen-lockfile')
+ expect(steps[installIndex].background ?? false).toBe(false)
+ expect(installIndex).toBeLessThan(productionIndex)
+ expect(steps[productionIndex].background).toBe(true)
+ expect(productionIndex).toBeLessThan(ratchetIndex)
+ expect(waitIndex).toBeGreaterThan(productionIndex)
+ expect(waitIndex).toBeLessThan(ratchetIndex)
+ expect(ratchetIndex).toBeLessThan(testIndex)
+ })
+})
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index caec90ee0869..2e8b77fd97cc 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -1,4 +1,5 @@
import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs'
+import { linuxInstallPackageList } from './pr-e2e-linux-packages.test-fixture.mjs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
@@ -20,6 +21,7 @@ import {
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
+
const reliabilityManifest = parseJsonc(
readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
)
@@ -218,7 +220,7 @@ describe('PR E2E gate contract', () => {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
step.name.startsWith('Install native build')
)
- expect(installStep.env.ORCA_E2E_APT_PACKAGES.split(/\s+/), jobName).toContain('zsh')
+ expect(linuxInstallPackageList(installStep, jobName), jobName).toMatch(/\bzsh\b/)
}
})
@@ -308,7 +310,7 @@ describe('PR E2E gate contract', () => {
const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) =>
step.name.startsWith('Install native build')
)
- expect(changedInstall.env.ORCA_E2E_APT_PACKAGES.split(/\s+/)).toContain('openssh-client')
+ expect(linuxInstallPackageList(changedInstall, 'changed-e2e')).toMatch(/\bopenssh-client\b/)
})
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
diff --git a/config/scripts/pr-e2e-linux-packages.test-fixture.mjs b/config/scripts/pr-e2e-linux-packages.test-fixture.mjs
new file mode 100644
index 000000000000..dfbb2d4b7981
--- /dev/null
+++ b/config/scripts/pr-e2e-linux-packages.test-fixture.mjs
@@ -0,0 +1,10 @@
+import { expect } from 'vitest'
+
+export function linuxInstallPackageList(step, jobName) {
+ const packages = step.env?.ORCA_E2E_APT_PACKAGES
+ if (packages !== undefined) {
+ expect(step.run, jobName).toContain('read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"')
+ expect(step.run, jobName).toContain('sudo apt-get install -y "${packages[@]}"')
+ }
+ return packages ?? step.run
+}
From 54ddebf837e59e2639f961c14a031100461e46e2 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 22:06:06 -0700
Subject: [PATCH 03/11] test(terminal): restore the live fish fixture
prerequisites (#24947)
A restored pane waits for the initial status replay before subscribing to
PTY output. This fixture never settled that replay, so fish printed its
mode-2031 arm before the renderer connected. Its PTY API also omitted the
reset-input listener required by the serializer, aborting attachment.
Settle and dispose the existing startup-snapshot registration and provide
the same reset-listener mock used by the other PTY tests. The real fish
child-stdin assertions and timeouts remain unchanged. No production change.
---
.../fish-color-scheme-child-stdin.node-pty.test.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts
index 36768745bd61..c11268000a99 100644
--- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts
+++ b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts
@@ -21,6 +21,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type * as React from 'react'
+import type { registerAgentStatusStartupSnapshot } from '@/hooks/ipc-events/agent-status-startup-snapshot'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
fishRequirementViolation,
@@ -340,15 +341,21 @@ describe('terminal query responder', () => {
describe('fish never receives a color-scheme report it did not query (#9993)', () => {
let configHome: string | null = null
+ let statusSnapshot: ReturnType | undefined
// Always runs, so the CI lane cannot report green with the regression below skipped.
it('has the fish this suite needs when CI requires one', () => {
expect(fishRequirementViolation(FISH)).toBeNull()
})
- beforeEach(() => {
+ beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
+ const { registerAgentStatusStartupSnapshot: registerSnapshot } =
+ await import('@/hooks/ipc-events/agent-status-startup-snapshot')
+ // A restored pane waits for the initial status replay before connecting.
+ statusSnapshot = registerSnapshot()
+ statusSnapshot.settle()
transportFactoryQueue = []
storeSubscribers = []
mockStoreState = {
@@ -429,6 +436,7 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
setPtyDeliveryInterest: vi.fn(),
ackColdRestore: vi.fn(),
onClearBufferRequest: vi.fn(() => vi.fn()),
+ onResetInputModesRequest: vi.fn(() => vi.fn()),
onSerializeBufferRequest: vi.fn(() => vi.fn()),
sendSerializedBuffer: vi.fn(),
declarePendingPaneSerializer: vi.fn().mockResolvedValue(1),
@@ -456,6 +464,8 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
})
afterEach(() => {
+ statusSnapshot?.dispose()
+ statusSnapshot = undefined
delete (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame
delete (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame
delete (globalThis as { window?: unknown }).window
From 08ee7ba9efa2f3842b7a057a1eb101c824ea0087 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 22:06:09 -0700
Subject: [PATCH 04/11] fix(shortcuts): defer TUI editing chords in
terminal-first mode (#24640)
Restack the original focused change onto current main, preserving every owned source and test blob and the merged CI contract and journal cleanup fixes.
Original-commit: f707cde14ab8cb2670b4076916cdfb7e28c79983
fix(shortcuts): defer TUI editing chords in terminal-first mode
Original-commit: 0c6348e49e0d968e0170961d294a018adceaa9f3
docs(shortcuts): describe deferred preview terminal chords
Original-commit: be62c1b6c6b305cb8091161c1ccdd943a6e1d470
Align worktree history shortcut metadata with terminal conflict policy
Restacked-from: be62c1b6c6b305cb8091161c1ccdd943a6e1d470
Restacked-onto: f7b1f9d8be14d12244957ee0b710a3482ec62383
---
.../preview-terminal-shortcuts.test.ts | 6 +++
.../preview-terminal-shortcuts.ts | 6 +--
.../settings/shortcut-terminal-status.ts | 2 +-
.../terminal-pane/terminal-shortcut-policy.ts | 41 ++++++++-------
.../keybindings-terminal-context.test.ts | 50 ++++++++++++++++++-
src/shared/keybindings/definitions-core-2.ts | 6 +--
src/shared/keybindings/effective.ts | 19 ++++++-
7 files changed, 98 insertions(+), 32 deletions(-)
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts
index 20ff21e6a91e..80fddd93c295 100644
--- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts
@@ -176,6 +176,12 @@ describe('resolvePreviewShortcutAction', () => {
expect(
resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor())
).toEqual({ type: 'splitActivePane', direction: 'vertical' })
+ expect(
+ resolvePreviewShortcutAction(
+ keydown({ key: 'd', code: 'KeyD', metaKey: true }),
+ contextFor({ terminalShortcutPolicy: 'terminal-first' })
+ )
+ ).toBeNull()
})
// Why: a terminal-first user remapped terminal.closePane away, so only the
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts
index fb90fb5b4f61..0fef94397d9c 100644
--- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts
@@ -22,15 +22,15 @@ export type PreviewShortcutContext = {
terminalInput: DashboardCardTerminalInput | null
/** Live kitty-protocol flags mirrored from this pty's output. */
getKittyKeyboardFlags: () => number
- /** The user's setting; terminal-first yields the tab.close alias to the shell. */
+ /** The user's policy for chords shared with shells and TUIs. */
terminalShortcutPolicy: TerminalShortcutPolicy | null | undefined
}
/**
* Runs the preview terminal's keys through the same policy a pane uses, so the
* dashboard encodes word-kills, Option chords, and modified Enter identically.
- * Every pane-scoped verdict (splits, search, focus) still comes back — the
- * caller swallows those rather than leaking raw bytes to the agent.
+ * Active pane commands come back for the caller to handle; deferred TUI
+ * chords continue through xterm.
*/
export function resolvePreviewShortcutAction(
event: KeyboardEvent,
diff --git a/src/renderer/src/components/settings/shortcut-terminal-status.ts b/src/renderer/src/components/settings/shortcut-terminal-status.ts
index f8abf54abbe9..4a1de39b801a 100644
--- a/src/renderer/src/components/settings/shortcut-terminal-status.ts
+++ b/src/renderer/src/components/settings/shortcut-terminal-status.ts
@@ -24,7 +24,7 @@ export function getShortcutTerminalStatus(
if (!hasEffectiveBinding) {
return undefined
}
- if (definition.scope === 'terminal') {
+ if (definition.scope === 'terminal' && isKeybindingAllowedInTerminal(definition)) {
return {
label: translate('auto.components.settings.ShortcutsPane.cb02e00202', 'Terminal'),
description: translate(
diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
index 98f47941bb74..1c8340678dd8 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
@@ -1,6 +1,7 @@
import {
keybindingMatchesAction,
type KeybindingInput,
+ type KeybindingActionId,
type KeybindingMatchOptions,
type KeybindingOverrides,
type TerminalShortcutPolicy
@@ -31,7 +32,7 @@ export type TerminalShortcutEvent = {
// Shared close-chord predicate: the terminal pane (L3) and the floating panel's focused-terminal
// branch (L2) both treat terminal.closePane OR a terminal-scope tab.close as "close the active
// pane," so the two layers can't diverge. Callers pass the options each binding needs —
-// terminal.closePane is context-free; tab.close is scoped to the terminal surface.
+// both bindings use the caller’s terminal priority policy.
export function isTerminalPaneCloseChord(
event: KeybindingInput,
platform: NodeJS.Platform,
@@ -88,77 +89,75 @@ export function resolveTerminalShortcutAction(
getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding,
// Why: keybindings follow the client OS, but byte protocols follow the PTY host — they differ for macOS clients on Windows runtimes.
isWindowsTerminalHost: () => boolean = () => isWindows,
- // Why: gates the tab.close pane-close alias — under terminal-first a remapped tab.close yields to the shell (terminal.closePane, scope terminal, still closes).
+ // Why: terminal-first yields editing and TUI chords before xterm encodes them.
terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first',
// Why: query-only Droid/Grok consumers need CSI-u even when the live kitty flags remain inactive.
hasCtrlEnterCsiUAuthority?: () => boolean
): TerminalShortcutAction | null {
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
+ const matchOptions: KeybindingMatchOptions = { context: 'terminal', terminalShortcutPolicy }
+ const matches = (action: KeybindingActionId): boolean =>
+ keybindingMatchesAction(action, event, platform, keybindings, matchOptions)
// Why: capture this chord even on repeat without blocking the OS default input-source switch.
- if (keybindingMatchesAction('terminal.switchInputSource', event, platform, keybindings)) {
+ if (matches('terminal.switchInputSource')) {
return { type: 'switchInputSource' }
}
// Why: held select-all and copy keydowns must remain claimed until keyup so
// Kitty event reporting cannot encode their repeat or release into the PTY.
- if (keybindingMatchesAction('terminal.selectAll', event, platform, keybindings)) {
+ if (matches('terminal.selectAll')) {
return { type: 'selectAll' }
}
- if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) {
+ if (matches('terminal.copySelection')) {
return { type: 'copySelection' }
}
if (!event.repeat) {
- if (keybindingMatchesAction('terminal.search', event, platform, keybindings)) {
+ if (matches('terminal.search')) {
return { type: 'toggleSearch' }
}
- if (keybindingMatchesAction('terminal.clear', event, platform, keybindings)) {
+ if (matches('terminal.clear')) {
return { type: 'clearActivePane' }
}
- if (keybindingMatchesAction('terminal.focusPreviousPane', event, platform, keybindings)) {
+ if (matches('terminal.focusPreviousPane')) {
return { type: 'focusPane', direction: 'previous' }
}
- if (keybindingMatchesAction('terminal.focusNextPane', event, platform, keybindings)) {
+ if (matches('terminal.focusNextPane')) {
return { type: 'focusPane', direction: 'next' }
}
- if (keybindingMatchesAction('terminal.equalizePaneSizes', event, platform, keybindings)) {
+ if (matches('terminal.equalizePaneSizes')) {
return { type: 'equalizePaneSizes' }
}
- if (keybindingMatchesAction('terminal.expandPane', event, platform, keybindings)) {
+ if (matches('terminal.expandPane')) {
return { type: 'toggleExpandActivePane' }
}
- if (keybindingMatchesAction('terminal.setTitle', event, platform, keybindings)) {
+ if (matches('terminal.setTitle')) {
return { type: 'setTitle' }
}
- if (keybindingMatchesAction('terminal.clearPaneTitle', event, platform, keybindings)) {
+ if (matches('terminal.clearPaneTitle')) {
return { type: 'clearPaneTitle' }
}
// Why: recognize the active tab.close binding as a pane-close alias too, so a user who remaps
// tab.close alone still closes the focused split pane (never the whole tab); L2 always defers to us.
- if (
- isTerminalPaneCloseChord(event, platform, keybindings, undefined, {
- context: 'terminal',
- terminalShortcutPolicy
- })
- ) {
+ if (isTerminalPaneCloseChord(event, platform, keybindings, matchOptions, matchOptions)) {
return { type: 'closeActivePane' }
}
- if (keybindingMatchesAction('terminal.splitRight', event, platform, keybindings)) {
+ if (matches('terminal.splitRight')) {
return { type: 'splitActivePane', direction: 'vertical' }
}
- if (keybindingMatchesAction('terminal.splitDown', event, platform, keybindings)) {
+ if (matches('terminal.splitDown')) {
return { type: 'splitActivePane', direction: 'horizontal' }
}
}
diff --git a/src/shared/keybindings-terminal-context.test.ts b/src/shared/keybindings-terminal-context.test.ts
index 887b8ee3b28a..daf4d880a0f3 100644
--- a/src/shared/keybindings-terminal-context.test.ts
+++ b/src/shared/keybindings-terminal-context.test.ts
@@ -38,9 +38,57 @@ describe('keybindings', () => {
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
- ).toBe(true)
+ ).toBe(false)
})
+ it.each(['darwin', 'linux', 'win32'] as const)(
+ 'defers editing and history chords on %s while app focus retains them',
+ (platform) => {
+ const chords = [
+ { action: 'terminal.search', key: 'f', code: 'KeyF', shift: false },
+ { action: 'terminal.clear', key: 'k', code: 'KeyK', shift: false },
+ { action: 'terminal.closePane', key: 'w', code: 'KeyW', shift: false },
+ { action: 'tab.newTerminal', key: 't', code: 'KeyT', shift: false },
+ { action: 'terminal.selectAll', key: 'a', code: 'KeyA', shift: platform !== 'darwin' },
+ { action: 'terminal.splitRight', key: 'd', code: 'KeyD', shift: platform !== 'darwin' }
+ ] as const
+ for (const chord of chords) {
+ const input = {
+ ...chord,
+ meta: platform === 'darwin',
+ control: platform !== 'darwin',
+ alt: false
+ }
+ expect(
+ keybindingMatchesAction(chord.action, input, platform, undefined, {
+ context: 'terminal',
+ terminalShortcutPolicy: 'terminal-first'
+ })
+ ).toBe(false)
+ expect(
+ keybindingMatchesAction(chord.action, input, platform, undefined, {
+ context: 'app',
+ terminalShortcutPolicy: 'terminal-first'
+ })
+ ).toBe(true)
+ }
+ const historyInput = {
+ key: 'ArrowLeft',
+ code: 'ArrowLeft',
+ meta: platform === 'darwin',
+ control: platform !== 'darwin',
+ alt: true,
+ shift: false
+ }
+ expect(
+ keybindingMatchesAction('worktree.history.back', historyInput, platform, undefined, {
+ context: 'terminal',
+ terminalShortcutPolicy: 'terminal-first'
+ })
+ ).toBe(false)
+ }
+ )
+
it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => {
const panelFocus = {
context: 'app',
diff --git a/src/shared/keybindings/definitions-core-2.ts b/src/shared/keybindings/definitions-core-2.ts
index d8fd6ad3227c..1ae589b67c54 100644
--- a/src/shared/keybindings/definitions-core-2.ts
+++ b/src/shared/keybindings/definitions-core-2.ts
@@ -55,8 +55,7 @@ export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [
group: 'Global',
scope: 'global',
searchKeywords: ['shortcut', 'worktree', 'history', 'back'],
- defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']),
- allowInTerminal: true
+ defaultBindings: platformBindings(['Mod+Alt+ArrowLeft'])
},
{
id: 'worktree.history.forward',
@@ -64,8 +63,7 @@ export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [
group: 'Global',
scope: 'global',
searchKeywords: ['shortcut', 'worktree', 'history', 'forward'],
- defaultBindings: platformBindings(['Mod+Alt+ArrowRight']),
- allowInTerminal: true
+ defaultBindings: platformBindings(['Mod+Alt+ArrowRight'])
},
{
id: 'tab.newTerminal',
diff --git a/src/shared/keybindings/effective.ts b/src/shared/keybindings/effective.ts
index 23489eb296e0..c8e8a879e4f7 100644
--- a/src/shared/keybindings/effective.ts
+++ b/src/shared/keybindings/effective.ts
@@ -73,12 +73,27 @@ export function normalizeTerminalShortcutPolicy(
return policy === 'terminal-first' ? 'terminal-first' : 'orca-first'
}
+const TUI_CONFLICT_ACTIONS = new Set([
+ 'terminal.search',
+ 'terminal.clear',
+ 'terminal.selectAll',
+ 'terminal.splitRight',
+ 'terminal.splitDown',
+ 'terminal.closePane',
+ 'worktree.history.back',
+ 'worktree.history.forward'
+])
+
export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean {
- return definition.scope === 'terminal' || definition.allowInTerminal === true
+ // Keep clipboard, input-source and pane navigation controls available.
+ return (
+ !TUI_CONFLICT_ACTIONS.has(definition.id) &&
+ (definition.scope === 'terminal' || definition.allowInTerminal === true)
+ )
}
export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean {
- return definition.scope !== 'terminal' && definition.allowInTerminal !== true
+ return !isKeybindingAllowedInTerminal(definition)
}
export function keybindingIsActiveInContext(
From 843607b1bcc3e17782de2ee18b8dc3d3aecb1f6f Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 22:13:26 -0700
Subject: [PATCH 05/11] Register supervised Qoder China and Qwen Code (#24616)
* Add Qoder session history and search with real CLI coverage
* Allow the real Qoder marker file to end with a newline
* Keep Qoder tool output out of history previews and search
* Keep Qoder search pages readable by older clients
* Verify persisted Qoder history after a real generated and resumed task
* Negotiate Qoder filters before searching an older execution host
* Combine search client imports for the CI plugin gate
* Keep the relay search oracle aligned with legacy agent filtering
* Register supervised Qoder China and Qwen lifecycle integration
* Cover Qoder China mobile assets and mixed-host resume gates
* Verify Qoder provider tags against the older released wire parser
* Verify China and Qwen keep independent Windows hook scripts
* Verify Qoder registrations against the installed older Windows release
* test(qoder): align search capability contracts and pin old-host fencing
* fix(qoder): rank exact picker identities and command aliases first
* test(qoder): preserve the regional CLI shared icon expectation
Keep the full bundled-asset and no-remote-image checks, with an explicit
shared-logo basename for Qoder China. The map also works with older
catalog type unions.
* fix(qoder): align China catalog entry with fallback order
---------
Co-authored-by: Orca Integration Recovery
---
config/tsconfig.cli.json | 1 +
.../components/mobile-agent-icon-assets.ts | 1 +
mobile/src/tasks/mobile-tui-agents.ts | 1 +
.../managed-agent-hook-registry.ts | 11 ++-
.../managed-hook-command-contract.test.ts | 33 +++++++-
...ote-hook-service-registry-coverage.test.ts | 5 +-
.../remote-managed-hook-installers.ts | 5 +-
.../server-retired-pane-new-turn.test.ts | 2 +
src/main/agent-workspace-trust-spawn.test.ts | 1 +
src/main/claude/hook-settings.ts | 10 ++-
src/main/claude/windows-hook-files.test.ts | 10 ++-
src/main/execution-host-workspace-trust.ts | 4 +
src/main/qoder/hook-service.test.ts | 52 +++++++++++-
src/main/qoder/hook-service.ts | 13 +++
src/main/qoder/workspace-trust.ts | 8 +-
src/main/qwen-code/hook-service.ts | 37 ++++++++
.../__fixtures__/qoder-cn-signin.meta.json | 15 ++++
.../runtime/__fixtures__/qoder-cn-signin.txt | 65 ++++++++++++++
.../__fixtures__/qoder-cn-startup.meta.json | 15 ++++
.../runtime/__fixtures__/qoder-cn-startup.txt | 65 ++++++++++++++
.../readiness-census/synthetic--qoder-cn.json | 79 ++++++++++++++++++
.../transcript--qoder-cn-signin@agent.json | 23 +++++
.../transcript--qoder-cn-signin@unknown.json | 25 ++++++
.../transcript--qoder-cn-startup@agent.json | 21 +++++
.../transcript--qoder-cn-startup@unknown.json | 23 +++++
src/main/runtime/qoder-transcripts.test.ts | 22 +++++
.../readiness-census-synthetic-matrix.ts | 1 +
.../readiness-census-transcript-catalog.ts | 1 +
src/main/runtime/terminal-wait-detection.ts | 2 +-
.../runtime/tui-idle-evidence-source.test.ts | 40 +++++++++
src/main/runtime/tui-idle-evidence-source.ts | 4 +-
src/main/runtime/tui-idle-evidence.ts | 2 +-
src/main/workspace-trust-test-fixtures.ts | 2 +
src/relay/agent-workspace-trust-spawn.test.ts | 6 +-
.../components/agent/AgentCombobox.test.tsx | 4 +-
src/renderer/src/i18n/locales/en.json | 3 +-
src/renderer/src/lib/agent-catalog.tsx | 12 ++-
src/renderer/src/lib/agent-favicon-assets.ts | 1 +
.../src/lib/agent-picker-search.test.ts | 35 +++++++-
src/renderer/src/lib/agent-picker-search.ts | 15 ++--
src/renderer/src/lib/agent-status.ts | 1 +
...t-resume-host-authority-capability.test.ts | 4 +
.../agent-resume-host-authority-capability.ts | 4 +
.../remote-agent-session-launch.test.ts | 4 +-
src/shared/agent-headless-command.ts | 1 +
.../agent-hook-listener/provider-dispatch.ts | 15 ++++
.../provider-event-routing.ts | 4 +
.../providers/compatible-lifecycle-events.ts | 2 +-
.../agent-hook-listener/source-routing.ts | 2 +
src/shared/agent-hook-relay.ts | 2 +
src/shared/agent-hook-types.ts | 2 +
src/shared/agent-icons/qoder-cn.png | Bin 0 -> 2416 bytes
src/shared/agent-kind.ts | 1 +
src/shared/agent-process-recognition.ts | 5 ++
...ent-session-resume-runtime-capabilities.ts | 33 ++++++++
src/shared/agent-session-resume.ts | 8 ++
src/shared/agent-title-identity.ts | 2 +-
src/shared/agent-title-status.ts | 3 +-
src/shared/protocol-version.ts | 38 ++++-----
src/shared/qoder-agent.test.ts | 43 ++++++++++
src/shared/qoder-cn-launch-recipe.test.ts | 49 +++++++++++
src/shared/skills-cli-agent-keys.ts | 1 +
src/shared/telemetry-property-schemas.ts | 1 +
src/shared/terminal-title-agent-type.ts | 4 +-
src/shared/tui-agent-config-types.ts | 2 +-
src/shared/tui-agent-config.ts | 6 ++
src/shared/tui-agent-display-names.ts | 1 +
src/shared/tui-agent-permissions.ts | 1 +
src/shared/tui-agent-rest-signal.test.ts | 1 +
src/shared/tui-agent-rest-signal.ts | 3 +-
src/shared/tui-agent-selection.ts | 1 +
src/shared/tui-agent.ts | 1 +
.../qoder-registration-downgrade.unit.test.ts | 46 ++++++++++
73 files changed, 903 insertions(+), 63 deletions(-)
create mode 100644 src/main/qwen-code/hook-service.ts
create mode 100644 src/main/runtime/__fixtures__/qoder-cn-signin.meta.json
create mode 100644 src/main/runtime/__fixtures__/qoder-cn-signin.txt
create mode 100644 src/main/runtime/__fixtures__/qoder-cn-startup.meta.json
create mode 100644 src/main/runtime/__fixtures__/qoder-cn-startup.txt
create mode 100644 src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json
create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json
create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json
create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json
create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json
create mode 100644 src/main/runtime/tui-idle-evidence-source.test.ts
create mode 100644 src/shared/agent-icons/qoder-cn.png
create mode 100644 src/shared/agent-session-resume-runtime-capabilities.ts
create mode 100644 src/shared/qoder-cn-launch-recipe.test.ts
create mode 100644 tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 2ff9a26c5696..fd89c22f66c4 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -9,6 +9,7 @@
"../src/main/claude/claude-hook-event-versions.ts",
"../src/main/claude/claude-managed-hook-events.ts",
"../src/main/qoder/hook-service.ts",
+ "../src/main/qwen-code/hook-service.ts",
"../src/main/codebuddy/hook-service.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts
index 53745ead77e6..49f27df69900 100644
--- a/mobile/src/components/mobile-agent-icon-assets.ts
+++ b/mobile/src/components/mobile-agent-icon-assets.ts
@@ -20,6 +20,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial>
omp: 'omp.sh',
'prime-agent': 'primeintellect.ai',
qoder: 'qoder.com',
+ 'qoder-cn': 'qoder.cn',
gemini: 'gemini.google.com',
antigravity: 'antigravity.google',
goose: 'goose-docs.ai',
diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts
index 11b642ee0900..ade721253124 100644
--- a/src/main/agent-hooks/managed-agent-hook-registry.ts
+++ b/src/main/agent-hooks/managed-agent-hook-registry.ts
@@ -1,5 +1,6 @@
import { codebuddyHookService } from '../codebuddy/hook-service'
-import { qoderHookService } from '../qoder/hook-service'
+import { qwenCodeHookService } from '../qwen-code/hook-service'
+import { qoderCnHookService, qoderHookService } from '../qoder/hook-service'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { ampHookService } from '../amp/hook-service'
@@ -47,6 +48,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['qoder', () => qoderHookService.install()],
+ ['qoder-cn', () => qoderCnHookService.install()],
+ ['qwen-code', () => qwenCodeHookService.install()],
['codebuddy', () => codebuddyHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
@@ -75,6 +78,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
['codex', () => codexHookService.refreshManagedScripts()],
['gemini', () => geminiHookService.refreshManagedScripts()],
['qoder', () => qoderHookService.refreshManagedScripts()],
+ ['qoder-cn', () => qoderCnHookService.refreshManagedScripts()],
+ ['qwen-code', () => qwenCodeHookService.refreshManagedScripts()],
['codebuddy', () => codebuddyHookService.refreshManagedScripts()],
['antigravity', () => antigravityHookService.refreshManagedScripts()],
['cursor', () => cursorHookService.refreshManagedScripts()],
@@ -95,6 +100,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['qoder', () => qoderHookService.remove()],
+ ['qoder-cn', () => qoderCnHookService.remove()],
+ ['qwen-code', () => qwenCodeHookService.remove()],
['codebuddy', () => codebuddyHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
@@ -121,6 +128,8 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['qoder', () => qoderHookService.getStatus()],
+ ['qoder-cn', () => qoderCnHookService.getStatus()],
+ ['qwen-code', () => qwenCodeHookService.getStatus()],
['codebuddy', () => codebuddyHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts
index 247c50e7f903..c38c30ac77b5 100644
--- a/src/main/agent-hooks/managed-hook-command-contract.test.ts
+++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts
@@ -92,6 +92,34 @@ const buildersByAgent = new Map([
remote: (path) => [getClaudeRemoteCommand(path)]
}
],
+ [
+ 'qoder-cn',
+ {
+ local: (path) => [
+ getManagedLifecycleHook(path, {
+ configDirName: '.qoder-cn',
+ scriptBaseName: 'qoder-cn-hook',
+ usesWindowsCompatLauncher: true,
+ windowsHookShell: 'powershell'
+ }).command
+ ],
+ remote: (path) => [getClaudeRemoteCommand(path)]
+ }
+ ],
+ [
+ 'qwen-code',
+ {
+ local: (path) => [
+ getManagedLifecycleHook(path, {
+ configDirName: '.qwen',
+ scriptBaseName: 'qwen-code-hook',
+ usesWindowsCompatLauncher: true,
+ windowsHookShell: 'powershell'
+ }).command
+ ],
+ remote: (path) => [getClaudeRemoteCommand(path)]
+ }
+ ],
[
'codebuddy',
{
@@ -233,7 +261,10 @@ describe('managed hook command contract', () => {
// Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner.
const scannedCommand =
platform === 'win32' &&
- (agent === 'qoder' || agent === 'codebuddy') &&
+ (agent === 'qoder' ||
+ agent === 'qoder-cn' ||
+ agent === 'qwen-code' ||
+ agent === 'codebuddy') &&
command.startsWith('$scriptPath = Join-Path')
? command
.replaceAll('$LASTEXITCODE', '')
diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts
index dea6c4a46634..1fb3dc75ee88 100644
--- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts
+++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts
@@ -23,7 +23,8 @@ import { kimiHookService } from '../kimi/hook-service'
import { dshHookService } from '../dsh/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
-import { qoderHookService } from '../qoder/hook-service'
+import { qwenCodeHookService } from '../qwen-code/hook-service'
+import { qoderCnHookService, qoderHookService } from '../qoder/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers'
@@ -54,6 +55,8 @@ describe('remote hook service registry coverage', () => {
['kimi', kimiHookService],
['muse', museHookService],
['qoder', qoderHookService],
+ ['qoder-cn', qoderCnHookService],
+ ['qwen-code', qwenCodeHookService],
['codebuddy', codebuddyHookService],
['zcode', zcodeHookService],
['dsh', dshHookService]
diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts
index b012af91ff35..a8367f64f19b 100644
--- a/src/main/agent-hooks/remote-managed-hook-installers.ts
+++ b/src/main/agent-hooks/remote-managed-hook-installers.ts
@@ -1,5 +1,6 @@
import { codebuddyHookService } from '../codebuddy/hook-service'
-import { qoderHookService } from '../qoder/hook-service'
+import { qwenCodeHookService } from '../qwen-code/hook-service'
+import { qoderCnHookService, qoderHookService } from '../qoder/hook-service'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import { ampHookService } from '../amp/hook-service'
@@ -65,6 +66,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['qoder', (sftp, remoteHome) => qoderHookService.installRemote(sftp, remoteHome)],
+ ['qoder-cn', (sftp, remoteHome) => qoderCnHookService.installRemote(sftp, remoteHome)],
+ ['qwen-code', (sftp, remoteHome) => qwenCodeHookService.installRemote(sftp, remoteHome)],
['codebuddy', (sftp, remoteHome) => codebuddyHookService.installRemote(sftp, remoteHome)],
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts
index 23dbb56c6956..30f61a0238f8 100644
--- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts
+++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts
@@ -23,6 +23,8 @@ afterEach(() => vi.restoreAllMocks())
* mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */
const NEW_TURN_EVENT: Record = {
qoder: 'SessionStart',
+ 'qoder-cn': 'SessionStart',
+ 'qwen-code': 'SessionStart',
claude: 'SessionStart',
kimi: 'UserPromptSubmit',
codebuddy: 'UserPromptSubmit',
diff --git a/src/main/agent-workspace-trust-spawn.test.ts b/src/main/agent-workspace-trust-spawn.test.ts
index 17204bd0f5d8..b87d58ff855c 100644
--- a/src/main/agent-workspace-trust-spawn.test.ts
+++ b/src/main/agent-workspace-trust-spawn.test.ts
@@ -47,6 +47,7 @@ describe('applyAgentWorkspaceTrustToSpawn', () => {
'cursor',
'copilot',
'qoder',
+ 'qoder-cn',
'antigravity'
])
)
diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts
index bb89cf388201..40449344f20e 100644
--- a/src/main/claude/hook-settings.ts
+++ b/src/main/claude/hook-settings.ts
@@ -18,8 +18,14 @@ import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-c
import type { ClaudeManagedHookPlan } from './claude-managed-hook-events'
export type ClaudeCompatibleHookSettings = {
- configDirName: '.claude' | '.openclaude' | '.qoder' | '.codebuddy'
- scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' | 'codebuddy-hook'
+ configDirName: '.claude' | '.openclaude' | '.qoder' | '.qoder-cn' | '.qwen' | '.codebuddy'
+ scriptBaseName:
+ | 'claude-hook'
+ | 'openclaude-hook'
+ | 'qoder-hook'
+ | 'qoder-cn-hook'
+ | 'qwen-code-hook'
+ | 'codebuddy-hook'
usesWindowsCompatLauncher: boolean
windowsHookShell?: 'powershell'
}
diff --git a/src/main/claude/windows-hook-files.test.ts b/src/main/claude/windows-hook-files.test.ts
index a3d369e75ec4..25bae132e16d 100644
--- a/src/main/claude/windows-hook-files.test.ts
+++ b/src/main/claude/windows-hook-files.test.ts
@@ -9,7 +9,8 @@ import { ClaudeHookService } from './hook-service'
import { getWindowsManagedLifecycleHook } from './hook-settings'
import { getWindowsClaudeHookEntry, getWindowsClaudeHookPayloadPath } from './windows-hook-files'
import { codebuddyHookService } from '../codebuddy/hook-service'
-import { qoderHookService } from '../qoder/hook-service'
+import { qoderHookService, qoderCnHookService } from '../qoder/hook-service'
+import { qwenCodeHookService } from '../qwen-code/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
const { home } = vi.hoisted(() => ({ home: { path: '' } }))
@@ -143,15 +144,16 @@ describe('Windows Claude hook files', () => {
it('keeps compatible agents on their existing single-file scripts', async () => {
for (const [name, compatible] of [
['qoder', qoderHookService],
+ ['qoder-cn', qoderCnHookService],
+ ['qwen-code', qwenCodeHookService],
['codebuddy', codebuddyHookService],
['openclaude', openClaudeHookService]
] as const) {
expect(compatible.install().state).toBe('installed')
await compatible.refreshManagedScripts()
const path = join(home.path, '.orca', 'agent-hooks', `${name}-hook.cmd`)
- expect(readFileSync(path, 'utf8')).toContain(
- `/hook/${name === 'openclaude' ? 'claude' : name}`
- )
+ const source = name === 'openclaude' ? 'claude' : name
+ expect(readFileSync(path, 'utf8')).toContain(`/hook/${source}`)
expect(readFileSync(path, 'utf8')).not.toContain('claude-hook-impl.cmd')
expect(existsSync(join(home.path, '.orca', 'agent-hooks', `${name}-hook-impl.cmd`))).toBe(
false
diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts
index 52562e68064c..acb84ea7ef1d 100644
--- a/src/main/execution-host-workspace-trust.ts
+++ b/src/main/execution-host-workspace-trust.ts
@@ -52,6 +52,7 @@ export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record
cursor: false,
copilot: true,
qoder: true,
+ 'qoder-cn': true,
antigravity: false
}
@@ -66,6 +67,7 @@ export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record ({
vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } }))
import { getManagedLifecycleHook, hasSameManagedHookInvocation } from '../claude/hook-settings'
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
-import { qoderHookService, QODER_HOOK_EVENTS } from './hook-service'
+import { qoderCnHookService, qoderHookService, QODER_HOOK_EVENTS } from './hook-service'
import { markQoderWorkspaceTrusted, withQoderTrustedWorkspace } from './workspace-trust'
+import { qwenCodeHookService, QWEN_CODE_HOOK_EVENTS } from '../qwen-code/hook-service'
beforeAll(() => {
sandbox.home = mkdtempSync(join(tmpdir(), 'orca-qoder-test-'))
@@ -96,3 +97,52 @@ describe('Qoder Windows hook shell', () => {
}
})
})
+
+it.each([
+ {
+ service: qoderCnHookService,
+ directory: '.qoder-cn',
+ source: 'qoder-cn',
+ events: QODER_HOOK_EVENTS
+ },
+ {
+ service: qwenCodeHookService,
+ directory: '.qwen',
+ source: 'qwen-code',
+ events: QWEN_CODE_HOOK_EVENTS
+ }
+])(
+ 'installs and removes $source hooks without changing user settings',
+ ({ service, directory, source, events }) => {
+ const configDir = join(sandbox.home, directory)
+ mkdirSync(configDir, { recursive: true })
+ const path = join(configDir, 'settings.json')
+ const userHook = { matcher: '', hooks: [{ type: 'command', command: 'echo user-owned' }] }
+ writeFileSync(
+ path,
+ JSON.stringify({
+ model: 'custom',
+ statusLine: { command: 'user-status' },
+ hooks: { Stop: [userHook] }
+ })
+ )
+ expect(service.install().state).toBe('installed')
+ const installed = JSON.parse(readFileSync(path, 'utf8'))
+ expect(Object.keys(installed.hooks).sort()).toEqual([...events].sort())
+ expect(installed.statusLine).toEqual({ command: 'user-status' })
+ expect(
+ readFileSync(join(sandbox.home, '.orca', 'agent-hooks', `${source}-hook.sh`), 'utf8')
+ ).toContain(`/hook/${source}`)
+ if (source === 'qoder-cn') {
+ markQoderWorkspaceTrusted('/cn-workspace', sandbox.home, '.qoder-cn')
+ expect(JSON.parse(readFileSync(path, 'utf8')).permissions.trustDirectories).toEqual([
+ '/cn-workspace'
+ ])
+ }
+ expect(service.remove().state).toBe('not_installed')
+ expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({
+ model: 'custom',
+ hooks: { Stop: [userHook] }
+ })
+ }
+)
diff --git a/src/main/qoder/hook-service.ts b/src/main/qoder/hook-service.ts
index ac9e869638c8..d69d6d656c7c 100644
--- a/src/main/qoder/hook-service.ts
+++ b/src/main/qoder/hook-service.ts
@@ -36,3 +36,16 @@ export const qoderHookService = new ClaudeHookService({
},
hookPlan: QODER_MANAGED_HOOK_PLAN
})
+
+export const qoderCnHookService = new ClaudeHookService({
+ agent: 'qoder-cn',
+ source: 'qoder-cn',
+ displayName: 'Qoder CLI China',
+ settings: {
+ configDirName: '.qoder-cn',
+ scriptBaseName: 'qoder-cn-hook',
+ usesWindowsCompatLauncher: true,
+ windowsHookShell: 'powershell'
+ },
+ hookPlan: QODER_MANAGED_HOOK_PLAN
+})
diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts
index bb11aaebfcfb..043b35270235 100644
--- a/src/main/qoder/workspace-trust.ts
+++ b/src/main/qoder/workspace-trust.ts
@@ -24,14 +24,18 @@ export function withQoderTrustedWorkspace(
}
}
-export function markQoderWorkspaceTrusted(workspacePath: string, home: string): void {
+export function markQoderWorkspaceTrusted(
+ workspacePath: string,
+ home: string,
+ configDirName: '.qoder' | '.qoder-cn' = '.qoder'
+): void {
let canonicalPath = workspacePath
try {
canonicalPath = realpathSync.native(workspacePath)
} catch {
/* Keep the supplied path when absent. */
}
- const configPath = join(home, '.qoder', 'settings.json')
+ const configPath = join(home, configDirName, 'settings.json')
const config = readHooksJson(configPath)
if (!config) {
return
diff --git a/src/main/qwen-code/hook-service.ts b/src/main/qwen-code/hook-service.ts
new file mode 100644
index 000000000000..7488271380e5
--- /dev/null
+++ b/src/main/qwen-code/hook-service.ts
@@ -0,0 +1,37 @@
+import { ClaudeHookService } from '../claude/hook-service'
+import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events'
+
+// Qwen's hook enum is authoritative; Claude's version table and statusline do not apply.
+export const QWEN_CODE_HOOK_EVENTS = [
+ 'SessionStart',
+ 'SessionEnd',
+ 'UserPromptSubmit',
+ 'PreToolUse',
+ 'PostToolUse',
+ 'PostToolUseFailure',
+ 'PermissionRequest',
+ 'Stop',
+ 'StopFailure',
+ 'Notification',
+ 'SubagentStart',
+ 'SubagentStop'
+] as const
+
+export const QWEN_CODE_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = {
+ install: QWEN_CODE_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })),
+ retire: [],
+ statusLine: 'leave'
+}
+
+export const qwenCodeHookService = new ClaudeHookService({
+ agent: 'qwen-code',
+ source: 'qwen-code',
+ displayName: 'Qwen Code',
+ settings: {
+ configDirName: '.qwen',
+ scriptBaseName: 'qwen-code-hook',
+ usesWindowsCompatLauncher: true,
+ windowsHookShell: 'powershell'
+ },
+ hookPlan: QWEN_CODE_MANAGED_HOOK_PLAN
+})
diff --git a/src/main/runtime/__fixtures__/qoder-cn-signin.meta.json b/src/main/runtime/__fixtures__/qoder-cn-signin.meta.json
new file mode 100644
index 000000000000..81ab5e8f43a0
--- /dev/null
+++ b/src/main/runtime/__fixtures__/qoder-cn-signin.meta.json
@@ -0,0 +1,15 @@
+{
+ "capturedAt": "2026-10-02T07:27:56.152Z",
+ "platform": "darwin",
+ "command": [
+ "qoderclicn",
+ "--config-dir",
+ "/tmp/orca-qoder-validation-1002/cn-config",
+ "--cwd",
+ "/tmp/orca-qoder-validation-1002/workspace"
+ ],
+ "cols": 120,
+ "rows": 40,
+ "note": "Qoder CLI China 1.1.65; accepted trust in isolated config; sign-in dialog; no China account",
+ "exitCode": 129
+}
diff --git a/src/main/runtime/__fixtures__/qoder-cn-signin.txt b/src/main/runtime/__fixtures__/qoder-cn-signin.txt
new file mode 100644
index 000000000000..81c7dd7fcd53
--- /dev/null
+++ b/src/main/runtime/__fixtures__/qoder-cn-signin.txt
@@ -0,0 +1,65 @@
+[8m[?u]11;?\[>q[>4;?m[c[2K
[0m]0;◇ Qoder CLI CN | Ready ]8;;[0m7[r8[J
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[H[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI CN | Ready [?2004h[?2004h[?1004h[0m
+[2G[38;5;78m [39m[4G[38;5;78m██████[39m[39G╭─[42G[38;5;78m[1mTips[22m[39m[47G[38;5;78m[1mfor[22m[39m[51G[38;5;78m[1mgetting[22m[39m[59G[38;5;78m[1mstarted[22m[39m[67G───────────────────────────────────╮
+[2G[38;5;78m██[39m[10G[38;5;78m██[39m[39G│[41G1.[44GUse[48GAGENTS.md[58Gfiles[64Gto[67Gcustomize[77Ginteractions[102G│
+[2G[38;5;78m██[39m[6G[38;5;78m██[39m[10G[38;5;78m██[39m[14G[1mQoder[22m[20G[1mCLI[22m[24G[48;5;78m[38;5;16m[1m CN [22m[39m[49m[29Gv1.1.65[39G│[41G2.[44G/help[50Gfor[54Gmore[59Ginformation[102G│
+[2G[38;5;78m██[39m[8G[38;5;78m██[39m[39G│[41G3.[44GAsk[48Gcoding[55Gquestions,[66Gedit[71Gcode[76Gor[79Grun[83Gcommands[102G│
+[2G[38;5;78m [39m[4G[38;5;78m████[39m[10G[38;5;78m██[39m[14G[1mNot[22m[18G[1mLogin[22m[24GPlease[31GAuth[39G│[41G4.[44GBe[47Gspecific[56Gfor[60Gthe[64Gbest[69Gresults[102G│
+[39G╰──────────────────────────────────────────────────────────────╯
+
+
+[1m Do[22m[5G[1myou[22m[9G[1mtrust[22m[15G[1mthe[22m[19G[1mfiles[22m[25G[1min[22m[28G[1mthis[22m[33G[1mfolder?[22m
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+[2GPlease[9Gconfirm[17Gthis[22Gis[25Gyour[30Gown[34Gproject[42Gor[45Gfrom[50Ga[52Gtrusted[60Gsource.[68GOnce[73Gtrusted,[82GQoder[88GCLI[92GCN[95Gcan[99Gread,[105Gmodify,[113Gand
+[2Gexecute[10Gfiles[16Ghere,[22Gand[26Gwill[31Gload[36Glocal[42Gconfigurations[57G(commands,[68Ghooks,[75GMCP[79Gservers,[88Gagents,[96Gskills,[104Gsettings).
+
+[2G[1m/private/tmp/orca-qoder-validation-1002/workspace[22m
+
+[3G❯[5G1.[8GTrust[14Gfolder
+[5G2.[8GDon't[14Gtrust[20Gand[24Gexit
+
+[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m
+[0m[>0q[c[0m
[11A ● Warning: True color (24-bit) support[41Gnot[45Gdetected.[55GUsing[61Ga[63Gterminal[72Gwith[77Gtrue[82Gcolor[88Genabled[96Gwill[101Gresult[108Gin[111Ga[113Gbetter
[1B visual experience.[K
[1C[1B[K
[1B[1m [22m[1mD[22m[1mo[22m[1m [22m[1my[22m[1mo[22m[1mu[22m[1m [22m[1mt[22m[1mr[22m[1mu[22m[1ms[22m[1mt[22m[1m [22m[1mt[22m[1mh[22m[1me[22m[1m [22m[1mf[22m[1mi[22m[1ml[22m[1me[22m[1ms[22m[1m [22m[1mi[22m[1mn[22m[1m [22m[1mt[22m[1mh[22m[1mi[22m[1ms[22m[1m [22m[1mf[22m[1mo[22m[1ml[22m[1md[22m[1me[22m[1mr[22m[1m?[22m[K
[1B────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
[1C[1BPlease confirm this is your own project or from a[52Gtrusted[60Gsource.[68GOnce[73Gtrusted,[82GQoder[88GCLI[92GCN[95Gcan[99Gread,[105Gmodify,[113Gand
[1C[1Bexecute[10Gfiles[16Ghere,[22Gand[26Gwill[31Gload[36Glocal[42Gconfigurations[57G(commands,[68Ghooks,[75GMCP[79Gservers,[88Gagents,[96Gskills,[104Gsettings).
[2C[1B[K
[1C[1B[1m/[22m[1mp[22m[1mr[22m[1mi[22m[1mv[22m[1ma[22m[1mt[22m[1me[22m[1m/[22m[1mt[22m[1mm[22m[1mp[22m[1m/[22m[1mo[22m[1mr[22m[1mc[22m[1ma[22m[1m-[22m[1mq[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[1m-[22m[1mv[22m[1ma[22m[1ml[22m[1mi[22m[1md[22m[1ma[22m[1mt[22m[1mi[22m[1mo[22m[1mn[22m[1m-[22m[1m1[22m[1m0[22m[1m0[22m[1m2[22m[1m/[22m[1mw[22m[1mo[22m[1mr[22m[1mk[22m[1ms[22m[1mp[22m[1ma[22m[1mc[22m[1me[22m
[1C[2B ❯ 1. Trust folder[K
+[5G2.[8GDon't[14Gtrust[20Gand[24Gexit
+
+[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m
+[0m[0m[2K[1A[2K[1A[2K[G[1A
[1C[8A[1mW[22m[1me[22m[1ml[22m[1mc[22m[7G[1mm[22m[1me[22m[1m [22m[1mt[22m[1mo[22m[1m [22m[1mQ[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[19G[1mC[22m[1mL[22m[1mI[22m[1m [22m[1mC[22m[1mN[22m[K
[1C[2BSign in to get[17Gstarted, [27Gr exit[34Gthe applicati[48Gn.[K
[1C[1B[K
[2C[1B❯[5G1.[8GSign[13Gin[16Gto[19Gcontinue
[1C[1B 2. Exit the application[K
[1C[2B[3m↑[23m[3m/[23m[3m↓[23m[3m [23m[3mn[23m[3ma[23m[3mv[23m[3mi[23m[3mg[23m[3ma[23m[3mt[23m[3me[23m[3m [23m[3m·[23m[3m [23m[3mE[23m[3mn[23m[3mt[23m[3me[23m[3mr[23m[3m [23m[3ms[23m[3me[23m[3ml[23m[3me[23m[3mc[23m[3mt[23m[3m [23m[3m·[23m[3m [23m[3mE[23m[3ms[23m[3mc[23m[3m [23m[3mb[23m[3ma[23m[3mc[23m[3mk[23m
[1B[K
[1B[K
[1B[K
[2A[0m
\ No newline at end of file
diff --git a/src/main/runtime/__fixtures__/qoder-cn-startup.meta.json b/src/main/runtime/__fixtures__/qoder-cn-startup.meta.json
new file mode 100644
index 000000000000..178f7bb8851b
--- /dev/null
+++ b/src/main/runtime/__fixtures__/qoder-cn-startup.meta.json
@@ -0,0 +1,15 @@
+{
+ "capturedAt": "2026-10-02T07:26:38.777Z",
+ "platform": "darwin",
+ "command": [
+ "qoderclicn",
+ "--config-dir",
+ "/tmp/orca-qoder-validation-1002/cn-config",
+ "--cwd",
+ "/tmp/orca-qoder-validation-1002/workspace"
+ ],
+ "cols": 120,
+ "rows": 40,
+ "note": "Qoder CLI China 1.1.65; isolated config; no China account",
+ "exitCode": 129
+}
diff --git a/src/main/runtime/__fixtures__/qoder-cn-startup.txt b/src/main/runtime/__fixtures__/qoder-cn-startup.txt
new file mode 100644
index 000000000000..454d7fa86106
--- /dev/null
+++ b/src/main/runtime/__fixtures__/qoder-cn-startup.txt
@@ -0,0 +1,65 @@
+[8m[?u]11;?\[>q[>4;?m[c[2K
[0m]0;◇ Qoder CLI CN | Ready ]8;;[0m7[r8[J
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[H[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI CN | Ready [?2004h[?2004h[?1004h[0m
+[2G[38;5;78m [39m[4G[38;5;78m██████[39m[39G╭─[42G[38;5;78m[1mTips[22m[39m[47G[38;5;78m[1mfor[22m[39m[51G[38;5;78m[1mgetting[22m[39m[59G[38;5;78m[1mstarted[22m[39m[67G───────────────────────────────────╮
+[2G[38;5;78m██[39m[10G[38;5;78m██[39m[39G│[41G1.[44GUse[48GAGENTS.md[58Gfiles[64Gto[67Gcustomize[77Ginteractions[102G│
+[2G[38;5;78m██[39m[6G[38;5;78m██[39m[10G[38;5;78m██[39m[14G[1mQoder[22m[20G[1mCLI[22m[24G[48;5;78m[38;5;16m[1m CN [22m[39m[49m[29Gv1.1.65[39G│[41G2.[44G/help[50Gfor[54Gmore[59Ginformation[102G│
+[2G[38;5;78m██[39m[8G[38;5;78m██[39m[39G│[41G3.[44GAsk[48Gcoding[55Gquestions,[66Gedit[71Gcode[76Gor[79Grun[83Gcommands[102G│
+[2G[38;5;78m [39m[4G[38;5;78m████[39m[10G[38;5;78m██[39m[14G[1mNot[22m[18G[1mLogin[22m[24GPlease[31GAuth[39G│[41G4.[44GBe[47Gspecific[56Gfor[60Gthe[64Gbest[69Gresults[102G│
+[39G╰──────────────────────────────────────────────────────────────╯
+
+
+[1m Do[22m[5G[1myou[22m[9G[1mtrust[22m[15G[1mthe[22m[19G[1mfiles[22m[25G[1min[22m[28G[1mthis[22m[33G[1mfolder?[22m
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+[2GPlease[9Gconfirm[17Gthis[22Gis[25Gyour[30Gown[34Gproject[42Gor[45Gfrom[50Ga[52Gtrusted[60Gsource.[68GOnce[73Gtrusted,[82GQoder[88GCLI[92GCN[95Gcan[99Gread,[105Gmodify,[113Gand
+[2Gexecute[10Gfiles[16Ghere,[22Gand[26Gwill[31Gload[36Glocal[42Gconfigurations[57G(commands,[68Ghooks,[75GMCP[79Gservers,[88Gagents,[96Gskills,[104Gsettings).
+
+[2G[1m/private/tmp/orca-qoder-validation-1002/workspace[22m
+
+[3G❯[5G1.[8GTrust[14Gfolder
+[5G2.[8GDon't[14Gtrust[20Gand[24Gexit
+
+[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m
+[0m[>0q[c[0m
[11A ● Warning: True color (24-bit) support[41Gnot[45Gdetected.[55GUsing[61Ga[63Gterminal[72Gwith[77Gtrue[82Gcolor[88Genabled[96Gwill[101Gresult[108Gin[111Ga[113Gbetter
[1B visual experience.[K
[1C[1B[K
[1B[1m [22m[1mD[22m[1mo[22m[1m [22m[1my[22m[1mo[22m[1mu[22m[1m [22m[1mt[22m[1mr[22m[1mu[22m[1ms[22m[1mt[22m[1m [22m[1mt[22m[1mh[22m[1me[22m[1m [22m[1mf[22m[1mi[22m[1ml[22m[1me[22m[1ms[22m[1m [22m[1mi[22m[1mn[22m[1m [22m[1mt[22m[1mh[22m[1mi[22m[1ms[22m[1m [22m[1mf[22m[1mo[22m[1ml[22m[1md[22m[1me[22m[1mr[22m[1m?[22m[K
[1B────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
[1C[1BPlease confirm this is your own project or from a[52Gtrusted[60Gsource.[68GOnce[73Gtrusted,[82GQoder[88GCLI[92GCN[95Gcan[99Gread,[105Gmodify,[113Gand
[1C[1Bexecute[10Gfiles[16Ghere,[22Gand[26Gwill[31Gload[36Glocal[42Gconfigurations[57G(commands,[68Ghooks,[75GMCP[79Gservers,[88Gagents,[96Gskills,[104Gsettings).
[2C[1B[K
[1C[1B[1m/[22m[1mp[22m[1mr[22m[1mi[22m[1mv[22m[1ma[22m[1mt[22m[1me[22m[1m/[22m[1mt[22m[1mm[22m[1mp[22m[1m/[22m[1mo[22m[1mr[22m[1mc[22m[1ma[22m[1m-[22m[1mq[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[1m-[22m[1mv[22m[1ma[22m[1ml[22m[1mi[22m[1md[22m[1ma[22m[1mt[22m[1mi[22m[1mo[22m[1mn[22m[1m-[22m[1m1[22m[1m0[22m[1m0[22m[1m2[22m[1m/[22m[1mw[22m[1mo[22m[1mr[22m[1mk[22m[1ms[22m[1mp[22m[1ma[22m[1mc[22m[1me[22m
[1C[2B ❯ 1. Trust folder[K
+[5G2.[8GDon't[14Gtrust[20Gand[24Gexit
+
+[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m
+[0m
\ No newline at end of file
diff --git a/src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json b/src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json
new file mode 100644
index 000000000000..210126a102cc
--- /dev/null
+++ b/src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json
@@ -0,0 +1,79 @@
+{
+ "description": "qoder-cn: title x first-party status on a painted screen; screen x foreground under the titles that leave the low lanes open; dialog order x title. Each read clocked and clockless.",
+ "cases": {
+ "title=working-spinner status=none screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=none screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=working-spinner status=done-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=done-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=working-spinner status=done-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=done-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=working-spinner status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=working-spinner status=working-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=working-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=working-spinner status=blocked-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=working-spinner status=blocked-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=working-spinner status=blocked-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=name-only status=none screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=none screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=done-fresh screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=done-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=done-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=done-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=name-only status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=name-only status=working-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=working-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=blocked-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=name-only status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=name-only status=blocked-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=blocked-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=none screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=none screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=done-fresh screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=done-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=done-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=done-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=none status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=none status=working-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=working-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=blocked-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending",
+ "title=none status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending",
+ "title=none status=blocked-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=blocked-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=none screen=present fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=none screen=present fg=shell clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=none screen=untrusted fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=none screen=untrusted fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=none screen=untrusted fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=none screen=untrusted fg=shell clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=none screen=absent fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=none screen=absent fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=name-only status=none screen=absent fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=name-only status=none screen=absent fg=shell clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=none screen=present fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=none screen=present fg=shell clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=none screen=untrusted fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=none screen=untrusted fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=none screen=untrusted fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=none screen=untrusted fg=shell clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=none screen=absent fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=none screen=absent fg=agent clock=clockless": "verdict=pending:closed wait=pending",
+ "title=none status=none screen=absent fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "title=none status=none screen=absent fg=shell clock=clockless": "verdict=pending:closed wait=pending",
+ "title=working-spinner status=none screen=dialog-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=working-spinner status=none screen=dialog-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=working-spinner status=none screen=ready-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=working-spinner status=none screen=ready-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=name-only status=none screen=dialog-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=name-only status=none screen=dialog-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=name-only status=none screen=ready-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=name-only status=none screen=ready-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=none status=none screen=dialog-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=none status=none screen=dialog-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=none status=none screen=ready-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "title=none status=none screen=ready-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start"
+ }
+}
diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json
new file mode 100644
index 000000000000..1234da5ab9f7
--- /dev/null
+++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json
@@ -0,0 +1,23 @@
+{
+ "description": "qoder-cn recording at 120x40 replayed on the agent pane, one entry per chunk",
+ "observations": {
+ "clocked": [
+ "0-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-65: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "66-79: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending"
+ ],
+ "clockless": [
+ "0-23: verdict=pending:closed wait=pending",
+ "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: verdict=pending:closed wait=pending",
+ "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-65: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "66-79: verdict=pending:closed wait=pending"
+ ]
+ }
+}
diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json
new file mode 100644
index 000000000000..2950beff30d5
--- /dev/null
+++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json
@@ -0,0 +1,25 @@
+{
+ "description": "qoder-cn recording at 120x40 replayed on the unknown pane, one entry per chunk",
+ "observations": {
+ "clocked": [
+ "0: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
+ "1-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-65: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "66-79: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending"
+ ],
+ "clockless": [
+ "0: verdict=pending:open wait=ready@poll",
+ "1-23: verdict=pending:closed wait=pending",
+ "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: verdict=pending:closed wait=pending",
+ "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-65: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "66-79: verdict=pending:closed wait=pending"
+ ]
+ }
+}
diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json
new file mode 100644
index 000000000000..73efdc1ed133
--- /dev/null
+++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json
@@ -0,0 +1,21 @@
+{
+ "description": "qoder-cn recording at 120x40 replayed on the agent pane, one entry per chunk",
+ "observations": {
+ "clocked": [
+ "0-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-66: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll"
+ ],
+ "clockless": [
+ "0-23: verdict=pending:closed wait=pending",
+ "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: verdict=pending:closed wait=pending",
+ "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-66: verdict=pending:closed wait=blocked:agent-trust-workspace@poll"
+ ]
+ }
+}
diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json
new file mode 100644
index 000000000000..ac41c374b386
--- /dev/null
+++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json
@@ -0,0 +1,23 @@
+{
+ "description": "qoder-cn recording at 120x40 replayed on the unknown pane, one entry per chunk",
+ "observations": {
+ "clocked": [
+ "0: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
+ "1-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
+ "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-66: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll"
+ ],
+ "clockless": [
+ "0: verdict=pending:open wait=ready@poll",
+ "1-23: verdict=pending:closed wait=pending",
+ "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "37-41: verdict=pending:closed wait=pending",
+ "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll",
+ "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start",
+ "47-66: verdict=pending:closed wait=blocked:agent-trust-workspace@poll"
+ ]
+ }
+}
diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts
index 89c5881382ad..cc44c70e5cab 100644
--- a/src/main/runtime/qoder-transcripts.test.ts
+++ b/src/main/runtime/qoder-transcripts.test.ts
@@ -48,3 +48,25 @@ describe('captured Qoder 1.1.64 startup', () => {
}
)
})
+
+describe('captured Qoder China 1.1.65 startup', () => {
+ it.each(['qoder-cn-startup', 'qoder-cn-signin'])('checks the composer in %s', async (fixture) => {
+ const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8')
+ const title = extractLastOscTitle(
+ data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '')
+ )
+ expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN')
+ const { runtime, handle } = await createTranscriptPane({
+ paneTitle: title ?? '',
+ foregroundProcess: 'qoderclicn',
+ launchAgent: 'qoder-cn',
+ data,
+ size: { cols: 120, rows: 40 }
+ })
+ expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn')
+ const readiness = await runtime
+ .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 800 })
+ .catch(() => null)
+ expect(readiness?.satisfied ?? false).toBe(false)
+ })
+})
diff --git a/src/main/runtime/readiness-census-synthetic-matrix.ts b/src/main/runtime/readiness-census-synthetic-matrix.ts
index 0d555565e270..2273ba080e45 100644
--- a/src/main/runtime/readiness-census-synthetic-matrix.ts
+++ b/src/main/runtime/readiness-census-synthetic-matrix.ts
@@ -95,6 +95,7 @@ const READY_SCREEN_FIXTURES: Partial<
codex: { name: 'codex-0157-plain-ready', cols: 120, rows: 40 },
muse: { name: 'muse-empty-folder-ready', cols: 120, rows: 32 },
qoder: { name: 'qoder-ready', cols: 100, rows: 32 },
+ 'qoder-cn': { name: 'qoder-cn-signin', cols: 120, rows: 40 },
cursor: { name: 'cursor-agent-idle-after-approval', cols: 80, rows: 24 }
}
diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts
index 3ba056ef1e60..3b17119050f8 100644
--- a/src/main/runtime/readiness-census-transcript-catalog.ts
+++ b/src/main/runtime/readiness-census-transcript-catalog.ts
@@ -44,6 +44,7 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [
['muse-', { agent: 'muse', foregroundProcess: 'muse' }],
['omp-', { agent: 'omp', foregroundProcess: 'omp' }],
['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }],
+ ['qoder-cn-', { agent: 'qoder-cn', foregroundProcess: 'qoderclicn' }],
['qoder-', { agent: 'qoder', foregroundProcess: 'qodercli' }],
['zcode-', { agent: 'zcode', foregroundProcess: 'zcode' }]
]
diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts
index a2e6ee3f0734..4965092bdc89 100644
--- a/src/main/runtime/terminal-wait-detection.ts
+++ b/src/main/runtime/terminal-wait-detection.ts
@@ -73,7 +73,7 @@ export function isKnownReadyPromptBody(
readScreenLines: () => readonly string[] | null,
hasOutputClock: boolean
): boolean {
- if (agent === 'qoder') {
+ if (agent === 'qoder' || agent === 'qoder-cn') {
return isQoderComposerReady(readScreenLines())
}
// Why before the rules: such an agent settles only on the quiet lane while it has a clock.
diff --git a/src/main/runtime/tui-idle-evidence-source.test.ts b/src/main/runtime/tui-idle-evidence-source.test.ts
new file mode 100644
index 000000000000..363c1f254992
--- /dev/null
+++ b/src/main/runtime/tui-idle-evidence-source.test.ts
@@ -0,0 +1,40 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import type { TuiAgent } from '../../shared/tui-agent'
+import { ptyTuiIdleEvidence, type TuiIdleEvidenceSource } from './tui-idle-evidence-source'
+import { makeTuiIdlePty } from './tui-idle-wait-test-harness'
+
+vi.mock('electron', () => ({
+ BrowserWindow: { fromId: vi.fn(() => null) },
+ webContents: { fromId: vi.fn(() => null) },
+ ipcMain: { on: vi.fn(), removeListener: vi.fn() },
+ app: { getPath: vi.fn(() => '/tmp') }
+}))
+
+function source(agent: TuiAgent): TuiIdleEvidenceSource {
+ return {
+ quiescenceMs: 3000,
+ getTabTitle: () => null,
+ getAdoptedPtyIdleStatus: () => 'idle',
+ getPaneAgent: () => agent,
+ getFirstPartyAgentStatus: () => null,
+ readScreenLines: () => null
+ }
+}
+
+describe('ptyTuiIdleEvidence', () => {
+ it.each([
+ ['qoder', 'qoder-trust-dialog'],
+ ['qoder-cn', 'qoder-cn-signin']
+ ] as const)('requires a live composer for adopted %s panes', (agent, fixture) => {
+ const transcript = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8')
+ const evidence = ptyTuiIdleEvidence(source(agent), makeTuiIdlePty(), () => transcript)
+ expect(evidence.readPositiveBodyEvidence()).toBe(false)
+ })
+
+ it('keeps adopted idle evidence for other agents', () => {
+ const evidence = ptyTuiIdleEvidence(source('claude'), makeTuiIdlePty(), () => '')
+ expect(evidence.readPositiveBodyEvidence()).toBe(true)
+ })
+})
diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts
index 3a1b52779990..4340dd00f219 100644
--- a/src/main/runtime/tui-idle-evidence-source.ts
+++ b/src/main/runtime/tui-idle-evidence-source.ts
@@ -134,7 +134,9 @@ export function ptyTuiIdleEvidence(
record: pty,
readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()),
readPositiveBodyEvidence: () =>
- (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
+ (agent !== 'qoder' &&
+ agent !== 'qoder-cn' &&
+ source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null),
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText),
diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts
index 2c28f3352328..68f472a6339b 100644
--- a/src/main/runtime/tui-idle-evidence.ts
+++ b/src/main/runtime/tui-idle-evidence.ts
@@ -274,7 +274,7 @@ function rankTuiIdleEvidence(input: TuiIdleEvaluationInput): TuiIdleVerdict {
return { kind: 'blocked', reason: blockedReason }
}
// Qoder publishes "Ready" before its trust dialog is dismissed; only its composer proves input is live.
- if (input.agent === 'qoder') {
+ if (input.agent === 'qoder' || input.agent === 'qoder-cn') {
if (
hasFreshWorkingFirstPartyStatus(input.firstPartyStatus) ||
input.record.lastAgentStatus === 'working'
diff --git a/src/main/workspace-trust-test-fixtures.ts b/src/main/workspace-trust-test-fixtures.ts
index 273c14493299..d8afda34524c 100644
--- a/src/main/workspace-trust-test-fixtures.ts
+++ b/src/main/workspace-trust-test-fixtures.ts
@@ -15,6 +15,8 @@ export function workspaceTrustWritten(home: string, preset: AgentTrustPreset): b
}
case 'copilot':
return existsSync(join(home, '.copilot', 'config.json'))
+ case 'qoder-cn':
+ return existsSync(join(home, '.qoder-cn', 'settings.json'))
case 'qoder':
return existsSync(join(home, '.qoder', 'settings.json'))
case 'antigravity':
diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts
index d7f20d2961c4..e17998e102e8 100644
--- a/src/relay/agent-workspace-trust-spawn.test.ts
+++ b/src/relay/agent-workspace-trust-spawn.test.ts
@@ -35,10 +35,11 @@ const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [
['codex', 'codex'],
['cursor', 'cursor'],
['copilot', 'copilot'],
- ['qoder', 'qoder']
+ ['qoder', 'qoder'],
+ ['qoder-cn', 'qoder-cn']
]
const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) =>
- ['claude', 'copilot', 'qoder'].includes(preset)
+ ['claude', 'copilot', 'qoder', 'qoder-cn'].includes(preset)
)
const ALL_PRESETS: AgentTrustPreset[] = [
'claude',
@@ -46,6 +47,7 @@ const ALL_PRESETS: AgentTrustPreset[] = [
'cursor',
'copilot',
'qoder',
+ 'qoder-cn',
'antigravity'
]
diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx
index 629aea0ee155..2ba0a0e2144c 100644
--- a/src/renderer/src/components/agent/AgentCombobox.test.tsx
+++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx
@@ -199,9 +199,11 @@ describe('AgentCombobox', () => {
it('renders bundled favicons for favicon-domain agents instead of the remote Google service', () => {
// Why: previously loaded from Google's favicon service (#8451). Iterate the
// full asset map so missing files/key mismatches fail the test.
+ const sharedIcons = new Map([['qoder-cn', 'qoder']])
for (const agent of Object.keys(AGENT_FAVICON_ASSETS) as TuiAgent[]) {
const markup = renderToStaticMarkup( )
- expect(markup).toContain(`/shared/agent-icons/${agent}.png`)
+ const iconName = sharedIcons.get(agent) ?? agent
+ expect(markup).toContain(`/shared/agent-icons/${iconName}.png`)
expect(markup).not.toContain('https://www.google.com/s2/favicons')
}
})
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 88943327a37c..a187c118eeb8 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -644,7 +644,8 @@
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"opencode2_label": "OpenCode 2",
- "codebuddy_label": "CodeBuddy"
+ "codebuddy_label": "CodeBuddy",
+ "qoder_cn_label": "Qoder CLI China"
},
"skill": {
"cli": {
diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx
index 325d838552d2..bef0244dd39b 100644
--- a/src/renderer/src/lib/agent-catalog.tsx
+++ b/src/renderer/src/lib/agent-catalog.tsx
@@ -128,6 +128,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] =>
faviconDomain: 'qoder.com',
homepageUrl: 'https://docs.qoder.com/cli/overview'
},
+ {
+ id: 'qoder-cn',
+ label: translate('auto.lib.agent.catalog.qoder_cn_label', 'Qoder CLI China'),
+ cmd: 'qoderclicn',
+ faviconDomain: 'qoder.cn',
+ homepageUrl: 'https://docs.qoder.cn/cli/overview'
+ },
{
id: 'zcode',
label: translate('auto.lib.agent.catalog.zcode_label', 'ZCode'),
@@ -391,10 +398,7 @@ export function AgentIcon({
if (agent === 'copilot') {
return
}
- if (agent === 'opencode') {
- return
- }
- if (agent === 'opencode2') {
+ if (agent === 'opencode' || agent === 'opencode2') {
return
}
const catalogEntry = getAgentCatalog().find((a) => a.id === agent)
diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts
index b041965736ef..226d4727380b 100644
--- a/src/renderer/src/lib/agent-favicon-assets.ts
+++ b/src/renderer/src/lib/agent-favicon-assets.ts
@@ -44,6 +44,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = {
trae: traeUrl,
'prime-agent': primeAgentUrl,
qoder: qoderUrl,
+ 'qoder-cn': qoderUrl,
gemini: geminiUrl,
antigravity: antigravityUrl,
goose: gooseUrl,
diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts
index caff18e5c671..c6f02089c0c6 100644
--- a/src/renderer/src/lib/agent-picker-search.test.ts
+++ b/src/renderer/src/lib/agent-picker-search.test.ts
@@ -66,12 +66,43 @@ describe('agent picker search', () => {
expect(replaceSpy).not.toHaveBeenCalled()
})
- it('resolves every catalog command alias to its agent first', () => {
+ it('resolves catalog commands to the first entry owning that command', () => {
for (const agent of AGENT_CATALOG) {
- expect(searchAgentPickerEntries(AGENT_CATALOG, agent.cmd)[0]?.id).toBe(agent.id)
+ const owner = AGENT_CATALOG.find((candidate) => candidate.cmd === agent.cmd)
+ expect(searchAgentPickerEntries(AGENT_CATALOG, agent.cmd)[0]?.id).toBe(owner?.id)
}
})
+ it.each([
+ ['qoder', 'qoder'],
+ ['qodercli', 'qoder'],
+ ['qoder-cn', 'qoder-cn'],
+ ['qoderclicn', 'qoder-cn'],
+ ['Qoder CLI China', 'qoder-cn'],
+ ['qwen', 'qwen-code']
+ ] as const)('resolves explicit identity or command %s to %s', (query, expected) => {
+ expect(searchAgentPickerEntries(AGENT_CATALOG, query)[0]?.id).toBe(expected)
+ })
+
+ it('keeps a stable default for a shared binary while explicit CN remains selectable', () => {
+ const shared = [
+ entry('qoder', 'Qoder CLI', 'qodercli'),
+ entry('qoder-cn', 'Qoder CLI China', 'qodercli')
+ ]
+ expect(searchAgentPickerEntries(shared, 'qodercli').map((agent) => agent.id)).toEqual([
+ 'qoder',
+ 'qoder-cn'
+ ])
+ expect(searchAgentPickerEntries(shared, 'qoder-cn')[0]?.id).toBe('qoder-cn')
+ expect(searchAgentPickerEntries(shared, 'Qoder CLI China')[0]?.id).toBe('qoder-cn')
+ expect(
+ searchAgentPickerEntries(
+ shared.filter((agent) => agent.id !== 'qoder'),
+ 'qodercli'
+ )[0]?.id
+ ).toBe('qoder-cn')
+ })
+
it('returns no entries for unrelated text', () => {
expect(searchAgentPickerEntries(agents, 'not-an-agent')).toEqual([])
})
diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts
index 24ee28967421..b3129feb8cc4 100644
--- a/src/renderer/src/lib/agent-picker-search.ts
+++ b/src/renderer/src/lib/agent-picker-search.ts
@@ -86,20 +86,25 @@ export function agentPickerBlankTerminalMatches(rawQuery: string): boolean {
function scoreAgent(agent: AgentCatalogEntry, query: string): number {
return Math.min(
scoreCandidate(query, agent.label, 0),
- scoreCandidate(query, agent.id, 600),
- scoreCandidate(query, agent.cmd, 650),
- ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650))
+ scoreCandidate(query, agent.id, 600, 1),
+ scoreCandidate(query, agent.cmd, 650, 2),
+ ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650, 2))
)
}
-function scoreCandidate(query: string, rawCandidate: string, baseScore: number): number {
+function scoreCandidate(
+ query: string,
+ rawCandidate: string,
+ baseScore: number,
+ exactScore = baseScore
+): number {
const candidate = normalizeSearchText(rawCandidate)
if (!candidate) {
return NO_MATCH
}
if (candidate === query) {
- return baseScore
+ return exactScore
}
if (candidate.startsWith(query)) {
return baseScore + 10
diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts
index 04ecd728541a..f28277cc3c97 100644
--- a/src/renderer/src/lib/agent-status.ts
+++ b/src/renderer/src/lib/agent-status.ts
@@ -111,6 +111,7 @@ const ICONABLE_AGENT_TYPES: Record = {
omp: true,
'prime-agent': true,
qoder: true,
+ 'qoder-cn': true,
gemini: true,
antigravity: true,
aider: true,
diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts
index fc8a80f1c9cb..861157eb6a8a 100644
--- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts
+++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume'
import {
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
@@ -53,6 +55,8 @@ describe('agentResumeHostAuthorityCapability', () => {
codex: undefined,
cursor: AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
+ 'qoder-cn': AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
+ 'qwen-code': AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
gemini: undefined,
antigravity: undefined,
opencode: undefined,
diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts
index 4431f30b220a..a296d4ef2d70 100644
--- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts
+++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts
@@ -4,6 +4,8 @@ import {
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
@@ -26,6 +28,8 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = {
codex: undefined,
cursor: AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
+ 'qoder-cn': AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
+ 'qwen-code': AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
gemini: undefined,
antigravity: undefined,
opencode: undefined,
diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts
index 99d090687200..b126f162c859 100644
--- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts
+++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts
@@ -45,7 +45,7 @@ describe('remote agent-session launch routing', () => {
expect(legacy).not.toHaveBeenCalled()
})
- it.each(['kimi', 'muse', 'codebuddy', 'cursor'] as const)(
+ it.each(['kimi', 'muse', 'codebuddy', 'qoder', 'qoder-cn', 'qwen-code', 'cursor'] as const)(
'falls back to legacy when an older host lacks the %s resume capability',
async (agent) => {
const hostAuthority = vi.fn().mockResolvedValue('structured')
@@ -70,7 +70,7 @@ describe('remote agent-session launch routing', () => {
}
)
- it.each(['muse', 'codebuddy'] as const)(
+ it.each(['muse', 'codebuddy', 'qoder', 'qoder-cn', 'qwen-code'] as const)(
'uses host authority when the host supports %s resume',
async (agent) => {
const hostAuthority = vi.fn().mockResolvedValue('host')
diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts
index 9f742a5355d1..b158cf868986 100644
--- a/src/shared/agent-headless-command.ts
+++ b/src/shared/agent-headless-command.ts
@@ -18,6 +18,7 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial<
Record boolean>
> = {
qoder: isQoderHeadlessCommand,
+ 'qoder-cn': isQoderHeadlessCommand,
claude: isPrintModeHeadlessOneShotCommand,
codebuddy: isCodebuddyNonInteractiveCommand,
trae: isPrintModeHeadlessOneShotCommand,
diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts
index b7fffa09f164..77f24b6eefc4 100644
--- a/src/shared/agent-hook-listener/provider-dispatch.ts
+++ b/src/shared/agent-hook-listener/provider-dispatch.ts
@@ -163,6 +163,21 @@ export function normalizeProviderEvent(input: {
case 'devin':
payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload)
break
+ case 'qoder-cn':
+ payload = normalizeCompatibleLifecycleEvent(
+ source,
+ state,
+ eventName,
+ promptText,
+ paneKey,
+ hookPayload
+ )
+ break
+ case 'qwen-code': {
+ const normalized = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload)
+ payload = normalized ? { ...normalized, agentType: 'qwen-code' } : null
+ break
+ }
case 'qoder':
payload = normalizeQoderEvent(state, eventName, promptText, paneKey, hookPayload)
break
diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts
index 80cd0fdc91c9..eee05a646956 100644
--- a/src/shared/agent-hook-listener/provider-event-routing.ts
+++ b/src/shared/agent-hook-listener/provider-event-routing.ts
@@ -25,6 +25,8 @@ import { extractHermesToolFields } from './providers/hermes-tool-fields'
export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false.
switch (source) {
+ case 'qoder-cn':
+ case 'qwen-code':
case 'qoder':
case 'claude':
// Why: SessionStart lands an idle row (STA-3386) and must also drop stale
@@ -140,6 +142,8 @@ export function extractToolFields(
): ToolSnapshot {
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor.
switch (source) {
+ case 'qoder-cn':
+ case 'qwen-code':
case 'qoder':
case 'claude':
// Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim.
diff --git a/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts
index 2678dfc5e1ea..ba7362b824a3 100644
--- a/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts
+++ b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts
@@ -48,7 +48,7 @@ function readCompatibleState(
}
export function normalizeCompatibleLifecycleEvent(
- source: 'qoder' | 'codebuddy',
+ source: 'qoder' | 'qoder-cn' | 'codebuddy',
state: HookListenerState,
eventName: unknown,
promptText: string,
diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts
index 2940685665c4..7f4b4c393569 100644
--- a/src/shared/agent-hook-listener/source-routing.ts
+++ b/src/shared/agent-hook-listener/source-routing.ts
@@ -6,6 +6,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly>
'/hook/claude': 'claude',
'/hook/codex': 'codex',
'/hook/qoder': 'qoder',
+ '/hook/qoder-cn': 'qoder-cn',
+ '/hook/qwen-code': 'qwen-code',
'/hook/codebuddy': 'codebuddy',
'/hook/gemini': 'gemini',
'/hook/antigravity': 'antigravity',
diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts
index 6da6f2b2df0e..9289bde73814 100644
--- a/src/shared/agent-hook-relay.ts
+++ b/src/shared/agent-hook-relay.ts
@@ -39,6 +39,8 @@ const AGENT_HOOK_SOURCES = [
'claude',
'codex',
'qoder',
+ 'qoder-cn',
+ 'qwen-code',
'codebuddy',
'gemini',
'antigravity',
diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts
index 0bc5b67bc284..191016259ac8 100644
--- a/src/shared/agent-hook-types.ts
+++ b/src/shared/agent-hook-types.ts
@@ -8,6 +8,8 @@ export const AGENT_HOOK_TARGETS = [
'openclaude',
'codex',
'qoder',
+ 'qoder-cn',
+ 'qwen-code',
'codebuddy',
'gemini',
'antigravity',
diff --git a/src/shared/agent-icons/qoder-cn.png b/src/shared/agent-icons/qoder-cn.png
new file mode 100644
index 0000000000000000000000000000000000000000..42c952ed33a1240c57832c8d71c093f59e76fa65
GIT binary patch
literal 2416
zcmYM0c{J4j8pppAGZ
z9|C|BKY;sh#%A9Sp2fZnX8z|~#h}0G{a*2*e`v#EuK(wQu`qgg%D(XtEv|$B0Ke#g
zKtOK(vHe}rElrFZB0*DE`L5%7#rJ&ZSC?}p`|J=nUO6O)+>O`9C@X=CbcRM`J}dNn
z1%pmw$BgakE3p?pnW=z~H+$rhK0cK>K~f?(%fNjdgneFN-1EvH2yp*d#FqtqdR`1C
z_eY?1%R=MkTN);ox%EX`J!p@^@&2MwNN?u$-TGx|3qrWZVyEE05)f#`oTi
zln#!zoZFx{!!a#MQu~AZQBDoAq8b@If+`?OS7{+!m7fA@d(y$MznzlbzTYfD5xbz*U
z=dar1MSOSQt^+DjM~ADc$H3gSq&a^UrM7%N0~vL4Y$BzN+a0Fz!}rA+;8uVN%YaKu
zfJ(DV94--Ls_bSI#r)Yb|E?8l-V?^vK3$`l$FHhgR|zq@n6rWO8V6^=R@)$LRGy=z
zG~_&Ud#8wDXjkv6wCK-kzfva--?PzYN%#+HpEOZhl)dJLmJ6ue>{q7iJzYdDcwLeo
z-o1M%AdzxMlFOM~Mg;o@PSuD>#}z!RRdvdQz&=kskU
zWRTpnlnl!`(EzJ~`l4ZSaEiUcgt{mYPHBC0CPeI0j`t^)hkD>`h<|my`Pa1}N2a3!
z8V~EZ4@y`})GWplF8yFXk$$&@R{(5_KWMU`1S?yk_q!olE}z6$^bw&i-Gepbg=Iu3
zDNl>q*~5&Q6Hoa>4KFfHzaenyq5ukb`K44+ThO@L{{N-Pns
z^!m}h14N`+ov9Peim|)QC}*?>7iwmmnnER(z7o}fG>{_fZQn)VVinE;sI@tyCDjO}
zM^{DL$3>^W&v~V8_-sxghO)YWm2*>#>Y|!^fwFnkNv+pjdQ~Nwnhl`6JR^NgD^5Y&
zq#i}bxri`-#)u{=gz|Y8#j$3dSks-<&h;RG!+0x-tdCwvR3Hmh5PWI03sSFB+CKB>
zsqE#9EX@0~3Pp)%uE=`|Q5>=EY8kif`^1J!C72hxCu(G!E&Kfq)V^)hT%16ewkxsD
zpU*BvY{cBE+c-N~FJIp6EB~kNA((pQMi-p6JoqvvT-{qIM>Px`@
zqmJBJxD(&^^MQ1xhSp4$w~hQUs@XkhVaArx%7z>LjSdv2a{O!nCrmSScYMm}JNvNt
z17rC*OoZs?`!udME58}iH@DWT`382zqkS3`geoIEvsXhF2cY-`N@KGRSI2Z4#03&_
z&$Yq?>i|CP?>M_@mMPhtQ5t+a=Ie7DKvZ}+^Dsj>Het1y+!|VA@Cy85JKr^g;(8~S
z1u%4zmAyEr1g_yXkDCMy;RcwJUe*I1LD$BNzfdur>;ZJL1^QZGwR9V!X?I{%&L$8i>E9oL_(0xa%rC4lrGO`0Gd)`C
zw-$VJml5nI{bqn2u}R|ht0EPz_YoHcC`W{V*x`sUo=gtbGOS!&i^?OM_p4y8_uQ0u
zD*b%ofU139FS{_;5jdflbwkn+XJIrOjB}tUO7MoqC*(vmVs-V$^FRKY%C`U6kSY2cb0QuT$MYUk@cTh8z*Pd9z>7
z0uolxqX(UfQqiP~l!H6cCsOuM1byR=&)Ux+q;Vn_gHk@a@XX`tCH=B;^?Pu#$I<
z6s|Cae^WXoLVEi2`=jAc-YG5$?1l*u2bNcrTR*-cC%^5Z8px4Taw9l05MR-Y4QKJRAa6y;i
z(QmcRJ?lJq=tu}@J0W)Gb>`E(pwR~r6kSfLRR8;0fWaF?aD|D1ewnqpCVPFv
agentType: 'qoder'
})
})
+
+it.each(['qoder-cn', 'qwen-code'] as const)('keeps %s lifecycle and resume attributed', (agent) => {
+ const state = createHookListenerState()
+ const send = (hook_event_name: string, extra = {}) =>
+ normalizeAndAccept(state, agent, {
+ hook_event_name,
+ session_id: 'provider-proof',
+ ...extra
+ })
+ expect(send('SessionStart', { source: 'startup' })?.payload).toMatchObject({
+ agentType: agent,
+ state: 'done',
+ sessionBoundary: true
+ })
+ expect(send('UserPromptSubmit', { prompt: 'Create proof.txt' })?.payload).toMatchObject({
+ agentType: agent,
+ state: 'working'
+ })
+ expect(send('PermissionRequest', { tool_name: 'Write' })?.payload).toMatchObject({
+ agentType: agent,
+ state: 'waiting'
+ })
+ expect(send('Stop', { last_assistant_message: 'Done' })?.payload).toMatchObject({
+ agentType: agent,
+ state: 'done'
+ })
+ expect(send('SessionStart', { source: 'resume' })?.providerSession?.id).toBe('provider-proof')
+ expect(getAgentResumeArgv(agent, { key: 'session_id', id: 'provider-proof' })).toEqual([
+ agent === 'qoder-cn' ? 'qoderclicn' : 'qwen',
+ '--resume',
+ 'provider-proof'
+ ])
+})
+
+it.each([
+ 'qoderclicn',
+ 'qodercn',
+ '/home/test/.qoder-cn/bin/qoderclicn-1.1.65',
+ 'C:\\Qoder\\qoderclicn.exe'
+])('recognizes the China executable %s', (command) => {
+ expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('qoder-cn')
+ expect(recognizeAgentProcessFromCommandLine(`${command} --print hello`)).toBeNull()
+})
diff --git a/src/shared/qoder-cn-launch-recipe.test.ts b/src/shared/qoder-cn-launch-recipe.test.ts
new file mode 100644
index 000000000000..510d2d940778
--- /dev/null
+++ b/src/shared/qoder-cn-launch-recipe.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup'
+
+describe('Qoder China launch recipe identity', () => {
+ it.each(['darwin', 'linux', 'win32'] as const)(
+ 'preserves the official China executable on %s',
+ (platform) => {
+ const plan = buildAgentStartupPlan({
+ agent: 'qoder-cn',
+ prompt: 'fixture',
+ cmdOverrides: {},
+ platform
+ })
+ expect(plan?.agent).toBe('qoder-cn')
+ expect(plan?.launchCommand).toBe("qoderclicn --prompt-interactive 'fixture'")
+ expect(
+ buildAgentResumeStartupPlan({
+ agent: 'qoder-cn',
+ providerSession: { key: 'session_id', id: 'fixture-session' },
+ cmdOverrides: {},
+ platform
+ })?.launchCommand
+ ).toBe("qoderclicn '--resume' 'fixture-session'")
+ }
+ )
+
+ it.each(['darwin', 'linux', 'win32'] as const)(
+ 'preserves an explicitly configured shared-binary --cn recipe on %s',
+ (platform) => {
+ const cmdOverrides = { 'qoder-cn': 'qodercli --cn' }
+ const plan = buildAgentStartupPlan({
+ agent: 'qoder-cn',
+ prompt: 'fixture',
+ cmdOverrides,
+ platform
+ })
+ expect(plan?.agent).toBe('qoder-cn')
+ expect(plan?.launchCommand).toBe("qodercli --cn --prompt-interactive 'fixture'")
+ expect(
+ buildAgentResumeStartupPlan({
+ agent: 'qoder-cn',
+ providerSession: { key: 'session_id', id: 'fixture-session' },
+ cmdOverrides,
+ platform
+ })?.launchCommand
+ ).toBe("qodercli --cn '--resume' 'fixture-session'")
+ }
+ )
+})
diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts
index 1955bfa6d581..0c3624ad2a87 100644
--- a/src/shared/skills-cli-agent-keys.ts
+++ b/src/shared/skills-cli-agent-keys.ts
@@ -26,6 +26,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = {
omp: null,
'prime-agent': null,
qoder: 'qoder',
+ 'qoder-cn': 'qoder-cn',
gemini: 'gemini-cli',
antigravity: 'antigravity',
aider: null,
diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts
index a45a2f8d9f20..b8ba933f5185 100644
--- a/src/shared/telemetry-property-schemas.ts
+++ b/src/shared/telemetry-property-schemas.ts
@@ -21,6 +21,7 @@ export const AGENT_KIND_VALUES = [
'omp',
'prime-agent',
'qoder',
+ 'qoder-cn',
'gemini',
'antigravity',
'aider',
diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts
index 045b0aeceece..65ba82e83d05 100644
--- a/src/shared/terminal-title-agent-type.ts
+++ b/src/shared/terminal-title-agent-type.ts
@@ -175,7 +175,7 @@ function computeAgentLabel(title: string): string | null {
return 'DeepSeek Harness'
}
if (isQoderTerminalTitle(title)) {
- return 'Qoder CLI'
+ return title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
@@ -258,6 +258,8 @@ const TITLE_LABEL_TO_AGENT: Partial> = {
OpenClaude: 'openclaude',
Codex: 'codex',
'Qoder CLI': 'qoder',
+ 'Qoder CLI CN': 'qoder-cn',
+ 'Qoder CLI China': 'qoder-cn',
'Gemini CLI': 'gemini',
'GitHub Copilot': 'copilot',
Grok: 'grok',
diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts
index aa393fd522b7..0729079d0936 100644
--- a/src/shared/tui-agent-config-types.ts
+++ b/src/shared/tui-agent-config-types.ts
@@ -38,7 +38,7 @@ export type TuiAgentConfig = {
/** Claude Code follows pasted text only where the user's typed words ask, so dispatch briefs need a typed lead line. */
pasteNeedsTypedRequest?: boolean
/** Trust Orca pre-writes at PTY spawn (agent-workspace-trust.ts) so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste. */
- preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder'
+ preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn'
/** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */
draftPasteReadySignal?: DraftPasteReadySignal
/** Hard deadline for the agent's composer readiness signal. */
diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts
index a7be259df2cd..3697079c91ea 100644
--- a/src/shared/tui-agent-config.ts
+++ b/src/shared/tui-agent-config.ts
@@ -146,6 +146,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = {
promptInjectionMode: 'flag-prompt-interactive',
preflightTrust: 'qoder'
},
+ 'qoder-cn': {
+ detectCmd: 'qoderclicn',
+ detectCmdAliases: ['qodercn'],
+ promptInjectionMode: 'flag-prompt-interactive',
+ preflightTrust: 'qoder-cn'
+ },
gemini: {
detectCmd: 'gemini',
promptInjectionMode: 'flag-prompt-interactive'
diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts
index 31b6035a9530..106244368137 100644
--- a/src/shared/tui-agent-display-names.ts
+++ b/src/shared/tui-agent-display-names.ts
@@ -25,6 +25,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = {
omp: 'OMP',
'prime-agent': 'Prime Agent',
qoder: 'Qoder CLI',
+ 'qoder-cn': 'Qoder CLI China',
gemini: 'Gemini',
antigravity: 'Antigravity',
aider: 'Aider',
diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts
index 9fc8eb0a6910..db014311459c 100644
--- a/src/shared/tui-agent-permissions.ts
+++ b/src/shared/tui-agent-permissions.ts
@@ -10,6 +10,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = {
openclaude: '--dangerously-skip-permissions',
codex: '--dangerously-bypass-approvals-and-sandbox',
qoder: '--dangerously-skip-permissions',
+ 'qoder-cn': '--dangerously-skip-permissions',
gemini: '--yolo',
antigravity: '--dangerously-skip-permissions',
aider: '--yes-always',
diff --git a/src/shared/tui-agent-rest-signal.test.ts b/src/shared/tui-agent-rest-signal.test.ts
index 6e573acc0b06..ad375e657c6e 100644
--- a/src/shared/tui-agent-rest-signal.test.ts
+++ b/src/shared/tui-agent-rest-signal.test.ts
@@ -29,6 +29,7 @@ const EXPECTED_REST_SIGNALS: Record = {
grok: 'title',
muse: 'ready-body',
qoder: 'ready-body',
+ 'qoder-cn': 'ready-body',
codebuddy: 'none',
autohand: 'none',
ante: 'none',
diff --git a/src/shared/tui-agent-rest-signal.ts b/src/shared/tui-agent-rest-signal.ts
index 93f694a5d799..b15797113786 100644
--- a/src/shared/tui-agent-rest-signal.ts
+++ b/src/shared/tui-agent-rest-signal.ts
@@ -20,7 +20,8 @@ export type TuiAgentRestSignal = 'hook-done' | 'synthetic-title' | 'title' | 're
const IDENTITY_REST_SIGNALS: Partial> = {
dsh: 'hook-done',
muse: 'ready-body',
- qoder: 'ready-body'
+ qoder: 'ready-body',
+ 'qoder-cn': 'ready-body'
}
// Why derived, not declared per agent: the title tables are the evidence, so a second
diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts
index acf305dad599..21390d9adfdb 100644
--- a/src/shared/tui-agent-selection.ts
+++ b/src/shared/tui-agent-selection.ts
@@ -18,6 +18,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'muse',
'dsh',
'qoder',
+ 'qoder-cn',
'zcode',
'pi',
'omp',
diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts
index a98c45101fd6..281b7c5b60d7 100644
--- a/src/shared/tui-agent.ts
+++ b/src/shared/tui-agent.ts
@@ -13,6 +13,7 @@ export type TuiAgent =
| 'pi' // Pi (pi.dev)
| 'omp' // OMP (omp.sh)
| 'qoder' // Qoder CLI
+ | 'qoder-cn' // Qoder CLI China
| 'gemini' // Gemini CLI
| 'antigravity' // Google Antigravity CLI
| 'aider' // Aider
diff --git a/tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts
new file mode 100644
index 000000000000..6d432c7c644e
--- /dev/null
+++ b/tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts
@@ -0,0 +1,46 @@
+import { expect, test } from 'vitest'
+import { EnsureAgentSessionParams } from '../../../src/shared/rpc-contract/agent-session-params'
+import { normalizeAgentStatusPayload } from '../../../src/shared/agent-status-types'
+import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
+
+test.each(['v1.4.205', 'v1.4.211'])(
+ 'release %s preserves new provider status tags but refuses unsupported resume',
+ async (ref) => {
+ const checkout = await materializeReleaseCheckout(ref)
+ const baselineStatus = await importReleaseCheckoutModule(
+ checkout,
+ 'src/shared/agent-status-types.ts'
+ )
+ const normalize = baselineStatus.normalizeAgentStatusPayload
+ if (typeof normalize !== 'function') {
+ throw new Error('Pinned release has no status normalizer')
+ }
+ const baselineResume = await importReleaseCheckoutModule(
+ checkout,
+ 'src/shared/rpc-contract/agent-session-params.ts'
+ )
+ const schema = baselineResume.EnsureAgentSessionParams
+ if (
+ !schema ||
+ typeof schema !== 'object' ||
+ !('safeParse' in schema) ||
+ typeof schema.safeParse !== 'function'
+ ) {
+ throw new Error('Pinned release has no resume request parser')
+ }
+ for (const agent of ['qoder-cn', 'qwen-code'] as const) {
+ const status = { state: 'working', agentType: agent }
+ expect(normalize(status)).toMatchObject(status)
+ expect(normalizeAgentStatusPayload(status)).toMatchObject(status)
+ const request = {
+ kind: 'explicit',
+ worktree: 'folder:/task-owned/folder',
+ agent,
+ providerSession: { key: 'session_id', id: 'test-session' }
+ }
+ expect(EnsureAgentSessionParams.safeParse(request).success).toBe(true)
+ expect(schema.safeParse(request)).toHaveProperty('success', false)
+ }
+ },
+ 120_000
+)
From 8f26bfad229d10f0f6e04a0cf1fa3611e7eae63b Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 22:32:04 -0700
Subject: [PATCH 06/11] Use the measured pnpm lookup policy automatically in
hosted root CI (#24951)
* Select lookup automatically for the measured hosted root-install profile
* Record hosted automatic-mode cold cache publication proof
---
.../install-node-dependencies/action.yml | 42 +++--
.../ci-dependency-download-cache.test.mjs | 24 ++-
config/scripts/ci-pnpm-store-mode.test.mjs | 150 ++++++++++++++++++
...ile-release-shell-switch-workflow.test.mjs | 2 +-
.../scripts/pr-workflow-parallelism.test.mjs | 2 +-
docs/reference/ci-runner-efficiency.md | 41 ++++-
6 files changed, 241 insertions(+), 20 deletions(-)
create mode 100644 config/scripts/ci-pnpm-store-mode.test.mjs
diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml
index 646bc34b387a..4163150f47e2 100644
--- a/.github/actions/install-node-dependencies/action.yml
+++ b/.github/actions/install-node-dependencies/action.yml
@@ -7,9 +7,9 @@ inputs:
required: false
default: 'true'
cache-pnpm-store-lookup-only:
- description: On non-PR producers, refresh existing stores without downloading and save stores on a miss.
+ description: Auto uses measured hosted Node 24 root installs; true forces lookup, false retains archive restoration.
required: false
- default: 'false'
+ default: auto
cache-pnpm-verification:
description: Restore pnpm's policy-checked lockfile verification record.
required: false
@@ -67,6 +67,30 @@ outputs:
runs:
using: composite
steps:
+ - name: Resolve pnpm store mode
+ id: pnpm-store-mode
+ if: >-
+ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' &&
+ (inputs.cache-pnpm-store-lookup-only == 'true' ||
+ (inputs.cache-pnpm-store-lookup-only == 'auto' &&
+ inputs.cache-dependency-path == 'pnpm-lock.yaml' &&
+ runner.environment == 'github-hosted' && job.container.id == '' &&
+ (runner.os == 'Linux' || runner.os == 'macOS' || runner.os == 'Windows') &&
+ (runner.arch == 'X64' || runner.arch == 'ARM64') &&
+ (inputs.node-version == '' || inputs.node-version == '24')))
+ shell: bash
+ env:
+ LOOKUP_REQUEST: ${{ inputs.cache-pnpm-store-lookup-only }}
+ run: |
+ lookup_only=true
+ case "$LOOKUP_REQUEST" in
+ [aA][uU][tT][oO])
+ # Hosted runners have Node for this manifest-only check before toolchain setup.
+ lookup_only="$(node -p 'const p = require("./package.json"); p.engines?.node === "24" && typeof p.packageManager === "string" && p.packageManager.split("+")[0] === "pnpm@12.8.1"')"
+ ;;
+ esac
+ printf 'lookup-only=%s\n' "$lookup_only" >> "$GITHUB_OUTPUT"
+
# setup-node needs pnpm on PATH to locate and restore its store.
- name: Setup pnpm
uses: pnpm/setup@v2
@@ -80,7 +104,7 @@ runs:
uses: actions/setup-node@v6
with:
node-version-file: package.json
- cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }}
+ cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
package-manager-cache: false
@@ -90,7 +114,7 @@ runs:
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
- cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }}
+ cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
package-manager-cache: false
@@ -104,11 +128,11 @@ runs:
!(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) &&
!((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) ||
(github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' &&
- inputs.cache-pnpm-store-lookup-only == 'true')
+ steps.pnpm-store-mode.outputs.lookup-only == 'true')
shell: bash
env:
LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }}
- STORE_LOOKUP_ONLY: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store-lookup-only == 'true' }}
+ STORE_LOOKUP_ONLY: ${{ steps.pnpm-store-mode.outputs.lookup-only == 'true' }}
run: |
test -n "$LOCKFILE_HASH"
cache_path="$(pnpm store path --silent)"
@@ -137,12 +161,10 @@ runs:
# Producers can refresh access and publish misses without downloading existing archives.
- name: Keep pnpm download store without restoring
id: pnpm-store-lookup
- if: >-
- github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' &&
- inputs.cache-pnpm-store-lookup-only == 'true'
+ if: steps.pnpm-store-mode.outputs.lookup-only == 'true'
uses: actions/cache@v5
with:
- # Post-job saves cannot resolve the composite's internal step outputs.
+ # Twice-nested composite cleanup loses internal step outputs.
path: ${{ env.ORCA_PNPM_STORE_CACHE_PATH }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}
lookup-only: true
diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs
index 98a80a9d63ff..f04aff20b352 100644
--- a/config/scripts/ci-dependency-download-cache.test.mjs
+++ b/config/scripts/ci-dependency-download-cache.test.mjs
@@ -12,7 +12,7 @@ describe('CI dependency download caches', () => {
expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml')
for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) {
expect(step.with.cache).toBe(
- "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }}"
+ "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}"
)
expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}')
expect(step.with['package-manager-cache']).toBe(false)
@@ -41,7 +41,7 @@ describe('CI dependency download caches', () => {
"github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))"
)
expect(resolve.if).toBe(
- `${restore.if} || (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only == 'true')`
+ `${restore.if} || (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only == 'true')`
)
expect(restore.uses).toBe('actions/cache/restore@v5')
expect(restore.with.path).toBe('${{ steps.pnpm-store.outputs.path }}')
@@ -72,11 +72,9 @@ describe('CI dependency download caches', () => {
it('keeps producer lookup optional and compatible with the existing store archive', () => {
const lookup = action.runs.steps.find((step) => step.id === 'pnpm-store-lookup')
const restore = action.runs.steps.find((step) => step.id === 'pnpm-store-restore')
- expect(action.inputs['cache-pnpm-store-lookup-only'].default).toBe('false')
+ expect(action.inputs['cache-pnpm-store-lookup-only'].default).toBe('auto')
expect(lookup.uses).toBe('actions/cache@v5')
- expect(lookup.if).toBe(
- "github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only == 'true'"
- )
+ expect(lookup.if).toBe("steps.pnpm-store-mode.outputs.lookup-only == 'true'")
expect(lookup.with).toEqual({
path: '${{ env.ORCA_PNPM_STORE_CACHE_PATH }}',
key: restore.with.key,
@@ -203,6 +201,16 @@ describe('CI dependency download caches', () => {
const context = {
github: { event_name: event },
runner: { os, arch },
+ steps: {
+ 'pnpm-store-mode': {
+ outputs: {
+ 'lookup-only':
+ event !== 'pull_request' && storeCache !== 'false' && lookupOnly === 'true'
+ ? 'true'
+ : ''
+ }
+ }
+ },
inputs: {
'cache-pnpm-store': storeCache,
'cache-pnpm-store-lookup-only': lookupOnly,
@@ -218,6 +226,10 @@ describe('CI dependency download caches', () => {
const evaluate = (expression) =>
runInNewContext(
expression
+ .replaceAll(
+ 'steps.pnpm-store-mode.outputs.lookup-only',
+ 'steps["pnpm-store-mode"].outputs["lookup-only"]'
+ )
.replaceAll(
'inputs.cache-pnpm-store-lookup-only',
'inputs["cache-pnpm-store-lookup-only"]'
diff --git a/config/scripts/ci-pnpm-store-mode.test.mjs b/config/scripts/ci-pnpm-store-mode.test.mjs
new file mode 100644
index 000000000000..d8402612b04e
--- /dev/null
+++ b/config/scripts/ci-pnpm-store-mode.test.mjs
@@ -0,0 +1,150 @@
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { runInNewContext } from 'node:vm'
+import { parse } from 'yaml'
+import { describe, expect, it } from 'vitest'
+import { runProcessSync } from './script-child-process.mjs'
+
+const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
+const mode = action.runs.steps.find((step) => step.id === 'pnpm-store-mode')
+const defaultContext = {
+ github: { event_name: 'push' },
+ runner: { os: 'Linux', arch: 'X64', environment: 'github-hosted' },
+ job: { container: { id: '' } },
+ inputs: {
+ 'cache-pnpm-store': 'true',
+ 'cache-pnpm-store-lookup-only': 'auto',
+ 'cache-dependency-path': 'pnpm-lock.yaml',
+ 'node-version': ''
+ }
+}
+const expression = mode.if.replaceAll(/inputs\.([\w-]+)/g, 'inputs["$1"]')
+
+function eligible(changes) {
+ const context = structuredClone(defaultContext)
+ for (const [name, fields] of Object.entries(changes)) {
+ Object.assign(context[name], fields)
+ }
+ return runInNewContext(expression, context)
+}
+
+function resolveMode(request, node, manager) {
+ const directory = mkdtempSync(join(tmpdir(), 'orca-store-mode-'))
+ const output = join(directory, 'output')
+ try {
+ writeFileSync(
+ join(directory, 'package.json'),
+ JSON.stringify({ engines: { node }, packageManager: manager })
+ )
+ const result = runProcessSync({
+ program: 'bash',
+ args: ['-e', '-o', 'pipefail', '-c', mode.run],
+ cwd: directory,
+ env: { ...process.env, LOOKUP_REQUEST: request, GITHUB_OUTPUT: output }
+ })
+ expect(result.code, result.stderr || result.stdout).toBe(0)
+ return readFileSync(output, 'utf8')
+ } finally {
+ rmSync(directory, { recursive: true, force: true })
+ }
+}
+
+describe('automatic pnpm store mode', () => {
+ it.each([
+ ['auto', '24', 'pnpm@12.8.1', '', true],
+ ['auto', '25', 'pnpm@12.8.1', 'pnpm', false],
+ ['auto', '24', 'pnpm@13.0.0', 'pnpm', false],
+ ['true', '25', 'pnpm@13.0.0', '', true]
+ ])(
+ 'routes resolved %s mode for Node %s / %s into both cache steps',
+ (request, node, manager, cache, lookup) => {
+ const context = structuredClone(defaultContext)
+ context.inputs['cache-pnpm-store-lookup-only'] = request
+ const resolved = resolveMode(request, node, manager).split('=')[1].trim()
+ const evaluate = (value) =>
+ runInNewContext(
+ value
+ .replaceAll(/inputs\.([\w-]+)/g, 'inputs["$1"]')
+ .replaceAll(
+ 'steps.pnpm-store-mode.outputs.lookup-only',
+ 'steps["pnpm-store-mode"].outputs["lookup-only"]'
+ ),
+ { ...context, steps: { 'pnpm-store-mode': { outputs: { 'lookup-only': resolved } } } }
+ )
+ const nodeSetup = action.runs.steps.find((step) => step.id === 'default-node')
+ expect(evaluate(nodeSetup.with.cache.slice(3, -2))).toBe(cache)
+ expect(evaluate(action.runs.steps.find((step) => step.id === 'pnpm-store-lookup').if)).toBe(
+ lookup
+ )
+ }
+ )
+
+ it.each(
+ ['Linux', 'Windows', 'macOS'].flatMap((os) => ['X64', 'ARM64'].map((arch) => [os, arch]))
+ )('qualifies the measured %s/%s hosted root context', (os, arch) => {
+ expect(eligible({ runner: { os, arch } })).toBe(true)
+ })
+
+ it.each([
+ ['PR', { github: { event_name: 'pull_request' } }],
+ ['opted-out store', { inputs: { 'cache-pnpm-store': 'false' } }],
+ ['opted-out lookup', { inputs: { 'cache-pnpm-store-lookup-only': 'false' } }],
+ ['unknown request', { inputs: { 'cache-pnpm-store-lookup-only': 'other' } }],
+ [
+ 'mixed lockfiles',
+ { inputs: { 'cache-dependency-path': 'pnpm-lock.yaml\nmobile/pnpm-lock.yaml' } }
+ ],
+ ['custom lockfile', { inputs: { 'cache-dependency-path': 'cloud/pnpm-lock.yaml' } }],
+ ['Node 25', { inputs: { 'node-version': '25' } }],
+ ['job container', { job: { container: { id: 'container-id' } } }],
+ ['self-hosted runner', { runner: { environment: 'self-hosted' } }],
+ ['unknown host kind', { runner: { environment: '' } }],
+ ['unmeasured architecture', { runner: { arch: 'X86' } }],
+ ['unmeasured OS', { runner: { os: 'other' } }]
+ ])('retains the legacy policy for %s', (_name, changes) => {
+ expect(eligible(changes)).toBe(false)
+ })
+
+ it('allows an explicit request to preserve the existing force-lookup contract', () => {
+ expect(
+ eligible({
+ inputs: {
+ 'cache-pnpm-store-lookup-only': 'true',
+ 'node-version': '25',
+ 'cache-dependency-path': 'custom-lock.yaml'
+ },
+ runner: { environment: 'self-hosted' },
+ job: { container: { id: 'container-id' } }
+ })
+ ).toBe(true)
+ expect(
+ eligible({
+ github: { event_name: 'pull_request' },
+ inputs: { 'cache-pnpm-store-lookup-only': 'true' }
+ })
+ ).toBe(false)
+ })
+
+ it.each([
+ ['24', 'pnpm@12.8.1', 'true'],
+ ['24', 'pnpm@12.8.1+sha512.fixture', 'true'],
+ ['25', 'pnpm@12.8.1', 'false'],
+ ['24.x', 'pnpm@12.8.1', 'false'],
+ ['24', 'pnpm@12.8.2', 'false'],
+ ['24', 'pnpm@12.8.10', 'false'],
+ ['24', undefined, 'false'],
+ [undefined, 'pnpm@12.8.1', 'false'],
+ ['24', 12, 'false']
+ ])('checks manifest Node %s and manager %s before choosing lookup', (node, manager, expected) => {
+ expect(resolveMode('auto', node, manager)).toBe(`lookup-only=${expected}\n`)
+ })
+
+ it('checks uppercase auto requests consistently with GitHub expression comparisons', () => {
+ expect(resolveMode('AUTO', '25', 'pnpm@12.8.1')).toBe('lookup-only=false\n')
+ })
+
+ it('does not constrain an explicit request to the automatic manifest profile', () => {
+ expect(resolveMode('true', '25', 'pnpm@13.0.0')).toBe('lookup-only=true\n')
+ })
+})
diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs
index 2da3b360ad3e..5f45d6872ac4 100644
--- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs
+++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs
@@ -123,7 +123,7 @@ const REVIEWED_COMPUTED_PATHS = [
'${{ env.ORCA_PNPM_STORE_CACHE_PATH }}',
// Only pnpm's lockfile-verified.jsonl record, never Metro transforms.
'${{ steps.verification-cache.outputs.path }}',
- "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }} store"
+ "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} store"
]
/** Every step a workflow runs, descending into the repository's own composite actions. */
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index a7bb79ee9ef4..1d49c34cc6b9 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -290,7 +290,7 @@ describe('PR workflow parallelism', () => {
expect(steps[pnpmIndex].with.version).toBeUndefined()
expect(steps[pnpmIndex].with.install).toBe(false)
const saveOutsidePrs =
- "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }}"
+ "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}"
expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs)
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md
index 7a3c55754ef1..da444138afe0 100644
--- a/docs/reference/ci-runner-efficiency.md
+++ b/docs/reference/ci-runner-efficiency.md
@@ -228,8 +228,8 @@ proved that lookup left the payload absent, refreshed the existing cache's acces
time, and published a miss that a fresh job restored. A
[nested composite control](https://github.com/stablyai/orca/actions/runs/37084946789)
then saved and restored a fresh payload using the actual environment-path pattern.
-The installer exports its resolved store path through `GITHUB_ENV`: post-job saves
-cannot resolve the composite's internal step outputs. The primary key is captured
+The installer exports its resolved store path through `GITHUB_ENV`: twice-nested composite post-job saves
+cannot resolve their internal step outputs. The primary key is captured
by the cache action before cleanup. Paths, architecture and lockfile keys match
`setup-node`, so existing default-branch archives remain reusable.
@@ -1814,3 +1814,40 @@ collector stopped its observer before signal routing, and the corrected trial
received the signal after both builders finished. The qualifying trial requested
normal cancellation earlier in the same preparation sequence to account for
observed delivery delay; no workload, wait or proof predicate was shortened.
+
+## October 3 producer follow-up: automatic selection for the measured profile
+
+The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927)
+passed all 46 PR checks, all five manual warmers and all 11 manual Headless
+qualifications on `a2c489c0cca5e46d24333a4d40ba910af0de0208`. The same root installer
+also serves recurring unit, browser and performance workflows that had not opted
+in. The follow-up defaults the existing input to `auto`, reusing lookup mode for
+non-PR root-only installs on GitHub-hosted Linux/macOS/Windows x64/ARM64 runners,
+with no job container, the manifest's Node 24/pnpm 12.8.1 profile and no conflicting
+Node override. Explicit `true` and `false` retain their previous meanings. Mixed
+lockfiles, other toolchains, containers and self-hosted runners retain full cache
+restoration; PR policies are unchanged. The manifest check runs only when the
+context is potentially eligible, before setup-node chooses its cache behavior.
+
+A second cleanup audit distinguished nesting depth. The
+[twice-nested control](https://github.com/stablyai/orca/actions/runs/37087090689)
+published the environment-path payload and lost the output-path payload with an
+`Input required and not supplied: path` warning. The
+[direct control](https://github.com/stablyai/orca/actions/runs/37087211236) published
+and restored both payloads. Current Electron archive callers are direct, so they
+need no cache-path change. Keeping the producer's exported path also makes its
+new lookup mode safe for callers that nest the shared installer. These tiny
+controls establish publication behavior, not installer time savings.
+
+The [actual automatic-mode cold publisher control](https://github.com/stablyai/orca/actions/runs/37097980789)
+passed both jobs on `7b8858bdc8f`. A twice-nested wrapper called the installer
+without overriding its default input. The writer selected lookup, missed its
+unique root-lockfile key, completed the frozen policy-checked install and saved
+that key during cleanup. A fresh reader restored the exact key and installed the
+same dependency successfully. The fixture retained the manifest toolchain and
+applicable workspace policies; its one dependency keeps the publication check
+small. Two earlier trials failed fixture assertions (the pnpm multi-document
+header placement, then its empty cache-miss output), and are excluded. This proves
+automatic selection and cold publication, not a new timing result. Local
+verification passed eight suites / 184 tests, the changed-code quality gate and
+compiled-composite actionlint.
From 0b0cf3226fd93320b8e9a418faa6254e7004c1dd Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 22:42:13 -0700
Subject: [PATCH 07/11] fix: bound remote generation setup and honor OpenCode
option terminators
Count execution-host profile resolution inside the existing request deadline, cancel its waiter promptly, and pass only the remaining time to the child. Shared bounded profile probes keep their existing cache lifetime; the SSH transport margin is unchanged.
Read OpenCode output format from active final-argv options before -- so literal prompt arguments cannot select the JSON finalizer.
Fresh exact-source controls reproduce nine failures before; 139 related checks pass after, including primary/fallback delays, deadline boundaries, cancellation, parser metadata, and SSH lanes. Node typecheck and strict changed-file lint pass.
---
src/relay/agent-exec-handler.ts | 67 +++---
.../agent-exec-shell-environment.test.ts | 202 +++++++++++++++++-
src/shared/commit-message-plan.test.ts | 54 +++++
src/shared/commit-message-plan.ts | 20 +-
4 files changed, 310 insertions(+), 33 deletions(-)
diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts
index b8d5e69f3303..377aac2fda94 100644
--- a/src/relay/agent-exec-handler.ts
+++ b/src/relay/agent-exec-handler.ts
@@ -1,4 +1,5 @@
import { mergeCommandEnvironment } from '../shared/command-environment'
+import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters'
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
@@ -115,10 +116,6 @@ export class AgentExecHandler {
// operation lanes let cancel target only the user-visible job that stopped.
private inFlightByLane = new Map()
- private laneKey(cwd: string, operation: unknown): string {
- return laneKeyFor(cwd, operation)
- }
-
constructor(dispatcher: RelayDispatcher) {
dispatcher.onRequest('agent.execNonInteractive', (p, context) =>
this.exec(p as ExecParams, context)
@@ -128,7 +125,7 @@ export class AgentExecHandler {
private async cancel(params: CancelParams): Promise<{ canceled: boolean }> {
const cwd = typeof params.cwd === 'string' ? params.cwd : ''
- const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation))
+ const entry = this.inFlightByLane.get(laneKeyFor(cwd, params.operation))
if (!entry) {
return { canceled: false }
}
@@ -146,33 +143,52 @@ export class AgentExecHandler {
const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null
const requestedTimeout =
typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS
- const timeoutMs = Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout))
+ const deadline = Date.now() + Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout))
const extraEnv =
params.env && typeof params.env === 'object' && !Array.isArray(params.env)
? (params.env as Record)
: null
let hostEnv = process.env
if (params.shell === true) {
- let canceled = false
- const key = this.laneKey(cwd ?? '', params.operation)
- const pending = {
- cancel: (): void => {
- canceled = true
- }
- }
+ const timeoutError = new Error('Profile resolution exceeded the request deadline')
+ const controller = new AbortController()
+ const key = laneKeyFor(cwd ?? '', params.operation)
+ const pending = { cancel: (): void => controller.abort() }
this.inFlightByLane.get(key)?.cancel()
this.inFlightByLane.set(key, pending)
+ context?.signal?.addEventListener('abort', pending.cancel, { once: true })
+ if (context?.signal?.aborted) {
+ pending.cancel()
+ }
try {
- hostEnv = await resolveLoginShellEnvironment({ env: process.env })
+ hostEnv = await new PromiseSettlementWaiters(
+ resolveLoginShellEnvironment({ env: process.env })
+ ).wait({
+ signal: controller.signal,
+ timeoutMs: Math.max(1, deadline - Date.now()),
+ createTimeoutError: () => timeoutError
+ })
+ } catch (error) {
+ if (error === timeoutError) {
+ return { stdout: '', stderr: '', exitCode: null, timedOut: true }
+ }
+ if (controller.signal.aborted) {
+ return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
+ }
+ throw error
} finally {
+ context?.signal?.removeEventListener('abort', pending.cancel)
if (this.inFlightByLane.get(key) === pending) {
this.inFlightByLane.delete(key)
}
}
- if (canceled || context?.signal?.aborted) {
+ if (controller.signal.aborted) {
return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
}
}
+ if (Date.now() >= deadline) {
+ return { stdout: '', stderr: '', exitCode: null, timedOut: true }
+ }
const baseEnv = mergeCommandEnvironment(hostEnv, extraEnv ? {} : undefined, process.platform)
const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform)
const spawnEnv = Object.fromEntries(
@@ -215,7 +231,7 @@ export class AgentExecHandler {
let timedOut = false
let canceled = false
let settled = false
- const laneKey = typeof cwd === 'string' ? this.laneKey(cwd, params.operation) : ''
+ const laneKey = typeof cwd === 'string' ? laneKeyFor(cwd, params.operation) : ''
let entry: InFlightExec | null = null
let timer: ReturnType | null = null
let detachChildListeners = (): void => {}
@@ -253,14 +269,17 @@ export class AgentExecHandler {
this.inFlightByLane.set(laneKey, entry)
}
- timer = setTimeout(() => {
- timedOut = true
- // Why: tree-kill because some CLIs trap SIGTERM and continue streaming;
- // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child
- // is not the real node.exe process.
- terminateRelaySubprocessTree(child)
- finish({ stdout, stderr, exitCode: null, timedOut, canceled })
- }, timeoutMs)
+ timer = setTimeout(
+ () => {
+ timedOut = true
+ // Why: tree-kill because some CLIs trap SIGTERM and continue streaming;
+ // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child
+ // is not the real node.exe process.
+ terminateRelaySubprocessTree(child)
+ finish({ stdout, stderr, exitCode: null, timedOut, canceled })
+ },
+ Math.max(1, deadline - Date.now())
+ )
const onStdoutData = (chunk: Buffer): void => {
stdoutBytes += chunk.byteLength
diff --git a/src/relay/agent-exec-shell-environment.test.ts b/src/relay/agent-exec-shell-environment.test.ts
index 2f64c21e145b..1c51a563fec9 100644
--- a/src/relay/agent-exec-shell-environment.test.ts
+++ b/src/relay/agent-exec-shell-environment.test.ts
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process'
import type * as ChildProcess from 'node:child_process'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
@@ -13,6 +13,10 @@ vi.mock('../main/startup/login-shell-environment', () => ({
}))
describe('relay headless generation shell environment', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
it('uses the execution host profile PATH and retains explicit command overrides', async () => {
vi.mocked(resolveLoginShellEnvironment).mockResolvedValue({
PATH: '/profile/bin:/usr/bin',
@@ -62,3 +66,199 @@ describe('relay headless generation shell environment', () => {
expect(spawn).not.toHaveBeenCalled()
})
})
+
+describe('relay generation deadline includes profile resolution', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ it('counts both five-second primary and fallback profile probes in the generation budget', async () => {
+ vi.useFakeTimers()
+ vi.mocked(resolveLoginShellEnvironment).mockImplementation(
+ () =>
+ new Promise((resolve) =>
+ setTimeout(() => {
+ setTimeout(() => resolve({ PATH: '/profile/bin' }), 5_000)
+ }, 5_000)
+ )
+ )
+ const child = createFakeChild()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
+ vi.mocked(spawn).mockReturnValue(child as never)
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 12_000 },
+ requestContext()
+ )
+ let result: unknown
+ void pending?.then((value) => {
+ result = value
+ })
+ await vi.advanceTimersByTimeAsync(10_000)
+ expect(spawn).toHaveBeenCalledTimes(1)
+ await vi.advanceTimersByTimeAsync(1_999)
+ expect(result).toBeUndefined()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(result).toMatchObject({ timedOut: true, exitCode: null })
+ child.emit('close', 0)
+ await pending
+ })
+
+ it('settles at the request deadline while the shared profile probe remains pending', async () => {
+ vi.useFakeTimers()
+ let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
+ vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
+ new Promise((resolve) => {
+ resolveProfile = resolve
+ })
+ )
+ const child = createFakeChild()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
+ vi.mocked(spawn).mockReturnValue(child as never)
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
+ requestContext()
+ )
+ let result: unknown
+ void pending?.then((value) => {
+ result = value
+ })
+ await vi.advanceTimersByTimeAsync(1_000)
+ try {
+ expect(result).toMatchObject({ timedOut: true, exitCode: null })
+ expect(spawn).not.toHaveBeenCalled()
+ } finally {
+ resolveProfile({ PATH: '/profile/bin' })
+ await vi.advanceTimersByTimeAsync(0)
+ child.emit('close', 0)
+ await pending
+ }
+ expect(spawn).not.toHaveBeenCalled()
+ })
+
+ it.each(['cancel', 'abort'] as const)(
+ 'settles %s immediately while the shared profile probe remains pending',
+ async (kind) => {
+ vi.useFakeTimers()
+ let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
+ vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
+ new Promise((resolve) => {
+ resolveProfile = resolve
+ })
+ )
+ const handlers = createHandlers()
+ const controller = new AbortController()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ {
+ binary: 'opencode',
+ args: ['run'],
+ cwd: '/repo',
+ operation: 'commit-message',
+ shell: true
+ },
+ { ...requestContext(), signal: controller.signal }
+ )
+ let result: unknown
+ void pending?.then((value) => {
+ result = value
+ })
+ if (kind === 'cancel') {
+ await handlers.get('agent.cancelExec')?.(
+ { cwd: '/repo', operation: 'commit-message' },
+ requestContext()
+ )
+ } else {
+ controller.abort()
+ }
+ await vi.advanceTimersByTimeAsync(0)
+ try {
+ expect(result).toMatchObject({ canceled: true, timedOut: false })
+ expect(spawn).not.toHaveBeenCalled()
+ } finally {
+ resolveProfile({ PATH: '/profile/bin' })
+ await vi.advanceTimersByTimeAsync(0)
+ await pending
+ }
+ }
+ )
+})
+
+describe('relay deadline boundaries and profile failures', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ it('retains the final millisecond of the request budget without resetting the minimum timeout', async () => {
+ vi.useFakeTimers()
+ vi.mocked(resolveLoginShellEnvironment).mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 999))
+ )
+ const child = createFakeChild()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
+ vi.mocked(spawn).mockReturnValue(child as never)
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
+ requestContext()
+ )
+ let result: unknown
+ void pending?.then((value) => {
+ result = value
+ })
+ await vi.advanceTimersByTimeAsync(999)
+ expect(spawn).toHaveBeenCalledTimes(1)
+ expect(result).toBeUndefined()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(result).toMatchObject({ timedOut: true })
+ child.emit('close', 0)
+ await pending
+ })
+
+ it('does not spawn when profile settlement consumes the whole request budget', async () => {
+ vi.useFakeTimers()
+ vi.mocked(resolveLoginShellEnvironment).mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 1_000))
+ )
+ const child = createFakeChild()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
+ vi.mocked(spawn).mockReturnValue(child as never)
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
+ requestContext()
+ )
+ let result: unknown
+ void pending?.then((value) => {
+ result = value
+ })
+ await vi.advanceTimersByTimeAsync(1_000)
+ try {
+ expect(result).toMatchObject({ timedOut: true })
+ expect(spawn).not.toHaveBeenCalled()
+ } finally {
+ child.emit('close', 0)
+ await pending
+ }
+ })
+
+ it('preserves a profile rejection and removes the pending cancellation lane', async () => {
+ const failure = new Error('profile resolution refused')
+ vi.mocked(resolveLoginShellEnvironment).mockRejectedValue(failure)
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true },
+ requestContext()
+ )
+ await expect(pending).rejects.toBe(failure)
+ expect(spawn).not.toHaveBeenCalled()
+ await expect(
+ handlers.get('agent.cancelExec')?.(
+ { cwd: '/repo', operation: 'commit-message' },
+ requestContext()
+ )
+ ).resolves.toEqual({ canceled: false })
+ })
+})
diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts
index ba6ab2b888d7..435c4b30c3d6 100644
--- a/src/shared/commit-message-plan.test.ts
+++ b/src/shared/commit-message-plan.test.ts
@@ -787,3 +787,57 @@ describe('backslash mode reaches every command the user can type (#11375)', () =
expect(plan.ok && plan.plan.args).toContain('/my dir')
})
})
+
+describe('OpenCode format metadata respects option terminators', () => {
+ it.each(['opencode', 'opencode2'] as const)(
+ 'ignores literal recipe format values for %s',
+ (agentId) => {
+ const result = planCommitMessageGeneration(
+ {
+ agentId,
+ model: 'opencode/gpt-5.4-mini',
+ agentArgs: '--format default -- --format json'
+ },
+ 'PROMPT'
+ )
+ expect(result.ok).toBe(true)
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ expect(result.plan.args).toContain('--')
+ expect(result.plan.outputFormat).toBeUndefined()
+ }
+ )
+
+ it('ignores literal equals-form flags after a command override terminator', () => {
+ const result = planCommitMessageGeneration(
+ {
+ agentId: 'opencode',
+ model: 'opencode/gpt-5.4-mini',
+ agentCommandOverride: 'opencode --format default -- --format=json'
+ },
+ 'PROMPT'
+ )
+ expect(result.ok).toBe(true)
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ expect(result.plan.outputFormat).toBeUndefined()
+ })
+
+ it('retains JSON metadata for the active option before a literal default value', () => {
+ const result = planCommitMessageGeneration(
+ {
+ agentId: 'opencode',
+ model: 'opencode/gpt-5.4-mini',
+ agentArgs: '--format=json -- --format default'
+ },
+ 'PROMPT'
+ )
+ expect(result.ok).toBe(true)
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ expect(result.plan.outputFormat).toBe('opencode-json')
+ })
+})
diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts
index f00158eb879c..8fe541c6028e 100644
--- a/src/shared/commit-message-plan.ts
+++ b/src/shared/commit-message-plan.ts
@@ -277,20 +277,24 @@ export function planCommitMessageGeneration(
promptDelivery: spec.promptDelivery,
prompt: argvPrompt
})
+ const generationArgs = mergeOpenCodeGenerationArgs(
+ input.agentId,
+ command.binary,
+ merged.prefixArgs,
+ args
+ )
+ const formatOption =
+ input.agentId === 'opencode' || input.agentId === 'opencode2'
+ ? findOptionOccurrence(generationArgs, ['--format'], true)
+ : null
return {
ok: true,
plan: {
binary: command.binary,
- args: mergeOpenCodeGenerationArgs(input.agentId, command.binary, merged.prefixArgs, args),
+ args: generationArgs,
stdinPayload: spec.promptDelivery === 'stdin' ? prompt : null,
label: spec.label,
- ...((input.agentId === 'opencode' || input.agentId === 'opencode2') &&
- [...merged.prefixArgs, ...args].some(
- (value, index, values) =>
- value === '--format=json' || (value === '--format' && values[index + 1] === 'json')
- )
- ? { outputFormat: 'opencode-json' as const }
- : {}),
+ ...(formatOption?.value === 'json' ? { outputFormat: 'opencode-json' as const } : {}),
...(command.env ? { env: command.env } : {})
}
}
From 055871ade60efa58cf1a9e24bdaac5f0cd30eccd Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:01:12 -0700
Subject: [PATCH 08/11] Continue Antigravity IDE and 2.0 history in new CLI
conversations (#24692)
* feat(antigravity): bridge IDE history into new CLI conversations
* fix(antigravity): preserve fresh-launch model and environment for IDE references
* fix(antigravity): forward IDE history opt-in through desktop IPC
* fix(antigravity): rebuild remote IDE reference startup on its host
* fix(antigravity): register IDE continuation action labels
* fix(antigravity): confine IDE references and bound metadata reads
* fix(antigravity): localize IDE continuation badges
* Preserve scanner service cache assertions and refresh Antigravity opening metadata
* Preserve Antigravity opening joins and target folder runtime authority
---
.../ai-vault/antigravity-history-prompt.ts | 12 +
.../ai-vault/antigravity-ide-history.test.ts | 228 ++++++++++++
.../antigravity-index-file-admission.test.ts | 122 ++++++
.../ai-vault/antigravity-index-reader.test.ts | 67 ++++
src/main/ai-vault/antigravity-index-reader.ts | 48 +++
...tigravity-opening-prompt-workspace.test.ts | 205 ++++++++++
.../antigravity-recorded-history.test.ts | 60 +++
.../antigravity-transcript-candidates.ts | 100 +++++
src/main/ai-vault/cached-session-list.ts | 6 +-
.../remote-session-scan-concurrency.ts | 10 +-
.../remote-session-scanner-discovery.ts | 6 +-
.../remote-session-scanner-sources.ts | 18 +-
.../ai-vault/remote-session-scanner-types.ts | 7 +-
src/main/ai-vault/remote-session-scanner.ts | 38 +-
src/main/ai-vault/runtime-session-scanner.ts | 1 +
...on-parse-cache-antigravity-upgrade.test.ts | 110 ++++++
.../session-parse-cache-bounds.test.ts | 16 +-
.../session-parse-cache-persistence.test.ts | 1 +
...sion-parse-cache-snapshot-serialization.ts | 2 +-
src/main/ai-vault/session-root-dedup.ts | 18 +
.../ai-vault/session-scanner-accumulator.ts | 3 +
.../session-scanner-antigravity-history.ts | 120 ++++--
.../session-scanner-antigravity-metadata.ts | 84 +++++
.../session-scanner-antigravity-parser.ts | 33 +-
.../session-scanner-antigravity-paths.ts | 4 +-
.../session-scanner-antigravity-sources.ts | 23 +-
.../ai-vault/session-scanner-candidates.ts | 7 +-
...sion-scanner-parser-stream-cleanup.test.ts | 15 +-
src/main/ai-vault/session-scanner-types.ts | 5 +
src/main/ai-vault/session-scanner.ts | 9 +-
src/main/ai-vault/ssh-session-list.ts | 7 +
src/main/ipc/ai-vault-runtime-scan.ts | 3 +
src/main/ipc/ai-vault.test.ts | 7 +-
src/main/ipc/ai-vault.ts | 20 +-
src/main/runtime/rpc/methods/ai-vault.test.ts | 16 +
src/main/runtime/rpc/methods/ai-vault.ts | 3 +-
src/relay/ai-vault-handler.ts | 4 +
src/relay/ai-vault-service-entry.ts | 1 +
src/relay/ai-vault-transcript-stream.ts | 16 +-
.../components/right-sidebar/AiVaultPanel.tsx | 2 +
.../right-sidebar/AiVaultSessionDetails.tsx | 22 +-
.../right-sidebar/AiVaultSessionRow.tsx | 6 +-
.../right-sidebar/AiVaultVirtualRow.test.tsx | 23 +-
.../right-sidebar/AiVaultVirtualRow.tsx | 15 +-
.../ai-vault-session-launch-target.ts | 4 +-
.../right-sidebar/ai-vault-session-refresh.ts | 1 +
.../ai-vault-session-resume-in-chat.ts | 3 +-
.../right-sidebar/ai-vault-session-resume.ts | 7 +-
.../ai-vault-session-row-display.tsx | 18 +
.../tab-group/AiVaultSessionDropLayer.tsx | 4 +-
src/renderer/src/i18n/locales/en.json | 5 +
...i-vault-antigravity-reference-host.test.ts | 351 ++++++++++++++++++
.../ai-vault-antigravity-reference-startup.ts | 81 ++++
.../src/lib/ai-vault-resume-command.test.ts | 42 +++
.../src/lib/ai-vault-resume-command.ts | 23 ++
src/renderer/src/lib/ai-vault-resume-shell.ts | 50 +++
.../src/lib/ai-vault-resume-target.ts | 30 +-
.../src/lib/local-preflight-context.ts | 37 +-
.../lib/local-project-runtime-workspace.ts | 57 +++
.../src/web/preload-api/web-ai-vault-api.ts | 1 +
src/shared/ai-vault-resume-command.ts | 11 +-
src/shared/ai-vault-types.ts | 4 +
src/shared/antigravity-session-origin.ts | 27 ++
.../antigravity-transcript-reference.test.ts | 88 +++++
src/shared/node-bounded-file-reader.ts | 33 +-
src/shared/rpc-contract/ai-vault-params.ts | 1 +
src/shared/ssh-ai-vault-relay.ts | 1 +
67 files changed, 2256 insertions(+), 146 deletions(-)
create mode 100644 src/main/ai-vault/antigravity-history-prompt.ts
create mode 100644 src/main/ai-vault/antigravity-ide-history.test.ts
create mode 100644 src/main/ai-vault/antigravity-index-file-admission.test.ts
create mode 100644 src/main/ai-vault/antigravity-index-reader.test.ts
create mode 100644 src/main/ai-vault/antigravity-index-reader.ts
create mode 100644 src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts
create mode 100644 src/main/ai-vault/antigravity-recorded-history.test.ts
create mode 100644 src/main/ai-vault/antigravity-transcript-candidates.ts
create mode 100644 src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts
create mode 100644 src/main/ai-vault/session-scanner-antigravity-metadata.ts
create mode 100644 src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts
create mode 100644 src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts
create mode 100644 src/renderer/src/lib/local-project-runtime-workspace.ts
create mode 100644 src/shared/antigravity-session-origin.ts
create mode 100644 src/shared/antigravity-transcript-reference.test.ts
diff --git a/src/main/ai-vault/antigravity-history-prompt.ts b/src/main/ai-vault/antigravity-history-prompt.ts
new file mode 100644
index 000000000000..cc40dd3010af
--- /dev/null
+++ b/src/main/ai-vault/antigravity-history-prompt.ts
@@ -0,0 +1,12 @@
+import { createHash } from 'node:crypto'
+import { normalizeTitleText } from './session-scanner-values'
+
+export function antigravityHistoryPromptHash(value: unknown): string | null {
+ if (typeof value !== 'string' || value.length > 4096) {
+ return null
+ }
+ const prompt = normalizeTitleText(value)
+ return prompt && !prompt.endsWith('...')
+ ? createHash('sha256').update(prompt).digest('hex')
+ : null
+}
diff --git a/src/main/ai-vault/antigravity-ide-history.test.ts b/src/main/ai-vault/antigravity-ide-history.test.ts
new file mode 100644
index 000000000000..3a10b67c7b4f
--- /dev/null
+++ b/src/main/ai-vault/antigravity-ide-history.test.ts
@@ -0,0 +1,228 @@
+import { runProcess } from '../../shared/child-process/run-process'
+import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { scanAiVaultSessions } from './session-scanner'
+import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures'
+import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
+import { scanRemoteAiVaultSessions } from './remote-session-scanner'
+import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { dedupeScannedSessions } from './session-root-dedup'
+import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
+import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
+import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
+
+const temporaryDirectories: string[] = []
+const conversationId = 'same-conversation-id'
+const origins = ['antigravity-cli', 'antigravity-ide', 'antigravity'] as const
+function records(prompt = 'Continue this verified example') {
+ return [
+ {
+ source: 'USER_EXPLICIT',
+ type: 'USER_INPUT',
+ created_at: '2026-10-02T01:00:00Z',
+ content: `${prompt} `
+ }
+ ]
+}
+async function temporaryHome(): Promise {
+ const home = await mkdtemp(join(tmpdir(), 'orca-agy-ide-'))
+ temporaryDirectories.push(home)
+ return home
+}
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((home) => rm(home, { recursive: true, force: true }))
+ )
+})
+
+describe('Antigravity IDE history discovery', () => {
+ it('requires client opt-in and keeps equal IDs in separate origins while selecting the full transcript', async () => {
+ const home = await temporaryHome()
+ for (const origin of origins) {
+ const path = await writeAntigravityTranscript(
+ join(home, '.gemini', origin, 'brain'),
+ conversationId,
+ records(origin)
+ )
+ await writeFile(
+ join(dirname(path), 'transcript_full.jsonl'),
+ `${jsonLines(records(origin))}\n`
+ )
+ await writeFile(
+ join(dirname(path), 'artifact.jsonl'),
+ `${jsonLines(records('Not history'))}\n`
+ )
+ }
+ const options = {
+ ...isolatedScanRoots(home),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home
+ }
+ const legacy = await scanAiVaultSessions(options)
+ expect(legacy.sessions).toHaveLength(1)
+ expect(antigravitySessionOrigin(legacy.sessions[0]!.filePath)).toBe('antigravity-cli')
+ const result = await scanAiVaultSessions({ ...options, includeAntigravityIdeSessions: true })
+ expect(result.issues).toEqual([])
+ expect(result.sessions).toHaveLength(3)
+ expect(new Set(result.sessions.map((session) => session.id)).size).toBe(3)
+ for (const session of result.sessions) {
+ expect(session.filePath).toContain('transcript_full.jsonl')
+ if (antigravitySessionOrigin(session.filePath) === 'antigravity-cli') {
+ expect(session.resumeCommand).toContain('--conversation')
+ } else {
+ expect(session.resumeCommand).toContain('--prompt-interactive')
+ expect(session.resumeCommand).not.toContain('--conversation')
+ expect(session.resumeCommand).toContain(session.filePath)
+ }
+ }
+ })
+
+ it('joins bounded metadata by exact conversation ID within each origin and refuses conflicting workspaces', async () => {
+ const home = await temporaryHome()
+ for (const origin of origins) {
+ const brain = join(home, '.gemini', origin, 'brain')
+ await writeAntigravityTranscript(brain, conversationId, records('Same title across origins'))
+ const cache = join(dirname(brain), 'cache')
+ await mkdir(cache, { recursive: true })
+ await writeFile(
+ join(cache, 'conversation_metadata.json'),
+ JSON.stringify({
+ conversations: { [conversationId]: { summary: { ProjectID: 'project-id' } } }
+ })
+ )
+ await writeFile(
+ join(cache, 'projects.json'),
+ JSON.stringify({ 'project-id': `/projects/${origin}` })
+ )
+ if (origin === 'antigravity') {
+ await writeFile(
+ join(cache, 'last_conversations.json'),
+ JSON.stringify({ '/conflicting-workspace': conversationId })
+ )
+ }
+ }
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true
+ })
+ expect(result.sessions).toHaveLength(3)
+ for (const session of result.sessions) {
+ const origin = antigravitySessionOrigin(session.filePath)
+ expect(session.cwd).toBe(origin === 'antigravity' ? null : `/projects/${origin}`)
+ }
+ })
+
+ it('discovers IDE and 2.0 history in the selected WSL home without falling into sibling artifacts', async () => {
+ const home = await temporaryHome()
+ const wslHome = join(home, 'wsl-home')
+ for (const origin of origins) {
+ await writeAntigravityTranscript(
+ join(wslHome, '.gemini', origin, 'brain'),
+ conversationId,
+ records(origin)
+ )
+ }
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ wslHomeDirs: [wslHome],
+ includeAntigravityIdeSessions: true,
+ platform: 'linux'
+ })
+ expect(result.sessions).toHaveLength(3)
+ expect(result.sessions.every((session) => session.filePath.startsWith(wslHome))).toBe(true)
+ })
+
+ it('selects an older full sibling even when the raw discovery cap only retained its compact file', async () => {
+ const home = await temporaryHome()
+ const brain = join(home, '.gemini', 'antigravity-ide', 'brain')
+ const compact = await writeAntigravityTranscript(brain, conversationId, records())
+ const full = join(dirname(compact), 'transcript_full.jsonl')
+ await writeFile(full, `${jsonLines(records())}\n`)
+ await utimes(full, new Date('2026-10-01T00:00:00Z'), new Date('2026-10-01T00:00:00Z'))
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true,
+ limit: 1,
+ limitPerAgent: 1
+ })
+ expect(result.sessions).toHaveLength(1)
+ expect(result.sessions[0]?.filePath).toBe(full)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'lists all origins despite a FIFO metadata cache and retains transcript workspace fallback',
+ async () => {
+ const home = await temporaryHome()
+ for (const origin of origins) {
+ const brain = join(home, '.gemini', origin, 'brain')
+ await writeAntigravityTranscript(brain, conversationId, records(origin))
+ const cache = join(dirname(brain), 'cache')
+ await mkdir(cache, { recursive: true })
+ const result = await runProcess({
+ program: 'mkfifo',
+ args: [join(cache, 'projects.json')],
+ timeoutMs: 2000
+ })
+ expect(result.code).toBe(0)
+ }
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true
+ })
+ expect(result.sessions).toHaveLength(3)
+ expect(result.issues).toEqual([])
+ expect(result.sessions.every((session) => session.cwd === null)).toBe(true)
+ },
+ 5000
+ )
+
+ it('does not retain an oversized metadata file', async () => {
+ const home = await temporaryHome()
+ const file = join(home, 'oversized.json')
+ await writeFile(file, ' '.repeat(ANTIGRAVITY_INDEX_MAX_BYTES + 1))
+ expect(await readLocalAntigravityHistory(file)).toBeNull()
+ })
+
+ it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => {
+ const provider = new MemoryRemoteProvider()
+ const remoteHome = '/remote/home'
+ for (const origin of origins) {
+ const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs`
+ provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10)
+ provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11)
+ }
+ const read = vi.spyOn(provider, 'readFile')
+ const options = {
+ provider,
+ remoteHome,
+ hostPlatform: getRemoteHostPlatform('linux-x64'),
+ includeAntigravityIdeSessions: true
+ }
+ const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' })
+ const second = await scanRemoteAiVaultSessions({
+ ...options,
+ executionHostId: 'ssh:second-host'
+ })
+ expect(first.sessions).toHaveLength(3)
+ expect(second.sessions).toHaveLength(3)
+ expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6)
+ expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true)
+ expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true)
+ expect(
+ first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl'))
+ ).toBe(true)
+ const legacy = await scanRemoteAiVaultSessions({
+ ...options,
+ executionHostId: 'ssh:first-host',
+ includeAntigravityIdeSessions: false
+ })
+ expect(legacy.sessions).toHaveLength(1)
+ })
+})
diff --git a/src/main/ai-vault/antigravity-index-file-admission.test.ts b/src/main/ai-vault/antigravity-index-file-admission.test.ts
new file mode 100644
index 000000000000..9e5a734370b5
--- /dev/null
+++ b/src/main/ai-vault/antigravity-index-file-admission.test.ts
@@ -0,0 +1,122 @@
+import type * as NodeFsPromises from 'node:fs/promises'
+import { constants } from 'node:fs'
+import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { runProcess } from '../../shared/child-process/run-process'
+
+const { afterStat } = vi.hoisted(() => {
+ const afterStat: { run: null | (() => Promise) } = { run: null }
+ return { afterStat }
+})
+vi.mock('node:fs/promises', async (original) => {
+ const actual = await original()
+ return {
+ ...actual,
+ stat: async (...args: Parameters) => {
+ const result = await actual.stat(...args)
+ await afterStat.run?.()
+ return result
+ }
+ }
+})
+import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
+import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
+import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream'
+
+const roots: string[] = []
+async function filePath() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-regular-index-'))
+ roots.push(root)
+ return join(root, 'projects.json')
+}
+async function fifo(path: string) {
+ const result = await runProcess({ program: 'mkfifo', args: [path], timeoutMs: 2000 })
+ if (result.code !== 0) {
+ throw new Error('Could not create task-owned FIFO')
+ }
+}
+afterEach(async () => {
+ afterStat.run = null
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('bounded regular metadata reads', () => {
+ it.skipIf(process.platform === 'win32')(
+ 'rejects real FIFO paths and symlinks without waiting for a writer',
+ async () => {
+ const file = await filePath()
+ await fifo(file)
+ const alias = `${file}.link`
+ await symlink(file, alias)
+ for (const path of [file, alias]) {
+ expect(await readLocalAntigravityHistory(path)).toBeNull()
+ await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow(
+ 'regular file'
+ )
+ await expect(
+ readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next()
+ ).rejects.toThrow('regular file')
+ }
+ },
+ 2000
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'rejects a regular path replaced by a FIFO between stat and open',
+ async () => {
+ const file = await filePath()
+ await writeFile(file, '{}')
+ afterStat.run = async () => {
+ afterStat.run = null
+ await rm(file)
+ await fifo(file)
+ }
+ await expect(readNodeFileWithinLimit(file, 64, { regularFileOnly: true })).rejects.toThrow(
+ 'regular file'
+ )
+ expect(constants.O_NONBLOCK).toBeGreaterThan(0)
+ },
+ 2000
+ )
+
+ it('retains ordinary metadata and regular-file symlinks within the byte bound', async () => {
+ const file = await filePath()
+ await writeFile(file, '{"project":"/workspace"}')
+ expect(await readLocalAntigravityHistory(file)).toBe('{"project":"/workspace"}')
+ await expect(readNodeFileWithinLimit(file, 1, { regularFileOnly: true })).rejects.toThrow(
+ 'File too large'
+ )
+ })
+
+ it('rejects growth beyond the bound after admission', async () => {
+ const file = await filePath()
+ await writeFile(file, '{}')
+ afterStat.run = async () => {
+ afterStat.run = null
+ await writeFile(file, 'x'.repeat(65))
+ }
+ await expect(readNodeFileWithinLimit(file, 64, { regularFileOnly: true })).rejects.toThrow(
+ 'File too large'
+ )
+ })
+
+ it('propagates cancellation before open and after admission', async () => {
+ const file = await filePath()
+ await writeFile(file, '{}')
+ const controller = new AbortController()
+ controller.abort()
+ await expect(readLocalAntigravityHistory(file, controller.signal)).rejects.toMatchObject({
+ name: 'AbortError'
+ })
+ const next = new AbortController()
+ afterStat.run = async () => {
+ afterStat.run = null
+ next.abort()
+ }
+ await expect(readLocalAntigravityHistory(file, next.signal)).rejects.toMatchObject({
+ name: 'AbortError'
+ })
+ })
+})
diff --git a/src/main/ai-vault/antigravity-index-reader.test.ts b/src/main/ai-vault/antigravity-index-reader.test.ts
new file mode 100644
index 000000000000..603c3401d13a
--- /dev/null
+++ b/src/main/ai-vault/antigravity-index-reader.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from 'vitest'
+import { readRemoteAntigravityIndex } from './antigravity-index-reader'
+import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
+import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
+import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
+
+function provider(): RemoteSessionFilesystemProvider {
+ return {
+ readDir: async () => [],
+ stat: async () => ({ type: 'file', size: 2, mtime: 0 }),
+ readFile: async () => ({ content: '{}', isBinary: false })
+ }
+}
+
+describe('execution-host Antigravity metadata reads', () => {
+ it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => {
+ const host = provider()
+ const read = vi.fn(async function* () {
+ yield Buffer.from('{}')
+ })
+ host.readTranscriptBytes = read
+ expect(
+ await readRemoteAntigravityIndex(
+ limitRemoteScanFilesystemConcurrency(host),
+ '/host/projects.json'
+ )
+ ).toBe('{}')
+ expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, {
+ regularFileOnly: true,
+ maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
+ })
+ })
+
+ it('preserves the existing bounded read-file options on legacy providers', async () => {
+ const host = provider()
+ const read = vi.spyOn(host, 'readFile')
+ expect(
+ await readRemoteAntigravityIndex(
+ limitRemoteScanFilesystemConcurrency(host),
+ '/host/projects.json'
+ )
+ ).toBe('{}')
+ expect(read).toHaveBeenCalledWith('/host/projects.json', {
+ maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES
+ })
+ })
+
+ it('does not read directories or symlinks from legacy providers', async () => {
+ for (const type of ['directory', 'symlink'] as const) {
+ const host = provider()
+ host.stat = async () => ({ type, size: 0, mtime: 0 })
+ const read = vi.spyOn(host, 'readFile')
+ expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull()
+ expect(read).not.toHaveBeenCalled()
+ }
+ })
+
+ it('stops waiting for a legacy request that cannot cancel on the wire', async () => {
+ const host = provider()
+ host.readFile = () => new Promise(() => {})
+ const controller = new AbortController()
+ const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal)
+ await Promise.resolve()
+ controller.abort()
+ await expect(read).rejects.toMatchObject({ name: 'AbortError' })
+ })
+})
diff --git a/src/main/ai-vault/antigravity-index-reader.ts b/src/main/ai-vault/antigravity-index-reader.ts
new file mode 100644
index 000000000000..a14d4cde5036
--- /dev/null
+++ b/src/main/ai-vault/antigravity-index-reader.ts
@@ -0,0 +1,48 @@
+import {
+ ANTIGRAVITY_INDEX_MAX_BYTES,
+ readBoundedAntigravityIndex
+} from './session-scanner-antigravity-metadata'
+import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
+import {
+ abandonRemoteSessionScanOnCancel,
+ throwIfAiVaultScanCancelled
+} from './ai-vault-scan-cancellation'
+
+export async function readRemoteAntigravityIndex(
+ provider: RemoteSessionFilesystemProvider,
+ path: string,
+ signal?: AbortSignal
+): Promise {
+ try {
+ throwIfAiVaultScanCancelled(signal)
+ if (provider.readTranscriptBytes) {
+ return await abandonRemoteSessionScanOnCancel(
+ readBoundedAntigravityIndex(
+ provider.readTranscriptBytes(path, signal, {
+ regularFileOnly: true,
+ maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
+ })
+ ),
+ signal
+ )
+ }
+ const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal)
+ if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return null
+ }
+ const read = await abandonRemoteSessionScanOnCancel(
+ provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }),
+ signal
+ )
+ throwIfAiVaultScanCancelled(signal)
+ return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES
+ ? null
+ : read.content
+ } catch (error) {
+ throwIfAiVaultScanCancelled(signal)
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw error
+ }
+ return null
+ }
+}
diff --git a/src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts b/src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts
new file mode 100644
index 000000000000..54f294124ab8
--- /dev/null
+++ b/src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it } from 'vitest'
+import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
+import {
+ createAntigravitySessionResumeState,
+ parseAntigravitySessionContent
+} from './session-scanner-antigravity-parser'
+import { jsonLines } from './session-scanner-test-fixtures'
+
+const openingTime = '2026-07-15T11:39:10.000Z'
+const historyPath = '/home/ada/.gemini/antigravity-cli/history.jsonl'
+const file = {
+ path: '/home/ada/.gemini/antigravity-cli/brain/brain-id/.system_generated/logs/transcript.jsonl',
+ mtimeMs: Date.parse(openingTime),
+ modifiedAt: openingTime
+}
+
+function user(prompt: string, timestamp: string | null = openingTime) {
+ return {
+ source: 'USER_EXPLICIT',
+ type: 'USER_INPUT',
+ created_at: timestamp,
+ content: `${prompt} `
+ }
+}
+
+function openingRecords(prompt: string, timestamp: string | null = openingTime) {
+ return [
+ {
+ source: 'SYSTEM',
+ type: 'CHECKPOINT',
+ created_at: '2026-07-15T11:38:00.000Z',
+ content: 'Earlier system metadata is not the first prompt timestamp'
+ },
+ user(prompt, timestamp),
+ ...Array.from({ length: 7 }, (_, index) =>
+ user(`Later request ${index}`, `2026-07-15T11:4${index}:10.000Z`)
+ )
+ ]
+}
+
+function historyRow(display: string, workspace = '/repo/original', conversationId?: string) {
+ return { display, workspace, timestamp: Date.parse(openingTime) / 1000, conversationId }
+}
+
+function resolver(rows: ReturnType[]) {
+ return createAntigravityWorkspaceResolver(async (path) =>
+ path === historyPath ? jsonLines(rows) : null
+ )
+}
+
+async function parse(prompt: string, timestamp: string | null = openingTime) {
+ const session = await parseAntigravitySessionContent(
+ file,
+ jsonLines(openingRecords(prompt, timestamp)),
+ 'linux'
+ )
+ if (!session) {
+ throw new Error('Missing parsed fixture session')
+ }
+ expect(session.previewMessagesTruncated).toBe(true)
+ expect(session.previewMessages.every((message) => message.text !== prompt)).toBe(true)
+ expect(session.firstUserPrompt).toBeUndefined()
+ return session
+}
+
+describe('Antigravity opening prompt workspace association', () => {
+ it.each([undefined, 'history-id'])(
+ 'retains a long session workspace with row id %s',
+ async (id) => {
+ const prompt = 'Locate the original workspace'
+ const session = await parse(prompt)
+ expect(session.createdAt).not.toBe(openingTime)
+ expect(
+ (await resolver([historyRow(prompt, '/repo/original', id)]).enrich(session, historyPath))
+ .cwd
+ ).toBe('/repo/original')
+ }
+ )
+
+ it('refuses fallback when unrelated asks share a truncated opening title', async () => {
+ const prefix = 'A long instruction shared by several unrelated projects '.repeat(3)
+ const prompt = `${prefix}original task`
+ const session = await parse(prompt)
+ expect(session.title.endsWith('...')).toBe(true)
+ const rows = [historyRow(`${prefix}other task`, '/repo/other'), historyRow(prompt)]
+ expect((await resolver(rows).enrich(session, historyPath)).cwd).toBeNull()
+ })
+
+ it('normalizes opening whitespace consistently with history display', async () => {
+ const session = await parse('Locate\n the original workspace')
+ expect(
+ (await resolver([historyRow('Locate the original workspace')]).enrich(session, historyPath))
+ .cwd
+ ).toBe('/repo/original')
+ })
+
+ it('keeps the established title normalization of hidden opening context', async () => {
+ const session = await parse('Locate the workspaceHidden ')
+ expect(session.title).toBe('Locate the workspace')
+ expect(
+ (await resolver([historyRow('Locate the workspace')]).enrich(session, historyPath)).cwd
+ ).toBe('/repo/original')
+ })
+
+ it('refuses identical duplicate rows and keeps the two-second timestamp window', async () => {
+ const prompt = 'Original request'
+ const session = await parse(prompt)
+ expect(
+ (await resolver([historyRow(prompt), historyRow(prompt)]).enrich(session, historyPath)).cwd
+ ).toBeNull()
+ for (const [offset, expected] of [
+ [2, '/repo/original'],
+ [2.001, null]
+ ] as const) {
+ const row = historyRow(prompt)
+ row.timestamp += offset
+ expect((await resolver([row]).enrich(session, historyPath)).cwd).toBe(expected)
+ }
+ })
+
+ it('leaves conflicting prompt/time matches unknown even when one row has an id', async () => {
+ const prompt = 'Repeated request'
+ const rows = [historyRow(prompt), historyRow(prompt, '/repo/other', 'other-id')]
+ expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBeNull()
+ })
+
+ it('gives an exact id authority over a conflicting prompt match', async () => {
+ const prompt = 'Repeated request'
+ const rows = [historyRow(prompt), historyRow('Different display', '/repo/exact', 'brain-id')]
+ expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBe('/repo/exact')
+ })
+
+ it('does not rescue conflicting exact ids through the prompt fallback', async () => {
+ const prompt = 'Repeated request'
+ const rows = [
+ historyRow('One', '/repo/one', 'brain-id'),
+ historyRow('Two', '/repo/two', 'brain-id'),
+ historyRow(prompt)
+ ]
+ expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBeNull()
+ })
+
+ it('keeps ambiguous project metadata authoritative over history and prompt fallback', async () => {
+ const prompt = 'Original request'
+ const read = createAntigravityWorkspaceResolver(async (path) => {
+ if (path === historyPath) {
+ return jsonLines([historyRow(prompt, '/repo/history', 'brain-id')])
+ }
+ if (path.endsWith('projects.json')) {
+ return JSON.stringify({ '/repo/one': 'project', '/repo/two': 'project' })
+ }
+ if (path.endsWith('conversation_metadata.json')) {
+ return JSON.stringify({
+ conversations: { 'brain-id': { summary: { ProjectID: 'project' } } }
+ })
+ }
+ return null
+ })
+ expect((await read.enrich(await parse(prompt), historyPath)).cwd).toBeNull()
+ })
+
+ it('never joins a later preview turn or a title-only history row', async () => {
+ const session = await parse('Opening request')
+ const rows = [historyRow('Later request 3'), historyRow(session.title, '/repo/wrong')]
+ rows[1].timestamp -= 60
+ expect((await resolver(rows).enrich(session, historyPath)).cwd).toBeNull()
+ })
+
+ it.each([null, 'invalid timestamp'])(
+ 'does not substitute createdAt for opening time %s',
+ async (time) => {
+ const prompt = 'Opening request'
+ const session = await parse(prompt, time)
+ const row = historyRow(prompt)
+ row.timestamp = Date.parse(session.createdAt ?? '') / 1000
+ expect((await resolver([row]).enrich(session, historyPath)).cwd).toBeNull()
+ }
+ )
+
+ it('does not let a later short prompt replace an oversized opening prompt', async () => {
+ const session = await parse('x'.repeat(4097))
+ expect(
+ (await resolver([historyRow('Later request 3')]).enrich(session, historyPath)).cwd
+ ).toBeNull()
+ })
+
+ it('keeps the opening association across incremental clones and preview shifts', async () => {
+ const prompt = 'Original incremental request'
+ const state = createAntigravitySessionResumeState(file)
+ state.consumeLine(JSON.stringify(user(prompt)))
+ const clone = state.clone()
+ for (const record of openingRecords('Unrelated later prompt')) {
+ clone.consumeLine(JSON.stringify(record))
+ }
+ const session = await clone.finalize('linux')
+ if (!session) {
+ throw new Error('Missing cloned session')
+ }
+ expect(session.previewMessagesTruncated).toBe(true)
+ expect((await resolver([historyRow(prompt)]).enrich(session, historyPath)).cwd).toBe(
+ '/repo/original'
+ )
+ expect((await state.finalize('linux'))?.previewMessages).toHaveLength(1)
+ })
+})
diff --git a/src/main/ai-vault/antigravity-recorded-history.test.ts b/src/main/ai-vault/antigravity-recorded-history.test.ts
new file mode 100644
index 000000000000..64c7e8380c38
--- /dev/null
+++ b/src/main/ai-vault/antigravity-recorded-history.test.ts
@@ -0,0 +1,60 @@
+import { homedir, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
+import { expect, it } from 'vitest'
+import { scanAiVaultSessions } from './session-scanner'
+import { isolatedScanRoots } from './session-scanner-test-fixtures'
+import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
+
+it.skipIf(process.env.ORCA_REAL_ANTIGRAVITY_HISTORY !== '1')(
+ 'scans bounded existing official artifacts without publishing their content',
+ async () => {
+ const sandbox = await mkdtemp(join(tmpdir(), 'orca-agy-recorded-'))
+ try {
+ const home = homedir()
+ const rootPresence = await Promise.all(
+ ['antigravity-cli', 'antigravity-ide', 'antigravity'].map(async (origin) => {
+ const present = await stat(join(home, '.gemini', origin, 'brain')).then(
+ (value) => value.isDirectory(),
+ () => false
+ )
+ return { origin, present }
+ })
+ )
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(sandbox),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true,
+ limit: 5,
+ limitPerAgent: 10
+ })
+ expect(result.issues.length).toBe(0)
+ expect(result.sessions.length).toBeGreaterThan(0)
+ expect(result.sessions.length).toBeLessThanOrEqual(5)
+ expect(result.sessions.some((session) => session.messageCount > 0)).toBe(true)
+ const proof = {
+ provenance:
+ 'Existing official recorded artifacts through production scanner; no live IDE/model claim',
+ rootPresence,
+ sessionCount: result.sessions.length,
+ issueCount: result.issues.length,
+ origins: result.sessions.map((session) => ({
+ origin: antigravitySessionOrigin(session.filePath),
+ fullTranscript: session.filePath.endsWith('transcript_full.jsonl'),
+ messageCount: session.messageCount,
+ hasWorkspace: session.cwd !== null,
+ cliResume: session.resumeCommand.includes('--conversation')
+ }))
+ }
+ if (process.env.ORCA_ANTIGRAVITY_HISTORY_PROOF_PATH) {
+ await writeFile(
+ process.env.ORCA_ANTIGRAVITY_HISTORY_PROOF_PATH,
+ `${JSON.stringify(proof, null, 2)}\n`
+ )
+ }
+ } finally {
+ await rm(sandbox, { recursive: true, force: true })
+ }
+ }
+)
diff --git a/src/main/ai-vault/antigravity-transcript-candidates.ts b/src/main/ai-vault/antigravity-transcript-candidates.ts
new file mode 100644
index 000000000000..74e3370ffb31
--- /dev/null
+++ b/src/main/ai-vault/antigravity-transcript-candidates.ts
@@ -0,0 +1,100 @@
+import { dirname, join } from 'node:path'
+import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access'
+import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
+import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
+import { recordSessionScanIssue } from './session-scan-issues'
+import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
+
+function transcriptDirectory(file: FileWithMtime): string {
+ return file.path
+ .split(/[\\/]+/)
+ .slice(0, -1)
+ .join('/')
+}
+
+/** Keep the file's own stat intact; alias recency only drives discovery/cutoff. */
+export function prioritizeAntigravityTranscriptCandidates(
+ candidates: T[],
+ isAntigravity: (candidate: T) => boolean
+): T[] {
+ const newest = new Map()
+ for (const candidate of candidates) {
+ if (isAntigravity(candidate)) {
+ const key = transcriptDirectory(candidate.file)
+ newest.set(key, Math.max(newest.get(key) ?? 0, candidate.file.mtimeMs))
+ }
+ }
+ return candidates
+ .map((candidate) =>
+ isAntigravity(candidate)
+ ? {
+ ...candidate,
+ file: {
+ ...candidate.file,
+ aliasMtimeMs: newest.get(transcriptDirectory(candidate.file))
+ }
+ }
+ : candidate
+ )
+ .sort((left, right) => {
+ const difference = (candidateFileTime(right.file) ?? 0) - (candidateFileTime(left.file) ?? 0)
+ if (difference !== 0) {
+ return difference
+ }
+ if (
+ isAntigravity(left) &&
+ isAntigravity(right) &&
+ transcriptDirectory(left.file) === transcriptDirectory(right.file)
+ ) {
+ return (
+ Number(right.file.path.endsWith('transcript_full.jsonl')) -
+ Number(left.file.path.endsWith('transcript_full.jsonl'))
+ )
+ }
+ return 0
+ })
+}
+
+export function candidateFileTime(file: FileWithMtime | undefined): number | undefined {
+ return file?.aliasMtimeMs ?? file?.mtimeMs
+}
+
+/** The recency cap may retain a newer compact file but omit its older full sibling. */
+export async function completeAntigravityTranscriptPairs(
+ discovery: SessionFileDiscovery,
+ issues: AiVaultScanIssue[]
+): Promise {
+ const files = new Map(discovery.files.map((file) => [file.path, file]))
+ for (const file of discovery.files) {
+ const sibling = join(
+ dirname(file.path),
+ file.path.endsWith('transcript_full.jsonl') ? 'transcript.jsonl' : 'transcript_full.jsonl'
+ )
+ if (files.has(sibling)) {
+ continue
+ }
+ try {
+ const observed = await wslGatedLstat(sibling, 'scan')
+ if (observed.isFile()) {
+ files.set(sibling, {
+ path: sibling,
+ mtimeMs: observed.mtimeMs,
+ modifiedAt: new Date(observed.mtimeMs).toISOString(),
+ sizeBytes: observed.size,
+ dev: observed.dev,
+ ino: observed.ino,
+ nlink: observed.nlink
+ })
+ }
+ } catch (error) {
+ if (error instanceof WslTranscriptFsError) {
+ recordSessionScanIssue(issues, {
+ agent: 'antigravity',
+ path: sibling,
+ message: error.message
+ })
+ }
+ }
+ }
+ return { ...discovery, files: [...files.values()] }
+}
diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts
index 0d1cbabb7c82..3dff9a9bd8e3 100644
--- a/src/main/ai-vault/cached-session-list.ts
+++ b/src/main/ai-vault/cached-session-list.ts
@@ -85,7 +85,10 @@ export async function listAiVaultSessions(
options: { signal?: AbortSignal } = {}
): Promise {
// Scope paths change the result set, so they must be part of the cache key.
- const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() })
+ const key = JSON.stringify({
+ scopePaths: [...new Set(args?.scopePaths ?? [])].sort(),
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
+ })
const depth = requestedAiVaultSessionDepth(args)
const scanKey = JSON.stringify({ key, depth })
const now = Date.now()
@@ -113,6 +116,7 @@ export async function listAiVaultSessions(
start: async (scanSignal) => {
const result = await scanAiVaultSessionsInBackground(
{
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths: args?.scopePaths,
diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts
index a336af84a069..7fe45d9f1a09 100644
--- a/src/main/ai-vault/remote-session-scan-concurrency.ts
+++ b/src/main/ai-vault/remote-session-scan-concurrency.ts
@@ -16,11 +16,15 @@ export function limitRemoteScanFilesystemConcurrency(
return {
openCode: provider.openCode,
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
- readFile: (filePath) => gate(() => provider.readFile(filePath)),
+ readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)),
stat: (filePath) => gate(() => provider.stat(filePath)),
...(provider.readTranscriptBytes
? {
- readTranscriptBytes: async function* (path: string, signal?: AbortSignal) {
+ readTranscriptBytes: async function* (
+ path: string,
+ signal?: AbortSignal,
+ options?: { regularFileOnly: true; maxBytes: number }
+ ) {
let enter!: () => void
let release!: () => void
const entered = new Promise((resolve) => {
@@ -35,7 +39,7 @@ export function limitRemoteScanFilesystemConcurrency(
})
await entered
try {
- yield* provider.readTranscriptBytes!(path, signal)
+ yield* provider.readTranscriptBytes!(path, signal, options)
} finally {
release()
await held
diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts
index f6b13b24e306..9f01855097cf 100644
--- a/src/main/ai-vault/remote-session-scanner-discovery.ts
+++ b/src/main/ai-vault/remote-session-scanner-discovery.ts
@@ -121,7 +121,11 @@ async function listRemoteFixedChildFiles(
// serialized SSH readDir round trips for every conversation directory.
return entries
.filter((entry) => entry.isDirectory && !entry.isSymlink)
- .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments))
+ .flatMap((entry) =>
+ [segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) =>
+ joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments)
+ )
+ )
.filter((path) => source.filePredicate?.(path) ?? true)
}
diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts
index 16b0938fee9a..88d5a7ad2ebe 100644
--- a/src/main/ai-vault/remote-session-scanner-sources.ts
+++ b/src/main/ai-vault/remote-session-scanner-sources.ts
@@ -1,3 +1,7 @@
+import {
+ ANTIGRAVITY_HISTORY_ROOTS,
+ type AntigravitySessionOrigin
+} from '../../shared/antigravity-session-origin'
import { parseQoderSessionContent } from './session-scanner-qoder-parser'
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
import type { RemoteSessionContent } from './remote-session-content-lines'
@@ -48,7 +52,8 @@ type RemoteContentParser = (
export function remoteSessionSources(
remoteHome: string,
- hostPlatform: RemoteHostPlatform
+ hostPlatform: RemoteHostPlatform,
+ includeAntigravityIdeSessions = false
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
@@ -88,7 +93,10 @@ export function remoteSessionSources(
),
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
},
- remoteAntigravitySource(remoteHome, hostPlatform),
+ ...(includeAntigravityIdeSessions
+ ? ANTIGRAVITY_HISTORY_ROOTS
+ : (['antigravity-cli'] as const)
+ ).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)),
source(
'gemini',
remoteHome,
@@ -168,9 +176,10 @@ export function remoteSessionSources(
function remoteAntigravitySource(
remoteHome: string,
- hostPlatform: RemoteHostPlatform
+ hostPlatform: RemoteHostPlatform,
+ origin: AntigravitySessionOrigin
): RemoteSessionSource {
- const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli')
+ const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin)
const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl')
const parse = async (
file: FileWithMtime,
@@ -192,6 +201,7 @@ function remoteAntigravitySource(
extensions: ['.jsonl'],
filePredicate: isAntigravityTranscriptPath,
fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'],
+ additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']],
parse,
parseLines: parse
}
diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts
index 408a2ca47be7..1ae848585715 100644
--- a/src/main/ai-vault/remote-session-scanner-types.ts
+++ b/src/main/ai-vault/remote-session-scanner-types.ts
@@ -27,7 +27,11 @@ export type RemoteSessionFilesystemProvider = Pick<
'readDir' | 'readFile' | 'stat'
> & {
/** Available only beside the execution host's disk; never opens a client path. */
- readTranscriptBytes?: (path: string, signal?: AbortSignal) => AsyncIterable
+ readTranscriptBytes?: (
+ path: string,
+ signal?: AbortSignal,
+ options?: { regularFileOnly: true; maxBytes: number }
+ ) => AsyncIterable
/** Execution-host database access; absent from remote filesystem RPC providers. */
openCode?: RemoteOpenCodeSessionReader
}
@@ -55,6 +59,7 @@ export type RemoteSessionSource = {
directoryPredicate?: (name: string, depth: number) => boolean
// A canonical file directly beneath every top-level session directory.
fixedChildFileSegments?: readonly string[]
+ additionalFixedChildFileSegments?: readonly (readonly string[])[]
// Sibling-subagent layouts (Claude `/subagents/`, OMP's same-named
// artifact dir): count subagent transcripts from the walked listing and drop
// them from candidates instead of indexing them as sessions.
diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts
index 17f9bdc35a8e..74d5a0d22aa8 100644
--- a/src/main/ai-vault/remote-session-scanner.ts
+++ b/src/main/ai-vault/remote-session-scanner.ts
@@ -1,3 +1,8 @@
+import {
+ candidateFileTime,
+ prioritizeAntigravityTranscriptCandidates
+} from './antigravity-transcript-candidates'
+import { readRemoteAntigravityIndex } from './antigravity-index-reader'
import { parseRemoteSessionTranscript } from './remote-session-transcript-read'
import { BinarySessionTranscriptError } from './remote-session-content-lines'
import type {
@@ -52,6 +57,7 @@ export async function scanRemoteAiVaultSessions(args: {
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
+ includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
scopePaths?: readonly string[]
@@ -69,25 +75,19 @@ export async function scanRemoteAiVaultSessions(args: {
hostPlatform: args.hostPlatform,
signal: args.signal,
titleCaches: new Map(),
- antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => {
- try {
- throwIfAiVaultScanCancelled(args.signal)
- const read = await provider.readFile(historyPath)
- throwIfAiVaultScanCancelled(args.signal)
- return read.isBinary ? null : read.content
- } catch (error) {
- if (error instanceof Error && error.name === 'AbortError') {
- throw error
- }
- return null
- }
- })
+ antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) =>
+ readRemoteAntigravityIndex(provider, path, args.signal)
+ )
}
- const candidates = dedupeCodexRolloutFileAliases(
+ const discoveredCandidates = dedupeCodexRolloutFileAliases(
(
await mapRemoteScanBatches(
[
- ...remoteSessionSources(args.remoteHome, args.hostPlatform),
+ ...remoteSessionSources(
+ args.remoteHome,
+ args.hostPlatform,
+ args.includeAntigravityIdeSessions
+ ),
...remoteOpenCodeSources(
provider.openCode,
limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER +
@@ -109,6 +109,10 @@ export async function scanRemoteAiVaultSessions(args: {
}
)
+ const candidates = prioritizeAntigravityTranscriptCandidates(
+ discoveredCandidates,
+ (candidate) => candidate.source.agent === 'antigravity'
+ )
const parsed = await parseRemoteSessionCandidates({
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
context,
@@ -153,7 +157,9 @@ async function parseRemoteSessionCandidates(args: {
let index = 0
while (index < args.candidates.length) {
- if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
+ if (
+ canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
+ ) {
break
}
diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts
index b876c029b451..1640f2f4b30c 100644
--- a/src/main/ai-vault/runtime-session-scanner.ts
+++ b/src/main/ai-vault/runtime-session-scanner.ts
@@ -56,6 +56,7 @@ export async function scanRuntimeAiVaultSessions(
environmentId,
'aiVault.listSessions',
{
+ includeAntigravityIdeSessions: args.includeAntigravityIdeSessions,
limit: args.limit,
unlimited: args.unlimited,
force: args.force,
diff --git a/src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts b/src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts
new file mode 100644
index 000000000000..4a9da9af3e20
--- /dev/null
+++ b/src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts
@@ -0,0 +1,110 @@
+import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import {
+ ensureSessionParseCacheLoaded,
+ flushSessionParseCachePersist,
+ initSessionParseCachePersistence,
+ resetSessionParseCachePersistenceForTests,
+ scheduleSessionParseCachePersist
+} from './session-parse-cache-persistence'
+import {
+ createSessionParseStats,
+ parseAgentSessionFileCached,
+ resetSessionParseCacheForTests,
+ snapshotSessionParseCacheForPersistence
+} from './session-scanner-parse-cache'
+import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
+import { writeAntigravityTranscript } from './session-scanner-test-fixtures'
+import type { SessionFileCandidate } from './session-scanner-types'
+
+let root: string | undefined
+afterEach(async () => {
+ resetSessionParseCacheForTests()
+ resetSessionParseCachePersistenceForTests()
+ if (root) {
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+async function candidate(): Promise {
+ root = await mkdtemp(join(tmpdir(), 'orca-agy-cache-upgrade-'))
+ const path = await writeAntigravityTranscript(
+ join(root, 'brain'),
+ 'conversation',
+ Array.from({ length: 8 }, (_, index) => ({
+ source: 'USER',
+ type: 'REQUEST',
+ created_at: new Date(Date.parse('2026-07-15T11:39:10Z') + index * 60_000).toISOString(),
+ content: index === 0 ? 'Original request' : `Follow-up ${index}`
+ }))
+ )
+ const fileStat = await stat(path)
+ return {
+ agent: 'antigravity',
+ codexHome: null,
+ file: {
+ path,
+ mtimeMs: fileStat.mtimeMs,
+ modifiedAt: fileStat.mtime.toISOString(),
+ sizeBytes: fileStat.size
+ }
+ }
+}
+
+it('reparses an unchanged pre-fix cached Antigravity transcript after an update', async () => {
+ const input = await candidate()
+ await parseAgentSessionFileCached(input, 'linux')
+ const entries = snapshotSessionParseCacheForPersistence().map(([path, entry]) => [
+ path,
+ {
+ ...entry,
+ session: entry.session && { ...entry.session, antigravityOpeningPrompt: undefined }
+ }
+ ])
+ const cacheFile = join(root ?? '', 'cache.json')
+ await writeFile(cacheFile, JSON.stringify({ schemaVersion: 3, appVersion: 'pre-fix', entries }))
+ resetSessionParseCacheForTests()
+ initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'fixed' })
+ await ensureSessionParseCacheLoaded()
+ const stats = createSessionParseStats()
+ const session = await parseAgentSessionFileCached(input, 'linux', stats)
+ expect(stats.fullParses).toBe(1)
+ expect(stats.reused).toBe(0)
+ if (!session) {
+ throw new Error('Missing reparsed session')
+ }
+ expect(session.previewMessagesTruncated).toBe(true)
+ const resolver = createAntigravityWorkspaceResolver(async (path) =>
+ path === 'history.jsonl'
+ ? JSON.stringify({
+ display: 'Original request',
+ timestamp: Date.parse('2026-07-15T11:39:10Z'),
+ workspace: '/repo/original'
+ })
+ : null
+ )
+ expect((await resolver.enrich(session, 'history.jsonl')).cwd).toBe('/repo/original')
+})
+
+it('retains the opening identity in current caches without retaining full prompt text', async () => {
+ const input = await candidate()
+ const cacheFile = join(root ?? '', 'cache.json')
+ initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'fixed' })
+ const stats = createSessionParseStats()
+ const original = await parseAgentSessionFileCached(input, 'linux', stats)
+ scheduleSessionParseCachePersist(stats)
+ await flushSessionParseCachePersist()
+ resetSessionParseCacheForTests()
+ resetSessionParseCachePersistenceForTests()
+ initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'next-release' })
+ await ensureSessionParseCacheLoaded()
+ const reusedStats = createSessionParseStats()
+ const reused = await parseAgentSessionFileCached(input, 'linux', reusedStats)
+ expect(reused).toEqual(original)
+ expect(reusedStats.reused).toBe(1)
+ expect(reusedStats.fullParses).toBe(0)
+ expect(reused?.antigravityOpeningPrompt?.timestamp).toBe('2026-07-15T11:39:10.000Z')
+ expect(reused?.firstUserPrompt).toBeUndefined()
+})
diff --git a/src/main/ai-vault/session-parse-cache-bounds.test.ts b/src/main/ai-vault/session-parse-cache-bounds.test.ts
index 6ad56a3e4f5f..51b7b293a182 100644
--- a/src/main/ai-vault/session-parse-cache-bounds.test.ts
+++ b/src/main/ai-vault/session-parse-cache-bounds.test.ts
@@ -25,6 +25,7 @@ import {
import {
SESSION_PARSE_CACHE_JSON_LIMITS,
SESSION_PARSE_CACHE_MAX_BYTES,
+ SESSION_PARSE_CACHE_SCHEMA_VERSION,
serializeSessionParseCacheSnapshotPiecesCooperatively
} from './session-parse-cache-snapshot-serialization'
@@ -59,7 +60,14 @@ it('loads the newest 4096 unique rows across duplicate-heavy input and keeps in-
entries.push(...Array.from({ length: 5000 }, (_, index) => row('p4199', 5000 + index)))
entries.push(row('in-process', 1))
seedSessionParseCache([row('in-process', 20_000)])
- await writeFile(file, JSON.stringify({ schemaVersion: 3, appVersion: 'old-release', entries }))
+ await writeFile(
+ file,
+ JSON.stringify({
+ schemaVersion: SESSION_PARSE_CACHE_SCHEMA_VERSION,
+ appVersion: 'old-release',
+ entries
+ })
+ )
await ensureSessionParseCacheLoaded()
expect(snapshotSessionParseCacheForPersistence()).toHaveLength(4096)
expect(getSessionParseCacheEntry('p4199')?.mtimeMs).toBe(9999)
@@ -93,10 +101,10 @@ it('rejects an oversized sparse file before decoding and preserves resident work
it('rejects excessive tokens, depth, and malformed older rows before seeding any tail', async () => {
for (const raw of [
- `{"schemaVersion":3,"appVersion":"old","entries":[],"wide":[${'0,'.repeat(1_000_000)}0]}`,
- `{"schemaVersion":3,"appVersion":"old","entries":[],"deep":${'['.repeat(33)}0${']'.repeat(33)}}`,
+ `{"schemaVersion":${SESSION_PARSE_CACHE_SCHEMA_VERSION},"appVersion":"old","entries":[],"wide":[${'0,'.repeat(1_000_000)}0]}`,
+ `{"schemaVersion":${SESSION_PARSE_CACHE_SCHEMA_VERSION},"appVersion":"old","entries":[],"deep":${'['.repeat(33)}0${']'.repeat(33)}}`,
JSON.stringify({
- schemaVersion: 3,
+ schemaVersion: SESSION_PARSE_CACHE_SCHEMA_VERSION,
appVersion: 'old',
entries: [['bad', {}], ...Array.from({ length: 4200 }, (_, index) => row(`p${index}`, index))]
})
diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts
index 32328e898370..5be88cfe669e 100644
--- a/src/main/ai-vault/session-parse-cache-persistence.test.ts
+++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts
@@ -162,6 +162,7 @@ const CACHED_SESSION_FIELDS = {
totalTokens: true,
previewMessages: true,
previewMessagesTruncated: true,
+ antigravityOpeningPrompt: true,
firstUserPrompt: true,
lastUserPrompt: true,
queuedMessageCount: true,
diff --git a/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts b/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts
index 9900dcfa8de7..a3a3fecdda23 100644
--- a/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts
+++ b/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts
@@ -11,7 +11,7 @@ import {
} from '../../shared/node-bounded-json-stringify'
import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store'
-export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 3
+export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 4
export const SESSION_PARSE_CACHE_MAX_BYTES = 64 * 1024 * 1024
export const SESSION_PARSE_CACHE_JSON_LIMITS = {
structuralTokens: 1_000_000,
diff --git a/src/main/ai-vault/session-root-dedup.ts b/src/main/ai-vault/session-root-dedup.ts
index da1c239c76cc..378ff86809db 100644
--- a/src/main/ai-vault/session-root-dedup.ts
+++ b/src/main/ai-vault/session-root-dedup.ts
@@ -1,8 +1,16 @@
+import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { codexSessionAliasKey, codexSessionAliasBeats } from './codex-session-root-dedup'
import { sessionSortTime } from './session-scanner-accumulator'
function sessionAliasKey(session: AiVaultSession): string | null {
+ if (session.agent === 'antigravity') {
+ const root = session.filePath
+ .split(/[\\/]+/)
+ .slice(0, -4)
+ .join('/')
+ return `antigravity\0${session.executionHostId}\0${antigravitySessionOrigin(session.filePath) ?? 'cli'}\0${root}\0${session.sessionId}`
+ }
if (session.agent !== 'devin') {
const codexKey = codexSessionAliasKey(session)
return codexKey ? `codex\0${codexKey}` : null
@@ -13,6 +21,16 @@ function sessionAliasKey(session: AiVaultSession): string | null {
}
function sessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean {
+ if (candidate.agent === 'antigravity') {
+ if (candidate.messageCount > 0 !== best.messageCount > 0) {
+ return candidate.messageCount > 0
+ }
+ const full = (path: string): boolean => path.split(/[\\/]+/).at(-1) === 'transcript_full.jsonl'
+ if (full(candidate.filePath) !== full(best.filePath)) {
+ return full(candidate.filePath)
+ }
+ return sessionSortTime(candidate) > sessionSortTime(best)
+ }
if (candidate.agent !== 'devin') {
return codexSessionAliasBeats(candidate, best)
}
diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts
index 34f1134f2700..cb664b41581c 100644
--- a/src/main/ai-vault/session-scanner-accumulator.ts
+++ b/src/main/ai-vault/session-scanner-accumulator.ts
@@ -161,6 +161,9 @@ export function finalizeSession(
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
...(accumulator.previewMessagesTruncated ? { previewMessagesTruncated: true } : {}),
+ ...(accumulator.antigravityOpeningPrompt
+ ? { antigravityOpeningPrompt: accumulator.antigravityOpeningPrompt }
+ : {}),
...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}),
...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}),
queuedMessageCount: accumulator.queuedMessageCount,
diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts
index ccfb506b7f7d..885b8e6ca74d 100644
--- a/src/main/ai-vault/session-scanner-antigravity-history.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-history.ts
@@ -1,7 +1,17 @@
+import {
+ ANTIGRAVITY_INDEX_MAX_BYTES,
+ antigravityCachePath,
+ antigravityMetadataWorkspaces,
+ readBoundedAntigravityIndex
+} from './session-scanner-antigravity-metadata'
import type { AiVaultSession } from '../../shared/ai-vault-types'
-import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
+import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
+import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
+import { isWslUncPath } from '../../shared/wsl-paths'
+import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
-import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values'
+import { parseJsonObject, timestampMs } from './session-scanner-values'
+import { antigravityHistoryPromptHash } from './antigravity-history-prompt'
const HISTORY_MATCH_WINDOW_MS = 2_000
@@ -12,10 +22,39 @@ const HISTORY_MATCH_WINDOW_MS = 2_000
* null lists the session with a missing cwd and no retry signal, and the
* resolver's memo relies on the rejection to evict rather than pin a stall.
*/
-export async function readLocalAntigravityHistory(path: string): Promise {
+export async function readLocalAntigravityHistory(
+ path: string,
+ signal?: AbortSignal
+): Promise {
try {
- return await wslGatedReadFile(path, 'utf-8', 'scan')
+ throwIfAiVaultScanCancelled(signal)
+ if (!isWslUncPath(path)) {
+ const read = await readNodeFileWithinLimit(path, ANTIGRAVITY_INDEX_MAX_BYTES, {
+ regularFileOnly: true,
+ signal
+ })
+ return read.buffer.toString('utf8')
+ }
+ const stats = await wslGatedStat(path, 'scan', signal)
+ if (!stats.isFile() || stats.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return null
+ }
+ const input = openTranscriptReadStream(
+ path,
+ { end: ANTIGRAVITY_INDEX_MAX_BYTES },
+ 'scan',
+ signal
+ )
+ try {
+ return await readBoundedAntigravityIndex(input)
+ } finally {
+ input.destroy()
+ }
} catch (error) {
+ throwIfAiVaultScanCancelled(signal)
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw error
+ }
if (error instanceof WslTranscriptFsError) {
throw error
}
@@ -28,7 +67,10 @@ type AntigravityHistoryEntry = {
workspace: string
}
-type AntigravityHistoryIndex = Map
+type AntigravityHistoryIndex = {
+ byPrompt: Map
+ byId: Map
+}
export type AntigravityWorkspaceResolver = {
enrich(session: AiVaultSession, historyPath: string): Promise
@@ -50,7 +92,27 @@ export function createAntigravityWorkspaceResolver(
// so it must not be memoized — every later session under this history
// file would inherit the rejection for the process lifetime.
const pending: Promise = readHistory(historyPath)
- .then(indexAntigravityHistory)
+ .then(async (history) => {
+ const index = indexAntigravityHistory(history)
+ const [metadata, projects, lastConversations] = await Promise.all(
+ ['conversation_metadata.json', 'projects.json', 'last_conversations.json'].map(
+ (name) => readHistory(antigravityCachePath(historyPath, name))
+ )
+ )
+ const paths = antigravityMetadataWorkspaces({
+ metadata: metadata ?? null,
+ projects: projects ?? null,
+ lastConversations: lastConversations ?? null
+ })
+ for (const [id, path] of paths) {
+ if (!index.byId.has(id)) {
+ index.byId.set(id, path)
+ } else if (index.byId.get(id) !== path) {
+ index.byId.set(id, null)
+ }
+ }
+ return index
+ })
.catch((error: unknown) => {
if (indexes.get(historyPath) === pending) {
indexes.delete(historyPath)
@@ -67,18 +129,32 @@ export function createAntigravityWorkspaceResolver(
}
function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex {
- const index: AntigravityHistoryIndex = new Map()
- for (const line of content?.split(/\r?\n/) ?? []) {
+ const index: AntigravityHistoryIndex = { byPrompt: new Map(), byId: new Map() }
+ if (content && Buffer.byteLength(content) > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return index
+ }
+ for (const line of content?.split(/\r?\n/).slice(0, 10_000) ?? []) {
const record = parseJsonObject(line)
- const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null
+ const promptHash = antigravityHistoryPromptHash(record?.display)
const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : ''
const entryTimestampMs = timestampMs(record?.timestamp)
- if (!display || !workspace || !Number.isFinite(entryTimestampMs)) {
+ const id = typeof record?.conversationId === 'string' ? record.conversationId : null
+ if (!workspace || workspace.length > 4096 || !Number.isFinite(entryTimestampMs)) {
continue
}
- const entries = index.get(display) ?? []
+ if (id) {
+ if (!index.byId.has(id)) {
+ index.byId.set(id, workspace)
+ } else if (index.byId.get(id) !== workspace) {
+ index.byId.set(id, null)
+ }
+ }
+ if (!promptHash) {
+ continue
+ }
+ const entries = index.byPrompt.get(promptHash) ?? []
entries.push({ timestampMs: entryTimestampMs, workspace })
- index.set(display, entries)
+ index.byPrompt.set(promptHash, entries)
}
return index
}
@@ -87,22 +163,18 @@ function findAntigravityWorkspace(
session: AiVaultSession,
index: AntigravityHistoryIndex
): string | null {
- // Why: truncated titles are not prompt identities; long worker prompts often
- // share the same 96-character prefix across unrelated workspaces.
- if (session.title.endsWith('...')) {
- return null
+ if (index.byId.has(session.sessionId)) {
+ return index.byId.get(session.sessionId) ?? null
}
- const firstTitledUserTimestamp = session.previewMessages.find(
- (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title
- )?.timestamp
- const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt)
- if (!Number.isFinite(promptTimestampMs)) {
+ const opening = session.antigravityOpeningPrompt
+ const promptTimestampMs = timestampMs(opening?.timestamp)
+ // Titles, createdAt and rolling previews cannot identify the original prompt.
+ if (!opening || !Number.isFinite(promptTimestampMs)) {
return null
}
- const matches = (index.get(session.title) ?? []).filter(
+ const matches = (index.byPrompt.get(opening.hash) ?? []).filter(
(entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS
)
- // Why: history rows have no conversation id. A unique prompt/time match is
- // evidence for cwd; ambiguity must stay unknown instead of crossing projects.
+ // Exact prompt/time fallback is valid only when a single history row matches.
return matches.length === 1 ? (matches[0]?.workspace ?? null) : null
}
diff --git a/src/main/ai-vault/session-scanner-antigravity-metadata.ts b/src/main/ai-vault/session-scanner-antigravity-metadata.ts
new file mode 100644
index 000000000000..1f1c4263fd05
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-antigravity-metadata.ts
@@ -0,0 +1,84 @@
+import { parseJsonObject } from './session-scanner-values'
+
+export const ANTIGRAVITY_INDEX_MAX_BYTES = 4 * 1024 * 1024
+const MAX_INDEX_ENTRIES = 10_000
+
+type JsonRecord = Record
+function isRecord(value: unknown): value is JsonRecord {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+}
+function record(value: unknown): JsonRecord | null {
+ return isRecord(value) ? value : null
+}
+function entries(value: unknown): [string, unknown][] {
+ const object = record(value)
+ return object ? Object.entries(object).slice(0, MAX_INDEX_ENTRIES) : []
+}
+function parse(content: string | null): JsonRecord | null {
+ return content && Buffer.byteLength(content) <= ANTIGRAVITY_INDEX_MAX_BYTES
+ ? parseJsonObject(content)
+ : null
+}
+function unique(map: Map, id: string, path: string | null): void {
+ if (!id || id.length > 512 || (path !== null && (!path || path.length > 4096))) {
+ return
+ }
+ if (map.has(id) && map.get(id) !== path) {
+ map.set(id, null)
+ } else if (!map.has(id)) {
+ map.set(id, path)
+ }
+}
+
+export function antigravityCachePath(historyPath: string, fileName: string): string {
+ const separator = historyPath.includes('\\') ? '\\' : '/'
+ return `${historyPath.replace(/[\\/]history\.jsonl$/, '') + separator}cache${separator}${fileName}`
+}
+
+export function antigravityMetadataWorkspaces(contents: {
+ metadata: string | null
+ projects: string | null
+ lastConversations: string | null
+}): Map {
+ const projectPaths = new Map()
+ for (const [key, value] of entries(parse(contents.projects))) {
+ if (typeof value !== 'string') {
+ continue
+ }
+ if (/^(?:[/\\]|[A-Za-z]:[/\\])/.test(key)) {
+ unique(projectPaths, value, key)
+ } else if (/^(?:[/\\]|[A-Za-z]:[/\\])/.test(value)) {
+ unique(projectPaths, key, value)
+ }
+ }
+ const paths = new Map()
+ for (const [path, id] of entries(parse(contents.lastConversations))) {
+ if (typeof id === 'string' && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(path)) {
+ unique(paths, id, path)
+ }
+ }
+ for (const [id, value] of entries(parse(contents.metadata)?.conversations)) {
+ const summary = record(record(value)?.summary)
+ const projectId = summary?.ProjectID
+ const path = typeof projectId === 'string' ? projectPaths.get(projectId) : undefined
+ if (path !== undefined) {
+ unique(paths, id, path)
+ }
+ }
+ return paths
+}
+
+export async function readBoundedAntigravityIndex(
+ chunks: AsyncIterable
+): Promise {
+ const retained: Buffer[] = []
+ let bytes = 0
+ for await (const chunk of chunks) {
+ bytes += chunk.length
+ if (bytes > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return null
+ }
+ retained.push(chunk)
+ }
+ return Buffer.concat(retained, bytes).toString('utf8')
+}
diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts
index 775cc2283707..c391f01bba73 100644
--- a/src/main/ai-vault/session-scanner-antigravity-parser.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts
@@ -2,17 +2,19 @@ import {
remoteSessionContentLines,
type RemoteSessionContent
} from './remote-session-content-lines'
-import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
-import { createInterface } from 'node:readline'
+import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
+import { MAX_SESSION_TRANSCRIPT_RECORD_BYTES } from './session-transcript-record-budget'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
accumulatorFoldResumeState,
addPreviewMessage,
createAccumulator,
+ timestampIso,
updateTimeline
} from './session-scanner-accumulator'
import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths'
+import { antigravityHistoryPromptHash } from './antigravity-history-prompt'
import type {
FileWithMtime,
ResumableSessionParseState,
@@ -31,16 +33,16 @@ export async function parseAntigravitySessionFile(
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise {
- const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
- const lines = createInterface({ input, crlfDelay: Infinity })
- try {
- return await parseAntigravitySessionLines({ file, lines, platform, messages })
- } finally {
- // readline.close() leaves the underlying stream open; destroy it so a
- // mid-parse throw cannot leak the gated transcript handle.
- lines.close()
- input.destroy()
+ const state = createAntigravitySessionResumeState(file, messages)
+ const read = await consumeCompleteJsonlLines({
+ path: file.path,
+ start: 0,
+ onLine: (line) => state.consumeLine(line)
+ })
+ if (read.trailingPartialLine) {
+ state.consumeLine(read.trailingPartialLine)
}
+ return state.finalize(platform)
}
export async function parseAntigravitySessionContent(
@@ -86,6 +88,9 @@ async function parseAntigravitySessionLines(args: {
}
function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void {
+ if (Buffer.byteLength(line) > MAX_SESSION_TRANSCRIPT_RECORD_BYTES) {
+ return
+ }
const record = parseJsonObject(line)
if (!record) {
return
@@ -105,6 +110,12 @@ function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: str
return
}
accumulator.messageCount++
+ if (accumulator.antigravityOpeningPrompt === undefined) {
+ const hash = antigravityHistoryPromptHash(request)
+ accumulator.antigravityOpeningPrompt = hash
+ ? { hash, timestamp: timestampIso(record.created_at) }
+ : null
+ }
accumulator.title ??= normalizeTitleText(request)
addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at })
return
diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts
index c505cb7e5eee..1930d78270b4 100644
--- a/src/main/ai-vault/session-scanner-antigravity-paths.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts
@@ -8,7 +8,9 @@ export function antigravityConversationIdFromTranscriptPath(filePath: string): s
const segments = pathSegments(filePath)
const transcriptIndex = segments.length - 1
if (
- segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE ||
+ ![ANTIGRAVITY_TRANSCRIPT_FILE, 'transcript_full.jsonl'].includes(
+ segments[transcriptIndex] ?? ''
+ ) ||
segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR ||
segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR
) {
diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts
index a79edc380e69..707384cd861e 100644
--- a/src/main/ai-vault/session-scanner-antigravity-sources.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts
@@ -1,3 +1,5 @@
+import { completeAntigravityTranscriptPairs } from './antigravity-transcript-candidates'
+import { ANTIGRAVITY_HISTORY_ROOTS } from '../../shared/antigravity-session-origin'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
@@ -8,17 +10,28 @@ import {
import { discoverFiles } from './session-scanner-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
-const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain')
-
export function antigravityDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise[] {
+ const origins =
+ options.includeAntigravityIdeSessions === true
+ ? ANTIGRAVITY_HISTORY_ROOTS
+ : (['antigravity-cli'] as const)
+ const home = options.antigravityAppHome ?? homedir()
const rootDirs = [
- options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR,
- ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain'))
+ options.antigravityBrainDir ?? join(home, '.gemini', 'antigravity-cli', 'brain'),
+ ...(options.includeAntigravityIdeSessions === true &&
+ (!options.antigravityBrainDir || options.antigravityAppHome)
+ ? origins
+ .filter((origin) => origin !== 'antigravity-cli')
+ .map((origin) => join(home, '.gemini', origin, 'brain'))
+ : []),
+ ...wslHomeDirs.flatMap((homeDir) =>
+ origins.map((origin) => join(homeDir, '.gemini', origin, 'brain'))
+ )
]
return rootDirs.map((rootDir) =>
discoverFiles({
@@ -29,6 +42,6 @@ export function antigravityDiscoveries(
extensions: ['.jsonl'],
filePredicate: isAntigravityTranscriptPath,
directoryPredicate: shouldDescendAntigravityBrainDirectory
- })
+ }).then((discovery) => completeAntigravityTranscriptPairs(discovery, issues))
)
}
diff --git a/src/main/ai-vault/session-scanner-candidates.ts b/src/main/ai-vault/session-scanner-candidates.ts
index 80f146367e54..a03c7ff5f5a9 100644
--- a/src/main/ai-vault/session-scanner-candidates.ts
+++ b/src/main/ai-vault/session-scanner-candidates.ts
@@ -1,3 +1,4 @@
+import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates'
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import { codexRolloutHardlinkIdentity, dedupeCodexRolloutAliases } from './codex-session-root-dedup'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
@@ -14,7 +15,7 @@ export async function sessionCandidatesFromDiscoveries(
discoveries: SessionFileDiscovery[],
options: AiVaultScanOptions
): Promise {
- return dedupeCodexRolloutAliases(
+ const candidates = await dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
@@ -43,4 +44,8 @@ export async function sessionCandidatesFromDiscoveries(
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
+ return prioritizeAntigravityTranscriptCandidates(
+ candidates,
+ (candidate) => candidate.agent === 'antigravity'
+ )
}
diff --git a/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts
index 7470b3430d27..59a67484fa19 100644
--- a/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts
+++ b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts
@@ -89,8 +89,21 @@ beforeEach(() => {
})
describe('session parsers that stop consuming a gated transcript early', () => {
+ it('destroys the bounded Antigravity stream when its read fails', async () => {
+ const stream = Readable.from(
+ (async function* () {
+ yield Buffer.from('{}\n')
+ throw new Error(PARSE_FAILURE)
+ })()
+ )
+ mocks.openStream.mockReturnValue(stream)
+ await expect(
+ parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')
+ ).rejects.toThrow(PARSE_FAILURE)
+ expect(stream.destroyed).toBe(true)
+ })
+
it.each([
- ['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')],
['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')],
['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')]
])('destroys the stream when the %s parse throws', async (_agent, parse) => {
diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts
index 2a285409ac27..22ecc151d45a 100644
--- a/src/main/ai-vault/session-scanner-types.ts
+++ b/src/main/ai-vault/session-scanner-types.ts
@@ -25,6 +25,8 @@ export type AiVaultScanOptions = {
wslOpenCodeReaders?: readonly OpenCodeWslRuntime[]
geminiSessionsDir?: string
antigravityBrainDir?: string
+ antigravityAppHome?: string
+ includeAntigravityIdeSessions?: boolean
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
@@ -61,6 +63,8 @@ export type AiVaultScanOptions = {
}
export type FileWithMtime = {
+ /** Antigravity alias observation, separate from the actual file stat/cache key. */
+ aliasMtimeMs?: number
path: string
mtimeMs: number
modifiedAt: string
@@ -146,6 +150,7 @@ export type SessionAccumulator = {
// True once an older message fell out of the newest-N preview window, so the
// earliest preview turn is no longer the session's opening ask.
previewMessagesTruncated: boolean
+ antigravityOpeningPrompt?: AiVaultSession['antigravityOpeningPrompt'] | null
firstUserPrompt: string | null
lastUserPrompt: string | null
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts
index f56f17af7961..8716440a565c 100644
--- a/src/main/ai-vault/session-scanner.ts
+++ b/src/main/ai-vault/session-scanner.ts
@@ -1,3 +1,4 @@
+import { candidateFileTime } from './antigravity-transcript-candidates'
import type {
AiVaultListResult,
AiVaultScanIssue,
@@ -78,8 +79,8 @@ export async function scanAiVaultSessions(
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
- const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
- readLocalAntigravityHistory
+ const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver((path) =>
+ readLocalAntigravityHistory(path, options.signal)
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
@@ -233,7 +234,9 @@ async function parseSessionCandidates(args: {
while (index < args.candidates.length) {
throwIfAiVaultScanCancelled(args.signal)
- if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
+ if (
+ canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
+ ) {
break
}
diff --git a/src/main/ai-vault/ssh-session-list.ts b/src/main/ai-vault/ssh-session-list.ts
index 79359873d69a..bb1bd18c4dac 100644
--- a/src/main/ai-vault/ssh-session-list.ts
+++ b/src/main/ai-vault/ssh-session-list.ts
@@ -63,6 +63,9 @@ async function scanOneSshHost(
const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs
try {
const params = {
+ ...(args?.includeAntigravityIdeSessions === true
+ ? { includeAntigravityIdeSessions: true }
+ : {}),
limit: args?.limit,
...(args?.unlimited === true ? { unlimited: true } : {}),
...(args?.force === true ? { force: true } : {}),
@@ -108,8 +111,12 @@ async function scanOneSshHost(
scanRemoteAiVaultSessions({
provider,
executionHostId,
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
remoteHome: hostInfo.remoteHome,
hostPlatform: hostInfo.hostPlatform,
+ ...(args?.includeAntigravityIdeSessions === true
+ ? { includeAntigravityIdeSessions: true }
+ : {}),
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths,
diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts
index f9432f143a88..0f7cb7a4109f 100644
--- a/src/main/ipc/ai-vault-runtime-scan.ts
+++ b/src/main/ipc/ai-vault-runtime-scan.ts
@@ -80,6 +80,9 @@ function runtimeScanArgs(
if (listArgs?.scopePaths !== undefined) {
scanArgs.scopePaths = listArgs.scopePaths
}
+ if (listArgs?.includeAntigravityIdeSessions === true) {
+ scanArgs.includeAntigravityIdeSessions = true
+ }
return scanArgs
}
diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts
index 826b7986d629..ec5269be4203 100644
--- a/src/main/ipc/ai-vault.test.ts
+++ b/src/main/ipc/ai-vault.test.ts
@@ -112,11 +112,16 @@ beforeEach(() => {
describe('listAiVaultSessions host routing', () => {
it('routes local scope to the local scanner', async () => {
- await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
+ await _internals.listAiVaultSessions({
+ executionHostScope: 'local',
+ scopePaths: ['/repo'],
+ includeAntigravityIdeSessions: true
+ })
expect(mocks.scanLocalSessions).toHaveBeenCalledWith(
expect.objectContaining({
scopePaths: ['/repo'],
+ includeAntigravityIdeSessions: true,
executionHostId: 'local'
}),
expect.any(AbortSignal)
diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts
index a2c4fc267667..3c0d258381a2 100644
--- a/src/main/ipc/ai-vault.ts
+++ b/src/main/ipc/ai-vault.ts
@@ -86,24 +86,22 @@ async function listAiVaultSessions(
options: { signal?: AbortSignal } = {}
): Promise {
const executionHostScope = requestedExecutionHostScope(args?.executionHostScope)
- // Scope paths change the result set, so they must be part of the cache key.
- // A scanner consumes at most 64 paths, so smaller equivalent workspace sets
- // can share a snapshot regardless of which worktree was selected first.
+ // Canonicalize bounded workspace sets so equivalent scopes share a scan.
const scopePaths = args?.scopePaths ?? []
const key = JSON.stringify({
scopePaths:
scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT
? [...new Set(scopePaths)].sort()
: scopePaths,
- executionHostScope
+ executionHostScope,
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
})
const depth = requestedAiVaultSessionDepth(args)
- const scanKey = JSON.stringify({ key, depth })
// Why: every renderer request carries its own cancellation signal, so
// coalescing has to survive them — the coordinator hands all same-key callers
// one scan and only aborts it once every one of them has cancelled.
return scanCoordinator.run({
- key: scanKey,
+ key: JSON.stringify({ key, depth }),
force: args?.force,
signal: options.signal,
start: (scanSignal) => {
@@ -252,15 +250,7 @@ async function scanLocalAiVaultSessions(
// Why: the shared cache module owns codex-home/WSL sourcing and the local
// scan cache, so the desktop IPC path and the runtime RPC method (mobile)
// share one cache instance and one source of managed-Codex homes.
- return listCachedLocalAiVaultSessions(
- {
- limit: args?.limit,
- unlimited: args?.unlimited,
- force: args?.force,
- scopePaths: args?.scopePaths
- },
- { signal }
- )
+ return listCachedLocalAiVaultSessions(args, { signal })
}
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts
index 6853b5f572f2..7848ddcd033a 100644
--- a/src/main/runtime/rpc/methods/ai-vault.test.ts
+++ b/src/main/runtime/rpc/methods/ai-vault.test.ts
@@ -299,6 +299,22 @@ describe('aiVault.listSessions handler + shared cache', () => {
resetAiVaultSessionListCacheForTests()
})
+ it('isolates IDE opt-in scans from legacy clients sharing the host cache', async () => {
+ const dispatcher = makeDispatcher()
+ await dispatcher.dispatch(
+ makeRequest('aiVault.listSessions', { includeAntigravityIdeSessions: true })
+ )
+ await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
+ await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
+ expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2)
+ expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({
+ includeAntigravityIdeSessions: true
+ })
+ expect(scanAiVaultSessionsInService.mock.calls[1]?.[0]?.includeAntigravityIdeSessions).not.toBe(
+ true
+ )
+ })
+
it('returns the AiVaultListResult unchanged', async () => {
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 }))
diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts
index b4169145902b..fc8286490196 100644
--- a/src/main/runtime/rpc/methods/ai-vault.ts
+++ b/src/main/runtime/rpc/methods/ai-vault.ts
@@ -76,7 +76,8 @@ export const AI_VAULT_METHODS = [
limit: params.unlimited ? undefined : params.limit,
unlimited: params.unlimited,
force: params.force,
- scopePaths: params.scopePaths
+ scopePaths: params.scopePaths,
+ includeAntigravityIdeSessions: params.includeAntigravityIdeSessions
})
} catch (error) {
if (error instanceof Error) {
diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts
index 75676a7be291..428253349bf4 100644
--- a/src/relay/ai-vault-handler.ts
+++ b/src/relay/ai-vault-handler.ts
@@ -97,6 +97,7 @@ export class AiVaultHandler {
try {
result = await this.scanCoordinator.run({
key: JSON.stringify({
+ includeAntigravityIdeSessions: params.includeAntigravityIdeSessions,
limit: params.limit,
unlimited: params.unlimited,
scopePaths: params.scopePaths,
@@ -194,6 +195,9 @@ export function normalizeSshAiVaultRelayListParams(
params.scopePathsTruncated === true ||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
return {
+ ...(params.includeAntigravityIdeSessions === true
+ ? { includeAntigravityIdeSessions: true }
+ : {}),
...(unlimited ? { unlimited: true } : {}),
...(limit === undefined ? {} : { limit }),
...(params.force === true ? { force: true } : {}),
diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts
index 3f0a2b02aafe..9380e08d4c11 100644
--- a/src/relay/ai-vault-service-entry.ts
+++ b/src/relay/ai-vault-service-entry.ts
@@ -53,6 +53,7 @@ async function execute(request: RelayAiVaultServiceRequest): Promise {
hostPlatform: init.hostPlatform,
limit: request.params.limit,
unlimited: request.params.unlimited,
+ includeAntigravityIdeSessions: request.params.includeAntigravityIdeSessions,
scopePaths: request.params.scopePaths,
signal: controller.signal
})
diff --git a/src/relay/ai-vault-transcript-stream.ts b/src/relay/ai-vault-transcript-stream.ts
index df040a166faa..42e24733551b 100644
--- a/src/relay/ai-vault-transcript-stream.ts
+++ b/src/relay/ai-vault-transcript-stream.ts
@@ -2,13 +2,27 @@ import { open } from 'node:fs/promises'
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines'
import { BINARY_PROBE_BYTES, isBinaryBuffer } from './fs-handler-utils'
+import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader'
/** The same open handle supplies the probe and stream, including across renames. */
export async function* readRelayTranscriptBytes(
path: string,
- signal?: AbortSignal
+ signal?: AbortSignal,
+ options?: { regularFileOnly: true; maxBytes: number }
): AsyncGenerator {
throwIfAiVaultScanCancelled(signal)
+ if (options?.regularFileOnly) {
+ const read = await readNodeFileWithinLimit(path, options.maxBytes, {
+ regularFileOnly: true,
+ signal
+ })
+ if (isBinaryBuffer(read.buffer.subarray(0, BINARY_PROBE_BYTES))) {
+ throw new BinarySessionTranscriptError()
+ }
+ throwIfAiVaultScanCancelled(signal)
+ yield read.buffer
+ return
+ }
const handle = await open(path, 'r')
try {
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
index 25ab02c9548b..9e56bc0a5a0e 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
@@ -68,6 +68,8 @@ export default function AiVaultPanel(): React.JSX.Element {
const projectHostSetupProjection = useProjectHostSetupProjection()
const resumeTargetState = useAppStore(
useShallow((state) => ({
+ projects: state.projects,
+ settings: state.settings,
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
repos: state.repos,
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
index 34fee190be69..fd7a8394f742 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
@@ -1,3 +1,4 @@
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents'
import type React from 'react'
import {
@@ -59,6 +60,7 @@ export function SessionInlineDetails({
}): React.JSX.Element {
// A zero-turn transcript would resume into an empty conversation, so the plain
// resume affordances are withheld and a distinct "not saved" state is shown.
+ const referenceSession = isAntigravityReferenceSession(session)
const hasResumableContent = isAiVaultSessionResumableContent(session)
const showResumeInWorktree = hasResumableContent && Boolean(resumeActions.worktree.worktreeId)
const showResumeInNewTab =
@@ -100,10 +102,12 @@ export function SessionInlineDetails({
className="h-7 shrink-0 px-2.5 text-[11px]"
>
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
- 'Resume in Worktree'
- )}
+ {referenceSession
+ ? translate('aiVault.continueInCli', 'Continue in CLI')
+ : translate(
+ 'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
+ 'Resume in Worktree'
+ )}
) : null}
{showResumeInNewTab ? (
@@ -120,10 +124,12 @@ export function SessionInlineDetails({
className="h-7 shrink-0 px-2.5 text-[11px]"
>
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
- 'Resume in New Tab'
- )}
+ {referenceSession
+ ? translate('aiVault.continueInCliNewTab', 'Continue in CLI in New Tab')
+ : translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
+ 'Resume in New Tab'
+ )}
) : null}
{onResumeInNewChat ? (
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
index ee0e6950e98c..3a3035f14db3 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
@@ -1,3 +1,4 @@
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents'
import { useCallback } from 'react'
import type React from 'react'
@@ -44,7 +45,7 @@ export function VaultSessionRow({
onResume,
onContinueInNewSession,
onResumeInNewChat,
- resumeLabel,
+ resumeLabel: defaultResumeLabel,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
@@ -88,6 +89,9 @@ export function VaultSessionRow({
onRequestDelete?: (session: AiVaultSession) => void
searchHit?: AiVaultSearchHit
}) {
+ const resumeLabel = isAntigravityReferenceSession(session)
+ ? translate('aiVault.continueInCli', 'Continue in CLI')
+ : defaultResumeLabel
const updatedAt = session.updatedAt ?? session.modifiedAt
const detailsId = getSessionDetailsId(session.id)
const latestTurn = latestSessionConversationTurn(session)
diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx
index 820527f764b8..4d655359b372 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx
@@ -34,7 +34,7 @@ afterEach(() => {
cleanup()
})
-function renderSession(session: AiVaultSession) {
+function renderSession(session: AiVaultSession, blocked = false) {
const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand }))
const onCopyResume = vi.fn()
render(
@@ -54,13 +54,13 @@ function renderSession(session: AiVaultSession) {
getSessionLiveState={() => null}
getWorktreeInfo={() => null}
getSessionResumeState={() => ({
- blocked: false,
- worktreeId: 'worktree-1',
+ blocked,
+ worktreeId: blocked ? null : 'worktree-1',
usesSessionWorktree: false
})}
getSessionResumeActions={() => ({
worktree: { worktreeId: null, disabled: true },
- newTab: { worktreeId: 'worktree-1', disabled: false }
+ newTab: { worktreeId: 'worktree-1', disabled: blocked }
})}
getSessionResumeInChat={() => ({ available: false, reason: 'already-structured' })}
onToggleGroup={vi.fn()}
@@ -84,6 +84,21 @@ function renderSession(session: AiVaultSession) {
}
describe('AiVaultVirtualRow resume command actions', () => {
+ it('renders blocked IDE references without preparing an unsafe startup command', () => {
+ const { buildResumeStartup } = renderSession(
+ {
+ ...cliSession,
+ agent: 'antigravity',
+ title: 'IDE reference on another host',
+ filePath:
+ '//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
+ },
+ true
+ )
+ expect(screen.getByText('IDE reference on another host')).toBeTruthy()
+ expect(buildResumeStartup).not.toHaveBeenCalled()
+ })
+
it('keeps Copy Resume Command in overflow and context actions for CLI sessions', async () => {
const { buildResumeStartup, onCopyResume } = renderSession(cliSession)
const user = userEvent.setup()
diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx
index 8e2ac92f28cc..92be9e9b8743 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx
@@ -1,4 +1,5 @@
import type { AgentStatusState } from '../../../../shared/agent-status-types'
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
@@ -135,10 +136,14 @@ export function AiVaultVirtualRow({
const searchResumeAllowed = searchHit ? canResumeAiVaultSearchHit(searchHit) : true
const searchPathAllowed = searchHit ? hasAiVaultSearchHitPath(searchHit) : true
const usesLegacyResumeCommand = row.type === 'session' && !row.session.structuredSession
- const resumeStartup =
- row.type === 'session' && searchResumeAllowed && usesLegacyResumeCommand
- ? buildResumeStartup(row.session, resumeState?.worktreeId)
- : { command: '' }
+ const canBuildResumeStartup =
+ row.type === 'session' &&
+ searchResumeAllowed &&
+ usesLegacyResumeCommand &&
+ (!isAntigravityReferenceSession(row.session) || !resumeGating.resumeDisabled)
+ const resumeStartup = canBuildResumeStartup
+ ? buildResumeStartup(row.session, resumeState?.worktreeId)
+ : { command: '' }
const visibleResumeActions =
searchResumeAllowed && resumeActions
? resumeActions
@@ -170,7 +175,7 @@ export function AiVaultVirtualRow({
liveState={getSessionLiveState(row.session)}
resumeStartup={resumeStartup}
realHomeResumeStartup={
- searchResumeAllowed && usesLegacyResumeCommand
+ canBuildResumeStartup
? buildResumeStartup({ ...row.session, codexHome: null }, resumeState?.worktreeId)
: resumeStartup
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts
index 98e136931d52..c4417ab2d0d3 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts
@@ -1,3 +1,4 @@
+import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
import {
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceExecutionHostId,
@@ -60,7 +61,8 @@ export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
- targetExecutionHostId
+ targetExecutionHostId,
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, targetWorktreeId)
})
) {
return { status: 'unsupported', targetStatus }
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
index 9ace8fbd3999..06ee98d47adc 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
@@ -174,6 +174,7 @@ export function useAiVaultSessionRefresh(
const scanKey = `${baseKey}\n${selectedLimit}`
try {
const result = await window.api.aiVault.listSessions({
+ includeAntigravityIdeSessions: true,
limit,
unlimited: selectedLimit === 'unlimited',
scopePaths: scopePathsRef.current,
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts
index 92817807f776..6468ca5ef006 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts
@@ -1,3 +1,4 @@
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
// Whether an Agent Session History row can be resumed into a structured native chat, and where.
//
// Separate from `ai-vault-session-resume.ts` because the answer is not the same question: the
@@ -70,7 +71,7 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: {
structuredRouteAvailable: boolean
}): AiVaultResumeInChatEligibility {
const { session } = args
- if (!isAgentSessionHandleProvider(session.agent)) {
+ if (isAntigravityReferenceSession(session) || !isAgentSessionHandleProvider(session.agent)) {
return { available: false, reason: 'agent' }
}
// An already-adopted row reopens its own chat instead; offering a second resume of it would ask
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
index f029d896e989..44820319035b 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
@@ -1,3 +1,4 @@
+import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
@@ -22,7 +23,8 @@ import {
export type AiVaultSessionResumeTargetState = Pick<
AppState,
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
->
+> &
+ Partial>
export type AiVaultSessionResumeState = {
blocked: boolean
@@ -201,7 +203,8 @@ function resolveSupportedResumeWorktreeId(args: {
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
- targetExecutionHostId
+ targetExecutionHostId,
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, args.worktreeId)
})
) {
return null
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx
index 2ad0b1cca395..d33f1de0b979 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx
@@ -1,3 +1,7 @@
+import {
+ antigravitySessionOrigin,
+ isAntigravityReferenceSession
+} from '../../../../shared/antigravity-session-origin'
import type React from 'react'
import { Badge } from '@/components/ui/badge'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
@@ -41,6 +45,7 @@ export function SessionMetadata({
vaultScope: AiVaultScope
}) {
const modelLabel = sessionModelLabel(session)
+ const origin = antigravitySessionOrigin(session.filePath)
return (
: null}
{agentLabel(session.agent)}
+ {isAntigravityReferenceSession(session) ? (
+
+ {origin === 'antigravity-ide'
+ ? translate('aiVault.antigravityIdeToCli', 'IDE → CLI')
+ : translate('aiVault.antigravity2ToCli', '2.0 → CLI')}
+
+ ) : null}
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
index 11569358c18c..eb954cbe0775 100644
--- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
+++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
@@ -1,3 +1,4 @@
+import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { toast } from 'sonner'
import {
@@ -200,7 +201,8 @@ export default function AiVaultSessionDropLayer({
sessionFilePath: payload.sessionFilePath ?? null,
sessionExecutionHostId: payload.sessionExecutionHostId ?? null,
targetStatus,
- targetExecutionHostId
+ targetExecutionHostId,
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(state, worktreeId)
})
) {
toast.error(
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index a187c118eeb8..5406a7e8a335 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18698,6 +18698,11 @@
}
},
"aiVault": {
+ "antigravityReferenceDescription": "Continues in a new Antigravity CLI conversation using this transcript as a reference.",
+ "antigravityIdeToCli": "IDE → CLI",
+ "antigravity2ToCli": "2.0 → CLI",
+ "continueInCli": "Continue in CLI",
+ "continueInCliNewTab": "Continue in CLI in New Tab",
"subagents": {
"loading": "Loading subagents…",
"loadError": "Could not load all subagents.",
diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts
new file mode 100644
index 000000000000..f30739ae35cc
--- /dev/null
+++ b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts
@@ -0,0 +1,351 @@
+import { describe, expect, it, vi } from 'vitest'
+import { makeFolderWorkspace, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
+import { resolveAiVaultSessionLaunchTarget } from '@/components/right-sidebar/ai-vault-session-launch-target'
+import {
+ buildAiVaultResumeCopyCommandForWorktree,
+ buildAiVaultResumeStartupForWorktree
+} from './ai-vault-resume-command'
+import { canResumeAiVaultSessionOnTarget } from './ai-vault-resume-target'
+import { getAiVaultResumeWorkspaceWslDistro } from './ai-vault-resume-shell'
+import { buildAgentLaunchRouteInput } from './agent-launch-route-input'
+import { getLocalProjectExecutionRuntimeContext } from './local-preflight-context'
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
+
+vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' }))
+vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => 'win32' }))
+
+type State = Parameters[0]['state']
+const windowsFile = 'C:/Users/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
+const linuxFile = '/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
+const debianFile =
+ '//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
+
+function stateFor(host: ExecutionHostId, distro?: string): State {
+ const worktree = makeWorktree({
+ id: 'repo::workspace',
+ repoId: 'repo',
+ hostId: host,
+ path: distro ? `//wsl.localhost/${distro}/home/example/project` : 'C:/project'
+ })
+ return {
+ activeRepoId: 'repo',
+ activeWorktreeId: worktree.id,
+ folderWorkspaces: [],
+ projectGroups: [],
+ settings: null,
+ repos: [
+ {
+ id: 'repo',
+ path: worktree.path,
+ displayName: 'project',
+ badgeColor: '',
+ addedAt: 0,
+ projectGroupId: 'group-1',
+ executionHostId: host
+ }
+ ],
+ projects: [
+ {
+ id: 'repo',
+ displayName: 'project',
+ badgeColor: '',
+ sourceRepoIds: ['repo'],
+ createdAt: 0,
+ updatedAt: 0,
+ localWindowsRuntimePreference: distro ? { kind: 'wsl', distro } : { kind: 'windows-host' }
+ }
+ ],
+ worktreesByRepo: { repo: [worktree] }
+ }
+}
+
+const cases = [
+ { source: 'local', target: 'local', file: debianFile, distro: 'Debian', allowed: true },
+ { source: 'local', target: 'local', file: debianFile, distro: 'Ubuntu', allowed: false },
+ { source: 'local', target: 'local', file: debianFile, allowed: false },
+ { source: 'local', target: 'local', file: windowsFile, distro: 'Debian', allowed: false },
+ { source: 'local', target: 'local', file: windowsFile, allowed: true },
+ { source: 'local', target: 'ssh:other', file: debianFile, allowed: false },
+ { source: 'ssh:owner', target: 'ssh:other', file: linuxFile, allowed: false },
+ { source: 'ssh:owner', target: 'local', file: linuxFile, allowed: false },
+ { source: 'ssh:owner', target: 'ssh:owner', file: linuxFile, allowed: true },
+ { source: 'runtime:owner', target: 'runtime:other', file: linuxFile, allowed: false },
+ { source: 'runtime:owner', target: 'runtime:owner', file: linuxFile, allowed: true }
+] as const
+
+describe('Antigravity transcript reference ownership', () => {
+ it.each(cases)(
+ 'confines $file from $source to $target ($distro)',
+ ({ source, target, file, allowed, ...options }) => {
+ const distro = 'distro' in options ? options.distro : undefined
+ const state = stateFor(target, distro)
+ const session = {
+ agent: 'antigravity' as const,
+ sessionId: 'copied-id',
+ cwd: null,
+ codexHome: null,
+ executionHostId: source,
+ executionHostPlatform: target === 'local' ? ('win32' as const) : ('linux' as const),
+ filePath: file
+ }
+ const launch = resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: file,
+ sessionExecutionHostId: source,
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ })
+ expect(launch.status).toBe(allowed ? 'ready' : 'unsupported')
+ for (const build of [
+ buildAiVaultResumeStartupForWorktree,
+ buildAiVaultResumeCopyCommandForWorktree
+ ]) {
+ if (!allowed) {
+ expect(() => build({ state, session })).toThrow('execution host or WSL distro')
+ } else {
+ const result = build({ state, session })
+ const command = typeof result === 'string' ? result : result.command
+ expect(command).toContain('--prompt-interactive')
+ expect(command).not.toContain('--conversation')
+ expect(command).not.toContain('wsl.localhost')
+ }
+ }
+ }
+ )
+
+ it('preserves legacy WSL ID resume eligibility on SSH', () => {
+ expect(
+ canResumeAiVaultSessionOnTarget({
+ sessionFilePath: debianFile.replace('antigravity-ide', 'antigravity-cli'),
+ sessionExecutionHostId: 'local',
+ targetStatus: 'ssh',
+ targetExecutionHostId: 'ssh:other'
+ })
+ ).toBe(true)
+ })
+
+ it('uses a project runtime override before the workspace UNC distro', () => {
+ const state = stateFor('local', 'Ubuntu')
+ const workspace = state.worktreesByRepo.repo?.[0]
+ if (!workspace) {
+ throw new Error('Missing fixture workspace')
+ }
+ workspace.path = '//wsl.localhost/Debian/home/example/project'
+ expect(getAiVaultResumeWorkspaceWslDistro(state, workspace.id)).toBe('Ubuntu')
+ expect(getAiVaultResumeWorkspaceWslDistro(state, `worktree:${workspace.id}`)).toBe('Ubuntu')
+ })
+
+ it('applies the same confinement to folder workspaces', () => {
+ const state = stateFor('local')
+ state.activeRepoId = null
+ state.repos = []
+ state.projects = []
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({ folderPath: '//wsl.localhost/Debian/home/example/project' })
+ ]
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: 'folder:folder-1',
+ targetState: state
+ }).status
+ ).toBe('ready')
+ state.folderWorkspaces[0].folderPath = '//wsl.localhost/Ubuntu/home/example/project'
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: 'folder:folder-1',
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+
+ it('uses the folder launch project runtime for a native folder path', () => {
+ const state = stateFor('local', 'Debian')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
+ expect(
+ buildAgentLaunchRouteInput(state, {
+ agent: 'antigravity',
+ workspace: { kind: 'folder', repoId: 'repo' }
+ }).projectRuntime
+ ).toMatchObject({ status: 'resolved', runtime: { kind: 'wsl', distro: 'Debian' } })
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBe('Debian')
+ for (const [file, status] of [
+ [windowsFile, 'unsupported'],
+ [debianFile, 'ready']
+ ] as const) {
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: file,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe(status)
+ }
+ })
+
+ it('lets an explicit folder project runtime override the UNC path distro', () => {
+ const state = stateFor('local', 'Ubuntu')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({
+ folderPath: '//wsl.localhost/Debian/home/example/project'
+ })
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBe('Ubuntu')
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+
+ it('does not substitute a local project runtime into an SSH folder launch', () => {
+ const state = stateFor('ssh:owner', 'Debian')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({
+ folderPath: '/remote/non-git-folder',
+ executionHostId: 'ssh:owner'
+ })
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeNull()
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: linuxFile,
+ sessionExecutionHostId: 'ssh:owner',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('ready')
+ })
+
+ it('keeps references refused for SSH aliases claiming local WSL ownership', () => {
+ for (const target of [
+ 'ssh:localhost',
+ 'ssh:127.0.0.1',
+ 'ssh:Debian',
+ 'ssh:local-wsl'
+ ] as const) {
+ expect(
+ canResumeAiVaultSessionOnTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ targetStatus: 'ssh',
+ targetExecutionHostId: target,
+ targetWslDistro: 'Debian'
+ })
+ ).toBe(false)
+ }
+ })
+
+ it('uses the target folder owner for both the pane and reference gate with another repo active', () => {
+ const state = stateFor('local', 'Ubuntu')
+ state.repos[0].path = 'C:/project'
+ state.repos[0].kind = 'folder'
+ state.activeRepoId = 'unrelated'
+ state.activeWorktreeId = 'unrelated::workspace'
+ state.repos = [
+ ...state.repos,
+ {
+ id: 'unrelated',
+ path: 'C:/elsewhere',
+ displayName: 'unrelated',
+ badgeColor: '',
+ addedAt: 0
+ }
+ ]
+ state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
+ const target = 'folder:folder-1'
+ expect(getLocalProjectExecutionRuntimeContext(state, target, 'win32')).toMatchObject({
+ status: 'resolved',
+ runtime: { kind: 'wsl', distro: 'Ubuntu' }
+ })
+ expect(getAiVaultResumeWorkspaceWslDistro(state, target)).toBe('Ubuntu')
+ for (const [file, expected] of [
+ [windowsFile, 'unsupported'],
+ [debianFile.replace('Debian', 'Ubuntu'), 'ready']
+ ] as const) {
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: file,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetWorktreeId: target,
+ targetState: state
+ }).status
+ ).toBe(expected)
+ }
+ })
+
+ it('does not borrow an unrelated active project override for an orphan native folder', () => {
+ const state = stateFor('local', 'Debian')
+ state.folderWorkspaces = [
+ makeFolderWorkspace({ folderPath: 'C:/orphan', projectGroupId: 'orphan-group' })
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, 'folder:folder-1')).toBeNull()
+ })
+
+ it('keeps explicit SSH folder authority ahead of a matching local project path', () => {
+ const state = stateFor('local', 'Debian')
+ state.repos[0].path = 'C:/project'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({ folderPath: 'C:/project/folder', executionHostId: 'ssh:owner' })
+ ]
+ expect(
+ getLocalProjectExecutionRuntimeContext(state, 'folder:folder-1', 'win32')
+ ).toBeUndefined()
+ expect(getAiVaultResumeWorkspaceWslDistro(state, 'folder:folder-1')).toBeNull()
+ })
+
+ it('refuses local references while the project runtime requires repair', () => {
+ const state = stateFor('local')
+ state.projects[0].localWindowsRuntimePreference = { kind: 'inherit-global' }
+ state.settings = createGlobalSettingsFixture({
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: null }
+ })
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeUndefined()
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: windowsFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+
+ it('refuses references when a folder has multiple possible owning projects', () => {
+ const state = stateFor('local', 'Ubuntu')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
+ state.repos = [
+ ...state.repos,
+ {
+ id: 'second',
+ path: 'C:/second',
+ projectGroupId: 'group-1',
+ displayName: 'second',
+ badgeColor: '',
+ addedAt: 0
+ }
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeUndefined()
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: windowsFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+})
diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts
new file mode 100644
index 000000000000..ac1e33e7feae
--- /dev/null
+++ b/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts
@@ -0,0 +1,81 @@
+import { isAntigravityReferenceSession } from '../../../shared/antigravity-session-origin'
+import {
+ canResumeAiVaultSessionOnTarget,
+ getAiVaultResumeWorkspaceExecutionHostId,
+ getAiVaultResumeWorkspaceTargetStatus
+} from './ai-vault-resume-target'
+import { getAiVaultResumeWorkspaceWslDistro } from './ai-vault-resume-shell'
+import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-resume-command'
+import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup'
+import {
+ resolveTuiAgentLaunchArgs,
+ resolveTuiAgentLaunchEnv
+} from '../../../shared/tui-agent-launch-defaults'
+import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
+import type { GlobalSettings } from '../../../shared/global-settings-types'
+import type {
+ AiVaultResumeStartup,
+ buildAiVaultResumeStartupForWorktree
+} from './ai-vault-resume-command'
+
+/** References are fresh launches, so preserve normal model arguments and environment. */
+export function buildAntigravityReferenceStartup(args: {
+ session: { sessionId: string; filePath?: string }
+ cwd: string | null
+ platform: NodeJS.Platform
+ shell?: AgentStartupShell
+ commandOverride?: string | null
+ settings?: Partial<
+ Pick
+ > | null
+}): AiVaultResumeStartup | null {
+ const plan = buildAgentStartupPlan({
+ agent: 'antigravity',
+ prompt: '',
+ allowEmptyPromptLaunch: true,
+ platform: args.platform,
+ shell: args.shell,
+ cmdOverrides: {
+ ...args.settings?.agentCmdOverrides,
+ ...(args.commandOverride?.trim() ? { antigravity: args.commandOverride } : {})
+ },
+ agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings?.agentDefaultArgs),
+ agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings?.agentDefaultEnv)
+ })
+ if (!plan) {
+ return null
+ }
+ return {
+ command: buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: args.session.sessionId,
+ resumeFilePath: args.session.filePath,
+ cwd: args.cwd,
+ platform: args.platform,
+ shell: args.shell,
+ commandOverride: plan.launchCommand
+ }),
+ ...(plan.env ? { env: plan.env } : {}),
+ launchConfig: plan.launchConfig
+ }
+}
+
+export function assertAntigravityReferenceTarget(
+ args: Parameters[0]
+): void {
+ const workspaceId = args.worktreeId ?? args.state.activeWorktreeId
+ if (
+ isAntigravityReferenceSession(args.session) &&
+ !canResumeAiVaultSessionOnTarget({
+ sessionFilePath: args.session.filePath,
+ sessionExecutionHostId: args.session.executionHostId,
+ targetStatus: getAiVaultResumeWorkspaceTargetStatus(args.state, workspaceId),
+ targetExecutionHostId: getAiVaultResumeWorkspaceExecutionHostId(args.state, workspaceId),
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.state, workspaceId)
+ })
+ ) {
+ throw new Error(
+ 'The Antigravity transcript belongs to a different execution host or WSL distro.'
+ )
+ }
+}
diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts
index dd4c6105abf1..048a4daf3efc 100644
--- a/src/renderer/src/lib/ai-vault-resume-command.test.ts
+++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import {
+ getAiVaultAgentProviderSession,
buildAiVaultResumeCopyCommandForWorktree,
buildAiVaultResumeStartupForWorktree
} from './ai-vault-resume-command'
@@ -68,6 +69,47 @@ function buildQueuedAiVaultResumeCommand(
}
describe('ai vault resume command runtime', () => {
+ it.each(['local', 'ssh:reference-host'] as const)(
+ 'starts a fresh IDE reference on %s with model and environment preserved',
+ (executionHostId) => {
+ const state = makeState({
+ worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\example\\project',
+ localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
+ })
+ if (executionHostId !== 'local') {
+ state.repos = state.repos.map((repo) => ({ ...repo, executionHostId }))
+ }
+ const session = {
+ agent: 'antigravity' as const,
+ sessionId: 'ide-id',
+ cwd: '/home/example/project',
+ codexHome: null,
+ executionHostId,
+ executionHostPlatform: 'linux' as const,
+ resumeCommand: 'agy --conversation ide-id',
+ filePath:
+ executionHostId === 'local'
+ ? '\\\\wsl.localhost\\Ubuntu\\home\\example\\.gemini\\antigravity-ide\\brain\\ide-id\\.system_generated\\logs\\transcript_full.jsonl'
+ : '/home/example/.gemini/antigravity-ide/brain/ide-id/.system_generated/logs/transcript_full.jsonl'
+ }
+ if (!state.settings) {
+ throw new Error('Missing fixture settings')
+ }
+ state.settings.agentDefaultArgs = { antigravity: '--model claude-sonnet-4-6' }
+ state.settings.agentDefaultEnv = { antigravity: { AGY_CLI_HIDE_ACCOUNT_INFO: '1' } }
+ const startup = buildAiVaultResumeStartupForWorktree({ state, session })
+ expect(startup.command).toContain('--prompt-interactive')
+ expect(startup.command).not.toContain('--conversation')
+ expect(startup.command).not.toContain('wsl.localhost')
+ expect(startup.command).toContain('--model')
+ expect(startup.command).toContain('claude-sonnet-4-6')
+ expect(startup.env).toMatchObject({ AGY_CLI_HIDE_ACCOUNT_INFO: '1' })
+ expect(startup.providerSession).toBeUndefined()
+ expect(getAiVaultAgentProviderSession(session)).toBeNull()
+ expect(startup.cwd).toBe('/home/example/project')
+ }
+ )
+
it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',
diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts
index a0789c43b960..6e5b5ce86c4d 100644
--- a/src/renderer/src/lib/ai-vault-resume-command.ts
+++ b/src/renderer/src/lib/ai-vault-resume-command.ts
@@ -1,3 +1,8 @@
+import {
+ assertAntigravityReferenceTarget,
+ buildAntigravityReferenceStartup
+} from './ai-vault-antigravity-reference-startup'
+import { isAntigravityReferenceSession } from '../../../shared/antigravity-session-origin'
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import {
buildAiVaultResumeCommand,
@@ -125,11 +130,13 @@ function buildAiVaultResumeForWorktree(
* Spawned startups drop them through `envToDelete` instead. */
clearEnvNames?: readonly string[]
): AiVaultResumeStartup {
+ assertAntigravityReferenceTarget(args)
const providerSession = getAiVaultAgentProviderSession(args.session)
if (
args.session.executionHostId &&
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
args.session.resumeCommand &&
+ !isAntigravityReferenceSession(args.session) &&
args.session.agent !== 'omp' &&
!(args.session.agent === 'codex' && args.session.codexHome === null) &&
!args.commandOverride?.trim()
@@ -160,6 +167,19 @@ function buildAiVaultResumeForWorktree(
: undefined
const cwd = embedCwd ? args.session.cwd : null
const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {}
+ if (isAntigravityReferenceSession(args.session)) {
+ const reference = buildAntigravityReferenceStartup({
+ session: { ...args.session, filePath: resumeFilePath },
+ cwd,
+ platform,
+ shell: liveShell,
+ commandOverride: args.commandOverride,
+ settings: args.state.settings
+ })
+ if (reference) {
+ return { ...reference, ...startupCwd }
+ }
+ }
if (providerSession && isResumableTuiAgent(args.session.agent)) {
const startupPlan = buildAgentResumeStartupPlan({
agent: args.session.agent,
@@ -256,6 +276,9 @@ export function getAiVaultAgentProviderSession(
if (!isResumableTuiAgent(session.agent)) {
return null
}
+ if (isAntigravityReferenceSession(session)) {
+ return null
+ }
if (session.agent === 'antigravity' || session.agent === 'cursor') {
return { key: 'conversation_id', id: session.sessionId }
}
diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts
index 3d36b28c07fd..c51626a338e2 100644
--- a/src/renderer/src/lib/ai-vault-resume-shell.ts
+++ b/src/renderer/src/lib/ai-vault-resume-shell.ts
@@ -1,5 +1,8 @@
import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
+import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
+import { getAiVaultResumeWorkspaceExecutionHostId } from './ai-vault-resume-target'
+import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime'
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
@@ -74,3 +77,50 @@ export function getAiVaultResumeWorkspacePath(
.find((candidate) => candidate.id === targetWorktreeId)?.path ?? null
)
}
+
+export function getAiVaultResumeWorkspaceWslDistro(
+ state: Pick &
+ Partial<
+ Pick<
+ AppState,
+ 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings' | 'projectGroups'
+ >
+ >,
+ workspaceId: string | null | undefined
+): string | null | undefined {
+ const workspacePath = getAiVaultResumeWorkspacePath(state, workspaceId)
+ const workspaceKey = workspaceId ? parseWorkspaceKey(workspaceId) : null
+ const runtimeState = {
+ activeRepoId: state.activeRepoId ?? null,
+ activeWorktreeId: state.activeWorktreeId ?? null,
+ projects: state.projects ?? [],
+ settings: state.settings ?? null,
+ folderWorkspaces: state.folderWorkspaces,
+ projectGroups: state.projectGroups ?? [],
+ repos: state.repos,
+ worktreesByRepo: state.worktreesByRepo
+ }
+ if (workspaceKey?.type === 'folder') {
+ if (
+ getAiVaultResumeWorkspaceExecutionHostId(runtimeState, workspaceId ?? null) !==
+ LOCAL_EXECUTION_HOST_ID
+ ) {
+ return null
+ }
+ if (getFolderWorkspaceCandidateRepos(runtimeState, workspaceKey.folderWorkspaceId).length > 1) {
+ return undefined
+ }
+ }
+ const runtime = getLocalProjectExecutionRuntimeContext(
+ runtimeState,
+ workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId,
+ CLIENT_PLATFORM
+ )
+ if (runtime?.status === 'repair-required') {
+ return undefined
+ }
+ if (runtime?.status === 'resolved') {
+ return runtime.runtime.kind === 'wsl' ? runtime.runtime.distro : null
+ }
+ return workspacePath ? (parseWslUncPath(workspacePath)?.distro ?? null) : null
+}
diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts
index 874bc7ae7b20..17afeeb344ca 100644
--- a/src/renderer/src/lib/ai-vault-resume-target.ts
+++ b/src/renderer/src/lib/ai-vault-resume-target.ts
@@ -9,7 +9,8 @@ import {
import type { Repo } from '../../../shared/repo-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
-import { isWslUncPath } from '../../../shared/wsl-paths'
+import { isWslUncPath, parseWslUncPath } from '../../../shared/wsl-paths'
+import { antigravitySessionOrigin } from '../../../shared/antigravity-session-origin'
import type { AppState } from '@/store/types'
import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
@@ -42,9 +43,36 @@ export function canResumeAiVaultSessionOnTarget(args: {
sessionExecutionHostId?: ExecutionHostId | null
targetStatus: AiVaultResumeTargetStatus
targetExecutionHostId?: ExecutionHostId | null
+ targetWslDistro?: string | null
}): boolean {
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
+ const origin = args.sessionFilePath ? antigravitySessionOrigin(args.sessionFilePath) : null
+ if (origin && origin !== 'antigravity-cli') {
+ if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
+ return false
+ }
+ const sourceHost = sessionExecutionHostId ?? LOCAL_EXECUTION_HOST_ID
+ const targetHost =
+ targetExecutionHostId ?? (args.targetStatus === 'local' ? LOCAL_EXECUTION_HOST_ID : null)
+ if (sourceHost !== targetHost) {
+ // #6270's SSH/UNC labels do not prove this host owns the referenced file.
+ return false
+ }
+ if (args.targetStatus === 'local' && args.targetWslDistro === undefined) {
+ return false
+ }
+ const sourceWsl = args.sessionFilePath ? parseWslUncPath(args.sessionFilePath) : null
+ if (sourceWsl) {
+ return (
+ args.targetStatus === 'local' &&
+ Boolean(args.targetWslDistro) &&
+ sourceWsl.distro.toLowerCase() === args.targetWslDistro?.toLowerCase()
+ )
+ }
+ // File references require the original filesystem, unlike legacy ID resumes.
+ return args.targetStatus !== 'local' || !args.targetWslDistro
+ }
if (args.targetStatus === 'runtime') {
// Runtime session stores live on one paired server; only queue resumes back
// onto that exact server host.
diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts
index 7988e4656a7b..c93efe80d83e 100644
--- a/src/renderer/src/lib/local-preflight-context.ts
+++ b/src/renderer/src/lib/local-preflight-context.ts
@@ -9,7 +9,9 @@ import {
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { Repo } from '../../../shared/repo-types'
import type { Worktree } from '../../../shared/worktree/types'
-import { getIndexedRepoMap, getIndexedWorktreeById } from '@/store/worktree-repo-index'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import { getIndexedRepoMap } from '@/store/worktree-repo-index'
+import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace'
import { getProviderRuntimeContextKey } from './provider-runtime-context'
import { getRendererAppPlatform } from './renderer-app-platform'
import {
@@ -35,11 +37,11 @@ export {
type LocalProjectRuntimeState = Pick<
AppState,
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
->
+> &
+ Partial>
// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}`
// or `[]` fallback would miss the cache on every read.
-const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
const EMPTY_REPOS: AppState['repos'] = []
type LocalProjectRuntimeWslContext = {
@@ -66,7 +68,13 @@ export function getLocalProjectExecutionRuntimeContext(
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return undefined
}
- const worktree = getLocalWorktree(state, worktreeId)
+ const worktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
+ if (
+ !worktree &&
+ parseWorkspaceKey(worktreeId ?? state.activeWorktreeId ?? '')?.type === 'folder'
+ ) {
+ return undefined
+ }
const repo = getLocalRuntimeRepoForWorktree(state, worktree)
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(worktree)) {
return undefined
@@ -262,7 +270,7 @@ function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext
}
function getLocalPreflightWslDistro(state: AppState, worktreeId?: string | null): string | null {
- const activeWorktree = getLocalWorktree(state, worktreeId)
+ const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree)
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) {
return null
@@ -275,6 +283,9 @@ function getLocalRuntimeRepoForWorktree(
state: LocalProjectRuntimeState,
worktree?: Pick | null
): Pick | undefined {
+ if (!worktree && parseWorkspaceKey(state.activeWorktreeId ?? '')?.type === 'folder') {
+ return undefined
+ }
const repoId = worktree?.repoId ?? state.activeRepoId
return repoId ? getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) : undefined
}
@@ -303,25 +314,11 @@ function getLocalRuntimeProject(
)
}
-function getLocalWorktree(
- state: LocalProjectRuntimeState,
- worktreeId?: string | null
-): Pick | null {
- const targetWorktreeId = worktreeId ?? state.activeWorktreeId
- if (!targetWorktreeId) {
- return null
- }
- return (
- getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ??
- null
- )
-}
-
function getLocalPreflightProjectId(
state: LocalProjectRuntimeState,
worktreeId?: string | null
): string {
- const activeWorktree = getLocalWorktree(state, worktreeId)
+ const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
return (
activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project'
)
diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts
new file mode 100644
index 000000000000..1eda3cb9f67f
--- /dev/null
+++ b/src/renderer/src/lib/local-project-runtime-workspace.ts
@@ -0,0 +1,57 @@
+import type { AppState } from '@/store/types'
+import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
+import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
+import {
+ findFolderWorkspaceCandidateRepos,
+ resolveFolderWorkspaceHost
+} from '../../../shared/folder-workspace-execution-host'
+import type { Worktree } from '../../../shared/worktree/types'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+
+type RuntimeWorkspaceState = Pick &
+ Partial>
+
+const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
+
+export function getLocalProjectRuntimeWorkspace(
+ state: RuntimeWorkspaceState,
+ worktreeId?: string | null
+): Pick | null {
+ const targetWorktreeId = worktreeId ?? state.activeWorktreeId
+ if (!targetWorktreeId) {
+ return null
+ }
+ const scope = parseWorkspaceKey(targetWorktreeId)
+ if (scope?.type !== 'folder') {
+ return (
+ getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ??
+ null
+ )
+ }
+ const folderState = {
+ folderWorkspaces: state.folderWorkspaces ?? [],
+ projectGroups: state.projectGroups ?? [],
+ repos: state.repos
+ }
+ const folder = folderState.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
+ if (!folder || resolveFolderWorkspaceHost(folderState, folder.id).kind !== 'local') {
+ return null
+ }
+ const group = folderState.projectGroups.find((entry) => entry.id === folder.projectGroupId)
+ if (
+ (folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !==
+ LOCAL_EXECUTION_HOST_ID
+ ) {
+ return null
+ }
+ const candidates = findFolderWorkspaceCandidateRepos(folderState, folder.id)
+ const repo = candidates.length === 1 ? candidates[0] : undefined
+ return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
+ ? {
+ id: targetWorktreeId,
+ repoId: repo.id,
+ path: folder.folderPath,
+ hostId: LOCAL_EXECUTION_HOST_ID
+ }
+ : null
+}
diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts
index 5f257c5c12ac..10e1ade04f66 100644
--- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts
+++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts
@@ -55,6 +55,7 @@ export function createWebAiVaultApi(): NonNullable['aiVault'
}
// Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host.
return callRuntimeResult('aiVault.listSessions', {
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
limit: args?.limit,
force: args?.force,
scopePaths: args?.scopePaths,
diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts
index 60f50023188f..800efc56d60e 100644
--- a/src/shared/ai-vault-resume-command.ts
+++ b/src/shared/ai-vault-resume-command.ts
@@ -1,6 +1,11 @@
// Resume-command construction for Agent Session History rows: turns a scanned
// session into the shell line that re-enters it, quoted for the target platform
// and (when known) the live tab's shell.
+import {
+ isAntigravityReferenceSession,
+ antigravityTranscriptReferencePrompt
+} from './antigravity-session-origin'
+import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
clearEnvCommand,
@@ -41,7 +46,11 @@ export function buildAiVaultResumeCommand(args: {
: shell
? quoteStartupArg(resumeTarget, shell)
: quoteShellArg(resumeTarget, platform)
- const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg)
+ const referencePath = normalizeAiVaultResumeFilePath(resumeFilePath ?? undefined, platform)
+ const resumeCommand =
+ isAntigravityReferenceSession({ agent, filePath: referencePath }) && referencePath
+ ? `${baseCommand} --prompt-interactive ${quoteResumeArg(antigravityTranscriptReferencePrompt(referencePath), platform, shell)}`
+ : buildAgentResumeInvocation(agent, baseCommand, sessionArg)
return buildAiVaultResumeShellCommand({
resumeCommand,
diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts
index 26431f05810b..fc650abdfc7f 100644
--- a/src/shared/ai-vault-types.ts
+++ b/src/shared/ai-vault-types.ts
@@ -116,6 +116,8 @@ export type AiVaultSession = {
/** Older messages fell out of the newest-N window: the earliest preview turn
* is NOT the opening ask, so first-prompt consumers must not scan it. */
previewMessagesTruncated?: boolean
+ /** Bounded opening-prompt identity for Antigravity history joins; never a rolling preview. */
+ antigravityOpeningPrompt?: { hash: string; timestamp: string | null }
/**
* Full first non-injected user prompt. List scans omit this (payload/perf);
* populated only by on-demand `aiVault.getFirstUserPrompt` re-parses for copy.
@@ -212,6 +214,8 @@ export type AiVaultScanIssue = {
}
export type AiVaultListArgs = {
+ /** Opt-in promises this client starts IDE history in a new CLI conversation. */
+ includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
force?: boolean
diff --git a/src/shared/antigravity-session-origin.ts b/src/shared/antigravity-session-origin.ts
new file mode 100644
index 000000000000..de4b1d4b1b27
--- /dev/null
+++ b/src/shared/antigravity-session-origin.ts
@@ -0,0 +1,27 @@
+export const ANTIGRAVITY_HISTORY_ROOTS = [
+ 'antigravity-cli',
+ 'antigravity-ide',
+ 'antigravity'
+] as const
+export type AntigravitySessionOrigin = (typeof ANTIGRAVITY_HISTORY_ROOTS)[number]
+
+export function antigravitySessionOrigin(filePath: string): AntigravitySessionOrigin | null {
+ const segments = filePath.split(/[\\/]+/).filter(Boolean)
+ const brainIndex = segments.lastIndexOf('brain')
+ if (brainIndex < 2 || segments[brainIndex - 2] !== '.gemini') {
+ return null
+ }
+ return ANTIGRAVITY_HISTORY_ROOTS.find((origin) => origin === segments[brainIndex - 1]) ?? null
+}
+
+export function isAntigravityReferenceSession(session: {
+ agent: string
+ filePath?: string
+}): boolean {
+ const origin = session.filePath ? antigravitySessionOrigin(session.filePath) : null
+ return session.agent === 'antigravity' && origin !== null && origin !== 'antigravity-cli'
+}
+
+export function antigravityTranscriptReferencePrompt(filePath: string): string {
+ return `Review the chat transcript at ${JSON.stringify(filePath)}. Confirm your understanding of the previous conversation and continue from where it left off. This starts a new CLI conversation using the original transcript as a reference.`
+}
diff --git a/src/shared/antigravity-transcript-reference.test.ts b/src/shared/antigravity-transcript-reference.test.ts
new file mode 100644
index 000000000000..e5da85b3ed07
--- /dev/null
+++ b/src/shared/antigravity-transcript-reference.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { buildAiVaultResumeCommand } from './ai-vault-resume-command'
+import { tokenizeCommandLine } from './agent-command-line-entrypoint'
+import { antigravityTranscriptReferencePrompt } from './antigravity-session-origin'
+import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
+
+describe('Antigravity transcript reference launch', () => {
+ it.each(['antigravity-ide', 'antigravity'])(
+ 'starts a new conversation for %s and preserves the explicit transcript argument',
+ (origin) => {
+ const path = `/home/example/.gemini/${origin}/brain/same-id/.system_generated/logs/transcript_full.jsonl`
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'same-id',
+ cwd: null,
+ platform: 'linux',
+ resumeFilePath: path
+ })
+ expect(tokenizeCommandLine(command)).toEqual([
+ 'agy',
+ '--prompt-interactive',
+ antigravityTranscriptReferencePrompt(path)
+ ])
+ expect(command).not.toContain('--conversation')
+ }
+ )
+ it('quotes hostile path text as one prompt argument', () => {
+ const path = `/tmp/a'$(touch forbidden)/.gemini/antigravity-ide/brain/id/.system_generated/logs/transcript.jsonl`
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'linux',
+ resumeFilePath: path,
+ shell: 'posix'
+ })
+ expect(tokenizeCommandLine(command)).toEqual([
+ 'agy',
+ '--prompt-interactive',
+ antigravityTranscriptReferencePrompt(path)
+ ])
+ })
+ it('converts the selected WSL transcript into its owning Linux path', () => {
+ const path =
+ '\\\\wsl.localhost\\Ubuntu\\home\\example\\.gemini\\antigravity-ide\\brain\\id\\.system_generated\\logs\\transcript_full.jsonl'
+ const normalized = normalizeAiVaultResumeFilePath(path, 'linux')
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'linux',
+ resumeFilePath: path
+ })
+ expect(tokenizeCommandLine(command)).toEqual([
+ 'agy',
+ '--prompt-interactive',
+ antigravityTranscriptReferencePrompt(normalized!)
+ ])
+ expect(command).not.toContain('wsl.localhost')
+ })
+ it.each(['cmd', 'powershell', 'posix'] as const)(
+ 'uses the actual Windows %s shell and preserves CLI resume behavior',
+ (shell) => {
+ const path =
+ 'C:\\User data\\.gemini\\antigravity-ide\\brain\\id\\.system_generated\\logs\\transcript_full.jsonl'
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'win32',
+ resumeFilePath: path,
+ shell
+ })
+ expect(command).toContain('--prompt-interactive')
+ expect(command).not.toContain('--conversation')
+ const cli = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'win32',
+ resumeFilePath: path.replace('antigravity-ide', 'antigravity-cli'),
+ shell
+ })
+ expect(cli).toContain('--conversation')
+ expect(cli).not.toContain('--prompt-interactive')
+ }
+ )
+})
diff --git a/src/shared/node-bounded-file-reader.ts b/src/shared/node-bounded-file-reader.ts
index bc4d19233dad..a8206b1a142a 100644
--- a/src/shared/node-bounded-file-reader.ts
+++ b/src/shared/node-bounded-file-reader.ts
@@ -1,5 +1,5 @@
-import { closeSync, fstatSync, openSync, readSync, type Stats } from 'node:fs'
-import { open, type FileHandle } from 'node:fs/promises'
+import { closeSync, constants, fstatSync, openSync, readSync, type Stats } from 'node:fs'
+import { open, stat, type FileHandle } from 'node:fs/promises'
const MIN_GROWTH_BYTES = 64 * 1024
@@ -20,6 +20,8 @@ export type BoundedNodeFileRead = {
stats: Stats
}
+type NodeFileReadOptions = { regularFileOnly?: boolean; signal?: AbortSignal }
+
function validateSize(size: number, maxBytes: number): void {
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error('File has an invalid byte size')
@@ -31,11 +33,21 @@ function validateSize(size: number, maxBytes: number): void {
export async function readNodeFileWithinLimit(
filePath: string,
- maxBytes: number
+ maxBytes: number,
+ options: NodeFileReadOptions = {}
): Promise {
- const handle = await open(filePath, 'r')
+ options.signal?.throwIfAborted()
+ if (options.regularFileOnly && !(await stat(filePath)).isFile()) {
+ throw new Error('Expected a regular file')
+ }
+ options.signal?.throwIfAborted()
+ // Nonblocking open fences replacement with a FIFO after the path check.
+ const flags = options.regularFileOnly
+ ? constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
+ : 'r'
+ const handle = await open(filePath, flags)
try {
- return await readNodeFileHandleWithinLimit(handle, maxBytes)
+ return await readNodeFileHandleWithinLimit(handle, maxBytes, options)
} finally {
await handle.close()
}
@@ -43,20 +55,28 @@ export async function readNodeFileWithinLimit(
export async function readNodeFileHandleWithinLimit(
handle: FileHandle,
- maxBytes: number
+ maxBytes: number,
+ options: NodeFileReadOptions = {}
): Promise {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError('File read limit must be a non-negative safe integer')
}
+ options.signal?.throwIfAborted()
const stats = await handle.stat()
+ options.signal?.throwIfAborted()
+ if (options.regularFileOnly && !stats.isFile()) {
+ throw new Error('Expected a regular file')
+ }
validateSize(stats.size, maxBytes)
let buffer = Buffer.allocUnsafe(stats.size)
let offset = 0
while (true) {
+ options.signal?.throwIfAborted()
while (offset < buffer.length) {
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
+ options.signal?.throwIfAborted()
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
@@ -65,6 +85,7 @@ export async function readNodeFileHandleWithinLimit(
const probe = Buffer.allocUnsafe(1)
const { bytesRead } = await handle.read(probe, 0, 1, offset)
+ options.signal?.throwIfAborted()
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts
index d036af9177f4..f2f86da6a22f 100644
--- a/src/shared/rpc-contract/ai-vault-params.ts
+++ b/src/shared/rpc-contract/ai-vault-params.ts
@@ -32,6 +32,7 @@ export const AiVaultListSessionsParams = z
)
.pipe(z.union([z.number().int(), z.undefined()]))
.optional(),
+ includeAntigravityIdeSessions: OptionalBoolean,
unlimited: OptionalBoolean,
force: OptionalBoolean,
scopePaths: z
diff --git a/src/shared/ssh-ai-vault-relay.ts b/src/shared/ssh-ai-vault-relay.ts
index 2672cfe75669..4a88d7b4558a 100644
--- a/src/shared/ssh-ai-vault-relay.ts
+++ b/src/shared/ssh-ai-vault-relay.ts
@@ -6,6 +6,7 @@ export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
export type SshAiVaultRelayListParams = {
+ includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
force?: boolean
From e8402b4619aa121e8bbaca9f22c343776a6e14d3 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:21:23 -0700
Subject: [PATCH 09/11] fix(opencode): retry timed-out SSH plugin updates
(#24666)
Preserve bounded retry behavior and the current-main status-envelope fields.
Original-PR: #24124
Reviewed-source: 103144f9c5029f9217f64970271a5b7f69d3f823
Co-authored-by: Justas Brazauskas
---
.../ssh-relay-session-managed-hooks.test.ts | 186 ++++++++++++++++++
src/main/ssh/ssh-relay-session.ts | 46 ++++-
2 files changed, 230 insertions(+), 2 deletions(-)
diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts
index da10d8841a18..1ef197e170d0 100644
--- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts
+++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts
@@ -202,4 +202,190 @@ describe('SshRelaySession managed hooks', () => {
session.dispose()
expect(cleanup).toHaveBeenCalledOnce()
})
+
+ // Why: a timed-out install left the relay on the old sources (a disabled agent kept its plugin) until the next reconnect.
+ it('retries a settings-triggered plugin install that failed, using the latest settings', async () => {
+ muxRequestMock.mockResolvedValue({ agents: [] })
+ const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps()
+ const settings = getDefaultSettings('/synthetic-home')
+ mockStore.getSettings = () => settings
+ let listener: Parameters[0] | undefined
+ mockStore.onSettingsChanged = (callback) => {
+ listener = callback
+ return vi.fn()
+ }
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const session = new SshRelaySession(
+ 'target-settings',
+ getMainWindow,
+ mockStore,
+ mockPortForward
+ )
+ await session.establish(mockConn)
+ const installCalls = () =>
+ muxRequestMock.mock.calls.filter(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD)
+
+ vi.useFakeTimers()
+ try {
+ const before = installCalls().length
+ muxRequestMock.mockImplementationOnce(async (method: string) => {
+ if (method !== AGENT_HOOK_INSTALL_PLUGINS_METHOD) {
+ return { agents: [] }
+ }
+ throw Object.assign(new Error('timed out'), { code: 'SSH_MUX_REQUEST_TIMEOUT' })
+ })
+ settings.disabledTuiAgents = ['opencode2']
+ listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings)
+ await vi.advanceTimersByTimeAsync(60_000)
+
+ const calls = installCalls().slice(before)
+ expect(calls.length).toBeGreaterThan(1)
+ expect(calls.at(-1)?.[1]).toMatchObject({ opencode2PluginSource: '' })
+ } finally {
+ vi.useRealTimers()
+ warn.mockRestore()
+ session.dispose()
+ }
+ })
+
+ // Why: two overlapping failed installs must leave one retry chain, not two the next install can't cancel.
+ it('retries once when overlapping settings-triggered installs both fail', async () => {
+ muxRequestMock.mockResolvedValue({ agents: [] })
+ const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps()
+ const settings = getDefaultSettings('/synthetic-home')
+ mockStore.getSettings = () => settings
+ let listener: Parameters[0] | undefined
+ mockStore.onSettingsChanged = (callback) => {
+ listener = callback
+ return vi.fn()
+ }
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const session = new SshRelaySession(
+ 'target-settings',
+ getMainWindow,
+ mockStore,
+ mockPortForward
+ )
+ await session.establish(mockConn)
+ const installCalls = () =>
+ muxRequestMock.mock.calls.filter(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD)
+
+ vi.useFakeTimers()
+ try {
+ const before = installCalls().length
+ const pendingInstalls: ((error: Error) => void)[] = []
+ muxRequestMock.mockImplementation((method: string) => {
+ if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD && pendingInstalls.length < 2) {
+ return new Promise((_resolve, reject) => pendingInstalls.push(reject))
+ }
+ return Promise.resolve({ agents: [] })
+ })
+ settings.disabledTuiAgents = ['opencode2']
+ listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings)
+ settings.disabledTuiAgents = ['opencode']
+ listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings)
+ expect(pendingInstalls).toHaveLength(2)
+
+ for (const reject of pendingInstalls) {
+ reject(Object.assign(new Error('timed out'), { code: 'SSH_MUX_REQUEST_TIMEOUT' }))
+ }
+ await vi.advanceTimersByTimeAsync(60_000)
+
+ expect(installCalls().slice(before)).toHaveLength(3)
+ } finally {
+ vi.useRealTimers()
+ warn.mockRestore()
+ session.dispose()
+ }
+ })
+ it.each(['PERMISSION_DENIED', -32601, 'CONNECTION_LOST', 'DISPOSED'])(
+ 'does not retry permanent or retired transport failure %s',
+ async (code) => {
+ muxRequestMock.mockResolvedValue({ agents: [] })
+ const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps()
+ const settings = getDefaultSettings('/synthetic-home')
+ mockStore.getSettings = () => settings
+ let listener: Parameters[0] | undefined
+ mockStore.onSettingsChanged = (callback) => {
+ listener = callback
+ return vi.fn()
+ }
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const session = new SshRelaySession(
+ 'target-no-retry',
+ getMainWindow,
+ mockStore,
+ mockPortForward
+ )
+ await session.establish(mockConn)
+ warn.mockClear()
+ vi.useFakeTimers()
+ try {
+ const before = muxRequestMock.mock.calls.filter(
+ ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
+ ).length
+ muxRequestMock.mockRejectedValueOnce(Object.assign(new Error('controlled error'), { code }))
+ listener?.({ disabledTuiAgents: ['opencode2'] }, settings)
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(
+ muxRequestMock.mock.calls.filter(
+ ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
+ )
+ ).toHaveLength(before + 1)
+ expect(warn).toHaveBeenCalledTimes(code === 'PERMISSION_DENIED' ? 1 : 0)
+ } finally {
+ vi.useRealTimers()
+ warn.mockRestore()
+ session.dispose()
+ }
+ }
+ )
+ it.each([false, true])(
+ 'bounds timeout retries and cancels them on disposal=%s',
+ async (dispose) => {
+ muxRequestMock.mockResolvedValue({ agents: [] })
+ const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps()
+ const settings = getDefaultSettings('/synthetic-home')
+ mockStore.getSettings = () => settings
+ let listener: Parameters[0] | undefined
+ mockStore.onSettingsChanged = (callback) => {
+ listener = callback
+ return vi.fn()
+ }
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const session = new SshRelaySession(
+ 'target-bounded',
+ getMainWindow,
+ mockStore,
+ mockPortForward
+ )
+ await session.establish(mockConn)
+ const before = muxRequestMock.mock.calls.filter(
+ ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
+ ).length
+ muxRequestMock.mockImplementation(async (method) => {
+ if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) {
+ throw Object.assign(new Error('timeout'), { code: 'SSH_MUX_REQUEST_TIMEOUT' })
+ }
+ return { agents: [] }
+ })
+ vi.useFakeTimers()
+ try {
+ listener?.({ disabledTuiAgents: ['opencode2'] }, settings)
+ if (dispose) {
+ session.dispose()
+ }
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(
+ muxRequestMock.mock.calls.filter(
+ ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
+ )
+ ).toHaveLength(before + (dispose ? 1 : 4))
+ } finally {
+ vi.useRealTimers()
+ warn.mockRestore()
+ session.dispose()
+ }
+ }
+ )
})
diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts
index 673fc25fed58..2cd1f394cd72 100644
--- a/src/main/ssh/ssh-relay-session.ts
+++ b/src/main/ssh/ssh-relay-session.ts
@@ -162,6 +162,8 @@ const SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS = 2
// store read, an attach round trip and a store write.
const SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS = 12
const SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS = 150
+const SSH_PLUGIN_INSTALL_RETRY_DELAY_MS = 5_000
+const SSH_PLUGIN_INSTALL_MAX_RETRIES = 3
const SSH_SOURCE_RECOVERY_CANCELLATION_FAILED = 'ssh_source_recovery_cancellation_failed'
// Why: superseded attempts stop quietly; a dead mux still owned by this attempt must enter recovery.
@@ -323,6 +325,8 @@ export class SshRelaySession {
// Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup).
private muxNotificationCleanup: (() => void) | null = null
private pluginSettingsCleanup: (() => void) | null = null
+ private pluginInstallRetryTimer: ReturnType | null = null
+ private pluginInstallGeneration = 0
// Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect.
private _onRelayLost: ((targetId: string) => void) | null = null
// Why: a version mismatch or a blocked owner admission is terminal, so it needs a separate callback
@@ -1611,11 +1615,16 @@ export class SshRelaySession {
}
// Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host.
- private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise {
+ private async installPluginsOnRelay(
+ mux: SshChannelMultiplexer,
+ attempt = 0,
+ generation = ++this.pluginInstallGeneration
+ ): Promise {
if (!isRemoteAgentHooksEnabled()) {
return
}
try {
+ this.clearPluginInstallRetry()
const hooksEnabled = this.areAgentStatusHooksEnabled()
await mux.request(
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
@@ -1636,7 +1645,7 @@ export class SshRelaySession {
)
} catch (err) {
// Why: -32601 = older relay without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both.
- const code = (err as { code?: unknown })?.code
+ const code = err instanceof Error && 'code' in err ? err.code : undefined
if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
return
}
@@ -1648,6 +1657,37 @@ export class SshRelaySession {
err instanceof Error ? err.message : String(err)
}`
)
+ if (code === 'SSH_MUX_REQUEST_TIMEOUT') {
+ this.schedulePluginInstallRetry(mux, attempt, generation)
+ }
+ }
+ }
+
+ private schedulePluginInstallRetry(
+ mux: SshChannelMultiplexer,
+ attempt: number,
+ generation: number
+ ): void {
+ if (
+ attempt >= SSH_PLUGIN_INSTALL_MAX_RETRIES ||
+ this.mux !== mux ||
+ generation !== this.pluginInstallGeneration
+ ) {
+ return
+ }
+ this.pluginInstallRetryTimer = setTimeout(() => {
+ this.pluginInstallRetryTimer = null
+ if (this.mux === mux && !mux.isDisposed()) {
+ void this.installPluginsOnRelay(mux, attempt + 1, generation)
+ }
+ }, SSH_PLUGIN_INSTALL_RETRY_DELAY_MS)
+ this.pluginInstallRetryTimer.unref?.()
+ }
+
+ private clearPluginInstallRetry(): void {
+ if (this.pluginInstallRetryTimer !== null) {
+ clearTimeout(this.pluginInstallRetryTimer)
+ this.pluginInstallRetryTimer = null
}
}
@@ -1763,6 +1803,8 @@ export class SshRelaySession {
this.releaseRelayLossWatcher()
this.pluginSettingsCleanup?.()
this.pluginSettingsCleanup = null
+ this.pluginInstallGeneration += 1
+ this.clearPluginInstallRetry()
this.leavePlainSshMode()
this.muxNotificationCleanup?.()
this.muxNotificationCleanup = null
From 53f9ea783986d0a336e079e8ed8d59a85552ca4c Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:21:27 -0700
Subject: [PATCH 10/11] fix(opencode): keep Go credentials private and resolve
backend keys (#24615)
Preserve the complete credential storage, migration, IPC, Settings and rate-limit refresh change alongside standalone GLM plans, current-main database diagnostics and the reviewed unknown-backend environment correction. Keep native discovery cancellation third and selected environment fourth.
Original-topic-commit: 7903f1cddbc08fe1179f8bd964fff3dcb0e5d020
Original-topic-commit: 588117b5cf1dd57e2310c80068e3ddcc9f9044ec
Original-topic-commit: dedd4f8c862a961393977dc3c2547f049391b509
Original-topic-commit: 6645dae104ee5271139fcc0e9564d9223b74835d
Original-topic-commit: a25b80c02c1af7830b0e6a65e72d965b3ad98276
Restacked-from: a25b80c02c1af7830b0e6a65e72d965b3ad98276
Restacked-onto: b032867021c7ef1436ca606559e9d786580a84dc
Co-authored-by: kespineira
Co-authored-by: kevimux
Reported-by: pullfrog
Reviewed-full-source: 849fe093073f4c1606bd65d79a0c725d955d0d1f
Native-helper-source: 80dbe23237db191c1e6280001c7e0563f3ca8846
Reviewed-full-current-source: 36acb57d44adb3d378c0289c8c15f7da0fda214c
---
.../api-key-file-unreadable-error.ts | 2 +
.../encrypted-api-key-file-store.ts | 177 +++++++++
...edential-change-rate-limit-refresh.test.ts | 40 ++
.../credential-change-rate-limit-refresh.ts | 19 +
src/main/ipc/minimax-credentials.ts | 12 +-
src/main/ipc/opencode-go-credentials.test.ts | 87 +++++
src/main/ipc/opencode-go-credentials.ts | 47 +++
.../register-core-handlers.test.ts | 8 +
.../register-core-handlers.ts | 2 +
.../minimax/minimax-api-key-store.test.ts | 25 +-
src/main/minimax/minimax-api-key-store.ts | 161 +-------
.../opencode-database-discovery.ts | 30 +-
.../opencode-credential-backend.test.ts | 182 +++++++++
.../opencode/opencode-credential-backend.ts | 91 +++++
.../opencode-go-api-key-store.test.ts | 99 +++++
.../opencode/opencode-go-api-key-store.ts | 13 +
.../applying-settings/settings-update.ts | 3 -
.../terminal-settings-migrations.ts | 4 +
.../leasing-ssh-ptys/secret-validation.ts | 7 -
...gacy-opencode-go-api-key-migration.test.ts | 200 ++++++++++
.../legacy-opencode-go-api-key-migration.ts | 100 +++++
.../loading-store/loaded-state-parsing.ts | 10 +-
...profile-state-delayed-authority-fixture.ts | 13 +-
...file-state-worker-secret-retention.test.ts | 34 +-
.../state-serialization-secret-handling.ts | 8 +-
.../state-write-round-trip.test.ts | 66 +++-
src/main/persistence/loading-store/store.ts | 11 +
src/main/protected-secret-persistence.ts | 12 +-
.../opencode-go-api-key-source.test.ts | 350 +++++++++++++++++-
.../rate-limits/opencode-go-api-key-source.ts | 100 +++--
...opencode-go-usage-source-selection.test.ts | 38 ++
.../opencode-go-usage-source-selection.ts | 14 +-
.../service-opencode-go-credentials.test.ts | 195 ++++++++++
.../service-refresh-orchestration.test.ts | 12 +-
.../service/service-account-refresh.ts | 13 +
.../service/service-configuration.ts | 6 +-
.../service/service-fetch-targets.ts | 30 ++
.../service/service-full-cycle-preparation.ts | 34 +-
src/main/rate-limits/service/service-state.ts | 1 +
src/main/rate-limits/service/service-types.ts | 9 +-
.../startup/main-process-account-services.ts | 24 +-
src/preload/api-types.ts | 5 +
.../api/opencode-go-credentials-bridge.ts | 11 +
src/preload/index.ts | 2 +
src/preload/opencode-go-credentials.test.ts | 23 ++
.../AccountsPane.section-lifetime.test.tsx | 5 +
...accounts-pane-opencode-credentials.test.ts | 47 +++
.../accounts-pane-opencode-credentials.tsx | 113 ++++++
...ccounts-pane-provider-setting-sections.tsx | 54 +--
.../web/preload-api/web-agent-accounts-api.ts | 10 +
.../web-preload-api-agent-providers.test.ts | 11 +
.../web/web-preload-api-composition.test.ts | 1 +
src/renderer/src/web/web-preload-api.ts | 2 +
src/shared/default-global-settings.ts | 1 -
src/shared/global-settings-types.ts | 2 -
55 files changed, 2273 insertions(+), 303 deletions(-)
create mode 100644 src/main/credentials/api-key-file-unreadable-error.ts
create mode 100644 src/main/credentials/encrypted-api-key-file-store.ts
create mode 100644 src/main/ipc/credential-change-rate-limit-refresh.test.ts
create mode 100644 src/main/ipc/credential-change-rate-limit-refresh.ts
create mode 100644 src/main/ipc/opencode-go-credentials.test.ts
create mode 100644 src/main/ipc/opencode-go-credentials.ts
create mode 100644 src/main/opencode/opencode-credential-backend.test.ts
create mode 100644 src/main/opencode/opencode-credential-backend.ts
create mode 100644 src/main/opencode/opencode-go-api-key-store.test.ts
create mode 100644 src/main/opencode/opencode-go-api-key-store.ts
create mode 100644 src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts
create mode 100644 src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts
create mode 100644 src/main/rate-limits/service-opencode-go-credentials.test.ts
create mode 100644 src/preload/api/opencode-go-credentials-bridge.ts
create mode 100644 src/preload/opencode-go-credentials.test.ts
create mode 100644 src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts
create mode 100644 src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx
diff --git a/src/main/credentials/api-key-file-unreadable-error.ts b/src/main/credentials/api-key-file-unreadable-error.ts
new file mode 100644
index 000000000000..9c1af30dc65f
--- /dev/null
+++ b/src/main/credentials/api-key-file-unreadable-error.ts
@@ -0,0 +1,2 @@
+/** A transient read failure; the saved key may be fine, so callers must not ask to re-enter it. */
+export class ApiKeyFileUnreadableError extends Error {}
diff --git a/src/main/credentials/encrypted-api-key-file-store.ts b/src/main/credentials/encrypted-api-key-file-store.ts
new file mode 100644
index 000000000000..8d6e9f68f7b3
--- /dev/null
+++ b/src/main/credentials/encrypted-api-key-file-store.ts
@@ -0,0 +1,177 @@
+import { safeStorage } from 'electron'
+import { existsSync, readFileSync, rmSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import {
+ hardenExistingSecureFile,
+ isUnreadableError,
+ writeSecureFile
+} from '../../shared/secure-file'
+import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
+import { ApiKeyFileUnreadableError } from './api-key-file-unreadable-error'
+
+type EncryptedApiKeyFileStore = {
+ protection: () => SecretAtRestProtection | null
+ has: () => boolean
+ save: (key: string) => void
+ read: () => string | null
+ clear: () => void
+}
+
+export function createEncryptedApiKeyFileStore({
+ fileName,
+ envelopePrefix,
+ providerLabel,
+ logScope
+}: {
+ fileName: string
+ envelopePrefix: string
+ providerLabel: string
+ logScope: string
+}): EncryptedApiKeyFileStore {
+ let cachedApiKey: string | null = null
+ let warnedStatusHardenFailure = false
+
+ type ApiKeyEnvelope = {
+ kind: 'encrypted' | 'plaintext'
+ payload: Buffer
+ }
+
+ function getOrcaDir(): string {
+ return join(homedir(), '.orca')
+ }
+
+ function getApiKeyPath(): string {
+ return join(getOrcaDir(), fileName)
+ }
+
+ function encodeApiKeyEnvelope(kind: ApiKeyEnvelope['kind'], payload: Buffer): string {
+ return `${envelopePrefix}${kind}:${payload.toString('base64')}`
+ }
+
+ function decodeApiKeyEnvelope(raw: Buffer): ApiKeyEnvelope {
+ const text = raw.toString('utf8')
+ if (!text.startsWith(envelopePrefix)) {
+ throw new Error(`${providerLabel} API key could not be decrypted`)
+ }
+ const rest = text.slice(envelopePrefix.length)
+ const separator = rest.indexOf(':')
+ if (separator === -1) {
+ throw new Error(`${providerLabel} API key could not be decrypted`)
+ }
+ const kind = rest.slice(0, separator)
+ if (kind !== 'encrypted' && kind !== 'plaintext') {
+ throw new Error(`${providerLabel} API key could not be decrypted`)
+ }
+ return {
+ kind,
+ payload: Buffer.from(rest.slice(separator + 1), 'base64')
+ }
+ }
+
+ function readEnvelope(envelope: ApiKeyEnvelope): string {
+ if (envelope.kind === 'plaintext') {
+ return envelope.payload.toString('utf8')
+ }
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error(`${providerLabel} API key could not be decrypted`)
+ }
+ return safeStorage.decryptString(envelope.payload)
+ }
+
+ function has(): boolean {
+ const keyPath = getApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return false
+ }
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ if (!warnedStatusHardenFailure) {
+ warnedStatusHardenFailure = true
+ console.warn(
+ `[${logScope}] Failed to harden ${providerLabel} API key file while checking status`,
+ error
+ )
+ }
+ }
+ return true
+ }
+
+ function protection(): SecretAtRestProtection | null {
+ const path = getApiKeyPath()
+ if (!existsSync(path)) {
+ return null
+ }
+ try {
+ return decodeApiKeyEnvelope(readFileSync(path)).kind === 'plaintext' ? 'plaintext' : 'sealed'
+ } catch {
+ return null
+ }
+ }
+
+ function save(key: string): void {
+ const trimmed = key.trim()
+ if (!trimmed) {
+ throw new Error(`${providerLabel} API key is required`)
+ }
+ if (safeStorage.isEncryptionAvailable()) {
+ writeSecureFile(
+ getApiKeyPath(),
+ encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)),
+ { durable: true }
+ )
+ cachedApiKey = trimmed
+ return
+ }
+ console.warn(
+ `[${logScope}] safeStorage encryption unavailable — storing ${providerLabel} API key in plaintext`
+ )
+ writeSecureFile(
+ getApiKeyPath(),
+ encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')),
+ { durable: true }
+ )
+ cachedApiKey = trimmed
+ }
+
+ function read(): string | null {
+ if (cachedApiKey !== null) {
+ return cachedApiKey
+ }
+ const keyPath = getApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return null
+ }
+ // Why: permission failures must not be reported as decryption failures.
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ console.warn(
+ `[${logScope}] Failed to harden ${providerLabel} API key file while reading`,
+ error
+ )
+ }
+ let raw: Buffer | null = null
+ try {
+ raw = readFileSync(keyPath)
+ const envelope = decodeApiKeyEnvelope(raw)
+ cachedApiKey = readEnvelope(envelope)
+ return cachedApiKey
+ } catch (error) {
+ if (raw === null && isUnreadableError(error)) {
+ console.warn(`[${logScope}] failed to read API key file`, error)
+ throw new ApiKeyFileUnreadableError(`${providerLabel} API key file could not be read`)
+ }
+ console.error(`[${logScope}] failed to decode/decrypt API key`, error)
+ throw new Error(`${providerLabel} API key could not be decrypted`)
+ }
+ }
+
+ function clear(): void {
+ cachedApiKey = null
+ rmSync(getApiKeyPath(), { force: true })
+ }
+
+ return { has, save, read, clear, protection }
+}
diff --git a/src/main/ipc/credential-change-rate-limit-refresh.test.ts b/src/main/ipc/credential-change-rate-limit-refresh.test.ts
new file mode 100644
index 000000000000..98b06918c709
--- /dev/null
+++ b/src/main/ipc/credential-change-rate-limit-refresh.test.ts
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory'
+import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('refreshAfterCredentialChange', () => {
+ it('does nothing without a rate-limit service', () => {
+ const invalidate = vi.fn()
+ refreshAfterCredentialChange(null, invalidate, '[test] refresh failed:')
+ expect(invalidate).not.toHaveBeenCalled()
+ })
+
+ it('invalidates before refreshing', () => {
+ const order: string[] = []
+ const service = {
+ refresh: vi.fn(async () => {
+ order.push('refresh')
+ return createEmptyRateLimitState()
+ })
+ }
+ refreshAfterCredentialChange(service, () => order.push('invalidate'), '[test] refresh failed:')
+ expect(order).toEqual(['invalidate', 'refresh'])
+ })
+
+ it('logs a failed background refresh with the caller message', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const failure = new Error('network down')
+ const service = {
+ refresh: vi.fn(async () => {
+ throw failure
+ })
+ }
+
+ refreshAfterCredentialChange(service, () => {}, '[test] refresh failed:')
+ await vi.waitFor(() => expect(error).toHaveBeenCalledWith('[test] refresh failed:', failure))
+ })
+})
diff --git a/src/main/ipc/credential-change-rate-limit-refresh.ts b/src/main/ipc/credential-change-rate-limit-refresh.ts
new file mode 100644
index 000000000000..7e5bf3eaf3e2
--- /dev/null
+++ b/src/main/ipc/credential-change-rate-limit-refresh.ts
@@ -0,0 +1,19 @@
+import type { RateLimitService } from '../rate-limits/service'
+
+/**
+ * Drops a provider's stale usage, then refreshes in the background.
+ * Why fire-and-forget: callers return the persisted credential status immediately; a failed refresh only logs.
+ */
+export function refreshAfterCredentialChange>(
+ rateLimits: T | null,
+ invalidate: (rateLimits: T) => void,
+ failureLogMessage: string
+): void {
+ if (!rateLimits) {
+ return
+ }
+ invalidate(rateLimits)
+ void rateLimits.refresh().catch((error: unknown) => {
+ console.error(failureLogMessage, error)
+ })
+}
diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts
index cac2567db98c..c488c1d8defe 100644
--- a/src/main/ipc/minimax-credentials.ts
+++ b/src/main/ipc/minimax-credentials.ts
@@ -12,6 +12,7 @@ import {
saveMiniMaxApiKey
} from '../minimax/minimax-api-key-store'
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax/minimax-request-context'
+import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh'
import type { RateLimitService } from '../rate-limits/service'
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
@@ -36,16 +37,15 @@ function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
}
}
-// Why: fire-and-forget — callers get the persisted credential status immediately;
-// the rate-limit refresh runs in the background and only logs on failure.
function refreshAfterMiniMaxCredentialChange(
rateLimits: RateLimitService | null,
action: 'save' | 'clear'
): void {
- rateLimits?.invalidateMiniMaxCredentialState()
- void rateLimits?.refresh().catch((error: unknown) => {
- console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error)
- })
+ refreshAfterCredentialChange(
+ rateLimits,
+ (service) => service.invalidateMiniMaxCredentialState(),
+ `[minimax] failed to trigger rate-limit refresh after ${action}:`
+ )
}
export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
diff --git a/src/main/ipc/opencode-go-credentials.test.ts b/src/main/ipc/opencode-go-credentials.test.ts
new file mode 100644
index 000000000000..a90438127b7c
--- /dev/null
+++ b/src/main/ipc/opencode-go-credentials.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory'
+import { registerOpenCodeGoCredentialsHandlers } from './opencode-go-credentials'
+
+const mocks = vi.hoisted(() => ({
+ handlers: new Map unknown>(),
+ has: vi.fn(() => false),
+ save: vi.fn(),
+ clear: vi.fn()
+}))
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: (channel: string, handler: (event: unknown, key?: unknown) => unknown) => {
+ mocks.handlers.set(channel, handler)
+ }
+ }
+}))
+vi.mock('../opencode/opencode-go-api-key-store', () => ({
+ hasOpenCodeGoApiKey: mocks.has,
+ saveOpenCodeGoApiKey: mocks.save,
+ clearOpenCodeGoApiKey: mocks.clear
+}))
+
+function invoke(action: string, value?: unknown): unknown {
+ const handler = mocks.handlers.get(`opencodeGoCredentials:${action}`)
+ if (!handler) {
+ throw new Error('Missing credential handler')
+ }
+ return handler({}, value)
+}
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ mocks.handlers.clear()
+})
+
+describe('OpenCode Go write-only credentials', () => {
+ it('returns only boolean status and provides no key reader', () => {
+ registerOpenCodeGoCredentialsHandlers(null)
+ mocks.has.mockReturnValue(true)
+ expect(invoke('getStatus')).toEqual({ apiKeyConfigured: true })
+ expect([...mocks.handlers.keys()]).toEqual([
+ 'opencodeGoCredentials:getStatus',
+ 'opencodeGoCredentials:saveApiKey',
+ 'opencodeGoCredentials:clearApiKey'
+ ])
+ })
+
+ it.each(['saveApiKey', 'clearApiKey'])('invalidates before refreshing on %s', (action) => {
+ const invalidate = vi.fn()
+ const refresh = vi.fn(async () => {
+ expect(invalidate).toHaveBeenCalledOnce()
+ return createEmptyRateLimitState()
+ })
+ registerOpenCodeGoCredentialsHandlers({
+ invalidateOpenCodeGoCredentialState: invalidate,
+ refresh
+ })
+ mocks.has.mockReturnValue(action === 'saveApiKey')
+ expect(invoke(action, 'fake-key')).toEqual({ apiKeyConfigured: action === 'saveApiKey' })
+ expect(action === 'saveApiKey' ? mocks.save : mocks.clear).toHaveBeenCalledOnce()
+ expect(refresh).toHaveBeenCalledOnce()
+ // Why: only clearing the saved key may hide the chip; another key source can still exist after a save.
+ expect(invalidate).toHaveBeenCalledWith({ apiKeyCleared: action === 'clearApiKey' })
+ })
+
+ it.each([null, undefined, 42, {}])('rejects a non-string key', (key) => {
+ registerOpenCodeGoCredentialsHandlers(null)
+ expect(() => invoke('saveApiKey', key)).toThrow('OpenCode Go API key must be a string')
+ expect(mocks.save).not.toHaveBeenCalled()
+ })
+
+ it('does not refresh after a failed save', () => {
+ const invalidate = vi.fn()
+ const refresh = vi.fn()
+ registerOpenCodeGoCredentialsHandlers({
+ invalidateOpenCodeGoCredentialState: invalidate,
+ refresh
+ })
+ mocks.save.mockImplementation(() => {
+ throw new Error('Could not save credential')
+ })
+ expect(() => invoke('saveApiKey', 'fake-key')).toThrow('Could not save credential')
+ expect(invalidate).not.toHaveBeenCalled()
+ expect(refresh).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/ipc/opencode-go-credentials.ts b/src/main/ipc/opencode-go-credentials.ts
new file mode 100644
index 000000000000..58788257714c
--- /dev/null
+++ b/src/main/ipc/opencode-go-credentials.ts
@@ -0,0 +1,47 @@
+import { ipcMain } from 'electron'
+import {
+ clearOpenCodeGoApiKey,
+ hasOpenCodeGoApiKey,
+ saveOpenCodeGoApiKey
+} from '../opencode/opencode-go-api-key-store'
+import type { RateLimitService } from '../rate-limits/service'
+import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh'
+
+type CredentialRateLimits = Pick<
+ RateLimitService,
+ 'invalidateOpenCodeGoCredentialState' | 'refresh'
+>
+
+function getOpenCodeGoCredentialsStatus(): { apiKeyConfigured: boolean } {
+ return { apiKeyConfigured: hasOpenCodeGoApiKey() }
+}
+
+function refreshAfterOpenCodeGoCredentialChange(
+ rateLimits: CredentialRateLimits | null,
+ apiKeyCleared: boolean
+): void {
+ refreshAfterCredentialChange(
+ rateLimits,
+ (service) => service.invalidateOpenCodeGoCredentialState({ apiKeyCleared }),
+ '[opencode-go] failed to refresh usage after a credential change:'
+ )
+}
+
+export function registerOpenCodeGoCredentialsHandlers(
+ rateLimits: CredentialRateLimits | null
+): void {
+ ipcMain.handle('opencodeGoCredentials:getStatus', () => getOpenCodeGoCredentialsStatus())
+ ipcMain.handle('opencodeGoCredentials:saveApiKey', (_event, key: unknown) => {
+ if (typeof key !== 'string') {
+ throw new Error('OpenCode Go API key must be a string')
+ }
+ saveOpenCodeGoApiKey(key)
+ refreshAfterOpenCodeGoCredentialChange(rateLimits, false)
+ return getOpenCodeGoCredentialsStatus()
+ })
+ ipcMain.handle('opencodeGoCredentials:clearApiKey', () => {
+ clearOpenCodeGoApiKey()
+ refreshAfterOpenCodeGoCredentialChange(rateLimits, true)
+ return getOpenCodeGoCredentialsStatus()
+ })
+}
diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts
index 99d459501aa1..7620a07c8db0 100644
--- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts
@@ -35,6 +35,7 @@ const {
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerClaudeAccountHandlersMock,
+ registerOpenCodeGoCredentialsHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerZcodePlanCredentialsHandlersMock,
registerGrokAccountHandlersMock,
@@ -103,6 +104,7 @@ const {
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
+ registerOpenCodeGoCredentialsHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerZcodePlanCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
@@ -334,6 +336,10 @@ vi.mock('../claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
+vi.mock('../opencode-go-credentials', () => ({
+ registerOpenCodeGoCredentialsHandlers: registerOpenCodeGoCredentialsHandlersMock
+}))
+
vi.mock('../minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))
@@ -446,6 +452,7 @@ describe('registerCoreHandlers', () => {
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
+ registerOpenCodeGoCredentialsHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
registerZcodePlanCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
@@ -546,6 +553,7 @@ describe('registerCoreHandlers', () => {
)
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
+ expect(registerOpenCodeGoCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts
index 618dab409d9b..45a3ecb390f9 100644
--- a/src/main/ipc/register-core-handlers/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts
@@ -61,6 +61,7 @@ import { registerAgentHookHandlers } from '../agent-hooks'
import { registerCodexConfigSyncHandlers } from '../codex-config-sync'
import { getPtyIdForPaneKey } from '../pty'
import { registerClaudeAccountHandlers } from '../claude-accounts'
+import { registerOpenCodeGoCredentialsHandlers } from '../opencode-go-credentials'
import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials'
import { registerZcodePlanCredentialsHandlers } from '../zcode-plan-credentials'
import { registerGrokAccountHandlers } from '../grok-accounts'
@@ -151,6 +152,7 @@ export function registerCoreHandlers(
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
registerClaudeAccountHandlers(claudeAccounts)
+ registerOpenCodeGoCredentialsHandlers(rateLimits)
registerMiniMaxCredentialsHandlers(rateLimits)
registerZcodePlanCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
diff --git a/src/main/minimax/minimax-api-key-store.test.ts b/src/main/minimax/minimax-api-key-store.test.ts
index 756ed9069b1b..90ac8012fc53 100644
--- a/src/main/minimax/minimax-api-key-store.test.ts
+++ b/src/main/minimax/minimax-api-key-store.test.ts
@@ -36,6 +36,8 @@ vi.mock('node:path', () => ({
vi.mock('../../shared/secure-file', () => ({
hardenExistingSecureFile: hardenExistingSecureFileMock,
+ isUnreadableError: (error: unknown) =>
+ error instanceof Error && 'code' in error && error.code === 'EBUSY',
writeSecureFile: writeSecureFileMock
}))
@@ -102,7 +104,8 @@ describe('minimax-api-key-store', () => {
expect(safeStorageMock.encryptString).toHaveBeenCalledWith('sk-test-1234567890')
expect(writeSecureFileMock).toHaveBeenCalledWith(
storePath,
- envelope('encrypted', 'sk-test-1234567890')
+ envelope('encrypted', 'sk-test-1234567890'),
+ { durable: true }
)
})
@@ -114,7 +117,8 @@ describe('minimax-api-key-store', () => {
store.saveMiniMaxApiKey('sk-test-1234567890')
expect(writeSecureFileMock).toHaveBeenCalledWith(
storePath,
- envelope('plaintext', 'sk-test-1234567890')
+ envelope('plaintext', 'sk-test-1234567890'),
+ { durable: true }
)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
warn.mockRestore()
@@ -211,6 +215,23 @@ describe('minimax-api-key-store', () => {
expect(() => store.readMiniMaxApiKey()).toThrow(/could not be decrypted/)
})
+ it('reports a transient read failure as unreadable, not undecryptable, and retries next read', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockImplementationOnce(() => {
+ throw Object.assign(new Error('resource busy'), { code: 'EBUSY' })
+ })
+ readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockReturnValueOnce('sk-after-retry')
+ const store = await loadStore()
+ expect(() => store.readMiniMaxApiKey()).toThrow('MiniMax API key file could not be read')
+ expect(error).not.toHaveBeenCalled()
+ expect(store.readMiniMaxApiKey()).toBe('sk-after-retry')
+ warn.mockRestore()
+ error.mockRestore()
+ })
+
it('clears the cached key and removes the file', async () => {
existsSyncMock.mockReturnValueOnce(true)
readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
diff --git a/src/main/minimax/minimax-api-key-store.ts b/src/main/minimax/minimax-api-key-store.ts
index c2fea05621fa..ac18c8822b3f 100644
--- a/src/main/minimax/minimax-api-key-store.ts
+++ b/src/main/minimax/minimax-api-key-store.ts
@@ -1,147 +1,14 @@
-import { safeStorage } from 'electron'
-import { existsSync, readFileSync, rmSync } from 'node:fs'
-import { homedir } from 'node:os'
-import { join } from 'node:path'
-import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
-import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
-
-const MINIMAX_API_KEY_FILE = 'minimax-api-key.enc'
-const API_KEY_ENVELOPE_PREFIX = 'orca-minimax-api-key:v1:'
-let cachedMiniMaxApiKey: string | null = null
-let warnedMiniMaxApiKeyStatusHardenFailure = false
-
-type MiniMaxApiKeyEnvelope = {
- kind: 'encrypted' | 'plaintext'
- payload: Buffer
-}
-
-function getOrcaDir(): string {
- return join(homedir(), '.orca')
-}
-
-function getMiniMaxApiKeyPath(): string {
- return join(getOrcaDir(), MINIMAX_API_KEY_FILE)
-}
-
-function encodeApiKeyEnvelope(kind: MiniMaxApiKeyEnvelope['kind'], payload: Buffer): string {
- return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
-}
-
-function decodeApiKeyEnvelope(raw: Buffer): MiniMaxApiKeyEnvelope {
- const text = raw.toString('utf8')
- if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) {
- throw new Error('MiniMax API key could not be decrypted')
- }
- const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length)
- const separator = rest.indexOf(':')
- if (separator === -1) {
- throw new Error('MiniMax API key could not be decrypted')
- }
- const kind = rest.slice(0, separator)
- if (kind !== 'encrypted' && kind !== 'plaintext') {
- throw new Error('MiniMax API key could not be decrypted')
- }
- return {
- kind,
- payload: Buffer.from(rest.slice(separator + 1), 'base64')
- }
-}
-
-function readEnvelope(envelope: MiniMaxApiKeyEnvelope): string {
- if (envelope.kind === 'plaintext') {
- return envelope.payload.toString('utf8')
- }
- if (!safeStorage.isEncryptionAvailable()) {
- throw new Error('MiniMax API key could not be decrypted')
- }
- return safeStorage.decryptString(envelope.payload)
-}
-
-export function hasMiniMaxApiKey(): boolean {
- const keyPath = getMiniMaxApiKeyPath()
- if (!existsSync(keyPath)) {
- return false
- }
- try {
- hardenExistingSecureFile(keyPath)
- } catch (error) {
- if (!warnedMiniMaxApiKeyStatusHardenFailure) {
- warnedMiniMaxApiKeyStatusHardenFailure = true
- console.warn('[minimax] Failed to harden MiniMax API key file while checking status', error)
- }
- }
- return true
-}
-
-/**
- * How the stored key is protected, or null when none is stored.
- *
- * Reads the envelope kind only — no decrypt, so this cannot trigger a keychain prompt
- * and is safe to call from a status handler.
- */
-export function getMiniMaxApiKeyProtection(): SecretAtRestProtection | null {
- const keyPath = getMiniMaxApiKeyPath()
- if (!existsSync(keyPath)) {
- return null
- }
- try {
- return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed'
- } catch {
- // An undecodable envelope is a decrypt-time error to report, not a protection claim.
- return null
- }
-}
-
-export function saveMiniMaxApiKey(key: string): void {
- const trimmed = key.trim()
- if (!trimmed) {
- throw new Error('MiniMax API key is required')
- }
- if (safeStorage.isEncryptionAvailable()) {
- writeSecureFile(
- getMiniMaxApiKeyPath(),
- encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed))
- )
- cachedMiniMaxApiKey = trimmed
- return
- }
- console.warn(
- '[minimax] safeStorage encryption unavailable — storing MiniMax API key in plaintext'
- )
- writeSecureFile(
- getMiniMaxApiKeyPath(),
- encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
- )
- cachedMiniMaxApiKey = trimmed
-}
-
-export function readMiniMaxApiKey(): string | null {
- if (cachedMiniMaxApiKey !== null) {
- return cachedMiniMaxApiKey
- }
- const keyPath = getMiniMaxApiKeyPath()
- if (!existsSync(keyPath)) {
- return null
- }
- // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
- // failure isn't misreported as a decrypt failure (matches hasMiniMaxApiKey).
- try {
- hardenExistingSecureFile(keyPath)
- } catch (error) {
- console.warn('[minimax] Failed to harden MiniMax API key file while reading', error)
- }
- try {
- const raw = readFileSync(keyPath)
- const envelope = decodeApiKeyEnvelope(raw)
- cachedMiniMaxApiKey = readEnvelope(envelope)
- return cachedMiniMaxApiKey
- } catch (error) {
- console.error('[minimax] failed to decode/decrypt API key', error)
- throw new Error('MiniMax API key could not be decrypted')
- }
-}
-
-export function clearMiniMaxApiKey(): void {
- cachedMiniMaxApiKey = null
- rmSync(getMiniMaxApiKeyPath(), { force: true })
-}
+import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store'
+
+const store = createEncryptedApiKeyFileStore({
+ fileName: 'minimax-api-key.enc',
+ envelopePrefix: 'orca-minimax-api-key:v1:',
+ providerLabel: 'MiniMax',
+ logScope: 'minimax'
+})
+
+export const hasMiniMaxApiKey = store.has
+export const getMiniMaxApiKeyProtection = store.protection
+export const saveMiniMaxApiKey = store.save
+export const readMiniMaxApiKey = store.read
+export const clearMiniMaxApiKey = store.clear
diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts
index 7f7fea72ecf3..5f7ca25415db 100644
--- a/src/main/opencode-usage/opencode-database-discovery.ts
+++ b/src/main/opencode-usage/opencode-database-discovery.ts
@@ -10,8 +10,11 @@ type OpenCodeDatabaseOverride = {
path: string | null
}
-function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
- const raw = process.env.OPENCODE_DB?.trim()
+function getOpenCodeDatabaseOverride(
+ dataDirectory: string,
+ environment: NodeJS.ProcessEnv
+): OpenCodeDatabaseOverride {
+ const raw = environment.OPENCODE_DB?.trim()
if (!raw) {
return { isConfigured: false, path: null }
}
@@ -32,31 +35,44 @@ export async function listOpenCodeDatabases(
* "OpenCode not used" rather than "we could not look". */
onRefusal?: (path: string, error: WslTranscriptFsError) => void,
/** Every other stat/readdir failure, including ENOENT; also read as an empty list. */
- onFsError?: (path: string, error: unknown) => void
+ onFsError?: (path: string, error: unknown) => void,
+ signal?: AbortSignal,
+ environment: NodeJS.ProcessEnv = process.env
): Promise {
- const dataDirectory = resolveOpenCodeDataDirectory()
- const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
+ const dataDirectory = resolveOpenCodeDataDirectory(environment)
+ const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment)
if (databaseOverride.isConfigured) {
if (!databaseOverride.path) {
return []
}
try {
- return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
+ return (await wslGatedStat(databaseOverride.path, 'scan', signal)).isFile()
? [databaseOverride.path]
: []
} catch (error) {
+ signal?.throwIfAborted()
reportFailure(databaseOverride.path, error, onRefusal, onFsError)
return []
}
}
+ return listOpenCodeDatabasesInDirectory(dataDirectory, onRefusal, signal, onFsError)
+}
+
+export async function listOpenCodeDatabasesInDirectory(
+ dataDirectory: string,
+ onRefusal?: (path: string, error: WslTranscriptFsError) => void,
+ signal?: AbortSignal,
+ onFsError?: (path: string, error: unknown) => void
+): Promise {
try {
- const entries = await wslGatedReaddir(dataDirectory, 'scan')
+ const entries = await wslGatedReaddir(dataDirectory, 'scan', signal)
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDirectory, entry.name))
.sort()
} catch (error) {
+ signal?.throwIfAborted()
reportFailure(dataDirectory, error, onRefusal, onFsError)
return []
}
diff --git a/src/main/opencode/opencode-credential-backend.test.ts b/src/main/opencode/opencode-credential-backend.test.ts
new file mode 100644
index 000000000000..3bf00336eb10
--- /dev/null
+++ b/src/main/opencode/opencode-credential-backend.test.ts
@@ -0,0 +1,182 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { runProcess } from '../../shared/child-process/run-process'
+import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
+import {
+ detectOpenCodeCredentialBackend,
+ resetOpenCodeCredentialBackendProbes
+} from './opencode-credential-backend'
+
+const files = vi.hoisted(() => ({ realpath: vi.fn(), stat: vi.fn() }))
+
+vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() }))
+vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() }))
+vi.mock('node:fs/promises', () => files)
+
+describe('OpenCode credential execution backend', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ resetOpenCodeCredentialBackendProbes()
+ files.realpath.mockImplementation(async (path: string) => path)
+ files.stat.mockResolvedValue({ dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 })
+ vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/task/bin/opencode')
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout: '1.18.30\n',
+ stderr: '',
+ timedOut: false
+ })
+ })
+
+ it.each([
+ ['1.18.30\n', 'v1'],
+ ['opencode v2.0.16\n', 'v2'],
+ ['2.0.16', 'v2'],
+ ['opencode v2.0.16-beta.1', 'v2'],
+ ['3.0.0', null],
+ ['wrapper 2.0.16', null],
+ ['', null]
+ ])('uses the reported backend for %j', async (stdout, backend) => {
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout,
+ stderr: '',
+ timedOut: false
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBe(backend)
+ })
+
+ it('resolves and executes the binary in the caller environment with a bounded probe', async () => {
+ const environment = { PATH: '/task/bin', XDG_DATA_HOME: '/task/data' }
+
+ await detectOpenCodeCredentialBackend(environment, '/task/workspace')
+
+ expect(resolveCommandOnLocalPath).toHaveBeenCalledExactlyOnceWith('opencode', {
+ env: environment,
+ cwd: '/task/workspace'
+ })
+ expect(runProcess).toHaveBeenCalledExactlyOnceWith({
+ program: '/task/bin/opencode',
+ args: ['--version'],
+ env: environment,
+ cwd: '/task/workspace',
+ timeoutMs: 5_000,
+ maxOutputBytes: 1_024
+ })
+ })
+
+ it('probes opencode2 only when the default opencode command is absent', async () => {
+ vi.mocked(resolveCommandOnLocalPath)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce('/task/bin/opencode2')
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout: 'opencode v2.0.16',
+ stderr: '',
+ timedOut: false
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBe('v2')
+ expect(runProcess).toHaveBeenCalledWith(
+ expect.objectContaining({ program: '/task/bin/opencode2' })
+ )
+ })
+
+ it.each([
+ { code: 1, timedOut: false },
+ { code: 0, timedOut: true },
+ { code: 0, timedOut: false, outputTruncated: true }
+ ])('withholds authority when the installed probe fails: %j', async (failure) => {
+ vi.mocked(runProcess).mockResolvedValue({
+ ...failure,
+ signal: null,
+ stdout: 'opencode v2.0.16',
+ stderr: ''
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1)
+ })
+
+ it('withholds authority after a spawn error without substituting another CLI', async () => {
+ vi.mocked(runProcess).mockRejectedValue(new Error('unavailable'))
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1)
+ })
+
+ it('withholds authority when neither CLI is installed', async () => {
+ vi.mocked(resolveCommandOnLocalPath).mockResolvedValue(null)
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(runProcess).not.toHaveBeenCalled()
+ })
+
+ it('coalesces concurrent calls and reuses a successful binary identity', async () => {
+ expect(
+ await Promise.all([
+ detectOpenCodeCredentialBackend(),
+ detectOpenCodeCredentialBackend(),
+ detectOpenCodeCredentialBackend()
+ ])
+ ).toEqual(['v1', 'v1', 'v1'])
+ expect(await detectOpenCodeCredentialBackend()).toBe('v1')
+ expect(runProcess).toHaveBeenCalledTimes(1)
+ })
+
+ it('reprobes when the resolved binary is replaced', async () => {
+ expect(await detectOpenCodeCredentialBackend()).toBe('v1')
+ files.stat.mockResolvedValue({ dev: 1, ino: 6, size: 3, mtimeMs: 7, ctimeMs: 8 })
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout: 'opencode v2.0.16',
+ stderr: '',
+ timedOut: false
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBe('v2')
+ expect(runProcess).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not reuse a probe across caller environments or working directories', async () => {
+ await detectOpenCodeCredentialBackend(
+ { PATH: '/task/bin', XDG_DATA_HOME: '/task/a' },
+ '/task/a'
+ )
+ await detectOpenCodeCredentialBackend(
+ { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' },
+ '/task/a'
+ )
+ await detectOpenCodeCredentialBackend(
+ { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' },
+ '/task/b'
+ )
+
+ expect(runProcess).toHaveBeenCalledTimes(3)
+ })
+
+ it('retries an unknown backend after its short cache expires', async () => {
+ const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
+ vi.mocked(runProcess).mockRejectedValueOnce(new Error('temporarily unavailable'))
+ try {
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ now.mockReturnValue(6_001)
+ expect(await detectOpenCodeCredentialBackend()).toBe('v1')
+ expect(runProcess).toHaveBeenCalledTimes(2)
+ } finally {
+ now.mockRestore()
+ }
+ })
+
+ it('withholds authority when the selected binary identity cannot be read', async () => {
+ files.stat.mockRejectedValue(new Error('unreadable executable'))
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(runProcess).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/opencode/opencode-credential-backend.ts b/src/main/opencode/opencode-credential-backend.ts
new file mode 100644
index 000000000000..69c860dbe48d
--- /dev/null
+++ b/src/main/opencode/opencode-credential-backend.ts
@@ -0,0 +1,91 @@
+import { runProcess } from '../../shared/child-process/run-process'
+import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
+import { createHash } from 'node:crypto'
+import { realpath, stat } from 'node:fs/promises'
+
+export type OpenCodeCredentialBackend = 'v1' | 'v2'
+
+// Native runtime processes own separate execution-host caches.
+const probes = new Map<
+ string,
+ { result: Promise; expiresAt: number }
+>()
+const MAX_PROBES = 32
+
+export function resetOpenCodeCredentialBackendProbes(): void {
+ probes.clear()
+}
+
+export async function detectOpenCodeCredentialBackend(
+ environment: NodeJS.ProcessEnv = process.env,
+ cwd = process.cwd()
+): Promise {
+ // The execution host's default CLI owns this lookup; table presence proves neither backend.
+ const program =
+ (await resolveCommandOnLocalPath('opencode', { env: environment, cwd })) ??
+ (await resolveCommandOnLocalPath('opencode2', { env: environment, cwd }))
+ if (!program) {
+ return null
+ }
+ try {
+ const binary = await realpath(program)
+ const identity = await stat(binary)
+ const environmentDigest = createHash('sha256')
+ .update(JSON.stringify(Object.entries(environment).sort(([a], [b]) => a.localeCompare(b))))
+ .digest('hex')
+ const key = JSON.stringify([
+ binary,
+ identity.dev,
+ identity.ino,
+ identity.size,
+ identity.mtimeMs,
+ identity.ctimeMs,
+ cwd,
+ environmentDigest
+ ])
+ const cached = probes.get(key)
+ if (cached && cached.expiresAt > Date.now()) {
+ return cached.result
+ }
+ const result = probeBackend(binary, environment, cwd)
+ const entry = { result, expiresAt: Number.POSITIVE_INFINITY }
+ probes.set(key, entry)
+ if (probes.size > MAX_PROBES) {
+ const oldest = probes.keys().next().value
+ if (oldest !== undefined) {
+ probes.delete(oldest)
+ }
+ }
+ const backend = await result
+ entry.expiresAt = Date.now() + (backend ? 60_000 : 5_000)
+ return backend
+ } catch {
+ return null
+ }
+}
+
+async function probeBackend(
+ program: string,
+ environment: NodeJS.ProcessEnv,
+ cwd: string
+): Promise {
+ try {
+ const result = await runProcess({
+ program,
+ args: ['--version'],
+ env: environment,
+ cwd,
+ timeoutMs: 5_000,
+ maxOutputBytes: 1_024
+ })
+ if (result.code !== 0 || result.timedOut || result.outputTruncated) {
+ return null
+ }
+ const version = /^(?:opencode\s+)?v?([12])\.\d+\.\d+(?:[-+][\w.-]+)?$/i.exec(
+ result.stdout.trim()
+ )
+ return version?.[1] === '1' ? 'v1' : version?.[1] === '2' ? 'v2' : null
+ } catch {
+ return null
+ }
+}
diff --git a/src/main/opencode/opencode-go-api-key-store.test.ts b/src/main/opencode/opencode-go-api-key-store.test.ts
new file mode 100644
index 000000000000..728665076f93
--- /dev/null
+++ b/src/main/opencode/opencode-go-api-key-store.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import type * as NodeFs from 'node:fs'
+import type * as NodeOs from 'node:os'
+import { join } from 'node:path'
+
+const home = vi.hoisted(() => {
+ const state: { directory: string; readError: Error | null } = { directory: '', readError: null }
+ return state
+})
+vi.mock('node:os', async (importOriginal) => ({
+ ...(await importOriginal()),
+ homedir: () => home.directory
+}))
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ readFileSync: (...args: Parameters) => {
+ if (home.readError) {
+ throw home.readError
+ }
+ return actual.readFileSync(...args)
+ }
+ }
+})
+vi.mock('electron', () => ({
+ safeStorage: {
+ isEncryptionAvailable: () => true,
+ encryptString: (key: string) => Buffer.from(`encrypted:${key}`),
+ decryptString: (bytes: Buffer) => bytes.toString().slice('encrypted:'.length)
+ }
+}))
+
+beforeEach(() => {
+ home.readError = null
+ home.directory = mkdtempSync(join(tmpdir(), 'orca-go-key-store-'))
+ vi.resetModules()
+})
+afterEach(() => rmSync(home.directory, { recursive: true, force: true }))
+
+describe('OpenCode Go main-owned API key file', () => {
+ it('persists a versioned encrypted envelope, reads it after restart, and clears it', async () => {
+ const store = await import('./opencode-go-api-key-store')
+ expect(store.hasOpenCodeGoApiKey()).toBe(false)
+ store.saveOpenCodeGoApiKey(' fake-key ')
+ expect(store.hasOpenCodeGoApiKey()).toBe(true)
+ const path = join(home.directory, '.orca', 'opencode-go-api-key.enc')
+ expect(readFileSync(path, 'utf8')).toBe(
+ `orca-opencode-go-api-key:v1:encrypted:${Buffer.from('encrypted:fake-key').toString('base64')}`
+ )
+ vi.resetModules()
+ const restarted = await import('./opencode-go-api-key-store')
+ expect(restarted.readOpenCodeGoApiKey()).toBe('fake-key')
+ restarted.clearOpenCodeGoApiKey()
+ expect(restarted.hasOpenCodeGoApiKey()).toBe(false)
+ expect(restarted.readOpenCodeGoApiKey()).toBeNull()
+ })
+
+ it('keeps the MiniMax cache and file independent', async () => {
+ const go = await import('./opencode-go-api-key-store')
+ const miniMax = await import('../minimax/minimax-api-key-store')
+ go.saveOpenCodeGoApiKey('fake-go')
+ miniMax.saveMiniMaxApiKey('fake-minimax')
+ expect(go.readOpenCodeGoApiKey()).toBe('fake-go')
+ expect(miniMax.readMiniMaxApiKey()).toBe('fake-minimax')
+ go.clearOpenCodeGoApiKey()
+ expect(miniMax.hasMiniMaxApiKey()).toBe(true)
+ expect(miniMax.readMiniMaxApiKey()).toBe('fake-minimax')
+ })
+
+ it('rejects empty keys and malformed envelopes without returning the key', async () => {
+ const store = await import('./opencode-go-api-key-store')
+ expect(() => store.saveOpenCodeGoApiKey(' ')).toThrow('required')
+ store.saveOpenCodeGoApiKey('fake-key')
+ writeFileSync(join(home.directory, '.orca', 'opencode-go-api-key.enc'), 'fake-invalid-envelope')
+ vi.resetModules()
+ const restarted = await import('./opencode-go-api-key-store')
+ expect(() => restarted.readOpenCodeGoApiKey()).toThrow(
+ 'OpenCode Go API key could not be decrypted'
+ )
+ })
+
+ it('throws a distinct unreadable error for a transient read failure', async () => {
+ const store = await import('./opencode-go-api-key-store')
+ store.saveOpenCodeGoApiKey('fake-key')
+ vi.resetModules()
+ const restarted = await import('./opencode-go-api-key-store')
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ home.readError = Object.assign(new Error('resource busy'), { code: 'EBUSY' })
+
+ expect(() => restarted.readOpenCodeGoApiKey()).toThrow(
+ 'OpenCode Go API key file could not be read'
+ )
+ home.readError = null
+ expect(restarted.readOpenCodeGoApiKey()).toBe('fake-key')
+ })
+})
diff --git a/src/main/opencode/opencode-go-api-key-store.ts b/src/main/opencode/opencode-go-api-key-store.ts
new file mode 100644
index 000000000000..212f91662550
--- /dev/null
+++ b/src/main/opencode/opencode-go-api-key-store.ts
@@ -0,0 +1,13 @@
+import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store'
+
+const store = createEncryptedApiKeyFileStore({
+ fileName: 'opencode-go-api-key.enc',
+ envelopePrefix: 'orca-opencode-go-api-key:v1:',
+ providerLabel: 'OpenCode Go',
+ logScope: 'opencode-go'
+})
+
+export const hasOpenCodeGoApiKey = store.has
+export const saveOpenCodeGoApiKey = store.save
+export const readOpenCodeGoApiKey = store.read
+export const clearOpenCodeGoApiKey = store.clear
diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts
index 97190a6f8907..26703ab43965 100644
--- a/src/main/persistence/applying-settings/settings-update.ts
+++ b/src/main/persistence/applying-settings/settings-update.ts
@@ -60,9 +60,6 @@ export function updateSettings(
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
}
- if ('opencodeGoApiKey' in updates && !updates.opencodeGoApiKey) {
- operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeGoApiKey)
- }
if ('httpProxyUrl' in updates && !updates.httpProxyUrl) {
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl)
}
diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts
index 161afcd1de5e..551516e70b3a 100644
--- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts
+++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts
@@ -60,6 +60,8 @@ type RetiredGlobalSettings = {
terminalScrollbackBytes?: unknown
enableGitHubAttribution?: unknown
showAgentsSidebar?: unknown
+ // Why: #22551 kept this key in settings; it now lives in a main-owned store and must never ride along.
+ opencodeGoApiKey?: unknown
}
export function stripRetiredGlobalSettings(
@@ -69,11 +71,13 @@ export function stripRetiredGlobalSettings(
terminalScrollbackBytes: _legacyScrollbackBytes,
enableGitHubAttribution: _legacyGitHubAttribution,
showAgentsSidebar: _legacyShowAgentsSidebar,
+ opencodeGoApiKey: _legacyOpenCodeGoApiKey,
...rest
} = (settings ?? {}) as Partial & RetiredGlobalSettings
void _legacyScrollbackBytes
void _legacyGitHubAttribution
void _legacyShowAgentsSidebar
+ void _legacyOpenCodeGoApiKey
return rest
}
diff --git a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts
index 8ead3d56fe38..33303b660c73 100644
--- a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts
+++ b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts
@@ -6,13 +6,6 @@ export function isLegacyOpenCodeSessionCookie(value: string): boolean {
)
}
-// OpenCode Go keys are opaque bearer tokens; the console issues `sk-`
-// (legacy) and `oc_sk_` (new console) prefixes.
-export function isLegacyOpenCodeGoApiKey(value: string): boolean {
- const trimmed = value.trim()
- return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(trimmed)
-}
-
export function isLegacySshPtyOwnerLease(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
diff --git a/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts
new file mode 100644
index 000000000000..a5e94937b516
--- /dev/null
+++ b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts
@@ -0,0 +1,200 @@
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+
+const secretStore = vi.hoisted(() => ({
+ isEncryptionAvailable: vi.fn(() => true),
+ encryptString: vi.fn((value: string) => Buffer.from(`enc:${value}`)),
+ decryptString: vi.fn((value: Buffer) => value.toString().slice(4))
+}))
+
+vi.mock('../../../shared/secret-store', () => ({ getSecretStore: () => secretStore }))
+
+const { ProtectedSecretPersistence } = await import('../../protected-secret-persistence')
+const {
+ LEGACY_OPENCODE_GO_API_KEY_SLOT,
+ migrateLegacyOpenCodeGoApiKey,
+ retainLegacyOpenCodeGoApiKey
+} = await import('./legacy-opencode-go-api-key-migration')
+
+const SEALED = Buffer.from('enc:fake-legacy-key').toString('base64')
+
+function memoryStore(initial: string | null = null): {
+ has: () => boolean
+ read: () => string | null
+ save: Mock<(key: string) => void>
+ value: () => string | null
+} {
+ let saved = initial
+ return {
+ has: () => saved !== null,
+ read: () => saved,
+ save: vi.fn((key: string) => {
+ saved = key
+ }),
+ value: () => saved
+ }
+}
+
+function parked(value: unknown = SEALED): InstanceType {
+ const secrets = new ProtectedSecretPersistence()
+ retainLegacyOpenCodeGoApiKey({ opencodeGoApiKey: value }, secrets)
+ return secrets
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ secretStore.isEncryptionAvailable.mockReturnValue(true)
+ secretStore.decryptString.mockImplementation((value: Buffer) => value.toString().slice(4))
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('retainLegacyOpenCodeGoApiKey', () => {
+ it('drops the field from settings and parks the ciphertext without decrypting it', () => {
+ const settings: Record = { opencodeGoApiKey: SEALED, other: 1 }
+ const secrets = new ProtectedSecretPersistence()
+
+ retainLegacyOpenCodeGoApiKey(settings, secrets)
+
+ expect(settings).toEqual({ other: 1 })
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED)
+ expect(secretStore.decryptString).not.toHaveBeenCalled()
+ })
+
+ it('seals a #22551 plaintext key before parking it, and migration still recovers it', () => {
+ const secrets = parked('sk-fake-plaintext-key')
+
+ const blob = secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)
+ expect(blob).toBe(Buffer.from('enc:sk-fake-plaintext-key').toString('base64'))
+ const store = memoryStore()
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true)
+ expect(store.value()).toBe('sk-fake-plaintext-key')
+ })
+
+ it('parks a plaintext key verbatim while encryption is unavailable', () => {
+ secretStore.isEncryptionAvailable.mockReturnValue(false)
+ const secrets = parked('sk-fake-plaintext-key')
+
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe('sk-fake-plaintext-key')
+ expect(secretStore.encryptString).not.toHaveBeenCalled()
+ })
+
+ it('ignores settings without the field and drops malformed values', () => {
+ const secrets = new ProtectedSecretPersistence()
+ retainLegacyOpenCodeGoApiKey({ other: 1 }, secrets)
+ retainLegacyOpenCodeGoApiKey(undefined, secrets)
+ const malformed: Record = { opencodeGoApiKey: { nested: 'fake' } }
+ retainLegacyOpenCodeGoApiKey(malformed, secrets)
+
+ expect(malformed).toEqual({})
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull()
+ })
+})
+
+describe('migrateLegacyOpenCodeGoApiKey', () => {
+ it('saves the key into an empty store, then releases the legacy value', () => {
+ const secrets = parked()
+ const store = memoryStore()
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true)
+ expect(store.value()).toBe('fake-legacy-key')
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull()
+ })
+
+ it('is idempotent: a second run has nothing left to move', () => {
+ const secrets = parked()
+ const store = memoryStore()
+
+ migrateLegacyOpenCodeGoApiKey(secrets, store)
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false)
+ expect(store.save).toHaveBeenCalledOnce()
+ })
+
+ it('never overwrites a readable key already saved in the store, and releases the legacy value', () => {
+ const secrets = parked()
+ const store = memoryStore('fake-current-key')
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true)
+ expect(store.save).not.toHaveBeenCalled()
+ expect(store.value()).toBe('fake-current-key')
+ expect(secretStore.decryptString).not.toHaveBeenCalled()
+ expect(console.warn).toHaveBeenCalledWith(
+ '[opencode-go] Kept the existing saved API key and dropped the one from settings.'
+ )
+ })
+
+ it('keeps the legacy value when the existing store file cannot be read by this build', () => {
+ const secrets = parked()
+ const store = {
+ ...memoryStore('fake-other-build-key'),
+ read: () => {
+ throw new Error('OpenCode Go API key could not be decrypted')
+ }
+ }
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false)
+ expect(store.save).not.toHaveBeenCalled()
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED)
+ })
+
+ it('keeps the ciphertext while safeStorage is unavailable so a later startup retries', () => {
+ const secrets = parked()
+ const store = memoryStore()
+ secretStore.isEncryptionAvailable.mockReturnValue(false)
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false)
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED)
+
+ secretStore.isEncryptionAvailable.mockReturnValue(true)
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true)
+ expect(store.value()).toBe('fake-legacy-key')
+ })
+
+ it('keeps the ciphertext after a definitive decrypt failure', () => {
+ const secrets = parked()
+ const store = memoryStore()
+ secretStore.decryptString.mockImplementation(() => {
+ throw new Error('bad key')
+ })
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false)
+ expect(store.save).not.toHaveBeenCalled()
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED)
+ })
+
+ it('moves a plaintext key #22551 accepted when it failed to decrypt', () => {
+ secretStore.isEncryptionAvailable.mockReturnValue(false)
+ const secrets = parked('sk-fake-plaintext-key')
+ secretStore.isEncryptionAvailable.mockReturnValue(true)
+ const store = memoryStore()
+ secretStore.decryptString.mockImplementation(() => {
+ throw new Error('not ciphertext')
+ })
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true)
+ expect(store.value()).toBe('sk-fake-plaintext-key')
+ })
+
+ it('keeps the ciphertext when the store cannot save, without logging the key', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const secrets = parked()
+ const store = {
+ has: () => false,
+ read: () => null,
+ save: () => {
+ throw new Error('disk full')
+ }
+ }
+
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false)
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED)
+ expect(JSON.stringify(warn.mock.calls)).not.toContain('fake-legacy-key')
+
+ const working = memoryStore()
+ expect(migrateLegacyOpenCodeGoApiKey(secrets, working)).toBe(true)
+ expect(working.value()).toBe('fake-legacy-key')
+ expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull()
+ })
+})
diff --git a/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts
new file mode 100644
index 000000000000..2571f6af024c
--- /dev/null
+++ b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts
@@ -0,0 +1,100 @@
+import type { ProtectedSecretPersistence } from '../../protected-secret-persistence'
+
+// Why a private slot: #22551 sealed the key under this name; only this migration still reads it.
+export const LEGACY_OPENCODE_GO_API_KEY_SLOT = 'settings.opencodeGoApiKey'
+
+type LegacyOpenCodeGoApiKeySecrets = Pick<
+ ProtectedSecretPersistence,
+ 'decryptWithStatus' | 'removeRetainedBlob' | 'retainSealed' | 'sealedBlob'
+>
+
+export type OpenCodeGoApiKeyTarget = {
+ has: () => boolean
+ /** Throws when the saved key cannot be read or decrypted by this build. */
+ read: () => string | null
+ save: (key: string) => void
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+// #22551 accepted a plaintext `sk-`/`oc_sk_` value that failed to decrypt; keep honoring it.
+function isLegacyPlaintextOpenCodeGoApiKey(value: string): boolean {
+ return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(value.trim())
+}
+
+/**
+ * Moves the #22551 settings value out of settings and parks its ciphertext undecrypted, so every
+ * Store consumer (orcad included) writes it back until the desktop migration gives it a new home.
+ */
+export function retainLegacyOpenCodeGoApiKey(
+ settings: unknown,
+ secrets: Pick
+): void {
+ if (!isRecord(settings) || !('opencodeGoApiKey' in settings)) {
+ return
+ }
+ const sealed = settings.opencodeGoApiKey
+ // Why: in-memory settings reach the renderer and remote settings.get; the key must not ride along.
+ delete settings.opencodeGoApiKey
+ if (typeof sealed !== 'string' || !sealed) {
+ return
+ }
+ if (!isLegacyPlaintextOpenCodeGoApiKey(sealed)) {
+ secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed)
+ return
+ }
+ // Why: orcad-only profiles never migrate, so seal it now; without encryption #22551 kept it plaintext too.
+ const encrypted = secrets.encrypt(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed)
+ secrets.retainSealed(
+ LEGACY_OPENCODE_GO_API_KEY_SLOT,
+ encrypted.degraded ? sealed : encrypted.blob
+ )
+}
+
+/**
+ * Saves the parked key into the main-owned store; a key already saved there wins.
+ * @returns True once the legacy value is released and may be dropped from disk.
+ */
+export function migrateLegacyOpenCodeGoApiKey(
+ secrets: LegacyOpenCodeGoApiKeySecrets,
+ target: OpenCodeGoApiKeyTarget
+): boolean {
+ const sealed = secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)
+ if (!sealed) {
+ return false
+ }
+ try {
+ // Why: a file another app identity sealed is unreadable here; read throws and keeps the legacy key.
+ const existing = target.has() ? target.read() : null
+ if (existing === null) {
+ const decrypted = secrets.decryptWithStatus(
+ LEGACY_OPENCODE_GO_API_KEY_SLOT,
+ sealed,
+ isLegacyPlaintextOpenCodeGoApiKey
+ )
+ // Why keep a definitive failure too: a restored keychain can still open the inert ciphertext, while dropping it is irreversible.
+ if (decrypted.status === 'unavailable' || !decrypted.plaintext) {
+ secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed)
+ return false
+ }
+ const key = decrypted.plaintext.trim()
+ if (key) {
+ target.save(key)
+ }
+ } else {
+ // Why: the store is machine-wide, so another profile's key can win; leave a trace of the drop.
+ console.warn(
+ '[opencode-go] Kept the existing saved API key and dropped the one from settings.'
+ )
+ }
+ } catch {
+ // Why: startup must not fail on a disk or keychain error; the next startup retries.
+ secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed)
+ console.warn('[opencode-go] Could not migrate the saved API key out of settings.')
+ return false
+ }
+ secrets.removeRetainedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)
+ return true
+}
diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts
index b93dc2515001..cf699cd77246 100644
--- a/src/main/persistence/loading-store/loaded-state-parsing.ts
+++ b/src/main/persistence/loading-store/loaded-state-parsing.ts
@@ -14,11 +14,11 @@ import {
sshPtyOwnerLeaseSecretSlot
} from '../../protected-secret-persistence'
import {
- isLegacyOpenCodeGoApiKey,
isLegacyOpenCodeSessionCookie,
isLegacySshPtyOwnerLease
} from '../leasing-ssh-ptys/secret-validation'
import { readGithubCacheSnapshot } from './user-data-path'
+import { retainLegacyOpenCodeGoApiKey } from './legacy-opencode-go-api-key-migration'
import {
gcStaleWorktreeMeta,
normalizeWorktreeLinkedItemMetadata
@@ -113,13 +113,7 @@ export class LoadedStateParsingOperations {
isLegacyOpenCodeSessionCookie
)
}
- if (parsed.settings?.opencodeGoApiKey) {
- parsed.settings.opencodeGoApiKey = this.runtime.protectedSecrets.decrypt(
- PROTECTED_SECRET_SLOT.opencodeGoApiKey,
- parsed.settings.opencodeGoApiKey,
- isLegacyOpenCodeGoApiKey
- )
- }
+ retainLegacyOpenCodeGoApiKey(parsed.settings, this.runtime.protectedSecrets)
if (parsed.settings?.httpProxyUrl) {
const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus(
PROTECTED_SECRET_SLOT.httpProxyUrl,
diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts
index 26709b33e3fa..feaddd284c3c 100644
--- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts
+++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts
@@ -113,12 +113,21 @@ afterEach(async () => {
vi.restoreAllMocks()
})
-export async function fixture() {
+export async function fixture(legacyOpenCodeGoApiKey?: string) {
const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-'))
const path = join(directory, 'profile-state.db')
const inner = new ProfileStateSqliteAuthority(path, 'coordination-test')
+ const initial = buildProfileStateCutoverFixture(directory)
inner.writeSerializedState(
- Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory)))
+ Buffer.from(
+ JSON.stringify({
+ ...initial,
+ settings: {
+ ...initial.settings,
+ ...(legacyOpenCodeGoApiKey ? { opencodeGoApiKey: legacyOpenCodeGoApiKey } : {})
+ }
+ })
+ )
)
const authority = new DelayedAuthority(inner)
const store = new Store({
diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts
index 4d72ce144c62..648319f59f01 100644
--- a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts
+++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts
@@ -27,7 +27,7 @@ beforeEach(() => {
})
afterEach(() => setSecretStore(previousSecretStore))
-describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)(
+describe.each(['opencodeSessionCookie'] as const)(
'Store %s retention across worker acknowledgements',
(setting) => {
it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => {
@@ -94,14 +94,14 @@ describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)(
describe('worker protected settings serialization', () => {
it.each(['selective', 'complete'] as const)(
- 'encrypts both protected credentials in a %s write to SQLite',
+ 'seals the cookie and retains the legacy Go key privately in a %s SQLite write',
async (mode) => {
- const { store, authority, readState } = await fixture()
+ const { store, authority, readState } = await fixture(ciphertext('legacy-go-key'))
const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains')
const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains')
const secrets = {
opencodeSessionCookie: 'cookie-only-plaintext',
- opencodeGoApiKey: 'api-key-only-plaintext'
+ opencodeGoApiKey: 'retired-write-is-ignored'
}
store.updateSettings(secrets)
if (mode === 'complete') {
@@ -113,12 +113,34 @@ describe('worker protected settings serialization', () => {
const persisted = readState()
expect(persisted.settings).toMatchObject({
opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie),
- opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey)
+ opencodeGoApiKey: ciphertext('legacy-go-key')
})
for (const plaintext of Object.values(secrets)) {
expect(JSON.stringify(persisted)).not.toContain(plaintext)
}
- expect(store.getSettings()).toMatchObject(secrets)
+ expect(store.getSettings().opencodeSessionCookie).toBe(secrets.opencodeSessionCookie)
+ expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey')
}
)
})
+
+it('does not restore the migrated legacy Go ciphertext from a pending worker acknowledgement', async () => {
+ const { store, authority, readState } = await fixture(ciphertext('legacy-go-key'))
+ const gate = authority.pause()
+ store.updateSettings({ theme: 'dark' })
+ const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
+ await gate.started.promise
+ const save = vi.fn()
+ store.migrateLegacyOpenCodeGoApiKey({ has: () => false, read: () => null, save })
+ encryptionAvailable = false
+ gate.finish.resolve()
+ await write
+ await store.flushPendingOrThrowAsync()
+ expect(save).toHaveBeenCalledExactlyOnceWith('legacy-go-key')
+ expect(readState().settings).not.toHaveProperty('opencodeGoApiKey')
+ expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey')
+ encryptionAvailable = true
+ store.updateSettings({ theme: 'light' })
+ await store.flushPendingOrThrowAsync()
+ expect(readState().settings).not.toHaveProperty('opencodeGoApiKey')
+})
diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts
index 40d40818343c..c4346545ef4c 100644
--- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts
+++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts
@@ -12,6 +12,7 @@ import {
type ProtectedSecretRetentionUpdate
} from '../../protected-secret-persistence'
import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations'
+import { LEGACY_OPENCODE_GO_API_KEY_SLOT } from './legacy-opencode-go-api-key-migration'
import { omitDefaultWorktreeMetaFieldsInMap } from '../../../shared/worktree/meta-persisted-defaults'
import { projectWorktreeMetaByIdentityOntoLocators } from './worktree-meta-alias-projection'
import { withoutRedundantPartitionGlobals } from '../../../shared/workspace-session-host-field-ownership'
@@ -260,10 +261,9 @@ export class StateSerializationSecretHandlingOperations {
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
this.runtime.state.settings.opencodeSessionCookie
),
- opencodeGoApiKey: encrypt(
- PROTECTED_SECRET_SLOT.opencodeGoApiKey,
- this.runtime.state.settings.opencodeGoApiKey ?? ''
- ),
+ ...(this.runtime.protectedSecrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)
+ ? { opencodeGoApiKey: encrypt(LEGACY_OPENCODE_GO_API_KEY_SLOT, '') }
+ : {}),
httpProxyUrl: encrypt(
PROTECTED_SECRET_SLOT.httpProxyUrl,
this.runtime.state.settings.httpProxyUrl ?? ''
diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts
index 1d543fb5b5fb..3550c3b53125 100644
--- a/src/main/persistence/loading-store/state-write-round-trip.test.ts
+++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts
@@ -1,7 +1,8 @@
import {
closeTestStores,
createSqliteTestStore,
- readPersistedStateJson
+ readPersistedStateJson,
+ writePersistedStateJson
} from '../../persistence-test-harness'
/**
* The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt
@@ -10,6 +11,7 @@ import {
* against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until
* something reads the bytes back.
*/
+import { getSecretStore } from '../../../shared/secret-store'
import { mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -77,6 +79,68 @@ function session(activeTabId: string): WorkspaceSessionState {
}
describe('persisted state survives a save/load round trip', () => {
+ it('keeps a #22551 settings-slot OpenCode Go key on disk until its new owner has it', () => {
+ const dataFile = join(
+ realpathSync(mkdtempSync(join(tmpdir(), 'orca-legacy-opencode-go-key-'))),
+ 'state.json'
+ )
+ const first = openStore(dataFile)
+ first.updateSettings({ opencodeWorkspaceId: 'wrk_test' })
+ first.flush()
+ const persisted = JSON.parse(readPersistedStateJson(dataFile))
+ // Sealed exactly as #22551's protected-secret slot wrote it.
+ const sealed = getSecretStore().encryptString('fake-legacy-key').toString('base64')
+ persisted.settings.opencodeGoApiKey = sealed
+ writePersistedStateJson(dataFile, JSON.stringify(persisted))
+ const onDiskKey = (): unknown =>
+ JSON.parse(readPersistedStateJson(dataFile)).settings.opencodeGoApiKey
+
+ // orcad-style consumer: loads and flushes the profile but never runs the migration.
+ const daemon = createSqliteTestStore(Store, { dataFile })
+ stores.push(daemon)
+ expect(daemon.getSettings()).not.toHaveProperty('opencodeGoApiKey')
+ daemon.updateSettings({ opencodeWorkspaceId: 'wrk_daemon' })
+ daemon.flush()
+ expect(onDiskKey()).toBe(sealed)
+ expect(readPersistedStateJson(dataFile)).not.toContain('fake-legacy-key')
+
+ const loaded = openStore(dataFile)
+ expect(loaded.getSettings().opencodeWorkspaceId).toBe('wrk_daemon')
+ expect(loaded.getSettings()).not.toHaveProperty('opencodeGoApiKey')
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ loaded.migrateLegacyOpenCodeGoApiKey({
+ has: () => false,
+ read: () => null,
+ save: () => {
+ throw new Error('disk full')
+ }
+ })
+ loaded.flush()
+ expect(onDiskKey()).toBe(sealed)
+
+ // Why: an older paired client can still send the retired field; it must not be stored.
+ const updates = { opencodeGoApiKey: 'fake-remote-key', opencodeWorkspaceId: 'wrk_next' }
+ expect(loaded.updateSettings(updates)).not.toHaveProperty('opencodeGoApiKey')
+ loaded.flush()
+ expect(onDiskKey()).toBe(sealed)
+
+ const saved: string[] = []
+ loaded.migrateLegacyOpenCodeGoApiKey({
+ has: () => saved.length > 0,
+ read: () => saved[0] ?? null,
+ save: (key) => saved.push(key)
+ })
+ loaded.migrateLegacyOpenCodeGoApiKey({
+ has: () => saved.length > 0,
+ read: () => saved[0] ?? null,
+ save: (key) => saved.push(key)
+ })
+ expect(saved).toEqual(['fake-legacy-key'])
+ loaded.flush()
+ expect(readPersistedStateJson(dataFile)).not.toContain('opencodeGoApiKey')
+ expect(openStore(dataFile).getSettings()).not.toHaveProperty('opencodeGoApiKey')
+ })
+
it('reloads settings, secrets and both session partitions unchanged', () => {
const dataFile = join(
realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))),
diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts
index d60f1a7e3e1e..20528b93bac7 100644
--- a/src/main/persistence/loading-store/store.ts
+++ b/src/main/persistence/loading-store/store.ts
@@ -17,6 +17,10 @@ import {
} from './store-domain-composition'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { scheduleSave } from './write-scheduling'
+import {
+ migrateLegacyOpenCodeGoApiKey,
+ type OpenCodeGoApiKeyTarget
+} from './legacy-opencode-go-api-key-migration'
import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes'
import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine'
import { writeVersionedProfileStateExport } from '../profile-state/legacy-json/profile-state-versioned-export'
@@ -151,6 +155,13 @@ export class Store {
}
}
+ /** Moves the #22551 settings-slot OpenCode Go key into `target`; it stays on disk until that succeeds. */
+ migrateLegacyOpenCodeGoApiKey(target: OpenCodeGoApiKeyTarget): void {
+ if (migrateLegacyOpenCodeGoApiKey(this.runtime.protectedSecrets, target)) {
+ scheduleSave(this.domains.scheduling)
+ }
+ }
+
getProfileStorageDirectory(): string {
return dirname(this.runtime.dataFile)
}
diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts
index c72af0966197..5a9c6ed3bc50 100644
--- a/src/main/protected-secret-persistence.ts
+++ b/src/main/protected-secret-persistence.ts
@@ -2,7 +2,6 @@ import { getSecretStore } from '../shared/secret-store'
export const PROTECTED_SECRET_SLOT = {
opencodeSessionCookie: 'settings.opencodeSessionCookie',
- opencodeGoApiKey: 'settings.opencodeGoApiKey',
httpProxyUrl: 'settings.httpProxyUrl',
browserKagiSessionLink: 'ui.browserKagiSessionLink'
} as const
@@ -49,6 +48,17 @@ export class ProtectedSecretPersistence {
this.pendingEncryption.delete(slot)
}
+ /** Parks ciphertext without allowing an earlier pending write to replace it. */
+ retainSealed(slot: string, blob: string): void {
+ this.removeRetainedBlob(slot)
+ this.retainedBlobs.set(slot, blob)
+ this.sealedSlots.add(slot)
+ }
+
+ sealedBlob(slot: string): string | null {
+ return this.sealedSlots.has(slot) ? (this.retainedBlobs.get(slot) ?? null) : null
+ }
+
isSealed(slot: string, value: string): boolean {
return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value
}
diff --git a/src/main/rate-limits/opencode-go-api-key-source.test.ts b/src/main/rate-limits/opencode-go-api-key-source.test.ts
index d1842792edad..51786fef1ffb 100644
--- a/src/main/rate-limits/opencode-go-api-key-source.test.ts
+++ b/src/main/rate-limits/opencode-go-api-key-source.test.ts
@@ -5,19 +5,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error'
import Database from '../sqlite/sync-database'
+import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
+import {
+ listOpenCodeDatabases,
+ listOpenCodeDatabasesInDirectory
+} from '../opencode-usage/opencode-database-discovery'
import {
getOpenCodeAuthFilePath,
readOpenCodeAuthFileGoKey,
resolveOpenCodeGoApiKey
} from './opencode-go-api-key-source'
+vi.mock('../opencode/opencode-credential-backend', () => ({
+ detectOpenCodeCredentialBackend: vi.fn()
+}))
+
// Placeholder values only — a real key must never reach a fixture.
const SETTINGS_KEY = 'settings-placeholder-key'
const ENVIRONMENT_KEY = 'environment-placeholder-key'
const AUTH_FILE_KEY = 'auth-file-placeholder-key'
const DATABASE_KEY = 'database-placeholder-key'
-const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const
+const ENVIRONMENT_KEYS = [
+ 'XDG_DATA_HOME',
+ 'OPENCODE_API_KEY',
+ 'OPENCODE_DB',
+ 'OPENCODE_AUTH_CONTENT'
+] as const
// Lets a test fail the data-directory listing with an error the host filesystem cannot portably produce.
const readdirFailure = vi.hoisted((): { error: unknown } => ({ error: null }))
@@ -61,10 +75,12 @@ describe('resolveOpenCodeGoApiKey', () => {
}
beforeEach(() => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockReset().mockResolvedValue('v2')
originalEnvironment = Object.fromEntries(ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]))
dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-go-key-'))
process.env.XDG_DATA_HOME = dataHome
delete process.env.OPENCODE_API_KEY
+ delete process.env.OPENCODE_AUTH_CONTENT
// Keeps the credential-database tier from touching the developer's own store.
process.env.OPENCODE_DB = ':memory:'
})
@@ -82,6 +98,69 @@ describe('resolveOpenCodeGoApiKey', () => {
rmSync(dataHome, { recursive: true, force: true })
})
+ it.each([undefined, 'selected.db'] as const)(
+ 'uses the fourth-argument environment for discovery (%s)',
+ async (override) => {
+ const directory = join(dataHome, 'opencode')
+ mkdirSync(directory)
+ const path = join(directory, override ?? 'opencode.db')
+ writeFileSync(path, '')
+ const onFsError = vi.fn()
+
+ await expect(
+ listOpenCodeDatabases(undefined, onFsError, undefined, {
+ XDG_DATA_HOME: dataHome,
+ OPENCODE_DB: override
+ })
+ ).resolves.toEqual([path])
+ expect(onFsError).not.toHaveBeenCalled()
+ expect(process.env.OPENCODE_DB).toBe(':memory:')
+ }
+ )
+
+ it('keeps filesystem error reporting in the second argument', async () => {
+ const error = Object.assign(new Error('discovery denied'), { code: 'EACCES' })
+ readdirFailure.error = error
+ const onFsError = vi.fn()
+
+ await expect(
+ listOpenCodeDatabases(undefined, onFsError, undefined, { XDG_DATA_HOME: dataHome })
+ ).resolves.toEqual([])
+ expect(onFsError).toHaveBeenCalledWith(join(dataHome, 'opencode'), error)
+ })
+
+ it.each([undefined, 'missing.db'] as const)(
+ 'preserves third-argument cancellation before reporting discovery errors (%s)',
+ async (override) => {
+ readdirFailure.error = new Error('discovery stopped')
+ const reason = new Error('caller cancelled')
+ const controller = new AbortController()
+ controller.abort(reason)
+ const onFsError = vi.fn()
+
+ await expect(
+ listOpenCodeDatabases(undefined, onFsError, controller.signal, {
+ XDG_DATA_HOME: dataHome,
+ OPENCODE_DB: override
+ })
+ ).rejects.toBe(reason)
+ expect(onFsError).not.toHaveBeenCalled()
+ }
+ )
+
+ it('preserves the native directory helper cancellation and error argument positions', async () => {
+ readdirFailure.error = new Error('directory read stopped')
+ const reason = new Error('caller cancelled')
+ const controller = new AbortController()
+ controller.abort(reason)
+ const onFsError = vi.fn()
+
+ await expect(
+ listOpenCodeDatabasesInDirectory(dataHome, undefined, controller.signal, onFsError)
+ ).rejects.toBe(reason)
+ expect(onFsError).not.toHaveBeenCalled()
+ })
+
it('reads auth.json from XDG_DATA_HOME, which OpenCode uses on every platform', () => {
expect(getOpenCodeAuthFilePath({ XDG_DATA_HOME: '/data' })).toBe('/data/opencode/auth.json')
// OpenCode's global-roots.ts falls back to os.homedir() + .local/share even on Windows.
@@ -121,13 +200,13 @@ describe('resolveOpenCodeGoApiKey', () => {
})
})
- it('falls back to the key OpenCode 1.x saved on /connect', async () => {
+ it('uses the key OpenCode 1.x saved on /connect', async () => {
writeAuthFile({
anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 },
'opencode-go': { type: 'api', key: AUTH_FILE_KEY }
})
- await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
status: 'found',
key: AUTH_FILE_KEY,
tier: 'opencode-auth-file'
@@ -231,6 +310,271 @@ describe('resolveOpenCodeGoApiKey', () => {
})
})
+ it('prefers the credential table over a stale auth.json, since OpenCode 2 stops writing the file', async () => {
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const { path } = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ])
+ process.env.OPENCODE_DB = path
+
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: DATABASE_KEY,
+ tier: 'opencode-credential-database'
+ })
+ })
+
+ it('uses v1 auth.json despite a conflicting populated credential table', async () => {
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const { path } = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ])
+ process.env.OPENCODE_DB = path
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: AUTH_FILE_KEY,
+ tier: 'opencode-auth-file'
+ })
+ })
+
+ it('does not use a v2 table-only credential for v1 execution', async () => {
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ const { path } = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ])
+ process.env.OPENCODE_DB = path
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: ENVIRONMENT_KEY,
+ tier: 'environment'
+ })
+ })
+
+ it('applies the installed v1 backend even when the table has a key', async () => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue('v1')
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: AUTH_FILE_KEY,
+ tier: 'opencode-auth-file'
+ })
+ })
+
+ it('uses v1 inline authentication ahead of a conflicting auth file and table', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+ process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
+ 'opencode-go': { type: 'api', key: 'inline-placeholder-key' }
+ })
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: 'inline-placeholder-key',
+ tier: 'opencode-auth-content'
+ })
+ })
+
+ it('does not fall back to the auth file when valid v1 inline auth omits Go', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_AUTH_CONTENT = '{}'
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: ENVIRONMENT_KEY,
+ tier: 'environment'
+ })
+ })
+
+ it('uses the v1 auth file after malformed inline authentication', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_AUTH_CONTENT = '{invalid'
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: AUTH_FILE_KEY,
+ tier: 'opencode-auth-file'
+ })
+ })
+
+ it('withholds stored credentials when the execution backend cannot be determined', async () => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' })
+ })
+
+ it('uses the provided environment key without reading stores when the backend is unknown', async () => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
+ const environment: NodeJS.ProcessEnv = { OPENCODE_API_KEY: ` ${ENVIRONMENT_KEY} ` }
+ const readStoreContext = vi.fn(() => {
+ throw new Error('Unknown backend must not read version-specific stores')
+ })
+ for (const name of ['OPENCODE_AUTH_CONTENT', 'XDG_DATA_HOME', 'OPENCODE_DB']) {
+ Object.defineProperty(environment, name, { get: readStoreContext })
+ }
+
+ await expect(resolveOpenCodeGoApiKey({ environment, cwd: dataHome })).resolves.toEqual({
+ status: 'found',
+ key: ENVIRONMENT_KEY,
+ tier: 'environment'
+ })
+ expect(vi.mocked(detectOpenCodeCredentialBackend).mock.calls[0]?.[0]).toBe(environment)
+ expect(vi.mocked(detectOpenCodeCredentialBackend).mock.calls[0]?.[1]).toBe(dataHome)
+ expect(readStoreContext).not.toHaveBeenCalled()
+ })
+
+ it.each([undefined, '', ' \t\n '])(
+ 'does not borrow the host key when an unknown selected backend has environment key %j',
+ async (key) => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ const environment: NodeJS.ProcessEnv = { OPENCODE_API_KEY: key }
+
+ await expect(resolveOpenCodeGoApiKey({ environment })).resolves.toEqual({ status: 'missing' })
+ expect(detectOpenCodeCredentialBackend).toHaveBeenCalledWith(environment, undefined)
+ }
+ )
+
+ it('keeps host and selected environment keys isolated when the backend is unknown', async () => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ const selected = { OPENCODE_API_KEY: 'selected-environment-placeholder' }
+ const inherited = { ...process.env }
+
+ const first = await resolveOpenCodeGoApiKey({})
+ const managed = await resolveOpenCodeGoApiKey({ environment: selected })
+ const restored = await resolveOpenCodeGoApiKey({})
+
+ expect(first).toEqual({ status: 'found', key: ENVIRONMENT_KEY, tier: 'environment' })
+ expect(managed).toEqual({
+ status: 'found',
+ key: 'selected-environment-placeholder',
+ tier: 'environment'
+ })
+ expect(restored).toEqual(first)
+ expect(process.env).toEqual(inherited)
+ expect(selected).toEqual({ OPENCODE_API_KEY: 'selected-environment-placeholder' })
+ })
+
+ it.each(['v1', 'v2'] as const)(
+ 'uses caller backend authority for %s without probing',
+ async (backend) => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe'))
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+
+ await expect(resolveOpenCodeGoApiKey({ backend })).resolves.toEqual(
+ backend === 'v1'
+ ? { status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' }
+ : { status: 'found', key: DATABASE_KEY, tier: 'opencode-credential-database' }
+ )
+ expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
+ }
+ )
+
+ it('returns the manual override before reading execution context or probing a CLI', async () => {
+ const input = { settingsOverride: SETTINGS_KEY }
+ Object.defineProperty(input, 'environment', {
+ get: () => {
+ throw new Error('Selected profile metadata is unreadable')
+ }
+ })
+ vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe'))
+
+ await expect(resolveOpenCodeGoApiKey(input)).resolves.toEqual({
+ status: 'found',
+ key: SETTINGS_KEY,
+ tier: 'settings'
+ })
+ expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
+ })
+
+ it('propagates unreadable selected context instead of reading host credentials', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const input = {}
+ Object.defineProperty(input, 'environment', {
+ get: () => {
+ throw new Error('Selected profile metadata is unreadable')
+ }
+ })
+
+ await expect(resolveOpenCodeGoApiKey(input)).rejects.toThrow(
+ 'Selected profile metadata is unreadable'
+ )
+ })
+
+ it.each(['v1', 'v2'] as const)(
+ 'keeps System and selected data roots isolated for %s',
+ async (backend) => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const system = { XDG_DATA_HOME: dataHome, OPENCODE_DB: ':memory:' }
+ const selectedDataHome = join(dataHome, 'selected')
+ mkdirSync(join(selectedDataHome, 'opencode'), { recursive: true })
+ writeFileSync(
+ join(selectedDataHome, 'opencode', 'auth.json'),
+ JSON.stringify({
+ 'opencode-go': { type: 'api', key: 'selected-auth-placeholder' }
+ })
+ )
+ const { path } = writeCredentialDatabase([
+ {
+ value: JSON.stringify({ type: 'key', key: 'selected-table-placeholder' }),
+ active: 1,
+ created: 1
+ }
+ ])
+ const selected = { XDG_DATA_HOME: selectedDataHome, OPENCODE_DB: path }
+ const inherited = { ...process.env }
+
+ const first = await resolveOpenCodeGoApiKey({ environment: system, backend })
+ const managed = await resolveOpenCodeGoApiKey({ environment: selected, backend })
+ const restored = await resolveOpenCodeGoApiKey({ environment: system, backend })
+
+ expect(first).toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' })
+ expect(managed).toEqual(
+ backend === 'v1'
+ ? { status: 'found', key: 'selected-auth-placeholder', tier: 'opencode-auth-file' }
+ : {
+ status: 'found',
+ key: 'selected-table-placeholder',
+ tier: 'opencode-credential-database'
+ }
+ )
+ expect(restored).toEqual(first)
+ expect(process.env).toEqual(inherited)
+ }
+ )
+
+ it('keeps the settings override above the credential table', async () => {
+ const { path } = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ])
+ process.env.OPENCODE_DB = path
+
+ await expect(resolveOpenCodeGoApiKey({ settingsOverride: SETTINGS_KEY })).resolves.toEqual({
+ status: 'found',
+ key: SETTINGS_KEY,
+ tier: 'settings'
+ })
+ })
+
it('reports missing when no tier holds a key', async () => {
writeAuthFile({ 'opencode-go': { type: 'oauth', refresh: 'r', access: 'a', expires: 1 } })
diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts
index f722e4cbeaa9..52a713f00587 100644
--- a/src/main/rate-limits/opencode-go-api-key-source.ts
+++ b/src/main/rate-limits/opencode-go-api-key-source.ts
@@ -8,6 +8,10 @@ import { tableExists } from '../opencode-usage/schema-helpers'
import { isWslUncPath } from '../../shared/wsl-paths'
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
import Database from '../sqlite/sync-database'
+import {
+ detectOpenCodeCredentialBackend,
+ type OpenCodeCredentialBackend
+} from '../opencode/opencode-credential-backend'
/** OpenCode's provider/integration id for the Go subscription. */
const OPENCODE_GO_INTEGRATION_ID = 'opencode-go'
@@ -20,6 +24,7 @@ const MAX_AUTH_FILE_BYTES = 1_000_000
export type OpenCodeGoApiKeyTier =
| 'settings'
| 'environment'
+ | 'opencode-auth-content'
| 'opencode-auth-file'
| 'opencode-credential-database'
@@ -61,6 +66,27 @@ function trimmedKey(value: unknown): string | null {
return trimmed ? trimmed : null
}
+function readOpenCodeInlineGoKey(environment: NodeJS.ProcessEnv): {
+ configured: boolean
+ key: string | null
+} {
+ const content = environment.OPENCODE_AUTH_CONTENT
+ if (content) {
+ try {
+ const parsed: unknown = JSON.parse(content)
+ return {
+ configured: true,
+ key: isRecord(parsed)
+ ? keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
+ : null
+ }
+ } catch {
+ // V1 ignores invalid inline JSON and then reads auth.json.
+ }
+ }
+ return { configured: false, key: null }
+}
+
/**
* Read the `opencode-go` API key OpenCode 1.x writes on `/connect`.
*
@@ -87,8 +113,6 @@ export function readOpenCodeAuthFileGoKey(
}
return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
} catch {
- // Why: a malformed or unreadable auth file is "no key here", not a fetch
- // failure — later tiers and the cookie path still deserve their turn.
return null
}
}
@@ -135,22 +159,28 @@ function selectCredentialKey(database: Database.Database): string | null {
* OpenCode 2 imports `auth.json` into SQLite once (migration
* `20260805200742_import_legacy_credentials`) and every later `/connect` writes
* only there, so a fresh OpenCode 2 install has no `auth.json` entry at all.
- * The table itself is not a version marker — 1.18.x creates it too (verified
- * empty on a real 1.18.16 install), so probe it regardless of version.
+ * V1 also creates this table and can populate it through integration routes;
+ * only a caller with V2 execution authority should use it for Go credentials.
* @returns The key; `missing` when no database, table, or row carries one;
- * `unreadable` when none had a key but discovery failed for a reason other than
- * absence, or at least one database failed to open or query.
+ * `unreadable` when discovery, opening, or querying failed without a key.
*/
-export async function readOpenCodeCredentialDatabaseGoKey(): Promise {
+export async function readOpenCodeCredentialDatabaseGoKey(
+ environment: NodeJS.ProcessEnv = process.env
+): Promise {
let sawUnreadable = false
let paths: string[]
try {
- const listed = await listOpenCodeDatabases(undefined, (path, error) => {
- // A UNC location is never opened here (below), so failing to list it is no evidence either.
- if (!isWslUncPath(path) && !isMissingPathError(error)) {
- sawUnreadable = true
- }
- })
+ const listed = await listOpenCodeDatabases(
+ undefined,
+ (path, error) => {
+ // A UNC location is never opened here (below), so failing to list it is no evidence either.
+ if (!isWslUncPath(path) && !isMissingPathError(error)) {
+ sawUnreadable = true
+ }
+ },
+ undefined,
+ environment
+ )
paths = [...listed].sort(compareOpenCodeClaimPriority)
} catch {
return { status: 'missing' }
@@ -184,38 +214,46 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise {
- const environment = input.environment ?? process.env
const override = trimmedKey(input.settingsOverride)
if (override) {
return { status: 'found', key: override, tier: 'settings' }
}
- const fromAuthFile = readOpenCodeAuthFileGoKey(environment)
+ const environment = input.environment ?? process.env
+ const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd))
+ let databaseUnreadable = false
+ if (backend === 'v2') {
+ const fromDatabase = await readOpenCodeCredentialDatabaseGoKey(environment)
+ if (fromDatabase.status === 'found') {
+ return { status: 'found', key: fromDatabase.key, tier: 'opencode-credential-database' }
+ }
+ databaseUnreadable = fromDatabase.status === 'unreadable'
+ }
+ const inline = backend === 'v1' ? readOpenCodeInlineGoKey(environment) : null
+ if (inline?.key) {
+ return { status: 'found', key: inline.key, tier: 'opencode-auth-content' }
+ }
+ const fromAuthFile =
+ backend && !inline?.configured ? readOpenCodeAuthFileGoKey(environment) : null
if (fromAuthFile) {
return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' }
}
- const fromDatabase = await readOpenCodeCredentialDatabaseGoKey()
- if (fromDatabase.status === 'found') {
- return { status: 'found', key: fromDatabase.key, tier: 'opencode-credential-database' }
- }
const fromEnvironment = trimmedKey(environment[OPENCODE_API_KEY_ENV])
- if (fromEnvironment && fromDatabase.status === 'unreadable') {
+ if (fromEnvironment && databaseUnreadable) {
return { status: 'credential-database-unreadable' }
}
if (fromEnvironment) {
diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
index d8fb15e145a4..64b7ac622caa 100644
--- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
+++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
@@ -76,6 +76,44 @@ describe('fetchOpenCodeGoUsage', () => {
expect(resolveApiKeyMock).toHaveBeenCalledWith({ settingsOverride: API_KEY })
})
+ it('passes trusted execution context and selected roots through to credential lookup', async () => {
+ resolveApiKeyMock.mockResolvedValue({ status: 'missing' })
+ const environment = { XDG_DATA_HOME: '/task/selected', OPENCODE_DB: ':memory:' }
+
+ await fetchOpenCodeGoUsage({ cookie: '', backend: 'v1', environment, cwd: '/task/workspace' })
+
+ expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({
+ settingsOverride: undefined,
+ backend: 'v1',
+ environment,
+ cwd: '/task/workspace'
+ })
+ })
+
+ it('passes a manual override without evaluating selected execution context', async () => {
+ resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' })
+ fetchWithApiKeyMock.mockResolvedValue({ kind: 'ok', windows: WINDOWS })
+ const input = { cookie: '', settingsApiKey: API_KEY }
+ Object.defineProperty(input, 'environment', {
+ get: () => {
+ throw new Error('Selected profile metadata is unreadable')
+ }
+ })
+
+ expect((await fetchOpenCodeGoUsage(input)).status).toBe('ok')
+ expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ settingsOverride: API_KEY })
+ })
+
+ it('propagates a rejected selected-account lookup without trying host or cookie credentials', async () => {
+ resolveApiKeyMock.mockRejectedValue(new Error('Selected profile metadata is unreadable'))
+
+ await expect(fetchOpenCodeGoUsage({ cookie: COOKIE })).rejects.toThrow(
+ 'Selected profile metadata is unreadable'
+ )
+ expect(fetchWithApiKeyMock).not.toHaveBeenCalled()
+ expect(fetchWithCookieMock).not.toHaveBeenCalled()
+ })
+
it('names the missing subscription instead of a generic refresh failure', async () => {
resolveApiKeyMock.mockResolvedValue({
status: 'found',
diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts
index 61378eddb148..c38fc1dc844d 100644
--- a/src/main/rate-limits/opencode-go-usage-source-selection.ts
+++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts
@@ -5,6 +5,7 @@ import {
type OpenCodeGoApiKeyResolution
} from './opencode-go-api-key-source'
import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing'
+import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
import {
fetchOpenCodeGoUsageWithApiKey,
type OpenCodeGoUsageApiOutcome
@@ -14,6 +15,9 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u
export type OpenCodeGoUsageSourceInput = {
/** Explicit Orca override; the highest-precedence key tier. */
settingsApiKey?: string
+ environment?: NodeJS.ProcessEnv
+ backend?: OpenCodeCredentialBackend
+ cwd?: string
cookie: string
workspaceIdOverride?: string
networkProxySettings?: NetworkProxySettings
@@ -96,8 +100,16 @@ function apiFailureResult(
export async function fetchOpenCodeGoUsage(
input: OpenCodeGoUsageSourceInput
): Promise {
+ const context = input.settingsApiKey?.trim()
+ ? {}
+ : {
+ ...(input.environment ? { environment: input.environment } : {}),
+ ...(input.backend ? { backend: input.backend } : {}),
+ ...(input.cwd ? { cwd: input.cwd } : {})
+ }
const apiKeyResolution = await resolveOpenCodeGoApiKey({
- settingsOverride: input.settingsApiKey
+ settingsOverride: input.settingsApiKey,
+ ...context
})
input.onApiKeyResolved?.(apiKeyResolution)
const hasCookie = Boolean(normalizeCookieInput(input.cookie))
diff --git a/src/main/rate-limits/service-opencode-go-credentials.test.ts b/src/main/rate-limits/service-opencode-go-credentials.test.ts
new file mode 100644
index 000000000000..5e98ce4a1d62
--- /dev/null
+++ b/src/main/rate-limits/service-opencode-go-credentials.test.ts
@@ -0,0 +1,195 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ProviderRateLimits } from '../../shared/rate-limit-types'
+import { RateLimitService } from './service'
+import { fetchClaudeRateLimits } from './claude-fetcher'
+import { fetchCodexRateLimits } from './codex-fetcher'
+import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
+import { ApiKeyFileUnreadableError } from '../credentials/api-key-file-unreadable-error'
+import {
+ deferred,
+ flushMicrotasks,
+ okProvider,
+ resetRateLimitProviderMocks,
+ unavailableProvider
+} from './rate-limit-service-test-harness'
+
+vi.mock('./claude-fetcher', () => ({
+ fetchClaudeRateLimits: vi.fn(),
+ fetchManagedAccountUsage: vi.fn()
+}))
+
+vi.mock('./codex-fetcher', () => ({
+ consumeCodexRateLimitResetCredit: vi.fn(),
+ fetchCodexRateLimits: vi.fn()
+}))
+
+vi.mock('./gemini-usage-fetcher', () => ({
+ fetchGeminiRateLimits: vi.fn()
+}))
+
+vi.mock('./kimi-fetcher', () => ({
+ fetchKimiRateLimits: vi.fn()
+}))
+
+vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() }))
+vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() }))
+vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() }))
+vi.mock('./antigravity-usage-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() }))
+
+vi.mock('./opencode-go-usage-source-selection', () => ({
+ fetchOpenCodeGoUsage: vi.fn()
+}))
+
+vi.mock('./minimax/minimax-fetcher', () => ({
+ fetchMiniMaxRateLimits: vi.fn()
+}))
+
+vi.mock('./grok-fetcher', () => ({
+ fetchGrokRateLimits: vi.fn()
+}))
+
+vi.mock('./grok-auth', () => ({
+ readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
+}))
+
+vi.mock('../minimax/minimax-cookie-store', () => ({
+ hasMiniMaxSessionCookie: vi.fn(() => false)
+}))
+
+const DECRYPT_ERROR =
+ 'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.'
+
+function serviceWithCookie(apiKeyResolver: () => string | null): RateLimitService {
+ const service = new RateLimitService()
+ service.setOpenCodeGoConfigResolver(
+ () => ({ sessionCookie: 'auth=fake-cookie', workspaceIdOverride: '' }),
+ apiKeyResolver
+ )
+ return service
+}
+
+function undecryptableKey(): string | null {
+ throw new Error('OpenCode Go API key could not be decrypted')
+}
+
+describe('OpenCode Go credential state', () => {
+ beforeEach(() => {
+ resetRateLimitProviderMocks()
+ vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0))
+ vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0))
+ })
+
+ it('treats an undecryptable saved key as absent so the cookie still produces usage', async () => {
+ const service = serviceWithCookie(undecryptableKey)
+ vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40))
+
+ await service.refresh()
+
+ expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
+ expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' })
+ )
+ const state = service.getState()
+ expect(state.opencodeGo?.status).toBe('ok')
+ expect(state.opencodeGo?.error).toBeNull()
+ })
+
+ it('shows the decrypt error only when no other source produced usage', async () => {
+ const service = serviceWithCookie(undecryptableKey)
+ vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => {
+ input.onApiKeyResolved?.({ status: 'missing' })
+ return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie')
+ })
+
+ await service.refresh()
+
+ const state = service.getState()
+ expect(state.opencodeGo?.status).toBe('error')
+ expect(state.opencodeGo?.error).toBe(DECRYPT_ERROR)
+ // Why: the bar must stay visible to surface how to fix the saved key.
+ expect(state.opencodeGoApiKeyConfigured).toBe(true)
+ })
+
+ it('skips a transiently unreadable saved key without blaming it, keeping the bar visible', async () => {
+ const service = serviceWithCookie(() => {
+ throw new ApiKeyFileUnreadableError('OpenCode Go API key file could not be read')
+ })
+ vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => {
+ input.onApiKeyResolved?.({ status: 'missing' })
+ return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie')
+ })
+
+ await service.refresh()
+
+ expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
+ expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' })
+ )
+ const state = service.getState()
+ expect(state.opencodeGo?.status).toBe('unavailable')
+ expect(state.opencodeGo?.error).not.toBe(DECRYPT_ERROR)
+ expect(state.opencodeGoApiKeyConfigured).toBe(true)
+ })
+
+ it('keeps a real cookie error instead of the decrypt error', async () => {
+ const service = serviceWithCookie(undecryptableKey)
+ vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce({
+ ...unavailableProvider('opencode-go', 'OpenCode session cookie expired'),
+ status: 'error'
+ })
+
+ await service.refresh()
+
+ const state = service.getState()
+ expect(state.opencodeGo?.status).toBe('error')
+ expect(state.opencodeGo?.error).toBe('OpenCode session cookie expired')
+ })
+
+ it('keeps the chip visible across a cookie change while a key source exists', async () => {
+ const service = serviceWithCookie(() => null)
+ vi.mocked(fetchOpenCodeGoUsage).mockImplementation(async (input) => {
+ input.onApiKeyResolved?.({ status: 'found', key: 'fake-env-key', tier: 'environment' })
+ return okProvider('opencode-go', 10)
+ })
+ await service.refresh()
+ expect(service.getState().opencodeGoApiKeyConfigured).toBe(true)
+
+ service.invalidateOpenCodeGoCredentialState()
+
+ expect(service.getState().opencodeGo?.status).toBe('fetching')
+ expect(service.getState().opencodeGoApiKeyConfigured).toBe(true)
+
+ service.invalidateOpenCodeGoCredentialState({ apiKeyCleared: true })
+ expect(service.getState().opencodeGoApiKeyConfigured).toBe(false)
+ })
+
+ it('passes the saved key to the fetch as the settings override', async () => {
+ const service = serviceWithCookie(() => 'fake-saved-key')
+
+ await service.refresh()
+
+ expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
+ expect.objectContaining({ settingsApiKey: 'fake-saved-key' })
+ )
+ })
+
+ it('drops an in-flight result fetched with a credential that was since replaced', async () => {
+ const service = serviceWithCookie(() => 'fake-old-key')
+ const pending = deferred()
+ let resolvedWithOldKey: (() => void) | undefined
+ vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce((input) => {
+ resolvedWithOldKey = () =>
+ input.onApiKeyResolved?.({ status: 'found', key: 'fake-old-key', tier: 'settings' })
+ return pending.promise
+ })
+
+ const refresh = service.refresh()
+ await flushMicrotasks()
+ service.invalidateOpenCodeGoCredentialState()
+ resolvedWithOldKey?.()
+ pending.resolve(okProvider('opencode-go', 90))
+ await refresh
+
+ const state = service.getState()
+ expect(state.opencodeGo?.session?.usedPercent).not.toBe(90)
+ expect(state.opencodeGoApiKeyConfigured).toBe(false)
+ })
+})
diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts
index e0a1d9c9b042..2cef94ba2b3e 100644
--- a/src/main/rate-limits/service-refresh-orchestration.test.ts
+++ b/src/main/rate-limits/service-refresh-orchestration.test.ts
@@ -431,8 +431,7 @@ describe('RateLimitService', () => {
const service = new RateLimitService()
service.setOpenCodeGoConfigResolver(() => ({
sessionCookie: 'session=abc123',
- workspaceIdOverride: '',
- apiKey: ''
+ workspaceIdOverride: ''
}))
const networkProxySettings = {
httpProxyUrl: 'http://proxy.example:8080',
@@ -562,8 +561,7 @@ describe('RateLimitService', () => {
const service = new RateLimitService()
service.setOpenCodeGoConfigResolver(() => ({
sessionCookie: '',
- workspaceIdOverride: '',
- apiKey: ''
+ workspaceIdOverride: ''
}))
vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down'))
@@ -587,8 +585,7 @@ describe('RateLimitService', () => {
let cookie = 'session=valid'
service.setOpenCodeGoConfigResolver(() => ({
sessionCookie: cookie,
- workspaceIdOverride: '',
- apiKey: ''
+ workspaceIdOverride: ''
}))
// 1. Success fetch
@@ -624,8 +621,7 @@ describe('RateLimitService', () => {
let workspaceId = 'wrk_A'
service.setOpenCodeGoConfigResolver(() => ({
sessionCookie: 'session=valid',
- workspaceIdOverride: workspaceId,
- apiKey: ''
+ workspaceIdOverride: workspaceId
}))
// 1. Success fetch for Workspace A
diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts
index e6090e28228c..6aeacd99583b 100644
--- a/src/main/rate-limits/service/service-account-refresh.ts
+++ b/src/main/rate-limits/service/service-account-refresh.ts
@@ -29,6 +29,19 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna
return this.getState()
}
+ invalidateOpenCodeGoCredentialState(options: { apiKeyCleared?: boolean } = {}): void {
+ this.opencodeFetchGeneration += 1
+ // Why: a key from env, OpenCode's DB or auth.json survives a cookie change; only clearing the saved key hides the chip.
+ if (options.apiKeyCleared) {
+ this.openCodeGoApiKeyConfigured = false
+ }
+ // Why: a credential change must discard the snapshot and any result still in flight.
+ this.updateState({
+ ...this.state,
+ opencodeGo: this.withFetchingStatus(null, 'opencode-go')
+ })
+ }
+
invalidateMiniMaxCredentialState(): void {
this.minimaxFetchGeneration += 1
// Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns.
diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts
index b2acd9882ede..fd9dcf1375c3 100644
--- a/src/main/rate-limits/service/service-configuration.ts
+++ b/src/main/rate-limits/service/service-configuration.ts
@@ -43,8 +43,12 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target)
}
- setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void {
+ setOpenCodeGoConfigResolver(
+ resolver: () => OpenCodeGoRateLimitConfig,
+ apiKeyResolver?: () => string | null
+ ): void {
this.openCodeGoConfigResolver = resolver
+ this.openCodeGoApiKeyResolver = apiKeyResolver ?? null
}
setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts
index 93dc9e20553e..3da1389d4830 100644
--- a/src/main/rate-limits/service/service-fetch-targets.ts
+++ b/src/main/rate-limits/service/service-fetch-targets.ts
@@ -7,6 +7,7 @@ import {
type CodexAccountSelectionTarget,
type MiniMaxResolvedConfig,
type ZcodePlanResolvedConfig,
+ type OpenCodeGoResolvedConfig,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
type ProviderRateLimits,
@@ -14,6 +15,7 @@ import {
toErrorMessage
} from './service-types'
import type { CodexRateLimitResetOutcome } from '../../../shared/rate-limit-types'
+import { ApiKeyFileUnreadableError } from '../../credentials/api-key-file-unreadable-error'
const CODEX_RESET_REFRESH_RETRIES = 3
const CODEX_RESET_REFRESH_DELAY_MS = 250
@@ -188,6 +190,34 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
return process.platform !== 'win32'
}
+ protected resolveOpenCodeGoConfig(): OpenCodeGoResolvedConfig {
+ const config = this.openCodeGoConfigResolver?.() ?? {
+ sessionCookie: '',
+ workspaceIdOverride: ''
+ }
+ try {
+ return {
+ ...config,
+ apiKey: this.openCodeGoApiKeyResolver?.() ?? '',
+ apiKeyError: null,
+ apiKeyReadSkipped: false
+ }
+ } catch (error) {
+ // Why: a transient read failure says nothing about the key, so skip it this cycle without blaming it.
+ if (error instanceof ApiKeyFileUnreadableError) {
+ return { ...config, apiKey: '', apiKeyError: null, apiKeyReadSkipped: true }
+ }
+ // Why: an unreadable saved key is treated as absent so the cookie and OpenCode's own key still run.
+ return {
+ ...config,
+ apiKey: '',
+ apiKeyError:
+ 'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.',
+ apiKeyReadSkipped: false
+ }
+ }
+ }
+
protected resolveMiniMaxConfig(): MiniMaxResolvedConfig {
try {
return {
diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts
index 842a2d022441..f847cbf15097 100644
--- a/src/main/rate-limits/service/service-full-cycle-preparation.ts
+++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts
@@ -84,10 +84,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
? null
: this.getCodexProvenance(codexTarget, codexHomePath)
const codexGeneration = this.codexFetchGeneration
- const openCodeGoConfig = this.openCodeGoConfigResolver?.()
- const cookie = openCodeGoConfig?.sessionCookie ?? ''
- const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
- const openCodeGoApiKey = openCodeGoConfig?.apiKey ?? ''
+ const openCodeGoConfig = this.resolveOpenCodeGoConfig()
+ const cookie = openCodeGoConfig.sessionCookie
+ const workspaceIdOverride = openCodeGoConfig.workspaceIdOverride
+ const openCodeGoApiKey = openCodeGoConfig.apiKey
+ const openCodeGoApiKeyError = openCodeGoConfig.apiKeyError
+ const openCodeGoApiKeyReadSkipped = openCodeGoConfig.apiKeyReadSkipped
const miniMaxConfigResult = this.resolveMiniMaxConfig()
const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
const miniMaxGroupId = miniMaxConfigResult.config.groupId
@@ -104,7 +106,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
const apiKeyFingerprint = openCodeGoApiKey
? createHash('sha256').update(openCodeGoApiKey).digest('hex')
: ''
- const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}`
+ const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}|${openCodeGoApiKeyError ?? ''}`
const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash
if (opencodeConfigChanged) {
this.lastOpencodeConfigHash = currentConfigHash
@@ -236,7 +238,15 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
// Why here: the key can also come from the environment or OpenCode's
// own store, so presence is only known once the fetch resolves it.
onApiKeyResolved: (resolution) => {
- this.openCodeGoApiKeyConfigured = resolution.status === 'found'
+ // Why: a credential change mid-fetch bumps the generation; its stale presence must not win.
+ if (opencodeGeneration !== this.opencodeFetchGeneration) {
+ return
+ }
+ // An undecryptable or briefly unreadable saved key still counts, so the bar stays up.
+ this.openCodeGoApiKeyConfigured =
+ resolution.status === 'found' ||
+ openCodeGoApiKeyError !== null ||
+ openCodeGoApiKeyReadSkipped
},
cookie,
workspaceIdOverride: workspaceIdOverride || undefined,
@@ -258,6 +268,18 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
if (signal.aborted) {
return null
}
+ // Why: the decrypt error only replaces a result with no usage and no diagnosis of its own; a real cookie error stays visible.
+ if (
+ openCodeGoApiKeyError &&
+ opencodeGoResult.status === 'fulfilled' &&
+ opencodeGoResult.value.status === 'unavailable'
+ ) {
+ opencodeGoResult.value = {
+ ...opencodeGoResult.value,
+ error: openCodeGoApiKeyError,
+ status: 'error'
+ }
+ }
return {
claudeTarget,
claudeGeneration,
diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts
index 78f59b281bf8..0ec13020a6e9 100644
--- a/src/main/rate-limits/service/service-state.ts
+++ b/src/main/rate-limits/service/service-state.ts
@@ -103,6 +103,7 @@ export abstract class RateLimitServiceState {
wslDistro: null
}
protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
+ protected openCodeGoApiKeyResolver: (() => string | null) | null = null
protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null
protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts
index c999277f61fc..5bae83b1449c 100644
--- a/src/main/rate-limits/service/service-types.ts
+++ b/src/main/rate-limits/service/service-types.ts
@@ -45,8 +45,15 @@ export type ClaudeAuthPreparationResolver = (
export type OpenCodeGoRateLimitConfig = {
sessionCookie: string
workspaceIdOverride: string
- /** Explicit Orca override; empty means fall back to env and OpenCode's own store. */
+}
+
+export type OpenCodeGoResolvedConfig = OpenCodeGoRateLimitConfig & {
+ /** Explicit Orca override; empty means fall back to OpenCode's own store and env. */
apiKey: string
+ /** Set when the saved override exists but cannot be decrypted. */
+ apiKeyError: string | null
+ /** Set when the saved override exists but a transient read failure skipped it this cycle. */
+ apiKeyReadSkipped: boolean
}
export type MiniMaxRateLimitConfig = {
diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts
index a974c7d9edca..63f32663f734 100644
--- a/src/main/startup/main-process-account-services.ts
+++ b/src/main/startup/main-process-account-services.ts
@@ -16,6 +16,11 @@ import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home
import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store'
import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
+import {
+ hasOpenCodeGoApiKey,
+ readOpenCodeGoApiKey,
+ saveOpenCodeGoApiKey
+} from '../opencode/opencode-go-api-key-store'
import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync'
import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection'
import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection'
@@ -88,6 +93,15 @@ export function initializeMainProcessAccountServices(): void {
void syncAccountRuntimeTargets(updates, settings).catch((error) =>
console.warn('[rate-limits] Failed to apply account runtime target:', error)
)
+ if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) {
+ state.rateLimits?.invalidateOpenCodeGoCredentialState()
+ void state.rateLimits?.refresh().catch((error: unknown) => {
+ console.warn(
+ '[rate-limits] Failed to refresh OpenCode Go usage after a settings change:',
+ error
+ )
+ })
+ }
// Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the
// previous endpoint would otherwise sit in the status bar until the next poll.
if (
@@ -122,14 +136,18 @@ export function initializeMainProcessAccountServices(): void {
agentHookServer.setClaudeStatusLineListener((event) => {
state.rateLimits!.ingestLiveClaudeRateLimits(event)
})
+ store.migrateLegacyOpenCodeGoApiKey({
+ has: hasOpenCodeGoApiKey,
+ read: readOpenCodeGoApiKey,
+ save: saveOpenCodeGoApiKey
+ })
state.rateLimits.setOpenCodeGoConfigResolver(() => {
const settings = store.getSettings()
return {
sessionCookie: settings.opencodeSessionCookie,
- workspaceIdOverride: settings.opencodeWorkspaceId,
- apiKey: settings.opencodeGoApiKey
+ workspaceIdOverride: settings.opencodeWorkspaceId
}
- })
+ }, readOpenCodeGoApiKey)
state.rateLimits.setMiniMaxConfigResolver(() => {
const settings = store.getSettings()
const apiKey = readMiniMaxApiKey() ?? ''
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 6c395100f907..cf5060fab898 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -142,6 +142,11 @@ export type PreloadApi = {
runtime: RuntimeApi['runtime']
runtimeEnvironments: RuntimeApi['runtimeEnvironments']
rateLimits: RateLimitsApi
+ opencodeGoCredentials: {
+ getStatus: () => Promise<{ apiKeyConfigured: boolean }>
+ saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }>
+ clearApiKey: () => Promise<{ apiKeyConfigured: boolean }>
+ }
minimaxCredentials: MinimaxCredentialsApi
zcodePlanCredentials: ZcodePlanCredentialsApi
grokAccounts: GrokAccountsApi
diff --git a/src/preload/api/opencode-go-credentials-bridge.ts b/src/preload/api/opencode-go-credentials-bridge.ts
new file mode 100644
index 000000000000..35e1a668bc16
--- /dev/null
+++ b/src/preload/api/opencode-go-credentials-bridge.ts
@@ -0,0 +1,11 @@
+import { ipcRenderer } from 'electron'
+import type { PreloadApi } from '../api-types'
+
+export const opencodeGoCredentialsApi = {
+ getStatus: (): Promise<{ apiKeyConfigured: boolean }> =>
+ ipcRenderer.invoke('opencodeGoCredentials:getStatus'),
+ saveApiKey: (key: string): Promise<{ apiKeyConfigured: boolean }> =>
+ ipcRenderer.invoke('opencodeGoCredentials:saveApiKey', key),
+ clearApiKey: (): Promise<{ apiKeyConfigured: boolean }> =>
+ ipcRenderer.invoke('opencodeGoCredentials:clearApiKey')
+} satisfies PreloadApi['opencodeGoCredentials']
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a68a39650702..5f64a1e2e351 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -75,6 +75,7 @@ import { nativeChatApi } from './api/native-chat-bridge'
import { runtimeApi } from './api/runtime-bridge'
import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge'
import { rateLimitsApi } from './api/rate-limits-bridge'
+import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge'
import { minimaxCredentialsApi } from './api/minimax-credentials-bridge'
import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge'
import { grokAccountsApi } from './api/grok-accounts-bridge'
@@ -175,6 +176,7 @@ const api = {
runtime: runtimeApi,
runtimeEnvironments: runtimeEnvironmentsApi,
rateLimits: rateLimitsApi,
+ opencodeGoCredentials: opencodeGoCredentialsApi,
minimaxCredentials: minimaxCredentialsApi,
zcodePlanCredentials: zcodePlanCredentialsApi,
grokAccounts: grokAccountsApi,
diff --git a/src/preload/opencode-go-credentials.test.ts b/src/preload/opencode-go-credentials.test.ts
new file mode 100644
index 000000000000..e7c5f6d4bea7
--- /dev/null
+++ b/src/preload/opencode-go-credentials.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest'
+import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge'
+
+const invoke = vi.hoisted(() => vi.fn(async () => ({ apiKeyConfigured: true })))
+vi.mock('electron', () => ({ ipcRenderer: { invoke } }))
+
+describe('OpenCode Go credential bridge', () => {
+ it('exposes only status and write operations', async () => {
+ expect(await opencodeGoCredentialsApi.getStatus()).toEqual({ apiKeyConfigured: true })
+ await opencodeGoCredentialsApi.saveApiKey('fake-key')
+ await opencodeGoCredentialsApi.clearApiKey()
+ expect(invoke.mock.calls).toEqual([
+ ['opencodeGoCredentials:getStatus'],
+ ['opencodeGoCredentials:saveApiKey', 'fake-key'],
+ ['opencodeGoCredentials:clearApiKey']
+ ])
+ expect(Object.keys(opencodeGoCredentialsApi)).toEqual([
+ 'getStatus',
+ 'saveApiKey',
+ 'clearApiKey'
+ ])
+ })
+})
diff --git a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx
index 89161b687efa..4f225e89ba9c 100644
--- a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx
@@ -83,6 +83,11 @@ beforeEach(() => {
fake.cursorUsage = { updatedAt: 0 }
Object.assign(window, {
api: {
+ opencodeGoCredentials: {
+ getStatus: vi.fn(async () => ({ apiKeyConfigured: false })),
+ saveApiKey: vi.fn(async () => ({ apiKeyConfigured: true })),
+ clearApiKey: vi.fn(async () => ({ apiKeyConfigured: false }))
+ },
minimaxCredentials: {
getStatus: vi.fn(async () => ({ cookieConfigured: false, apiKeyConfigured: false }))
},
diff --git a/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts
new file mode 100644
index 000000000000..63d5665defda
--- /dev/null
+++ b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts
@@ -0,0 +1,47 @@
+// @vitest-environment happy-dom
+import { createElement } from 'react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { OpenCodeGoCredentials } from './accounts-pane-opencode-credentials'
+
+vi.mock('../../store', () => ({
+ useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
+ selector({ settingsSearchQuery: '' })
+}))
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('OpenCode Go credentials setting', () => {
+ it('shows saved status without reading a key, saves a draft, and clears it', async () => {
+ const getStatus = vi.fn(async () => ({ apiKeyConfigured: true }))
+ const saveApiKey = vi.fn(async () => ({ apiKeyConfigured: true }))
+ const clearApiKey = vi.fn(async () => ({ apiKeyConfigured: false }))
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ opencodeGoCredentials: { getStatus, saveApiKey, clearApiKey }
+ }
+ })
+ const onSaved = vi.fn()
+ render(createElement(OpenCodeGoCredentials, { onSaved }))
+ await screen.findByText('Saved')
+ const input = screen.getByLabelText('OpenCode Go API key')
+ expect(input).toBeInstanceOf(HTMLInputElement)
+ if (!(input instanceof HTMLInputElement)) {
+ throw new Error('Missing credential input')
+ }
+ expect(input.value).toBe('')
+ fireEvent.change(input, { target: { value: 'fake-new-key' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
+ await waitFor(() => expect(saveApiKey).toHaveBeenCalledWith('fake-new-key'))
+ await waitFor(() => expect(input.value).toBe(''))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
+ await screen.findByText('Not saved')
+ expect(clearApiKey).toHaveBeenCalledOnce()
+ expect(onSaved).toHaveBeenCalledTimes(2)
+ expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx
new file mode 100644
index 000000000000..efddf3c02f3f
--- /dev/null
+++ b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { SearchableSetting } from './SearchableSetting'
+
+export function OpenCodeGoCredentials({ onSaved }: { onSaved: () => void }): React.JSX.Element {
+ const [draft, setDraft] = useState('')
+ const [configured, setConfigured] = useState(false)
+ const [busy, setBusy] = useState(false)
+ useEffect(() => {
+ let active = true
+ void window.api.opencodeGoCredentials.getStatus().then(
+ (status) => {
+ if (active) {
+ setConfigured(status.apiKeyConfigured)
+ }
+ },
+ () => console.error('Failed to load OpenCode Go credential status')
+ )
+ return () => {
+ active = false
+ }
+ }, [])
+
+ const updateCredential = async (clear: boolean): Promise => {
+ setBusy(true)
+ try {
+ const status = clear
+ ? await window.api.opencodeGoCredentials.clearApiKey()
+ : await window.api.opencodeGoCredentials.saveApiKey(draft.trim())
+ setConfigured(status.apiKeyConfigured)
+ setDraft('')
+ onSaved()
+ } catch {
+ toast.error(translate('sessionHistory.settings.saveError', 'Could not save. Try again.'))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {translate(
+ 'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
+ 'OpenCode Go API key'
+ )}
+
+
+ {configured
+ ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved')
+ : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')}
+
+
+
+ setDraft(event.target.value)}
+ placeholder={translate(
+ 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder',
+ 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY'
+ )}
+ spellCheck={false}
+ className="flex-1"
+ />
+ void updateCredential(false)}
+ >
+ {configured
+ ? translate('auto.components.settings.AccountsPane.f38b9cc4bd', 'Replace')
+ : translate('auto.components.settings.AccountsPane.590a3130f9', 'Save')}
+
+ {configured && (
+ void updateCredential(true)}
+ >
+ {translate('auto.components.settings.AccountsPane.b398b834c9', 'Clear')}
+
+ )}
+
+
+ {translate(
+ 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help',
+ 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.'
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx
index 4111bfe10622..388a9945e84e 100644
--- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx
+++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx
@@ -5,6 +5,7 @@ import { Switch } from '../ui/switch'
import { GeminiIcon, OpenCodeGoIcon } from '../status-bar/icons'
import { SearchableSetting } from './SearchableSetting'
import type { AccountsPaneSectionModel } from './accounts-pane-types'
+import { OpenCodeGoCredentials } from './accounts-pane-opencode-credentials'
import { DebouncedSettingsTextInput } from './DebouncedSettingsTextInput'
export function renderGeminiAccountsSection(model: AccountsPaneSectionModel): React.JSX.Element {
@@ -95,58 +96,7 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
-
-
- {translate(
- 'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
- 'OpenCode Go API key'
- )}
-
-
- recordOpenCodeSettingEdit('apiKey')}
- commit={(opencodeGoApiKey) => updateSettings({ opencodeGoApiKey })}
- placeholder={translate(
- 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder',
- 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY'
- )}
- spellCheck={false}
- className="flex-1 text-xs"
- />
- {settings.opencodeGoApiKey && (
- {
- recordFeatureInteraction('usage-tracking')
- updateSettings({ opencodeGoApiKey: '' })
- }}
- className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
- >
- {translate('auto.components.settings.AccountsPane.b398b834c9', 'Clear')}
-
- )}
-
-
- {translate(
- 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help',
- 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.'
- )}
-
-
+ recordOpenCodeSettingEdit('apiKey')} />
['curs
}
}
+export function createOpenCodeGoCredentialsApi(): PreloadApi['opencodeGoCredentials'] {
+ const notConfigured = { apiKeyConfigured: false }
+ return {
+ getStatus: () => Promise.resolve(notConfigured),
+ saveApiKey: () =>
+ Promise.reject(new Error('OpenCode Go key storage is only available in the desktop app.')),
+ clearApiKey: () => Promise.resolve(notConfigured)
+ }
+}
+
export function createGrokAccountsApi(): NonNullable['grokAccounts']> {
const unsigned = {
signedIn: false,
diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts
index 8809047fba20..0eea8ecc3c82 100644
--- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts
+++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts
@@ -155,6 +155,17 @@ describe('web MiniMax preload API', () => {
vi.unstubAllGlobals()
})
+ it('keeps OpenCode Go credential operations local to the desktop', async () => {
+ const { api } = await installApi('Linux')
+ await expect(api.opencodeGoCredentials.getStatus()).resolves.toEqual({
+ apiKeyConfigured: false
+ })
+ await expect(api.opencodeGoCredentials.saveApiKey('fake-key')).rejects.toThrow(/desktop app/i)
+ await expect(api.opencodeGoCredentials.clearApiKey()).resolves.toEqual({
+ apiKeyConfigured: false
+ })
+ })
+
it('exposes desktop-only MiniMax credential reads as unconfigured and rejects saves', async () => {
const { api } = await installApi('Linux')
diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts
index 9ec74fb2d7ee..2e67ca4391c7 100644
--- a/src/renderer/src/web/web-preload-api-composition.test.ts
+++ b/src/renderer/src/web/web-preload-api-composition.test.ts
@@ -53,6 +53,7 @@ describe('web preload API composition', () => {
'preflight',
'notifications',
'rateLimits',
+ 'opencodeGoCredentials',
'minimaxCredentials',
'zcodePlanCredentials',
'grokAccounts',
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 7d221c0e0dcb..635e6dd924e5 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -7,6 +7,7 @@ import {
createCursorAccountsApi,
createGrokAccountsApi,
createZcodePlanCredentialsApi,
+ createOpenCodeGoCredentialsApi,
createMiniMaxCredentialsApi
} from './preload-api/web-agent-accounts-api'
import { createWebAgentStatusApi } from './preload-api/web-agent-status-api'
@@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial {
preflight: createPreflightApi(),
notifications: createNotificationsApi(),
rateLimits: createRateLimitsApi(),
+ opencodeGoCredentials: createOpenCodeGoCredentialsApi(),
minimaxCredentials: createMiniMaxCredentialsApi(),
zcodePlanCredentials: createZcodePlanCredentialsApi(),
grokAccounts: createGrokAccountsApi(),
diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts
index cb085cfd7284..80aa334b21c3 100644
--- a/src/shared/default-global-settings.ts
+++ b/src/shared/default-global-settings.ts
@@ -211,7 +211,6 @@ export function buildDefaultSettings(args: {
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
- opencodeGoApiKey: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
minimaxEndpoint: 'overseas',
diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts
index 995b97ebbf48..c078070d298a 100644
--- a/src/shared/global-settings-types.ts
+++ b/src/shared/global-settings-types.ts
@@ -394,8 +394,6 @@ export type GlobalSettings = {
opencodeSessionCookie: string
/** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */
opencodeWorkspaceId: string
- /** Optional OpenCode Go API key override. Takes precedence over OpenCode's own stored key and OPENCODE_API_KEY. Stored encrypted. */
- opencodeGoApiKey: string
/** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */
minimaxGroupId: string
/** Comma-separated MiniMax model names to show in the status bar usage window. */
From 1aeb41bd5dd9ed4c395be346685c4a55ea5fc9f0 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 23:48:47 -0700
Subject: [PATCH 11/11] fix(opencode): enforce deadline through executable
startup
Keep synchronous Windows PATH resolution and child startup within the existing request budget.
---
src/relay/agent-exec-handler.ts | 48 ++++------
.../agent-exec-shell-environment.test.ts | 96 ++++++++++++++++++-
2 files changed, 115 insertions(+), 29 deletions(-)
diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts
index 377aac2fda94..6a4cf295a6b2 100644
--- a/src/relay/agent-exec-handler.ts
+++ b/src/relay/agent-exec-handler.ts
@@ -207,6 +207,10 @@ export class AgentExecHandler {
let child
try {
const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv)
+ if (Date.now() >= deadline) {
+ resolve({ stdout: '', stderr: '', exitCode: null, timedOut: true })
+ return
+ }
child = spawn(spawnCmd, spawnArgs, {
cwd,
env: spawnEnv,
@@ -262,25 +266,10 @@ export class AgentExecHandler {
// that process until timeout because future cancelExec calls reach only
// the newest map entry.
this.inFlightByLane.get(laneKey)?.cancel()
- entry = {
- child,
- cancel: cancelCurrent
- }
+ entry = { child, cancel: cancelCurrent }
this.inFlightByLane.set(laneKey, entry)
}
- timer = setTimeout(
- () => {
- timedOut = true
- // Why: tree-kill because some CLIs trap SIGTERM and continue streaming;
- // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child
- // is not the real node.exe process.
- terminateRelaySubprocessTree(child)
- finish({ stdout, stderr, exitCode: null, timedOut, canceled })
- },
- Math.max(1, deadline - Date.now())
- )
-
const onStdoutData = (chunk: Buffer): void => {
stdoutBytes += chunk.byteLength
if (stdoutBytes > MAX_OUTPUT_BYTES) {
@@ -297,18 +286,10 @@ export class AgentExecHandler {
}
stderr += chunk.toString('utf-8')
}
- const onError = (error: Error): void => {
- finish({
- stdout,
- stderr,
- exitCode: null,
- timedOut,
- spawnError: error.message
- })
- }
- const onClose = (code: number | null): void => {
+ const onError = (error: Error): void =>
+ finish({ stdout, stderr, exitCode: null, timedOut, spawnError: error.message })
+ const onClose = (code: number | null): void =>
finish({ stdout, stderr, exitCode: code, timedOut, canceled })
- }
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
@@ -320,6 +301,19 @@ export class AgentExecHandler {
child.off('close', onClose)
}
+ const expireCurrent = (): void => {
+ timedOut = true
+ // Why: wrappers and signal-trapping CLIs require terminating the whole tree.
+ terminateRelaySubprocessTree(child)
+ finish({ stdout, stderr, exitCode: null, timedOut, canceled })
+ }
+ const remainingTimeoutMs = deadline - Date.now()
+ if (remainingTimeoutMs <= 0) {
+ expireCurrent()
+ return
+ }
+ timer = setTimeout(expireCurrent, remainingTimeoutMs)
+
if (context?.signal) {
if (context.signal.aborted) {
cancelCurrent()
diff --git a/src/relay/agent-exec-shell-environment.test.ts b/src/relay/agent-exec-shell-environment.test.ts
index 1c51a563fec9..a369251635ac 100644
--- a/src/relay/agent-exec-shell-environment.test.ts
+++ b/src/relay/agent-exec-shell-environment.test.ts
@@ -1,4 +1,6 @@
-import { spawn } from 'node:child_process'
+import { execFile, spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import type * as FileSystem from 'node:fs'
import type * as ChildProcess from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
@@ -6,12 +8,18 @@ import { createFakeChild, createHandlers, requestContext } from './agent-exec-ha
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal()),
- spawn: vi.fn()
+ spawn: vi.fn(),
+ execFile: vi.fn()
}))
vi.mock('../main/startup/login-shell-environment', () => ({
resolveLoginShellEnvironment: vi.fn()
}))
+vi.mock('node:fs', async (importOriginal) => {
+ const original = await importOriginal()
+ return { ...original, existsSync: vi.fn(original.existsSync) }
+})
+
describe('relay headless generation shell environment', () => {
afterEach(() => {
vi.useRealTimers()
@@ -262,3 +270,87 @@ describe('relay deadline boundaries and profile failures', () => {
).resolves.toEqual({ canceled: false })
})
})
+
+describe('relay deadline covers synchronous command startup', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.mocked(existsSync).mockReset()
+ vi.clearAllMocks()
+ })
+
+ it('does not spawn after a Windows PATH lookup exhausts the request budget', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ vi.mocked(resolveLoginShellEnvironment).mockImplementation(() => {
+ vi.setSystemTime(999)
+ return Promise.resolve({ PATH: 'C:\\slow-network-bin' })
+ })
+ vi.mocked(existsSync).mockImplementation(() => {
+ vi.setSystemTime(1_001)
+ return false
+ })
+ const child = createFakeChild()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
+ vi.mocked(spawn).mockReturnValue(child as never)
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ try {
+ const pending = createHandlers().get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: 'C:\\repo', shell: true, timeoutMs: 1_000 },
+ requestContext()
+ )
+ await vi.advanceTimersByTimeAsync(0)
+ try {
+ expect(existsSync).toHaveBeenCalled()
+ expect(spawn).not.toHaveBeenCalled()
+ await expect(pending).resolves.toMatchObject({ timedOut: true, exitCode: null })
+ } finally {
+ child.emit('close', 0)
+ await pending
+ }
+ } finally {
+ Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
+ }
+ })
+
+ it('times out immediately when spawning consumes the remaining request budget', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ vi.mocked(existsSync).mockReturnValue(false)
+ const child = createFakeChild()
+ vi.mocked(spawn).mockImplementation(() => {
+ vi.setSystemTime(1_001)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
+ return child as never
+ })
+ const handlers = createHandlers()
+ const pending = handlers.get('agent.execNonInteractive')?.(
+ { binary: 'opencode', args: ['run'], cwd: '/repo', timeoutMs: 1_000 },
+ requestContext()
+ )
+ let result: unknown
+ void pending?.then((value) => {
+ result = value
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ try {
+ expect(spawn).toHaveBeenCalledTimes(1)
+ expect(result).toMatchObject({ timedOut: true, exitCode: null })
+ if (process.platform === 'win32') {
+ expect(execFile).toHaveBeenCalledWith(
+ 'taskkill',
+ ['/pid', String(child.pid), '/T', '/F'],
+ expect.any(Function)
+ )
+ } else {
+ expect(child.kill).toHaveBeenCalled()
+ }
+ await expect(
+ handlers.get('agent.cancelExec')?.({ cwd: '/repo' }, requestContext())
+ ).resolves.toEqual({ canceled: false })
+ } finally {
+ child.emit('close', 0)
+ await pending
+ }
+ })
+})