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/.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/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/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
+}
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/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/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.
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 {
})
})
+ it.each(['opencode', 'opencode2'])('admits %s only on the execution host', (agent) => {
+ const tab = {
+ type: 'terminal',
+ launchAgent: agent,
+ agentStatus: status({
+ providerSession: { key: 'session_id', id: 'real-session' }
+ })
+ }
+ expect(resolveMobileNativeChat(tab, true)).toEqual({
+ agent,
+ sessionId: 'real-session',
+ transcriptPath: null
+ })
+ expect(resolveMobileNativeChat(tab, false)).toBeNull()
+ })
+
it('returns null for unsupported agents', () => {
expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull()
})
@@ -166,3 +183,17 @@ describe('resolveMobileNativeChat', () => {
expect(canShowMobileNativeChat(null)).toBe(false)
})
})
+
+it('resolves folder readability from the serving host catalog and rejects Model-A SSH', () => {
+ const read = (connectionId: unknown) =>
+ isMobileFolderNativeChatReadable(
+ {
+ folderWorkspaces: [{ id: 'one', connectionId }]
+ },
+ 'folder:one'
+ )
+ expect(read(null)).toBe(true)
+ expect(read('ssh:box')).toBe(false)
+ expect(isMobileFolderNativeChatReadable({ folderWorkspaces: [] }, 'folder:one')).toBe(false)
+ expect(isMobileFolderNativeChatReadable(null, 'folder:one')).toBe(false)
+})
diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts
index c04f5ec72dc1..1ad104f5c2d1 100644
--- a/mobile/src/session/mobile-native-chat-eligibility.ts
+++ b/mobile/src/session/mobile-native-chat-eligibility.ts
@@ -96,3 +96,26 @@ export function resolveMobileNativeChatFileSessionId(
}
return null
}
+
+export function isMobileFolderNativeChatReadable(value: unknown, worktreeId: string): boolean {
+ if (
+ !value ||
+ typeof value !== 'object' ||
+ !('folderWorkspaces' in value) ||
+ !Array.isArray(value.folderWorkspaces)
+ ) {
+ return false
+ }
+ const id = worktreeId.slice('folder:'.length)
+ const workspace = value.folderWorkspaces.find(
+ (row: unknown) => row !== null && typeof row === 'object' && 'id' in row && row.id === id
+ )
+ if (!workspace || typeof workspace !== 'object') {
+ return false
+ }
+ const connectionId = 'connectionId' in workspace ? workspace.connectionId : null
+ return (
+ connectionId === null ||
+ (typeof connectionId === 'string' && isMobileNativeChatTranscriptReadable(connectionId))
+ )
+}
diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts
index 4744c394756f..3062e35c238a 100644
--- a/mobile/src/session/mobile-native-chat-permission.test.ts
+++ b/mobile/src/session/mobile-native-chat-permission.test.ts
@@ -142,3 +142,10 @@ describe('parseApprovalFromStatus', () => {
expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull()
})
})
+
+for (const agent of ['opencode', 'opencode2']) {
+ it(`${agent} approves the captured default selector with Enter`, () => {
+ const card = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'shell' } }), agent)
+ expect(card?.options[0].send).toBe('\r')
+ })
+}
diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts
index 72650b2dd225..220599d73bdf 100644
--- a/mobile/src/session/mobile-native-chat-permission.ts
+++ b/mobile/src/session/mobile-native-chat-permission.ts
@@ -1,3 +1,4 @@
+import { nativeChatApprovalAcceptKey } from '../../../src/shared/native-chat-agent-support'
import type {
AgentJournalApprovalMatchedAskRule,
AgentJournalApprovalSubject
@@ -37,7 +38,8 @@ const ESCAPE = String.fromCharCode(27)
* detectAgentPermission still takes precedence when it can read the real numbered
* options from the prompt text. */
export function parseApprovalFromStatus(
- interactivePrompt: string | undefined | null
+ interactivePrompt: string | undefined | null,
+ agent?: string
): MobileChatPermission | null {
if (!interactivePrompt) {
return null
@@ -64,7 +66,7 @@ export function parseApprovalFromStatus(
title: `Allow ${tool}?`,
detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined,
options: [
- { label: 'Allow', send: '1' },
+ { label: 'Allow', send: nativeChatApprovalAcceptKey(agent) },
{ label: 'Deny', send: ESCAPE }
]
}
diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts
index 394c5555e2f0..634d42f69458 100644
--- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts
@@ -108,50 +108,58 @@ describe('useMobileNativeChatAnswerSend', () => {
})
}
- it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => {
- const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
- await mount({ sendRequest } as unknown as RpcClient, vi.fn())
-
- // Spaces is option 2 — the STA-1860 case where label text committed Tabs.
- await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
- expect(sendRequest).toHaveBeenCalledTimes(1)
- expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
- })
-
- it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => {
- const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
- await mount({ sendRequest } as unknown as RpcClient, vi.fn())
-
- const prompt: AskPrompt = {
- questions: [
- {
- question: 'Pick fruits',
- multiSelect: true,
- options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }]
- }
- ]
+ it.each(['claude', 'opencode', 'opencode2'] as const)(
+ '%s single-select: sends the picked option number without a trailing Enter',
+ async (agent) => {
+ const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture exercises only the scripted sendRequest port.
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn(), agent)
+
+ // Spaces is option 2 — the STA-1860 case where label text committed Tabs.
+ await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
}
- let result: Promise | undefined
- await act(async () => {
- result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }])
- })
- expect(sendRequest).toHaveBeenCalledTimes(1)
- expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
+ )
+
+ it.each(['claude', 'opencode', 'opencode2'] as const)(
+ '%s multi-select: toggles option numbers and confirms in paced steps',
+ async (agent) => {
+ const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture exercises only the scripted sendRequest port.
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn(), agent)
+
+ const prompt: AskPrompt = {
+ questions: [
+ {
+ question: 'Pick fruits',
+ multiSelect: true,
+ options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }]
+ }
+ ]
+ }
+ let result: Promise | undefined
+ await act(async () => {
+ result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }])
+ })
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
- await act(async () => {
- await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)
- })
- expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false })
- await act(async () => {
- await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)
- })
- expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false })
- await act(async () => {
- await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)
- })
- await expect(result).resolves.toBe(true)
- expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false })
- })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)
+ })
+ expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)
+ })
+ expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)
+ })
+ await expect(result).resolves.toBe(true)
+ expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false })
+ }
+ )
it('multi-question: option numbers auto-advance, one final submit Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts
new file mode 100644
index 000000000000..0220e09c6655
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts
@@ -0,0 +1,76 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
+import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
+
+let renderer: ReactTestRenderer | undefined
+afterEach(() => act(() => renderer?.unmount()))
+
+function fixture() {
+ const sendRequest = vi.fn().mockResolvedValue({
+ id: 'send',
+ ok: true,
+ result: { send: { accepted: true } }
+ })
+ const client: RpcClient = {
+ sendRequest,
+ subscribe: () => () => {},
+ updateTerminalSubscriptionViewport: () => {},
+ getState: () => 'connected',
+ getReconnectAttempt: () => 0,
+ getLastConnectedAt: () => 1,
+ onStateChange: () => () => {},
+ notifyForeground: () => {},
+ close: () => {}
+ }
+ const cancelPending = vi.fn()
+ const onSendError = vi.fn()
+ let cancelAsk: (() => Promise) | undefined
+ function Probe(): null {
+ cancelAsk = useMobileNativeChatCancelAsk({
+ client,
+ enabled: true,
+ handleRef: { current: 'terminal' },
+ deviceTokenRef: { current: 'device' },
+ cancelPending,
+ onSendError
+ })
+ return null
+ }
+ act(() => {
+ renderer = create(createElement(Probe))
+ })
+ return {
+ sendRequest,
+ cancelPending,
+ onSendError,
+ cancel: () => {
+ if (!cancelAsk) {
+ throw new Error('Cancel hook did not mount')
+ }
+ return cancelAsk()
+ }
+ }
+}
+
+describe('mobile question rejection', () => {
+ it('cancels pending answer writes then sends exactly one Escape without submitting', async () => {
+ const f = fixture()
+ await expect(f.cancel()).resolves.toBe(true)
+ expect(f.cancelPending).toHaveBeenCalledOnce()
+ const sends = f.sendRequest.mock.calls.filter(([method]) => method === 'terminal.send')
+ expect(sends).toHaveLength(1)
+ expect(sends[0]?.[1]).toMatchObject({ terminal: 'terminal', text: '\x1b', enter: false })
+ expect(f.onSendError).not.toHaveBeenCalled()
+ })
+
+ it('reports ambiguous rejection delivery without retrying into a changed selector', async () => {
+ const f = fixture()
+ f.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('connection lost')))
+ await expect(f.cancel()).resolves.toBe(false)
+ expect(f.sendRequest).toHaveBeenCalledOnce()
+ expect(f.onSendError).toHaveBeenCalledWith('Cancel unconfirmed — check chat before retrying')
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts
index 905b92155e6a..a82d16ade68a 100644
--- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts
@@ -39,6 +39,39 @@ function permissionFor(status: Partial | null): unknown {
}
describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
+ it.each(['opencode', 'opencode2'])(
+ 'keeps the %s approval event authoritative over numbered assistant prose',
+ (agentType) => {
+ expect(
+ permissionFor({
+ state: 'waiting',
+ agentType,
+ interactivePrompt: APPROVAL,
+ lastAssistantMessage: 'Allow this Bash command?\n1. Yes\n2. No'
+ })
+ ).toMatchObject({
+ title: 'Allow Bash?',
+ options: [
+ { label: 'Allow', send: '\r' },
+ { label: 'Deny', send: '\x1b' }
+ ]
+ })
+ }
+ )
+
+ it.each(['opencode', 'opencode2'])(
+ 'does not mistake %s question text for an approval',
+ (agentType) => {
+ const prompts = promptsFor({
+ state: 'waiting',
+ agentType,
+ interactivePrompt: ASK,
+ lastAssistantMessage: 'Allow this Bash command?\n1. Yes\n2. No'
+ })
+ expect(prompts.permission).toBeNull()
+ expect(prompts.ask?.questions[0]?.question).toBe('Which path?')
+ }
+ )
it('renders no approval card while the agent is working', () => {
expect(permissionFor({ state: 'working', interactivePrompt: APPROVAL })).toBeNull()
})
diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts
index 35acecbe844d..58a17e77f996 100644
--- a/mobile/src/session/use-mobile-native-chat-prompts.ts
+++ b/mobile/src/session/use-mobile-native-chat-prompts.ts
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import { resolveNativeChatTranscriptAgent } from '../../../src/shared/native-chat-agent-support'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
@@ -23,17 +24,20 @@ export function useMobileNativeChatPrompts(args: {
}): MobileNativeChatPrompts {
const { enabled, status, messages, transcriptLoading } = args
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
+ const openCode = resolveNativeChatTranscriptAgent(status?.agentType) === 'opencode'
// Both permission paths sit inside the paused gate: an approval envelope can
// outlive its answer (the host keeps it sticky), so only a waiting/blocked
// agent may surface it — never a working or done one (STA-3144).
const permission =
blocked && status
- ? (detectAgentPermission({
- state: status.state,
- lastAssistantMessage: status.lastAssistantMessage,
- toolName: status.toolName,
- toolInput: status.toolInput
- }) ?? parseApprovalFromStatus(status.interactivePrompt))
+ ? openCode
+ ? parseApprovalFromStatus(status.interactivePrompt, status.agentType)
+ : (detectAgentPermission({
+ state: status.state,
+ lastAssistantMessage: status.lastAssistantMessage,
+ toolName: status.toolName,
+ toolInput: status.toolInput
+ }) ?? parseApprovalFromStatus(status.interactivePrompt, status.agentType))
: null
const question =
blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null
diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts
index 167e70f832f1..c8c67f62119d 100644
--- a/mobile/src/session/use-mobile-native-chat-readability.ts
+++ b/mobile/src/session/use-mobile-native-chat-readability.ts
@@ -5,7 +5,11 @@ import {
type MobileRuntimeRepoSummary
} from './mobile-session-read-operations'
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
-import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility'
+import { resumeFolderWorkspaceListRead } from '../agent-history/mobile-agent-history-operations'
+import {
+ isMobileFolderNativeChatReadable,
+ isMobileNativeChatTranscriptReadable
+} from './mobile-native-chat-eligibility'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean }
@@ -30,6 +34,29 @@ export function useMobileNativeChatReadability(
setState({ client, worktreeId, readable: false })
return
}
+ if (worktreeId.startsWith('folder:')) {
+ void resumeFolderWorkspaceListRead
+ .request(client)
+ .then((response) => {
+ if (!active) {
+ return
+ }
+ const result = resumeFolderWorkspaceListRead.interpret(response)
+ setState({
+ client,
+ worktreeId,
+ readable: result.accepted && isMobileFolderNativeChatReadable(result.value, worktreeId)
+ })
+ })
+ .catch(() => {
+ if (active) {
+ setState({ client, worktreeId, readable: false })
+ }
+ })
+ return () => {
+ active = false
+ }
+ }
void nativeChatRepoListRead
.request(client)
.then((response) => {
diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts
index 3b601ec2f682..4ba71af0b78f 100644
--- a/mobile/src/tasks/mobile-tui-agents.ts
+++ b/mobile/src/tasks/mobile-tui-agents.ts
@@ -28,6 +28,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: 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/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-opencode-native-page-limit.test.ts b/src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts
new file mode 100644
index 000000000000..56664bf9abfe
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest'
+
+const readNativeChat = vi.hoisted(() =>
+ vi.fn(async () => ({ items: [], hasMore: false, beforeMessageRowId: null }))
+)
+vi.mock('./session-scanner-opencode-sqlite-worker-client', () => ({
+ OpenCodeSqliteWorkerClient: class {
+ readNativeChat = readNativeChat
+ }
+}))
+vi.mock('./session-scanner-opencode-wsl-client', () => ({
+ openCodeWslPath: (path: string) =>
+ path.startsWith('wsl:') ? { distro: 'Ubuntu', linuxPath: '/home/test/opencode.db' } : null,
+ openCodeWslClient: async () => ({ readNativeChat }),
+ mapOpenCodeWslSession: (value: unknown) => value
+}))
+import { readOpenCodeTranscriptPageViaWorker } from './session-scanner-opencode-sqlite-worker-spawn'
+
+describe.each(['native.db', 'wsl:opencode.db'])('native-page protocol bounds for %s', (dbPath) => {
+ it.each([
+ [10.9, 10],
+ [0, 1],
+ [-1, 1],
+ [0.1, 1],
+ [2401, 2400],
+ [Number.MAX_VALUE, 2400],
+ [Number.MAX_SAFE_INTEGER + 1, 2400],
+ [Number.NaN, 300],
+ [Infinity, 300],
+ [-Infinity, 300]
+ ])(
+ 'normalizes %s to a finite safe page limit of %s before transport',
+ async (limit, expected) => {
+ await readOpenCodeTranscriptPageViaWorker({ dbPath, sessionId: 'session', limit })
+ expect(readNativeChat).toHaveBeenLastCalledWith(
+ {
+ kind: 'native-page',
+ dbPath: dbPath.startsWith('wsl:') ? '/home/test/opencode.db' : dbPath,
+ sessionId: 'session',
+ limit: expected
+ },
+ undefined
+ )
+ }
+ )
+})
diff --git a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts
new file mode 100644
index 000000000000..9e8511967f2e
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts
@@ -0,0 +1,153 @@
+import { build } from 'esbuild'
+import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Worker } from 'node:worker_threads'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import SyncDatabase from '../sqlite/sync-database'
+import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture'
+import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
+import { resolveOpenCodeSqliteWorkerEntryPath } from './session-scanner-opencode-sqlite-worker-spawn'
+import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
+import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows'
+
+const directory = mkdtempSync(join(tmpdir(), 'orca-opencode-native-worker-'))
+const entry = resolveOpenCodeSqliteWorkerEntryPath(directory)
+const v1Path = join(directory, 'v1', 'opencode.db')
+const v2Path = join(directory, 'v2.db')
+
+beforeAll(async () => {
+ await build({
+ entryPoints: ['src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts'],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ target: 'node22',
+ external: ['electron'],
+ outfile: entry,
+ logLevel: 'silent'
+ })
+ writeOpenCodeSqliteDatabase(v1Path, [
+ {
+ id: 'session',
+ turns: ['first', 'second', 'third'].map((text) => ({ role: 'user', parts: [text] }))
+ }
+ ])
+ const db = new SyncDatabase(v2Path)
+ try {
+ db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY);
+ CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT,
+ seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER);
+ INSERT INTO session_v2 VALUES ('session');`)
+ const insert = db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, 1, 1)')
+ for (const [index, text] of ['first', 'second', 'third'].entries()) {
+ insert.run(String(index), 'session', 'user', index + 1, JSON.stringify({ text }))
+ }
+ } finally {
+ db.close()
+ }
+})
+
+afterAll(() => {
+ rmSync(directory, { recursive: true, force: true })
+})
+
+describe('OpenCode reads through the production shared worker', () => {
+ it.each(['v1-message', 'v1-part', 'v2-message'] as const)(
+ 'degrades %s oversize content through the actual worker and retains the next page',
+ async (kind) => {
+ const dbPath = join(directory, `${kind}.db`)
+ const v2 = kind === 'v2-message'
+ copyFileSync(v2 ? v2Path : v1Path, dbPath)
+ const db = new SyncDatabase(dbPath)
+ const table = v2 ? 'session_message' : kind === 'v1-part' ? 'part' : 'message'
+ db.prepare(`UPDATE ${table} SET data = ?, time_updated = 2 WHERE rowid = 2`).run(
+ JSON.stringify({ text: '😀'.repeat(600_000) })
+ )
+ db.close()
+ const client = new OpenCodeSqliteWorkerClient({ workerFactory: () => new Worker(entry) })
+ try {
+ const args = { dbPath, sessionId: 'session', kind: 'native-page' as const, limit: 2 }
+ const page = await client.readNativeChat(args)
+ if (!page || !('items' in page)) {
+ throw new Error('Expected the bounded native page')
+ }
+ expect(page.items.map((item) => item.rowid)).toEqual([2, 3])
+ expect(page.items[0]?.message.blocks).toEqual([
+ { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
+ ])
+ const repeat = await client.readNativeChat(args)
+ expect(repeat).toEqual(page)
+ const older = await client.readNativeChat({
+ ...args,
+ beforeMessageRowId: page.beforeMessageRowId ?? undefined
+ })
+ expect(older).toMatchObject({ items: [{ rowid: 1 }], hasMore: false })
+ } finally {
+ client.dispose()
+ }
+ }
+ )
+
+ it.each([v1Path, v2Path])(
+ 'reads native signals and paginated history from %s',
+ async (dbPath) => {
+ const start = vi.fn(() => new Worker(entry))
+ const client = new OpenCodeSqliteWorkerClient({ workerFactory: start })
+ const args = { dbPath, sessionId: 'session' }
+ try {
+ await expect(
+ client.readNativeChat({ ...args, kind: 'native-signal' })
+ ).resolves.toMatchObject({
+ messageCount: 3,
+ maxMessageRowId: 3
+ })
+ const page = await client.readNativeChat({ ...args, kind: 'native-page', limit: 2 })
+ if (!page || !('items' in page)) {
+ throw new Error('Expected a native transcript page')
+ }
+ expect(page.items.map((item) => item.message.blocks)).toEqual([
+ [{ type: 'text', text: 'second' }],
+ [{ type: 'text', text: 'third' }]
+ ])
+ expect(page.hasMore).toBe(true)
+ const older = await client.readNativeChat({
+ ...args,
+ kind: 'native-page',
+ limit: 2,
+ beforeMessageRowId: page.beforeMessageRowId ?? undefined
+ })
+ if (!older || !('items' in older)) {
+ throw new Error('Expected the older transcript page')
+ }
+ expect(older.items.map((item) => item.message.blocks)).toEqual([
+ [{ type: 'text', text: 'first' }]
+ ])
+ expect(older.hasMore).toBe(false)
+ expect(start).toHaveBeenCalledOnce()
+ } finally {
+ client.dispose()
+ }
+ }
+ )
+
+ it('keeps list, parse and capture supported in the same worker', async () => {
+ const start = vi.fn(() => new Worker(entry))
+ const client = new OpenCodeSqliteWorkerClient({ workerFactory: start })
+ const issues: AiVaultScanIssue[] = []
+ const args = { dbPath: v1Path, sessionId: 'session', platform: process.platform }
+ try {
+ expect(await client.list({ dbPaths: [v1Path], limit: 5, issues })).toHaveLength(1)
+ expect(issues).toEqual([])
+ expect(await client.parse(args)).toMatchObject({ sessionId: 'session' })
+ expect((await client.capture(args)).messages.map((message) => message.text)).toEqual([
+ 'first',
+ 'second',
+ 'third'
+ ])
+ expect(start).toHaveBeenCalledOnce()
+ } finally {
+ client.dispose()
+ }
+ })
+})
diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts
index 5f15f17d7582..45abf16160a4 100644
--- a/src/main/ai-vault/session-scanner-opencode-sources.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sources.ts
@@ -1,9 +1,11 @@
import { basename, dirname, join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
-import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
-import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
+import type { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory'
-import { listOpenCodeDatabases } from '../opencode-usage/opencode-database-discovery'
+import {
+ listOpenCodeDatabases,
+ listOpenCodeDatabasesInDirectory
+} from '../opencode-usage/opencode-database-discovery'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import { listOpenCode2SqliteSessionsViaWorker } from './session-scanner-opencode-sqlite-worker-spawn'
@@ -56,45 +58,26 @@ async function opencodeDbPathsForSource(
if (options.opencodeDbPaths) {
return sourceIndex === 0 ? options.opencodeDbPaths : []
}
+ const onRefusal = (path: string, error: WslTranscriptFsError): void => {
+ recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message })
+ }
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
if (sourceIndex === 0 && options.opencodeStorageDir) {
- return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues)
+ return listOpenCodeDatabasesInDirectory(dirname(storageDir), onRefusal, options.signal)
}
if (sourceIndex === 0) {
- return listOpenCodeDatabases((path, error) => {
- recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message })
- })
+ return listOpenCodeDatabases(onRefusal, undefined, options.signal)
}
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
return wslHomeDir
- ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues)
+ ? listOpenCodeDatabasesInDirectory(
+ join(wslHomeDir, '.local', 'share', 'opencode'),
+ onRefusal,
+ options.signal
+ )
: []
}
-async function listOpenCodeDatabasesInDirectory(
- dataDir: string,
- issues: AiVaultScanIssue[]
-): Promise {
- try {
- const entries = await wslGatedReaddir(dataDir, 'scan')
- return entries
- .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
- .map((entry) => join(dataDir, entry.name))
- .sort()
- } catch (error) {
- // A stalled WSL data dir still degrades to "no databases", but the gap has
- // to be reportable — an empty list otherwise reads as "OpenCode not used".
- if (error instanceof WslTranscriptFsError) {
- recordSessionScanIssue(issues, {
- agent: 'opencode',
- path: dataDir,
- message: error.message
- })
- }
- return []
- }
-}
-
async function discoverOpenCode2Sessions(
storageDir: string,
dbPaths: readonly string[],
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts
index e483f9482e11..d9bc0b3a27c6 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts
@@ -1,3 +1,7 @@
+import {
+ readOpenCodeTranscriptPage,
+ readOpenCodeTranscriptSignal
+} from '../native-chat/transcript-opencode-sqlite-query'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
@@ -18,6 +22,13 @@ export async function handleOpenCodeSqliteRequest(
request: OpenCodeSqliteWorkerRequest
): Promise {
try {
+ if (request.kind === 'native-page' || request.kind === 'native-signal') {
+ const value =
+ request.kind === 'native-signal'
+ ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId)
+ : readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 })
+ return { id: request.id, ok: true, value }
+ }
if (request.kind === 'list') {
const issues: AiVaultScanIssue[] = []
const candidates =
@@ -45,6 +56,9 @@ export async function handleOpenCodeSqliteRequest(
})
return { id: request.id, ok: true, value: capture }
}
+ if (request.kind !== 'parse') {
+ throw new Error('Unsupported OpenCode SQLite request')
+ }
const parse = async () =>
request.agent === 'opencode2'
? await parseOpenCode2SqliteSession({
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts
index 32f807cb1d9f..168a7ee66201 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts
@@ -35,7 +35,7 @@ export function openCodeBusyTimeoutMs(dbPath: string): number {
return isWslUncPath(dbPath) ? 0 : OPENCODE_SQLITE_BUSY_TIMEOUT_MS
}
-function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase {
+export function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, {
readonly: true,
fileMustExist: true,
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts
index db9c60f0b703..c46fcd195e2a 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts
@@ -16,6 +16,17 @@ describe('splitOpenCodeSqliteCandidate', () => {
expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' })
})
+ it('preserves Windows host paths including hashes in directory names', () => {
+ const dbPath = String.raw`C:\Users\reader\project#one\opencode.db`
+ expect(splitOpenCodeSqliteCandidate(`${dbPath}#ses_abc`)).toEqual({
+ dbPath,
+ sessionId: 'ses_abc'
+ })
+ expect(
+ splitOpenCodeSqliteCandidate(String.raw`C:\Users\opencode.db\notes.txt#ses_abc`)
+ ).toBeNull()
+ })
+
it('rejects a path whose db basename is not opencode*.db', () => {
expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull()
expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull()
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts
index 77c0e464154b..cc9d93781dfb 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts
@@ -1,61 +1,5 @@
-import { basename } from 'node:path'
-
-// Why: keep the synthetic candidate-path helpers separate from the SQLite
-// discovery/parser so both the scanner and the agent-parser dispatcher can
-// import them without pulling in the SyncDatabase dependency.
-
-const OPENCODE_SQLITE_PATH_SEPARATOR = '#'
-
-/**
- * Build a synthetic candidate path that encodes the SQLite DB path and session ID
- * as `#`. Used by the discovery layer so SQLite-backed
- * sessions flow through the same FileWithMtime pipeline as file-backed ones.
- * @param dbPath - Absolute path to the opencode.db file.
- * @param sessionId - The OpenCode session ID (primary key in the session table).
- * @returns The synthetic candidate path string.
- */
-export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string {
- return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}`
-}
-
-/**
- * Parse a synthetic candidate path back into its DB path and session ID parts.
- * Validates that the DB basename matches `opencode*.db` so real filesystem paths
- * that happen to contain `#` are never misrouted to the SQLite parser.
- * @param candidatePath - The synthetic path to parse.
- * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise.
- */
-export function splitOpenCodeSqliteCandidate(
- candidatePath: string,
- agent: 'opencode' | 'opencode2' | 'zcode' = 'opencode'
-): { dbPath: string; sessionId: string } | null {
- const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR)
- if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) {
- return null
- }
- const dbPath = candidatePath.slice(0, separatorIndex)
- const sessionId = candidatePath.slice(separatorIndex + 1)
- if (!dbPath || !sessionId) {
- return null
- }
- // Why: OpenCode DB files are named opencode*.db; reject anything else so we
- // never misroute a real filesystem path that happens to contain '#'.
- const validName =
- agent === 'zcode'
- ? basename(dbPath).toLowerCase() === 'db.sqlite'
- : /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))
- if (!validName) {
- return null
- }
- return { dbPath, sessionId }
-}
-
-/**
- * Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite
- * candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null).
- * @param candidatePath - The path to test.
- * @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise.
- */
-export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean {
- return splitOpenCodeSqliteCandidate(candidatePath) !== null
-}
+export {
+ buildOpenCodeSqliteCandidatePath,
+ splitOpenCodeSqliteCandidate,
+ looksLikeOpenCodeSqliteCandidate
+} from '../../shared/opencode-sqlite-session-path'
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts
index e53d89eeeaba..bcd380bc51e4 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts
@@ -24,6 +24,23 @@ function validRequest(value: unknown): value is OpenCodeSqliteWorkerRequest {
) {
return false
}
+ if (value.kind === 'native-page' || value.kind === 'native-signal') {
+ return (
+ 'dbPath' in value &&
+ typeof value.dbPath === 'string' &&
+ 'sessionId' in value &&
+ typeof value.sessionId === 'string' &&
+ (!('limit' in value) ||
+ (typeof value.limit === 'number' &&
+ Number.isSafeInteger(value.limit) &&
+ value.limit > 0 &&
+ value.limit <= 2400)) &&
+ (!('beforeMessageRowId' in value) ||
+ (typeof value.beforeMessageRowId === 'number' &&
+ Number.isSafeInteger(value.beforeMessageRowId) &&
+ value.beforeMessageRowId >= 0))
+ )
+ }
if (value.kind === 'list') {
return (
'dbPaths' in value &&
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts
index c5f96c030e3b..f303de524434 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts
@@ -2,6 +2,8 @@ import type { WorkerThreadFactory } from '../lazy-worker-thread-host'
import { WorkerThreadRequestQueue } from '../worker-thread-request-queue'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type {
+ OpenCodeNativeChatReadRequest,
+ OpenCodeNativeChatReadValue,
OpenCodeSqliteCaptureValue,
OpenCodeSqliteListValue,
OpenCodeSqliteWorkerRequest,
@@ -223,6 +225,15 @@ export class OpenCodeSqliteWorkerClient {
}
}
+ async readNativeChat(
+ args: Omit,
+ signal?: AbortSignal
+ ): Promise {
+ const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames.
+ return value as OpenCodeNativeChatReadValue
+ }
+
dispose(): void {
this.requests.dispose()
}
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts
index 9c8e42a5c7f4..afee7778e3db 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts
@@ -1,3 +1,7 @@
+import type {
+ OpenCodeTranscriptPage,
+ OpenCodeTranscriptSignal
+} from '../native-chat/transcript-opencode-sqlite-query'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
import type { TranscriptMessage } from './session-transcript-consumers'
@@ -39,10 +43,22 @@ export type OpenCodeSqliteCaptureRequest = {
agent?: 'opencode2' | 'zcode'
}
+export type OpenCodeNativeChatReadRequest = {
+ id: number
+ kind: 'native-page' | 'native-signal'
+ dbPath: string
+ sessionId: string
+ limit?: number
+ beforeMessageRowId?: number
+}
+
+export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null
+
export type OpenCodeSqliteWorkerRequest = (
| OpenCodeSqliteListRequest
| OpenCodeSqliteParseRequest
| OpenCodeSqliteCaptureRequest
+ | OpenCodeNativeChatReadRequest
) & { timeoutMs?: number }
// The list leg returns candidates plus the issues it accumulated; the worker
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts
index ef3285f20016..13b9cd3cd5e8 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
import { Worker } from 'node:worker_threads'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
+import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit'
import type { SessionFileCandidate } from './session-scanner-types'
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
@@ -238,3 +239,48 @@ async function captureForHost(
const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
}
+
+export async function readOpenCodeTranscriptPageViaWorker(
+ args: {
+ dbPath: string
+ sessionId: string
+ limit: number
+ beforeMessageRowId?: number
+ },
+ signal?: AbortSignal
+) {
+ const wsl = openCodeWslPath(args.dbPath)
+ const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient()
+ const value = await client.readNativeChat(
+ {
+ ...args,
+ limit: openCodeTranscriptPageLimit(args.limit),
+ dbPath: wsl?.linuxPath ?? args.dbPath,
+ kind: 'native-page'
+ },
+ signal
+ )
+ if (value !== null && !('items' in value)) {
+ throw new Error('Invalid OpenCode transcript page')
+ }
+ return value
+}
+
+export async function readOpenCodeTranscriptSignalViaWorker(
+ args: {
+ dbPath: string
+ sessionId: string
+ },
+ signal?: AbortSignal
+) {
+ const wsl = openCodeWslPath(args.dbPath)
+ const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient()
+ const value = await client.readNativeChat(
+ { ...args, dbPath: wsl?.linuxPath ?? args.dbPath, kind: 'native-signal' },
+ signal
+ )
+ if (value !== null && !('messageCount' in value)) {
+ throw new Error('Invalid OpenCode transcript signal')
+ }
+ return value
+}
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/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/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/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 {
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/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/native-chat.ts b/src/main/ipc/native-chat.ts
index 9e5058282ef4..02e2928f3c69 100644
--- a/src/main/ipc/native-chat.ts
+++ b/src/main/ipc/native-chat.ts
@@ -1,3 +1,4 @@
+import { DESKTOP_READ_WINDOW } from '../native-chat/transcript-watch-contract'
import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
import type {
AgentType,
@@ -31,7 +32,6 @@ export type NativeChatReadSessionArgs = {
// Why: render and parse only the recent window so long transcripts do not stall
// either the main process or the message list. Pagination raises this limit.
-const DESKTOP_READ_WINDOW = 300
async function readSession(args: NativeChatReadSessionArgs): Promise {
const { agent, sessionId } = args
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 4e67441ea343..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,7 +35,9 @@ const {
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerClaudeAccountHandlersMock,
+ registerOpenCodeGoCredentialsHandlersMock,
registerMiniMaxCredentialsHandlersMock,
+ registerZcodePlanCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerCursorAccountHandlersMock,
registerClipboardHandlersMock,
@@ -102,7 +104,9 @@ const {
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
+ registerOpenCodeGoCredentialsHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
+ registerZcodePlanCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
registerCursorAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
@@ -332,10 +336,18 @@ vi.mock('../claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
+vi.mock('../opencode-go-credentials', () => ({
+ registerOpenCodeGoCredentialsHandlers: registerOpenCodeGoCredentialsHandlersMock
+}))
+
vi.mock('../minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))
+vi.mock('../zcode-plan-credentials', () => ({
+ registerZcodePlanCredentialsHandlers: registerZcodePlanCredentialsHandlersMock
+}))
+
vi.mock('../grok-accounts', () => ({
registerGrokAccountHandlers: registerGrokAccountHandlersMock
}))
@@ -440,7 +452,9 @@ describe('registerCoreHandlers', () => {
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
+ registerOpenCodeGoCredentialsHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
+ registerZcodePlanCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
@@ -539,7 +553,9 @@ describe('registerCoreHandlers', () => {
)
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
+ expect(registerOpenCodeGoCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
+ expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
expect(registerCursorAccountHandlersMock).toHaveBeenCalled()
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts)
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 d3a647cafd71..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,7 +61,9 @@ 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'
import { registerCursorAccountHandlers } from '../cursor-accounts'
import { registerUpdaterHandlers } from '../../window/attach-main-window-services'
@@ -150,7 +152,9 @@ export function registerCoreHandlers(
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
registerClaudeAccountHandlers(claudeAccounts)
+ registerOpenCodeGoCredentialsHandlers(rateLimits)
registerMiniMaxCredentialsHandlers(rateLimits)
+ registerZcodePlanCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerCursorAccountHandlers()
registerRateLimitHandlers(rateLimits, codexAccounts)
diff --git a/src/main/ipc/zcode-plan-credentials.test.ts b/src/main/ipc/zcode-plan-credentials.test.ts
new file mode 100644
index 000000000000..ae675aeb6502
--- /dev/null
+++ b/src/main/ipc/zcode-plan-credentials.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { registerZcodePlanCredentialsHandlers } from './zcode-plan-credentials'
+
+const mocks = vi.hoisted(() => ({
+ handle: vi.fn(),
+ hasKey: vi.fn(() => false),
+ protection: vi.fn<() => 'sealed' | 'plaintext' | null>(() => null),
+ hasCli: vi.fn(() => false),
+ save: vi.fn(),
+ clear: vi.fn()
+}))
+vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
+vi.mock('../zcode/zcode-plan-api-key-store', () => ({
+ hasZcodePlanApiKey: mocks.hasKey,
+ getZcodePlanApiKeyProtection: mocks.protection,
+ saveZcodePlanApiKey: mocks.save,
+ clearZcodePlanApiKey: mocks.clear
+}))
+vi.mock('../rate-limits/zcode-usage-fetcher', () => ({ hasZcodeCliPlanCredentials: mocks.hasCli }))
+
+function handler(channel: string) {
+ const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
+ if (!registration) {
+ throw new Error('Handler missing')
+ }
+ return registration[1]
+}
+
+describe('GLM credential IPC', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.hasKey.mockReturnValue(false)
+ mocks.hasCli.mockReturnValue(false)
+ mocks.protection.mockReturnValue(null)
+ registerZcodePlanCredentialsHandlers(null)
+ })
+
+ it('returns only presence and protection without exposing the key', () => {
+ mocks.hasKey.mockReturnValue(true)
+ mocks.hasCli.mockReturnValue(true)
+ mocks.protection.mockReturnValue('sealed')
+ expect(handler('zcodePlanCredentials:getStatus')()).toEqual({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: true,
+ apiKeyProtection: 'sealed'
+ })
+ })
+
+ it('validates an untyped IPC key before persistence', () => {
+ expect(() => handler('zcodePlanCredentials:saveApiKey')(null, 42)).toThrow('must be a string')
+ expect(mocks.save).not.toHaveBeenCalled()
+ })
+
+ it('saves and removes keys while returning status only', () => {
+ mocks.save.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(true))
+ mocks.clear.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(false))
+ expect(handler('zcodePlanCredentials:saveApiKey')(null, 'synthetic-key')).toEqual({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ })
+ expect(handler('zcodePlanCredentials:clearApiKey')()).toEqual({
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ })
+ })
+})
diff --git a/src/main/ipc/zcode-plan-credentials.ts b/src/main/ipc/zcode-plan-credentials.ts
new file mode 100644
index 000000000000..9923acba3bb4
--- /dev/null
+++ b/src/main/ipc/zcode-plan-credentials.ts
@@ -0,0 +1,49 @@
+import { ipcMain } from 'electron'
+import {
+ clearZcodePlanApiKey,
+ getZcodePlanApiKeyProtection,
+ hasZcodePlanApiKey,
+ saveZcodePlanApiKey
+} from '../zcode/zcode-plan-api-key-store'
+import { hasZcodeCliPlanCredentials } from '../rate-limits/zcode-usage-fetcher'
+import type { RateLimitService } from '../rate-limits/service'
+import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'
+
+function getZcodePlanCredentialsStatus(): ZcodePlanCredentialsStatus {
+ return {
+ apiKeyConfigured: hasZcodePlanApiKey(),
+ zcodeCliConfigured: hasZcodeCliPlanCredentials(),
+ apiKeyProtection: getZcodePlanApiKeyProtection()
+ }
+}
+
+// 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 refreshAfterZcodePlanCredentialChange(
+ rateLimits: RateLimitService | null,
+ action: 'save' | 'clear'
+): void {
+ rateLimits?.invalidateZcodeCredentialState()
+ void rateLimits?.refresh().catch((error: unknown) => {
+ console.error(`[zcode] failed to trigger rate-limit refresh after ${action}:`, error)
+ })
+}
+
+export function registerZcodePlanCredentialsHandlers(rateLimits: RateLimitService | null): void {
+ ipcMain.handle('zcodePlanCredentials:getStatus', () => getZcodePlanCredentialsStatus())
+ ipcMain.handle('zcodePlanCredentials:saveApiKey', (_event, key: string) => {
+ // Validate the IPC argument in the main process; the renderer-declared type
+ // is compile-time only and the value arrives as unknown over IPC.
+ if (typeof key !== 'string') {
+ throw new Error('GLM Coding Plan API key must be a string')
+ }
+ saveZcodePlanApiKey(key)
+ refreshAfterZcodePlanCredentialChange(rateLimits, 'save')
+ return getZcodePlanCredentialsStatus()
+ })
+ ipcMain.handle('zcodePlanCredentials:clearApiKey', () => {
+ clearZcodePlanApiKey()
+ refreshAfterZcodePlanCredentialChange(rateLimits, 'clear')
+ return getZcodePlanCredentialsStatus()
+ })
+}
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/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts
index cf9ff45805cf..ba6a918cc6e4 100644
--- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts
+++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts
@@ -90,7 +90,7 @@ export async function prepareLegacyTranscriptImport(input: {
const options = input.options ?? {}
const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS
const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent)
- if (!transcriptAgent) {
+ if (!transcriptAgent || transcriptAgent === 'opencode') {
return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` }
}
const filePath =
diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts
index 0f7d7c7367f7..08de49763b45 100644
--- a/src/main/native-chat/session-file-resolver.ts
+++ b/src/main/native-chat/session-file-resolver.ts
@@ -101,7 +101,7 @@ export async function resolveSessionFilePath(
): Promise {
signal?.throwIfAborted()
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
- if (!transcriptAgent) {
+ if (!transcriptAgent || transcriptAgent === 'opencode') {
return null
}
// Why: the hook's transcript_path is the exact file the agent is writing, so it
@@ -160,6 +160,9 @@ async function resolveSessionFileById(
options: ResolveSessionFileOptions,
signal?: AbortSignal
): Promise {
+ if (transcriptAgent === 'opencode') {
+ return null
+ }
const trimmedId = sessionId.trim()
if (!trimmedId) {
return null
diff --git a/src/main/native-chat/transcript-opencode-database.test.ts b/src/main/native-chat/transcript-opencode-database.test.ts
new file mode 100644
index 000000000000..daaeca3b6856
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-database.test.ts
@@ -0,0 +1,215 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import type { OpenCodeTranscriptSignal } from './transcript-opencode-sqlite-query'
+import { WslTranscriptFsError } from './wsl-transcript-fs-error'
+
+const mocks = vi.hoisted(() => ({
+ homes: vi.fn(async (): Promise => []),
+ native: vi.fn(async (): Promise => []),
+ directory: vi.fn(
+ async (
+ _path: string,
+ _onRefusal?: (path: string, error: WslTranscriptFsError) => void
+ ): Promise => []
+ ),
+ prepare: vi.fn(async () => []),
+ configure: vi.fn(),
+ wslPath: vi.fn((_path: string): { distro: string; linuxPath: string } | null => null),
+ readSignal: vi.fn(
+ async (
+ _args: { dbPath: string; sessionId: string },
+ _signal?: AbortSignal
+ ): Promise => null
+ )
+}))
+vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: mocks.homes }))
+vi.mock('../opencode-usage/opencode-database-discovery', () => ({
+ listOpenCodeDatabases: mocks.native,
+ listOpenCodeDatabasesInDirectory: mocks.directory,
+ compareOpenCodeClaimPriority: (a: string, b: string) => a.localeCompare(b)
+}))
+vi.mock('../ai-vault/opencode-wsl-runtime-preparation', () => ({
+ prepareOpenCodeWslReaders: mocks.prepare
+}))
+vi.mock('../ai-vault/session-scanner-opencode-wsl-client', () => ({
+ configureOpenCodeWslReaders: mocks.configure,
+ openCodeWslPath: mocks.wslPath
+}))
+vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', () => ({
+ readOpenCodeTranscriptSignalViaWorker: mocks.readSignal,
+ readOpenCodeTranscriptPageViaWorker: vi.fn()
+}))
+import {
+ resolveOpenCodeTranscriptDbPath,
+ openCodeTranscriptDefaultDeps
+} from './transcript-opencode'
+
+afterEach(() => {
+ vi.resetAllMocks()
+ vi.useRealTimers()
+})
+
+describe('OpenCode transcript owning-host database discovery', () => {
+ it.each(['homes', 'directory', 'prepare'] as const)(
+ 'opens a matching native database without waiting for WSL %s',
+ async (stage) => {
+ vi.useFakeTimers()
+ mocks.native.mockResolvedValue(['native.db'])
+ mocks.homes.mockResolvedValue(['wsl-home'])
+ mocks[stage].mockImplementation(() => new Promise(() => {}))
+ mocks.readSignal.mockResolvedValue({
+ messageCount: 1,
+ partCount: 1,
+ maxMessageRowId: 1,
+ maxPartTimeUpdated: 1
+ })
+ const settled = vi.fn()
+ void resolveOpenCodeTranscriptDbPath('session').then(settled, settled)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(settled).toHaveBeenCalledWith('native.db')
+ expect(mocks.readSignal).toHaveBeenCalledOnce()
+ expect(mocks.prepare).not.toHaveBeenCalled()
+ }
+ )
+
+ it('finds a WSL-only session using the same running homes and database discovery as Vault', async () => {
+ const home = 'wsl-home'
+ const dbPath = join(home, '.local', 'share', 'opencode', 'opencode.db')
+ mocks.homes.mockResolvedValue([home])
+ mocks.directory.mockResolvedValue([dbPath])
+ mocks.readSignal.mockResolvedValue(null)
+ mocks.readSignal.mockImplementation(async () => ({
+ messageCount: 1,
+ partCount: 1,
+ maxMessageRowId: 1,
+ maxPartTimeUpdated: 1
+ }))
+ await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe(dbPath)
+ expect(mocks.directory).toHaveBeenCalledWith(
+ join(home, '.local', 'share', 'opencode'),
+ expect.any(Function),
+ expect.any(AbortSignal)
+ )
+ expect(mocks.prepare).toHaveBeenCalledWith([home])
+ expect(mocks.configure).toHaveBeenCalledOnce()
+ expect(mocks.readSignal).toHaveBeenCalledWith(
+ { dbPath, sessionId: 'session' },
+ expect.any(AbortSignal)
+ )
+ })
+
+ it('caps candidate probes when a session is missing', async () => {
+ mocks.native.mockResolvedValue(Array.from({ length: 40 }, (_, index) => `db-${index}`))
+ await expect(resolveOpenCodeTranscriptDbPath('missing')).resolves.toBe(null)
+ expect(mocks.readSignal).toHaveBeenCalledTimes(32)
+ })
+
+ it.each(['database is locked', 'file is not a database', 'Preparing the WSL SQLite reader…'])(
+ 'continues to the next candidate after %s',
+ async (message) => {
+ mocks.native.mockResolvedValue(['a.db', 'b.db'])
+ mocks.readSignal.mockRejectedValueOnce(new Error(message)).mockResolvedValueOnce({
+ messageCount: 1,
+ partCount: 1,
+ maxMessageRowId: 1,
+ maxPartTimeUpdated: 1
+ })
+ await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe('b.db')
+ expect(mocks.readSignal).toHaveBeenCalledTimes(2)
+ }
+ )
+
+ it('retains the first probe failure when no candidate has the session', async () => {
+ const refusal = new Error('database is locked')
+ mocks.native.mockResolvedValue(['a.db', 'b.db'])
+ mocks.readSignal.mockRejectedValueOnce(refusal).mockResolvedValueOnce(null)
+ await expect(resolveOpenCodeTranscriptDbPath('session')).rejects.toBe(refusal)
+ expect(mocks.readSignal).toHaveBeenCalledTimes(2)
+ })
+
+ it('continues past an unprepared WSL reader to another owning-host candidate', async () => {
+ const home = 'wsl-home'
+ const failedPath = join(home, '.local', 'share', 'opencode', 'opencode-a.db')
+ const matchingPath = join(home, '.local', 'share', 'opencode', 'opencode-b.db')
+ mocks.homes.mockResolvedValue([home])
+ mocks.directory.mockResolvedValue([failedPath, matchingPath])
+ mocks.readSignal
+ .mockRejectedValueOnce(new Error('Preparing the WSL SQLite reader…'))
+ .mockResolvedValueOnce({
+ messageCount: 1,
+ partCount: 1,
+ maxMessageRowId: 1,
+ maxPartTimeUpdated: 1
+ })
+ await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe(matchingPath)
+ expect(mocks.prepare).toHaveBeenCalledOnce()
+ expect(mocks.configure).toHaveBeenCalledOnce()
+ expect(mocks.readSignal).toHaveBeenCalledTimes(2)
+ })
+
+ it('caps failed probes as well as missing-session probes', async () => {
+ const error = new Error('file is not a database')
+ mocks.native.mockResolvedValue(Array.from({ length: 40 }, (_, index) => `db-${index}`))
+ mocks.readSignal.mockRejectedValue(error)
+ await expect(resolveOpenCodeTranscriptDbPath('session')).rejects.toBe(error)
+ expect(mocks.readSignal).toHaveBeenCalledTimes(32)
+ })
+
+ it('rethrows cancellation during a probe without trying the next candidate', async () => {
+ const controller = new AbortController()
+ const reason = new Error('caller cancelled the probe')
+ mocks.native.mockResolvedValue(['a.db', 'b.db'])
+ mocks.readSignal.mockImplementationOnce(async () => {
+ controller.abort(reason)
+ throw new Error('database is locked')
+ })
+ await expect(
+ openCodeTranscriptDefaultDeps.resolveDbPath('session', controller.signal)
+ ).rejects.toBe(reason)
+ expect(mocks.readSignal).toHaveBeenCalledOnce()
+ })
+
+ it('prepares an overridden WSL database before its owning-host probe', async () => {
+ const dbPath = '\\\\wsl.localhost\\Ubuntu\\custom\\opencode.db'
+ mocks.native.mockResolvedValue([dbPath])
+ mocks.wslPath.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/custom/opencode.db' })
+ mocks.readSignal.mockImplementation(async () => {
+ expect(mocks.configure).toHaveBeenCalledOnce()
+ return { messageCount: 1, partCount: 1, maxMessageRowId: 1, maxPartTimeUpdated: 1 }
+ })
+ await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe(dbPath)
+ expect(mocks.prepare).toHaveBeenCalledWith([dbPath])
+ expect(mocks.readSignal).toHaveBeenCalledOnce()
+ })
+
+ it('reports a refused WSL read instead of treating that host as empty', async () => {
+ mocks.homes.mockResolvedValue(['wsl-home'])
+ const refusal = new WslTranscriptFsError('unavailable', 'WSL discovery unavailable')
+ mocks.directory.mockImplementation(async (path, onRefusal) => {
+ onRefusal?.(path, refusal)
+ return []
+ })
+ await expect(resolveOpenCodeTranscriptDbPath('session')).rejects.toBe(refusal)
+ })
+
+ it('aborts a slow candidate within the discovery deadline, including its in-flight read', async () => {
+ vi.useFakeTimers()
+ mocks.native.mockResolvedValue(['slow.db'])
+ mocks.readSignal.mockImplementation(() => new Promise(() => {}))
+ const pending = resolveOpenCodeTranscriptDbPath('session')
+ const assertion = expect(pending).rejects.toThrow('database discovery exceeded its time limit')
+ await vi.advanceTimersByTimeAsync(5000)
+ await assertion
+ const readSignal = mocks.readSignal.mock.calls[0]?.[1]
+ expect(readSignal?.aborted).toBe(true)
+ })
+
+ it('propagates caller cancellation rather than reporting a missing local transcript', async () => {
+ const controller = new AbortController()
+ controller.abort(new Error('cancelled'))
+ await expect(
+ openCodeTranscriptDefaultDeps.resolveDbPath('session', controller.signal)
+ ).rejects.toThrow('cancelled')
+ expect(mocks.readSignal).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts
new file mode 100644
index 000000000000..92b97e7c9b09
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-database.ts
@@ -0,0 +1,109 @@
+import { join } from 'node:path'
+import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
+import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
+import { prepareOpenCodeWslReaders } from '../ai-vault/opencode-wsl-runtime-preparation'
+import {
+ configureOpenCodeWslReaders,
+ openCodeWslPath
+} from '../ai-vault/session-scanner-opencode-wsl-client'
+import { readOpenCodeTranscriptSignalViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn'
+import {
+ compareOpenCodeClaimPriority,
+ listOpenCodeDatabases,
+ listOpenCodeDatabasesInDirectory
+} from '../opencode-usage/opencode-database-discovery'
+
+export async function discoverOpenCodeTranscriptDatabase(
+ sessionId?: string,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted()
+ const deadline = new AbortController()
+ const timer = setTimeout(
+ () =>
+ deadline.abort(new Error('OpenCode transcript database discovery exceeded its time limit')),
+ 5000
+ )
+ timer.unref?.()
+ const boundedSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal
+ const refusals: Error[] = []
+ const onRefusal = (_path: string, error: Error): void => {
+ refusals.push(error)
+ }
+ const probed = new Set()
+ async function findSession(paths: readonly string[]): Promise {
+ for (const dbPath of [...new Set(paths)].sort(compareOpenCodeClaimPriority)) {
+ if (probed.size >= 32) {
+ break
+ }
+ if (probed.has(dbPath)) {
+ continue
+ }
+ boundedSignal.throwIfAborted()
+ probed.add(dbPath)
+ if (!sessionId) {
+ return dbPath
+ }
+ try {
+ if (
+ await waitForPromiseWithSignal(
+ readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal),
+ boundedSignal
+ )
+ ) {
+ return dbPath
+ }
+ } catch (error) {
+ boundedSignal.throwIfAborted()
+ refusals.push(error instanceof Error ? error : new Error(String(error)))
+ }
+ }
+ return null
+ }
+ try {
+ const primary = await waitForPromiseWithSignal(
+ listOpenCodeDatabases(onRefusal, undefined, boundedSignal),
+ boundedSignal
+ )
+ // Unrelated WSL setup must not hold up a matching native database.
+ const native = await findSession(primary.filter((path) => !openCodeWslPath(path)))
+ if (native) {
+ return native
+ }
+ const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)
+ const sources = await waitForPromiseWithSignal(
+ Promise.all(
+ homes
+ .slice(0, 32)
+ .map((home) =>
+ listOpenCodeDatabasesInDirectory(
+ join(home, '.local', 'share', 'opencode'),
+ onRefusal,
+ boundedSignal
+ )
+ )
+ ),
+ boundedSignal
+ )
+ const primaryWsl = primary.filter((path) => openCodeWslPath(path))
+ const readerRoots = [...new Set([...homes, ...primaryWsl])]
+ if (readerRoots.length > 0) {
+ const readers = await waitForPromiseWithSignal(
+ prepareOpenCodeWslReaders(readerRoots),
+ boundedSignal
+ )
+ boundedSignal.throwIfAborted()
+ configureOpenCodeWslReaders(readers)
+ }
+ const wsl = await findSession([...primaryWsl, ...sources.flat()])
+ if (wsl) {
+ return wsl
+ }
+ if (refusals[0]) {
+ throw refusals[0]
+ }
+ return null
+ } finally {
+ clearTimeout(timer)
+ }
+}
diff --git a/src/main/native-chat/transcript-opencode-part-blocks.ts b/src/main/native-chat/transcript-opencode-part-blocks.ts
new file mode 100644
index 000000000000..572851b34ae2
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-part-blocks.ts
@@ -0,0 +1,127 @@
+import { fileURLToPath } from 'node:url'
+import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types'
+import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows'
+import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values'
+// query module so each stays under the repo's file-size cap. Electron-free:
+// runs on the OpenCode SQLite worker thread (#8864).
+
+export type OpenCodePartRow = {
+ message_id: string
+ time_updated: number
+ data: string | null
+}
+
+export const OPENCODE_TRANSCRIPT_MAX_ROW_BYTES = 2 * 1024 * 1024
+
+export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] {
+ const blocks: NativeChatBlock[] = []
+ for (const partRow of partRows) {
+ if (partRow.data === null) {
+ blocks.push({ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] })
+ continue
+ }
+ const part = parseJsonObject(partRow.data)
+ if (!part) {
+ continue
+ }
+ switch (part.type) {
+ case 'text': {
+ if (part.synthetic === true) {
+ break
+ }
+ const text = extractString(part.text)
+ if (text) {
+ blocks.push({ type: 'text', text })
+ }
+ break
+ }
+ case 'reasoning': {
+ const text = extractString(part.text)
+ if (text) {
+ blocks.push({ type: 'text', text })
+ }
+ break
+ }
+ case 'tool': {
+ blocks.push(...opencodeToolBlocks(part))
+ break
+ }
+ case 'file': {
+ const block = opencodeFileBlock(part)
+ if (block) {
+ blocks.push(block)
+ }
+ break
+ }
+ default:
+ // step-start / snapshot / unknown bookkeeping parts render nothing.
+ break
+ }
+ }
+ return blocks
+}
+
+function opencodeFileBlock(part: Record): NativeChatImageRefBlock | null {
+ const mime = extractString(part.mime)
+ if (!mime?.startsWith('image/')) {
+ return null
+ }
+ const url = extractString(part.url)
+ if (!url) {
+ return null
+ }
+ const alt = extractString(part.filename)
+ const withAlt = alt ? { alt } : {}
+ if (url.startsWith('data:') || /^https?:\/\//.test(url)) {
+ return { type: 'image-ref', url, ...withAlt }
+ }
+ if (url.startsWith('file://')) {
+ try {
+ return { type: 'image-ref', path: fileURLToPath(url), ...withAlt }
+ } catch {
+ // A malformed file URL still renders as an opaque ref.
+ return { type: 'image-ref', url, ...withAlt }
+ }
+ }
+ return { type: 'image-ref', path: url, ...withAlt }
+}
+
+function opencodeToolBlocks(part: Record): NativeChatBlock[] {
+ const name = extractString(part.tool) ?? 'tool'
+ const state = asRecord(part.state)
+ const callId = extractString(part.callID) ?? extractString(part.id)
+ const status = state?.status
+ const lifecycle = status === 'completed' ? 'completed' : status === 'error' ? 'failed' : 'running'
+ const blocks: NativeChatBlock[] = [
+ {
+ type: 'tool-call',
+ name,
+ ...(callId ? { callId } : {}),
+ state: lifecycle,
+ input: state ? state.input : undefined
+ }
+ ]
+ if (!state) {
+ return blocks
+ }
+ const output = state.output
+ const error = state.error
+ if (typeof output !== 'string' && error == null) {
+ // pending / running: the result has not been captured yet.
+ return blocks
+ }
+ blocks.push({
+ type: 'tool-result',
+ ...(callId ? { callId } : {}),
+ output:
+ typeof output === 'string'
+ ? output
+ : typeof error === 'string'
+ ? error
+ : error != null
+ ? JSON.stringify(error)
+ : '',
+ ...(error != null ? { isError: true } : {})
+ })
+ return blocks
+}
diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts
new file mode 100644
index 000000000000..d82613650729
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts
@@ -0,0 +1,543 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows'
+import Database from '../sqlite/sync-database'
+import {
+ readOpenCodeTranscriptPage,
+ readOpenCodeTranscriptSignal
+} from './transcript-opencode-sqlite-query'
+
+let tempDirs: string[] = []
+let openDbs: Database.Database[] = []
+
+afterEach(() => {
+ // Why: Windows keeps the file locked while the handle is open, which would
+ // make rmSync below fail with EPERM.
+ for (const db of openDbs) {
+ db.close()
+ }
+ openDbs = []
+ for (const dir of tempDirs) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ tempDirs = []
+})
+
+function createTempDb(): { db: Database.Database; path: string } {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-native-chat-'))
+ tempDirs.push(dir)
+ const path = join(dir, 'opencode.db')
+ const db = new Database(path)
+ openDbs.push(db)
+ return { db, path }
+}
+
+// The query module only touches these columns; the full production schema is
+// exercised by the scanner's own suite.
+function applySchema(db: Database.Database): void {
+ db.exec(`
+ CREATE TABLE session (id TEXT PRIMARY KEY);
+ CREATE TABLE message (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ time_created INTEGER NOT NULL,
+ time_updated INTEGER NOT NULL,
+ data TEXT NOT NULL
+ );
+ CREATE TABLE part (
+ id TEXT PRIMARY KEY,
+ message_id TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ time_updated INTEGER NOT NULL,
+ data TEXT NOT NULL
+ );
+ INSERT INTO session (id) VALUES ('ses-1'), ('ses-2');
+ `)
+}
+
+function insertMessage(
+ db: Database.Database,
+ args: { id: string; sessionId?: string; time: number; role?: string }
+): void {
+ db.prepare(
+ 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
+ ).run(
+ args.id,
+ args.sessionId ?? 'ses-1',
+ args.time,
+ args.time,
+ JSON.stringify({ role: args.role ?? 'user' })
+ )
+}
+
+function insertPart(
+ db: Database.Database,
+ args: { id: string; messageId: string; sessionId?: string; time: number; data: unknown }
+): void {
+ db.prepare(
+ 'INSERT INTO part (id, message_id, session_id, time_updated, data) VALUES (?, ?, ?, ?, ?)'
+ ).run(args.id, args.messageId, args.sessionId ?? 'ses-1', args.time, JSON.stringify(args.data))
+}
+
+function messageRowid(db: Database.Database, id: string): number {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite rows are unknown; the SELECT projects exactly one `r` integer column.
+ return (db.prepare('SELECT rowid AS r FROM message WHERE id = ?').get(id) as { r: number }).r
+}
+
+describe('readOpenCodeTranscriptPage', () => {
+ it.each(['message', 'part'] as const)(
+ 'omits an oversized %s without losing identity or pagination',
+ (table) => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ for (let index = 1; index <= 3; index++) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: { type: 'text', text: `message ${index}` }
+ })
+ }
+ db.prepare(`UPDATE ${table} SET data = ? WHERE id = ?`).run(
+ JSON.stringify({
+ role: 'user',
+ type: 'file',
+ mime: 'image/png',
+ url: 'x'.repeat(2 * 1024 * 1024)
+ }),
+ table === 'message' ? 'msg-2' : 'prt-2'
+ )
+ const args = { dbPath: path, sessionId: 'ses-1', limit: 2 }
+ const page = readOpenCodeTranscriptPage(args)
+ expect(page?.items.map((item) => item.message.id)).toEqual(['msg-2', 'msg-3'])
+ expect(page?.items[0]?.message.blocks).toEqual([
+ { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
+ ])
+ expect(page?.items[0]?.rowid).toBe(messageRowid(db, 'msg-2'))
+ expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe(
+ page?.items[0]?.fingerprint
+ )
+ db.prepare(`UPDATE ${table} SET time_updated = 10 WHERE id = ?`).run(
+ table === 'message' ? 'msg-2' : 'prt-2'
+ )
+ expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).not.toBe(
+ page?.items[0]?.fingerprint
+ )
+ expect(
+ readOpenCodeTranscriptPage({
+ ...args,
+ beforeMessageRowId: page?.beforeMessageRowId ?? undefined
+ })?.items.map((item) => item.message.id)
+ ).toEqual(['msg-1'])
+ }
+ )
+
+ it('preserves ordinary text beside an oversized UTF-8 attachment part', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ insertMessage(db, { id: 'msg', time: 1 })
+ insertPart(db, {
+ id: 'text',
+ messageId: 'msg',
+ time: 1,
+ data: { type: 'text', text: 'see this' }
+ })
+ insertPart(db, {
+ id: 'large',
+ messageId: 'msg',
+ time: 1,
+ data: { type: 'file', mime: 'image/png', url: '😀'.repeat(600_000) }
+ })
+ expect(
+ readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 })?.items[0]?.message
+ .blocks
+ ).toEqual([
+ { type: 'text', text: 'see this' },
+ { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
+ ])
+ })
+
+ it('returns null when the session row does not exist', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ expect(readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'missing', limit: 10 })).toBeNull()
+ })
+
+ it('treats a session_v2-only (OpenCode 2) DB as not-found instead of throwing', () => {
+ const { db, path } = createTempDb()
+ // Why: OpenCode 2 stores sessions in session_v2 — the v1 reader must fail
+ // soft like session-scanner-opencode2-sqlite-list.ts, not throw
+ // "no such table: session".
+ db.exec('CREATE TABLE session_v2 (id TEXT PRIMARY KEY)')
+ expect(readOpenCodeTranscriptSignal(path, 'ses-1')).toBeNull()
+ expect(readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })).toBeNull()
+ })
+
+ it('returns the NEWEST limit messages when more history exists', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ for (let index = 1; index <= 10; index++) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: { type: 'text', text: `message ${index}` }
+ })
+ }
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 3 })
+ expect(page).not.toBeNull()
+ // The probe row is the oldest extra — the newest message must survive.
+ expect(page!.items.map((item) => item.message.id)).toEqual(['msg-8', 'msg-9', 'msg-10'])
+ expect(page!.hasMore).toBe(true)
+ expect(page!.beforeMessageRowId).toBe(page!.items[0]!.rowid)
+ })
+
+ it('pages strictly older messages from the raw-row cursor', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ for (let index = 1; index <= 10; index++) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: { type: 'text', text: `message ${index}` }
+ })
+ }
+ const first = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 3 })
+ const second = readOpenCodeTranscriptPage({
+ dbPath: path,
+ sessionId: 'ses-1',
+ limit: 3,
+ beforeMessageRowId: first!.beforeMessageRowId!
+ })
+ expect(second!.items.map((item) => item.message.id)).toEqual(['msg-5', 'msg-6', 'msg-7'])
+ })
+
+ it('counts the limit in renderable messages, batching past non-renderable rows', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ // Rows 1..6 alternate step-start (no blocks) / renderable — mirror of the
+ // forward page's batching test, walking newest-to-oldest this time.
+ for (let index = 1; index <= 6; index++) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: index % 2 === 0 ? { type: 'text', text: `message ${index}` } : { type: 'step-start' }
+ })
+ }
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 })
+ // Two renderable messages even though they span three raw rows.
+ expect(page!.items.map((item) => item.message.id)).toEqual(['msg-4', 'msg-6'])
+ expect(page!.hasMore).toBe(true)
+ // Cursor names the oldest RAW scanned row (msg-3, consumed but dropped),
+ // so the probe row msg-2 comes back on the next page — nothing is skipped.
+ expect(page!.beforeMessageRowId).toBe(messageRowid(db, 'msg-3'))
+ const next = readOpenCodeTranscriptPage({
+ dbPath: path,
+ sessionId: 'ses-1',
+ limit: 2,
+ beforeMessageRowId: page!.beforeMessageRowId!
+ })
+ expect(next!.items.map((item) => item.message.id)).toEqual(['msg-2'])
+ expect(next!.hasMore).toBe(false)
+ })
+
+ it('walks a fully non-renderable history to its oldest raw row without dead-ending', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ for (let index = 1; index <= 4; index++) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: { type: 'step-start' }
+ })
+ }
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 })
+ expect(page!.items).toEqual([])
+ expect(page!.hasMore).toBe(false)
+ // The raw cursor still names the oldest scanned row — a later renderable
+ // append is picked up instead of being swallowed by the sparse prefix.
+ expect(page!.beforeMessageRowId).toBe(messageRowid(db, 'msg-1'))
+ })
+
+ it('re-reads messages trimmed by an overshoot batch instead of skipping them', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ // Sparse-then-dense shape (reviewer repro): batch 1 yields one renderable
+ // past a step-start row, batch 2 is dense and overshoots the budget.
+ for (const index of [1, 2, 3, 5]) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: { type: 'text', text: `message ${index}` }
+ })
+ }
+ insertMessage(db, { id: 'msg-4', time: 4 })
+ insertPart(db, {
+ id: 'prt-4',
+ messageId: 'msg-4',
+ time: 4,
+ data: { type: 'step-start' }
+ })
+ const seen: string[] = []
+ let cursor: number | null = null
+ for (;;) {
+ const page = readOpenCodeTranscriptPage({
+ dbPath: path,
+ sessionId: 'ses-1',
+ limit: 2,
+ ...(cursor !== null ? { beforeMessageRowId: cursor } : {})
+ })
+ seen.push(...page!.items.map((item) => item.message.id))
+ if (!page!.hasMore) {
+ break
+ }
+ cursor = page!.beforeMessageRowId
+ }
+ // Every renderable message returns exactly once — msg-2 must not fall
+ // through the gap between the trimmed batch and the cursor. Pages arrive
+ // newest-first (the walk starts at the tail), so chronological order
+ // only holds inside each page.
+ expect(seen).toEqual(['msg-3', 'msg-5', 'msg-1', 'msg-2'])
+ })
+
+ it('keeps hasMore true when a trim happens on the final short batch', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ // Batch 1 collects one renderable past a step-start; batch 2 hits the
+ // table end (rows.length <= limit → probe says no more) while still
+ // overshooting — the trim itself must keep the walk alive.
+ for (const index of [1, 2, 4]) {
+ insertMessage(db, { id: `msg-${index}`, time: index })
+ insertPart(db, {
+ id: `prt-${index}`,
+ messageId: `msg-${index}`,
+ time: index,
+ data: { type: 'text', text: `message ${index}` }
+ })
+ }
+ insertMessage(db, { id: 'msg-3', time: 3 })
+ insertPart(db, {
+ id: 'prt-3',
+ messageId: 'msg-3',
+ time: 3,
+ data: { type: 'step-start' }
+ })
+ const first = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 })
+ // The overshoot trimmed msg-1 — that IS more history below the cursor.
+ expect(first!.hasMore).toBe(true)
+ const second = readOpenCodeTranscriptPage({
+ dbPath: path,
+ sessionId: 'ses-1',
+ limit: 2,
+ beforeMessageRowId: first!.beforeMessageRowId!
+ })
+ expect(second!.items.map((item) => item.message.id)).toEqual(['msg-1'])
+ expect(second!.hasMore).toBe(false)
+ })
+
+ it('maps image file parts to image refs and skips non-image files', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ // A file:// URL written on the READING machine's platform decodes back to
+ // a local path; a foreign-platform one falls back to the opaque ref.
+ const localPath = process.platform === 'win32' ? 'C:\\Users\\u\\pic.jpg' : '/home/user/pic.jpg'
+ const fileUrl = pathToFileURL(localPath).href
+ insertMessage(db, { id: 'msg-1', time: 1, role: 'user' })
+ insertPart(db, {
+ id: 'prt-data',
+ messageId: 'msg-1',
+ time: 1,
+ data: {
+ type: 'file',
+ mime: 'image/png',
+ url: 'data:image/png;base64,AAA',
+ filename: 'paste.png'
+ }
+ })
+ insertPart(db, {
+ id: 'prt-file-url',
+ messageId: 'msg-1',
+ time: 1,
+ data: { type: 'file', mime: 'image/jpeg', url: fileUrl }
+ })
+ insertPart(db, {
+ id: 'prt-plain-path',
+ messageId: 'msg-1',
+ time: 1,
+ data: { type: 'file', mime: 'image/webp', url: '/tmp/opencode/paste.webp' }
+ })
+ insertPart(db, {
+ id: 'prt-pdf',
+ messageId: 'msg-1',
+ time: 1,
+ data: { type: 'file', mime: 'application/pdf', url: 'file:///tmp/doc.pdf' }
+ })
+ insertPart(db, {
+ id: 'prt-text',
+ messageId: 'msg-1',
+ time: 1,
+ data: { type: 'text', text: 'prompt body' }
+ })
+
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })
+ // Blocks follow part-row order; the PDF (non-image) renders nothing.
+ expect(page!.items[0]!.message.blocks).toEqual([
+ { type: 'image-ref', url: 'data:image/png;base64,AAA', alt: 'paste.png' },
+ { type: 'image-ref', path: localPath },
+ { type: 'image-ref', path: '/tmp/opencode/paste.webp' },
+ { type: 'text', text: 'prompt body' }
+ ])
+ })
+
+ it('filters synthetic text parts and maps tool parts to call/result blocks', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ insertMessage(db, { id: 'msg-1', time: 1, role: 'user' })
+ insertPart(db, {
+ id: 'prt-syn',
+ messageId: 'msg-1',
+ time: 1,
+ data: { type: 'text', text: 'injected context', synthetic: true }
+ })
+ insertPart(db, {
+ id: 'prt-text',
+ messageId: 'msg-1',
+ time: 1,
+ data: { type: 'text', text: 'real prompt' }
+ })
+ insertMessage(db, { id: 'msg-2', time: 2, role: 'assistant' })
+ insertPart(db, {
+ id: 'prt-tool-done',
+ messageId: 'msg-2',
+ time: 2,
+ data: {
+ type: 'tool',
+ tool: 'bash',
+ callID: 'call-1',
+ state: { status: 'completed', input: { command: 'ls' }, output: 'file list' }
+ }
+ })
+ insertPart(db, {
+ id: 'prt-tool-pending',
+ messageId: 'msg-2',
+ time: 2,
+ data: { type: 'tool', tool: 'read', callID: 'call-2', state: { status: 'running' } }
+ })
+
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })
+ const user = page!.items.find((item) => item.message.id === 'msg-1')!
+ expect(user.message.blocks).toEqual([{ type: 'text', text: 'real prompt' }])
+ const assistant = page!.items.find((item) => item.message.id === 'msg-2')!
+ expect(assistant.message.blocks).toEqual([
+ {
+ type: 'tool-call',
+ callId: 'call-1',
+ name: 'bash',
+ state: 'completed',
+ input: { command: 'ls' }
+ },
+ { type: 'tool-result', callId: 'call-1', output: 'file list' },
+ // A running tool captured no output yet — call only, no result block.
+ { type: 'tool-call', callId: 'call-2', name: 'read', state: 'running', input: undefined }
+ ])
+ })
+
+ it('surfaces a tool error string as the result output', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ insertMessage(db, { id: 'msg-1', time: 1, role: 'assistant' })
+ insertPart(db, {
+ id: 'prt-tool-error',
+ messageId: 'msg-1',
+ time: 1,
+ data: {
+ type: 'tool',
+ tool: 'bash',
+ callID: 'call-1',
+ state: { status: 'error', input: { command: 'ls' }, error: 'exit 1: boom' }
+ }
+ })
+
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })
+ expect(page!.items[0]!.message.blocks).toEqual([
+ {
+ type: 'tool-call',
+ callId: 'call-1',
+ name: 'bash',
+ state: 'failed',
+ input: { command: 'ls' }
+ },
+ { type: 'tool-result', callId: 'call-1', output: 'exit 1: boom', isError: true }
+ ])
+ })
+})
+
+describe('readOpenCodeTranscriptSignal', () => {
+ it('aggregates counts and maxes per session', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ insertMessage(db, { id: 'msg-1', time: 1 })
+ insertMessage(db, { id: 'msg-2', time: 2 })
+ insertMessage(db, { id: 'other-1', time: 3, sessionId: 'ses-2' })
+ insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } })
+ insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } })
+
+ const signal = readOpenCodeTranscriptSignal(path, 'ses-1')
+ expect(signal).toEqual({
+ messageCount: 2,
+ partCount: 2,
+ maxMessageRowId: 2,
+ maxPartTimeUpdated: 20
+ })
+ })
+
+ it('returns null when the session row does not exist', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ expect(readOpenCodeTranscriptSignal(path, 'missing')).toBeNull()
+ })
+})
+
+it('stops before fetching a third part batch when earlier batches exhaust the page byte budget', () => {
+ const { db, path } = createTempDb()
+ applySchema(db)
+ const text = 'x'.repeat(100_000)
+ db.exec('BEGIN')
+ {
+ for (let i = 0; i < 300; i++) {
+ insertMessage(db, { id: `budget-${i}`, time: i })
+ insertPart(db, {
+ id: `part-${i}`,
+ messageId: `budget-${i}`,
+ time: i,
+ data: { type: 'text', text }
+ })
+ }
+ }
+ db.exec('COMMIT')
+ const prepared = vi.spyOn(Database.prototype, 'prepare')
+ try {
+ expect(() =>
+ readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 300 })
+ ).toThrow('read limit')
+ const partQueries = prepared.mock.calls.filter(
+ ([sql]) => sql.includes('FROM part') && sql.includes('message_id IN')
+ )
+ expect(partQueries).toHaveLength(2)
+ } finally {
+ prepared.mockRestore()
+ }
+})
diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts
new file mode 100644
index 000000000000..31729111c2ee
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts
@@ -0,0 +1,268 @@
+import { openOpenCodeDatabaseReadonly } from '../ai-vault/session-scanner-opencode-sqlite-open'
+import {
+ readOpenCode2TranscriptPage,
+ readOpenCode2TranscriptSignal
+} from './transcript-opencode2-sqlite'
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit'
+import { extractString, parseJsonObject } from '../ai-vault/session-scanner-values'
+import type SyncDatabase from '../sqlite/sync-database'
+
+type BindValue = SyncDatabase.BindValue
+type SqliteStatement = SyncDatabase.Statement
+import {
+ opencodeMessageBlocks,
+ OPENCODE_TRANSCRIPT_MAX_ROW_BYTES
+} from './transcript-opencode-part-blocks'
+// Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2.
+
+export type OpenCodeTranscriptItem = {
+ rowid: number
+ fingerprint: string
+ message: NativeChatMessage
+}
+
+export type OpenCodeTranscriptPage = {
+ items: OpenCodeTranscriptItem[]
+ hasMore: boolean
+ beforeMessageRowId: number | null
+}
+
+export type OpenCodeTranscriptSignal = {
+ messageCount: number
+ partCount: number
+ maxMessageRowId: number
+ maxPartTimeUpdated: number
+}
+
+const PART_ID_BATCH = 100
+
+function sessionExists(db: SyncDatabase, sessionId: string): boolean {
+ const table = rowsOf<{ name: string }>(
+ db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'")
+ )
+ return table.length > 0 && db.prepare('SELECT 1 FROM session WHERE id = ?').get(sessionId) != null
+}
+
+function rowsOf(statement: SqliteStatement, ...params: BindValue[]): T[] {
+ return rowsWithinBudget(statement, { bytes: 0 }, ...params)
+}
+
+function rowsWithinBudget(
+ statement: SqliteStatement,
+ budget: { bytes: number },
+ ...params: BindValue[]
+): T[] {
+ const rows: ReturnType = []
+ for (const row of statement.iterate(...params)) {
+ budget.bytes += Object.values(row).reduce(
+ (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0),
+ 0
+ )
+ if (rows.length >= 10000 || budget.bytes > 16 * 1024 * 1024) {
+ throw new Error('OpenCode transcript query exceeds its read limit')
+ }
+ rows.push(row)
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each caller names the columns projected by its internal SQL query.
+ return rows as T[]
+}
+
+export function readOpenCodeTranscriptSignal(
+ dbPath: string,
+ sessionId: string
+): OpenCodeTranscriptSignal | null {
+ const db = openOpenCodeDatabaseReadonly(dbPath)
+ try {
+ const v2 = readOpenCode2TranscriptSignal(db, sessionId)
+ if (v2) {
+ return v2
+ }
+ if (!sessionExists(db, sessionId)) {
+ return null
+ }
+ // Aggregates without GROUP BY always yield exactly one row, so [0] is it.
+ const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>(
+ db.prepare(
+ 'SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ?'
+ ),
+ sessionId
+ )
+ const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>(
+ db.prepare(
+ 'SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated FROM part WHERE session_id = ?'
+ ),
+ sessionId
+ )
+ return {
+ messageCount: messageRow?.message_count ?? 0,
+ partCount: partRow?.part_count ?? 0,
+ maxMessageRowId: messageRow?.max_message_rowid ?? 0,
+ maxPartTimeUpdated: partRow?.max_part_time_updated ?? 0
+ }
+ } finally {
+ db.close()
+ }
+}
+
+export function readOpenCodeTranscriptPage(args: {
+ dbPath: string
+ sessionId: string
+ limit: number
+ beforeMessageRowId?: number
+}): OpenCodeTranscriptPage | null {
+ const db = openOpenCodeDatabaseReadonly(args.dbPath)
+ try {
+ const v2 = readOpenCode2TranscriptPage(db, args)
+ if (v2) {
+ return v2
+ }
+ if (!sessionExists(db, args.sessionId)) {
+ return null
+ }
+ const limit = openCodeTranscriptPageLimit(args.limit)
+ // The upper bound is always bound: batching advances the cursor mid-page,
+ // so the statement cannot vary with `beforeMessageRowId`'s presence.
+ // MAX_SAFE_INTEGER is the "from the newest row" sentinel.
+ const select = db.prepare(
+ `SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data
+ FROM message
+ WHERE session_id = ? AND rowid < ?
+ ORDER BY rowid DESC
+ LIMIT ?`
+ )
+ const collected: OpenCodeTranscriptItem[] = []
+ let scannedRows = 0
+ let pageBytes = 0
+ const rawBudget = { bytes: 0 }
+ let cursor: number | undefined = args.beforeMessageRowId
+ let hasMore = false
+ for (;;) {
+ const rows = rowsWithinBudget(
+ select,
+ rawBudget,
+ args.sessionId,
+ cursor ?? Number.MAX_SAFE_INTEGER,
+ limit + 1
+ )
+ scannedRows += rows.length
+ if (scannedRows > 10_000) {
+ throw new Error('OpenCode transcript page exceeds its scan limit')
+ }
+ if (rows.length === 0) {
+ break
+ }
+ // The (limit+1)th row is only a hasMore probe — never decoded.
+ hasMore = rows.length > limit
+ const selected = hasMore ? rows.slice(0, limit) : rows
+ cursor = selected.at(-1)!.message_rowid
+ const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget)
+ pageBytes += Buffer.byteLength(JSON.stringify(mapped))
+ if (pageBytes > 16 * 1024 * 1024) {
+ throw new Error('OpenCode transcript page exceeds its byte limit')
+ }
+ collected.push(...mapped)
+ if (!hasMore || collected.length >= limit) {
+ break
+ }
+ }
+ const overshot = collected.length > limit
+ const trimmed = overshot ? collected.slice(0, limit) : collected
+ const items = trimmed.toReversed()
+ return {
+ items,
+ hasMore: hasMore || overshot,
+ beforeMessageRowId: overshot ? items[0]!.rowid : (cursor ?? null)
+ }
+ } finally {
+ db.close()
+ }
+}
+
+type MessageRow = {
+ message_rowid: number
+ id: string
+ time_created: number
+ time_updated: number
+ data: string | null
+}
+
+type PartRow = {
+ message_id: string
+ time_updated: number
+ data: string | null
+}
+
+function mapMessageRows(
+ db: SyncDatabase,
+ sessionId: string,
+ rows: MessageRow[],
+ rawBudget: { bytes: number }
+): OpenCodeTranscriptItem[] {
+ if (rows.length === 0) {
+ return []
+ }
+ const partsByMessage = new Map()
+ const ids = rows.map((row) => row.id)
+ for (let start = 0; start < ids.length; start += PART_ID_BATCH) {
+ const batch = ids.slice(start, start + PART_ID_BATCH)
+ const placeholders = batch.map(() => '?').join(', ')
+ const partRows = rowsWithinBudget(
+ db.prepare(
+ `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part
+ WHERE session_id = ? AND message_id IN (${placeholders})
+ ORDER BY rowid LIMIT 10001`
+ ),
+ rawBudget,
+ sessionId,
+ ...batch
+ )
+ if (partRows.length > 10000) {
+ throw new Error('OpenCode transcript parts exceed their read limit')
+ }
+ for (const partRow of partRows) {
+ const list = partsByMessage.get(partRow.message_id)
+ if (list) {
+ list.push(partRow)
+ } else {
+ partsByMessage.set(partRow.message_id, [partRow])
+ }
+ }
+ }
+ const items: OpenCodeTranscriptItem[] = []
+ for (const row of rows) {
+ const partList = partsByMessage.get(row.id) ?? []
+ const blocks = opencodeMessageBlocks(
+ row.data === null
+ ? [{ message_id: row.id, time_updated: row.time_updated, data: null }]
+ : partList
+ )
+ if (blocks.length === 0) {
+ continue
+ }
+ const record = row.data === null ? null : parseJsonObject(row.data)
+ const role = extractString(record?.role)
+ items.push({
+ rowid: row.message_rowid,
+ fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`,
+ message: {
+ id: row.id,
+ role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system',
+ blocks,
+ timestamp: Number.isFinite(row.time_created) ? row.time_created : null,
+ source: 'transcript'
+ }
+ })
+ }
+ return items
+}
+
+function maxPartTimeUpdated(partRows: PartRow[]): number {
+ let max = 0
+ for (const partRow of partRows) {
+ if (partRow.time_updated > max) {
+ max = partRow.time_updated
+ }
+ }
+ return max
+}
diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts
new file mode 100644
index 000000000000..b8e0fb01e78b
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts
@@ -0,0 +1,288 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import Database from '../sqlite/sync-database'
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+import {
+ readOpenCodeTranscriptPage,
+ readOpenCodeTranscriptSignal
+} from './transcript-opencode-sqlite-query'
+import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe'
+
+const fixtures: { db: Database.Database; root: string; stop: () => void }[] = []
+afterEach(() => {
+ for (const { db, root, stop } of fixtures.splice(0)) {
+ stop()
+ db.close()
+ rmSync(root, { recursive: true, force: true })
+ }
+ vi.useRealTimers()
+})
+
+function watchFixture(
+ version: 'v1' | 'v2',
+ hiddenFirst = false,
+ messageCount = 300,
+ initialLimit = 300
+) {
+ vi.useFakeTimers()
+ const root = mkdtempSync(join(tmpdir(), 'orca-opencode-watch-'))
+ const path = join(root, 'opencode.db')
+ const db = new Database(path)
+ db.exec(
+ version === 'v1'
+ ? `
+ CREATE TABLE session (id TEXT PRIMARY KEY);
+ CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
+ CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT, time_updated INTEGER);
+ INSERT INTO session VALUES ('session');
+ `
+ : `
+ CREATE TABLE session_v2 (id TEXT PRIMARY KEY);
+ CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER);
+ INSERT INTO session_v2 VALUES ('session');
+ `
+ )
+ const insert = (index: number, text = `message ${index}`) => {
+ if (version === 'v1') {
+ db.prepare('INSERT INTO message VALUES (?, ?, ?, 1, 1)').run(
+ String(index),
+ 'session',
+ '{"role":"user"}'
+ )
+ db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, 1)').run(
+ String(index),
+ String(index),
+ 'session',
+ JSON.stringify({ type: 'text', text })
+ )
+ } else {
+ db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, 1, 1)').run(
+ String(index),
+ 'session',
+ 'user',
+ index,
+ JSON.stringify({ text })
+ )
+ }
+ }
+ const update = (index: number, text: string, time = 2) => {
+ db.prepare(
+ version === 'v1'
+ ? 'UPDATE part SET data = ?, time_updated = ? WHERE message_id = ?'
+ : 'UPDATE session_message SET data = ?, time_updated = ? WHERE id = ?'
+ ).run(JSON.stringify(version === 'v1' ? { type: 'text', text } : { text }), time, String(index))
+ }
+ const remove = (index: number) => {
+ if (version === 'v1') {
+ db.prepare('DELETE FROM part WHERE message_id = ?').run(String(index))
+ db.prepare('DELETE FROM message WHERE id = ?').run(String(index))
+ } else {
+ db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index))
+ }
+ }
+ for (let index = 1; index <= messageCount; index++) {
+ insert(index, hiddenFirst && index === 1 ? '' : undefined)
+ }
+ let displayed: NativeChatMessage[] = []
+ const onReplace = vi.fn((messages: NativeChatMessage[]) => {
+ displayed = messages
+ })
+ const onAppend = vi.fn((messages: NativeChatMessage[]) => {
+ displayed.push(...messages)
+ })
+ const readPage = vi.fn(async (args: Parameters[0]) =>
+ readOpenCodeTranscriptPage(args)
+ )
+ const subscription = subscribeOpenCodeNativeChatTranscript(
+ {
+ agent: 'opencode',
+ sessionId: 'session',
+ initialLimit,
+ resolvePollIntervalMs: 5,
+ onInitialSnapshot: (messages) => {
+ displayed = messages
+ },
+ onAppend,
+ onReplace
+ },
+ undefined,
+ {
+ resolveDbPath: async () => path,
+ readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId),
+ readPage
+ }
+ )
+ fixtures.push({ db, root, stop: subscription.unsubscribe })
+ return { db, insert, update, remove, displayed: () => displayed, onReplace, onAppend, readPage }
+}
+
+describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => {
+ it('removes the oldest displayed row when an append keeps the database count unchanged', async () => {
+ const f = watchFixture(version, false, 1000)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(f.displayed()).toHaveLength(300)
+ f.remove(701)
+ f.insert(1001)
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()).toHaveLength(300)
+ expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }])
+ expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'message 1001' }])
+ expect(f.onReplace).toHaveBeenCalledOnce()
+ expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
+ })
+
+ it('replaces an edit older than the newest 100 displayed messages', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(f.displayed()).toHaveLength(300)
+ f.update(1, 'edited oldest')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'edited oldest' }])
+ expect(f.onReplace).toHaveBeenCalledOnce()
+ })
+
+ it('includes an older row that becomes renderable after the emitted frontier', async () => {
+ const f = watchFixture(version, true)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(f.displayed()).toHaveLength(299)
+ f.update(1, 'newly renderable')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'newly renderable' }])
+ expect(f.displayed()).toHaveLength(300)
+ })
+
+ it.each([1, 300])('removes a deleted row at %s from the displayed window', async (index) => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ f.remove(index)
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()).toHaveLength(299)
+ expect(
+ f
+ .displayed()
+ .some((message) => message.id === (version === 'v1' ? String(index) : `opencode:${index}`))
+ ).toBe(false)
+ expect(f.onReplace).toHaveBeenCalledOnce()
+ })
+
+ it('replaces an empty session and permits a reused provider cursor to append', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ for (let index = 1; index <= 300; index++) {
+ f.remove(index)
+ }
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()).toEqual([])
+ f.insert(1, 'after revert')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed().map((message) => message.blocks)).toEqual([
+ [{ type: 'text', text: 'after revert' }]
+ ])
+ })
+
+ it('removes a row whose last renderable content is removed', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ f.update(1, '')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()).toHaveLength(299)
+ expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 2' }])
+ })
+
+ it('continues checking previously appended history and bounds every read', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ for (let index = 301; index <= 450; index++) {
+ f.insert(index)
+ }
+ await vi.advanceTimersByTimeAsync(10)
+ f.update(1, 'edited after append')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'edited after append' }])
+ expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
+ const reads = f.readPage.mock.calls.length
+ await vi.advanceTimersByTimeAsync(20)
+ expect(f.readPage).toHaveBeenCalledTimes(reads)
+ })
+
+ it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ for (let index = 301; index <= 2800; index++) {
+ f.insert(index)
+ }
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()).toHaveLength(300)
+ expect(f.onReplace).not.toHaveBeenCalled()
+ const reads = f.readPage.mock.calls.length
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.readPage.mock.calls.length).toBeGreaterThan(reads)
+ expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
+ for (let index = 301; index <= 2800; index++) {
+ f.remove(index)
+ }
+ f.insert(301, 'after discarded burst')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.displayed()).toHaveLength(301)
+ expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }])
+ })
+
+ it('keeps appending after the remembered window reaches 2400 rows', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ for (let start = 301; start <= 2400; start += 300) {
+ for (let index = start; index < start + 300; index++) {
+ f.insert(index)
+ }
+ await vi.advanceTimersByTimeAsync(10)
+ }
+ f.onReplace.mockClear()
+ f.onAppend.mockClear()
+ for (let index = 2401; index <= 2403; index++) {
+ f.insert(index)
+ await vi.advanceTimersByTimeAsync(10)
+ }
+ expect(f.onReplace).not.toHaveBeenCalled()
+ expect(f.onAppend.mock.calls.map(([messages]) => messages.length)).toEqual([1, 1, 1])
+ expect(f.displayed()).toHaveLength(2403)
+ expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
+ })
+
+ it('keeps appending after a bridged burst fills the cap', async () => {
+ const f = watchFixture(version)
+ await vi.advanceTimersByTimeAsync(0)
+ for (let index = 301; index <= 2600; index++) {
+ f.insert(index)
+ }
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.onReplace).toHaveBeenCalledOnce()
+ expect(f.displayed()).toHaveLength(2400)
+ f.onReplace.mockClear()
+ f.insert(2601)
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.onReplace).not.toHaveBeenCalled()
+ expect(f.onAppend).toHaveBeenCalledOnce()
+ expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'message 2601' }])
+ })
+
+ it.each(['delete', 'hide', 'edit'] as const)(
+ 'detects a first-tail-row %s outside the capped page during an append',
+ async (operation) => {
+ const f = watchFixture(version, false, 2500, 2400)
+ await vi.advanceTimersByTimeAsync(0)
+ if (operation === 'delete') {
+ f.remove(101)
+ } else {
+ f.update(101, operation === 'hide' ? '' : 'edited first tail row')
+ }
+ f.insert(2501)
+ f.insert(2502)
+ await vi.advanceTimersByTimeAsync(10)
+ expect(f.onReplace).toHaveBeenCalledOnce()
+ expect(f.onAppend).not.toHaveBeenCalled()
+ expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
+ }
+ )
+})
diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts
new file mode 100644
index 000000000000..b5faaf884ca3
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode-subscribe.ts
@@ -0,0 +1,330 @@
+import { errorMessage } from '../ai-vault/session-scanner-values'
+import {
+ DESKTOP_READ_WINDOW,
+ UNFLUSHED_SETTLE_MS,
+ type NativeChatTranscriptSubscription,
+ type SubscribeNativeChatTranscriptArgs
+} from './transcript-watch-contract'
+import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query'
+import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode'
+import {
+ openCodeTranscriptPageLimit,
+ OPENCODE_TRANSCRIPT_MAX_WINDOW
+} from '../../shared/opencode-transcript-page-limit'
+
+const OPENCODE_POLL_MS = 1_000
+
+function pruneOpenCodeFingerprintCache(fingerprints: Map, cap: number): void {
+ if (fingerprints.size <= cap) {
+ return
+ }
+ const keep = new Set([...fingerprints.keys()].sort((a, b) => b - a).slice(0, cap))
+ for (const rowid of fingerprints.keys()) {
+ if (!keep.has(rowid)) {
+ fingerprints.delete(rowid)
+ }
+ }
+}
+
+export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCache
+
+export function subscribeOpenCodeNativeChatTranscript(
+ args: SubscribeNativeChatTranscriptArgs,
+ setupSignal?: AbortSignal,
+ deps: OpenCodeTranscriptDeps = {}
+): NativeChatTranscriptSubscription {
+ setupSignal?.throwIfAborted()
+ const controller = new AbortController()
+ const resolveDbPath = () =>
+ (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
+ args.sessionId,
+ controller.signal
+ )
+ const readSignal = (dbPath: string, sessionId: string) =>
+ (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)(
+ dbPath,
+ sessionId,
+ controller.signal
+ )
+ const readPage = (page: Parameters>[0]) =>
+ (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(page, controller.signal)
+ const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS
+ const initialLimit = openCodeTranscriptPageLimit(
+ args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW
+ )
+
+ let closed = false
+ let pollTimer: ReturnType | null = null
+ let dbPath: string | null = null
+ let lastSignal: string | null = null
+ let lastEmittedRowId = 0
+ let lastCounts = { messages: 0, parts: 0 }
+ let windowLimit = initialLimit
+ const fingerprints = new Map()
+ let gateErrorEmitted = false
+ // Whether the subscriber already has a frame (or pending notice) to render.
+ let settled = false
+ let settleTimer: ReturnType | null = null
+
+ const onAbort = () => unsubscribe()
+ setupSignal?.addEventListener('abort', onAbort, { once: true })
+
+ function stopSettleTimer(): void {
+ if (settleTimer) {
+ clearTimeout(settleTimer)
+ settleTimer = null
+ }
+ }
+
+ function settleUnflushed(): void {
+ settleTimer = null
+ if (closed || settled || !args.onTranscriptPending) {
+ return
+ }
+ settled = true
+ emitSafely(() => args.onTranscriptPending?.(), 'pending')
+ }
+
+ if (args.onTranscriptPending) {
+ settleTimer = setTimeout(settleUnflushed, UNFLUSHED_SETTLE_MS)
+ settleTimer.unref?.()
+ }
+ // never break the poll loop (an escaping throw would skip scheduleTick and
+ // leave polling permanently deaf on a void tick()).
+ function emitSafely(run: () => void, what: string): void {
+ try {
+ run()
+ } catch (err) {
+ console.warn(
+ `OpenCode native-chat subscriber (${what}) threw; polling continues. ${errorMessage(err)}`
+ )
+ }
+ }
+
+ function scheduleTick(): void {
+ if (closed) {
+ return
+ }
+ pollTimer = setTimeout(() => {
+ pollTimer = null
+ void tick()
+ }, pollMs)
+ pollTimer.unref?.()
+ }
+
+ async function tick(): Promise {
+ if (closed) {
+ return
+ }
+ try {
+ dbPath ??= await resolveDbPath()
+ if (closed) {
+ return
+ }
+ if (!dbPath) {
+ // No DB (yet): keep polling, like the JSONL resolve-poll path (#8401).
+ scheduleTick()
+ return
+ }
+ const signal = await readSignal(dbPath, args.sessionId)
+ if (closed) {
+ return
+ }
+ if (!signal) {
+ // Session row not landed yet — the hook can fire first.
+ scheduleTick()
+ return
+ }
+ const fingerprint = `${signal.messageCount}:${signal.partCount}:${signal.maxMessageRowId}:${signal.maxPartTimeUpdated}`
+ const firstSnapshot = lastSignal === null
+ if (!firstSnapshot && fingerprint === lastSignal) {
+ scheduleTick()
+ return
+ }
+ const page = await readPage({
+ dbPath,
+ sessionId: args.sessionId,
+ limit: firstSnapshot
+ ? initialLimit
+ : Math.min(
+ OPENCODE_TRANSCRIPT_MAX_WINDOW,
+ windowLimit + Math.max(0, signal.messageCount - lastCounts.messages)
+ )
+ })
+ if (closed) {
+ return
+ }
+ if (!page) {
+ // The session vanished between signal and page reads; keep polling.
+ scheduleTick()
+ return
+ }
+ if (firstSnapshot) {
+ lastSignal = fingerprint
+ lastCounts = { messages: signal.messageCount, parts: signal.partCount }
+ rememberItems(page.items)
+ settled = true
+ stopSettleTimer()
+ const snapshot = page.items.map((item) => item.message)
+ const snapshotHasMore = page.hasMore
+ const snapshotBefore = page.beforeMessageRowId ?? 0
+ emitSafely(
+ () => args.onInitialSnapshot?.(snapshot, snapshotHasMore, snapshotBefore),
+ 'snapshot'
+ )
+ scheduleTick()
+ return
+ }
+ let changed = page.items.some(
+ (item) =>
+ item.rowid <= lastEmittedRowId && fingerprints.get(item.rowid) !== item.fingerprint
+ )
+ const shrinking =
+ signal.messageCount < lastCounts.messages || signal.partCount < lastCounts.parts
+ const present = new Set(page.items.map((item) => item.rowid))
+ if (
+ !changed &&
+ !shrinking &&
+ page.hasMore &&
+ fingerprints.size === OPENCODE_TRANSCRIPT_MAX_WINDOW &&
+ page.items.length === OPENCODE_TRANSCRIPT_MAX_WINDOW
+ ) {
+ const oldest = page.items[0]!.rowid
+ const displaced = [...fingerprints.keys()].filter((rowid) => rowid < oldest)
+ if (displaced.length > 0) {
+ // Verify the displaced fringe: cap eviction and a real deletion look identical in the tail.
+ const older = await readPage({
+ dbPath,
+ sessionId: args.sessionId,
+ limit: displaced.length,
+ beforeMessageRowId: oldest
+ })
+ if (closed || !older) {
+ scheduleTick()
+ return
+ }
+ for (const item of older.items) {
+ present.add(item.rowid)
+ changed ||= fingerprints.get(item.rowid) !== item.fingerprint
+ }
+ }
+ }
+ // A balanced delete and append can move a removed row before the new tail.
+ const removed = [...fingerprints.keys()].some((rowid) => !present.has(rowid))
+ if (changed || shrinking || removed) {
+ if (await replaceWithBridgedWindow(dbPath, page, shrinking)) {
+ lastSignal = fingerprint
+ lastCounts = { messages: signal.messageCount, parts: signal.partCount }
+ }
+ scheduleTick()
+ return
+ }
+ const appended = page.items.filter((item) => item.rowid > lastEmittedRowId)
+ if (appended.length > 0 && appended.length === page.items.length && page.hasMore) {
+ if (await replaceWithBridgedWindow(dbPath, page)) {
+ lastSignal = fingerprint
+ lastCounts = { messages: signal.messageCount, parts: signal.partCount }
+ }
+ scheduleTick()
+ return
+ }
+ if (appended.length > 0) {
+ rememberItems(appended)
+ const appendedMessages = appended.map((item) => item.message)
+ emitSafely(() => args.onAppend(appendedMessages), 'append')
+ }
+ lastSignal = fingerprint
+ lastCounts = { messages: signal.messageCount, parts: signal.partCount }
+ scheduleTick()
+ } catch (err) {
+ if (!closed && lastSignal === null && !gateErrorEmitted && args.onInitialSnapshot) {
+ gateErrorEmitted = true
+ // Its retryable message outranks the empty settle; don't overwrite it.
+ settled = true
+ stopSettleTimer()
+ const failure = errorMessage(err)
+ emitSafely(() => args.onInitialSnapshot?.([], false, 0, failure), 'error')
+ }
+ scheduleTick()
+ }
+ }
+
+ function rememberItems(items: OpenCodeTranscriptItem[]): void {
+ for (const item of items) {
+ fingerprints.set(item.rowid, item.fingerprint)
+ if (item.rowid > lastEmittedRowId) {
+ lastEmittedRowId = item.rowid
+ }
+ }
+ pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW)
+ windowLimit = Math.max(windowLimit, fingerprints.size)
+ }
+
+ // Bounded reads on the worker thread shared with the AI-Vault scanner.
+ // Widens until the window overlaps the emitted frontier: a poll-gap burst
+ // bigger than one window must bridge, not skip. Holds everything back (no
+ // lastSignal/lastEmittedRowId advance) when even the capped window cannot
+ // overlap, so the gap retries instead of dropping rows.
+ async function replaceWithBridgedWindow(
+ db: string,
+ page: Awaited>,
+ shrinking = false
+ ): Promise {
+ let limit = Math.max(windowLimit, page?.items.length ?? 0)
+ for (;;) {
+ const replacement =
+ page ??
+ (await readPage({
+ dbPath: db,
+ sessionId: args.sessionId,
+ limit
+ }))
+ if (closed || !replacement) {
+ return false
+ }
+ const oldest = replacement.items[0]?.rowid
+ if (oldest === undefined) {
+ // Empty renderable window (only non-renderable rows moved the
+ // signal): nothing to bridge — settle like a normal replace.
+ const hasMore = replacement.hasMore
+ const before = replacement.beforeMessageRowId ?? 0
+ fingerprints.clear()
+ lastEmittedRowId = 0
+ emitSafely(() => args.onReplace?.([], hasMore, before), 'replace')
+ return true
+ }
+ if (shrinking || oldest <= lastEmittedRowId || !replacement.hasMore) {
+ fingerprints.clear()
+ lastEmittedRowId = 0
+ rememberItems(replacement.items)
+ const messages = replacement.items.map((item) => item.message)
+ const hasMore = replacement.hasMore
+ const before = replacement.beforeMessageRowId ?? 0
+ emitSafely(() => args.onReplace?.(messages, hasMore, before), 'replace')
+ return true
+ }
+ if (limit >= OPENCODE_TRANSCRIPT_MAX_WINDOW) {
+ return false
+ }
+ limit = Math.min(limit * 2, OPENCODE_TRANSCRIPT_MAX_WINDOW)
+ page = null
+ }
+ }
+
+ function unsubscribe(): void {
+ if (closed) {
+ return
+ }
+ closed = true
+ controller.abort()
+ setupSignal?.removeEventListener('abort', onAbort)
+ stopSettleTimer()
+ if (pollTimer) {
+ clearTimeout(pollTimer)
+ pollTimer = null
+ }
+ }
+
+ void tick()
+ return { unsubscribe, watching: true }
+}
diff --git a/src/main/native-chat/transcript-opencode.test.ts b/src/main/native-chat/transcript-opencode.test.ts
new file mode 100644
index 000000000000..6cf17a8cb75d
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode.test.ts
@@ -0,0 +1,230 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+import {
+ readOpenCodeNativeChatTranscriptFull,
+ readOpenCodeNativeChatTranscriptTail,
+ resolveOpenCodeTranscriptDbPath,
+ type OpenCodeTranscriptDeps
+} from './transcript-opencode'
+import { DESKTOP_READ_WINDOW } from './transcript-watch-contract'
+
+let tempDirs: string[] = []
+
+afterEach(() => {
+ for (const dir of tempDirs) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ tempDirs = []
+ vi.restoreAllMocks()
+ vi.unstubAllEnvs()
+})
+
+describe('readOpenCodeNativeChatTranscriptFull', () => {
+ const message = (id: string): NativeChatMessage => ({
+ id,
+ role: 'user',
+ blocks: [{ type: 'text', text: id }],
+ timestamp: null,
+ source: 'transcript'
+ })
+
+ it('returns the whole session oldest-first across page boundaries', async () => {
+ // Five messages served in windows of two: pages arrive newest-window-first
+ // (each oldest-first); the full read must concatenate them globally
+ // oldest-first like the JSONL full reader, not in page-fetch order.
+ const ids = ['m1', 'm2', 'm3', 'm4', 'm5']
+ const readPage: OpenCodeTranscriptDeps['readPage'] = async (args) => {
+ const upper = args.beforeMessageRowId ?? 6
+ const lower = Math.max(1, upper - 2)
+ const slice = ids.slice(lower - 1, upper - 1)
+ return {
+ items: slice.map((id, index) => ({
+ rowid: lower + index,
+ fingerprint: id,
+ message: message(id)
+ })),
+ hasMore: lower > 1,
+ beforeMessageRowId: lower > 1 ? lower : null
+ }
+ }
+ const result = await readOpenCodeNativeChatTranscriptFull('ses-1', {
+ resolveDbPath: async () => '/fake/opencode.db',
+ readPage
+ })
+ expect(result).toEqual({ messages: ids.map(message) })
+ })
+
+ it('reports notFound when the first page misses', async () => {
+ const readPage: OpenCodeTranscriptDeps['readPage'] = async () => null
+ const result = await readOpenCodeNativeChatTranscriptFull('missing', {
+ resolveDbPath: async () => '/fake/opencode.db',
+ readPage
+ })
+ expect(result).toEqual({ error: 'Transcript unavailable', notFound: true })
+ })
+
+ it('returns a retryable error when DB discovery throws', async () => {
+ const result = await readOpenCodeNativeChatTranscriptFull('ses-1', {
+ resolveDbPath: async () => {
+ throw new Error('EACCES')
+ },
+ readPage: async () => null
+ })
+ expect(result).toEqual({ error: 'EACCES' })
+ })
+})
+
+describe('readOpenCodeNativeChatTranscriptTail', () => {
+ const message = (id: string): NativeChatMessage => ({
+ id,
+ role: 'user',
+ blocks: [{ type: 'text', text: id }],
+ timestamp: null,
+ source: 'transcript'
+ })
+
+ it('passes beforeOffset through, floors the limit, and returns the page shape', async () => {
+ let seen: { limit: number; beforeMessageRowId?: number } | null = null
+ const result = await readOpenCodeNativeChatTranscriptTail(
+ { sessionId: 'ses-1', limit: 10.9, beforeOffset: 42 },
+ {
+ resolveDbPath: async () => '/fake/opencode.db',
+ readPage: async (args) => {
+ seen = args
+ return {
+ items: [{ rowid: 40, fingerprint: 'm1', message: message('m1') }],
+ hasMore: true,
+ beforeMessageRowId: 39
+ }
+ }
+ }
+ )
+ expect(seen).toMatchObject({
+ dbPath: '/fake/opencode.db',
+ sessionId: 'ses-1',
+ limit: 10,
+ beforeMessageRowId: 42
+ })
+ expect(result).toEqual({ messages: [message('m1')], hasMore: true, beforeOffset: 39 })
+ })
+
+ it('defaults a non-positive limit to the desktop window and 0 beforeOffset', async () => {
+ let seenLimit = 0
+ const result = await readOpenCodeNativeChatTranscriptTail(
+ { sessionId: 'ses-1', limit: 0 },
+ {
+ resolveDbPath: async () => '/fake/opencode.db',
+ readPage: async (args) => {
+ seenLimit = args.limit
+ return {
+ items: [{ rowid: 7, fingerprint: 'm1', message: message('m1') }],
+ hasMore: false,
+ beforeMessageRowId: null
+ }
+ }
+ }
+ )
+ expect(seenLimit).toBe(DESKTOP_READ_WINDOW)
+ expect(result).toEqual({ messages: [message('m1')], hasMore: false, beforeOffset: 0 })
+ })
+
+ it('reports notFound when the page misses — the session row has not landed', async () => {
+ const result = await readOpenCodeNativeChatTranscriptTail(
+ { sessionId: 'ses-1', limit: 10 },
+ {
+ resolveDbPath: async () => '/fake/opencode.db',
+ readPage: async () => null
+ }
+ )
+ expect(result).toEqual({ error: 'Transcript unavailable', notFound: true })
+ })
+
+ it('reports notFound when no DB resolves', async () => {
+ const result = await readOpenCodeNativeChatTranscriptTail(
+ { sessionId: 'ses-1', limit: 10 },
+ { resolveDbPath: async () => null, readPage: async () => null }
+ )
+ expect(result).toEqual({ error: 'Transcript unavailable', notFound: true })
+ })
+
+ it('returns a retryable error without notFound when the page read throws', async () => {
+ const result = await readOpenCodeNativeChatTranscriptTail(
+ { sessionId: 'ses-1', limit: 10 },
+ {
+ resolveDbPath: async () => '/fake/opencode.db',
+ readPage: async () => {
+ throw new Error('worker timeout')
+ }
+ }
+ )
+ expect(result).toEqual({ error: 'worker timeout' })
+ })
+
+ it('returns a retryable error when DB discovery throws', async () => {
+ // Discovery scans the filesystem and can throw (EACCES/EIO) — the
+ // value-error contract must not leak that as a rejection.
+ const result = await readOpenCodeNativeChatTranscriptTail(
+ { sessionId: 'ses-1', limit: 10 },
+ {
+ resolveDbPath: async () => {
+ throw new Error('EACCES')
+ },
+ readPage: async () => null
+ }
+ )
+ expect(result).toEqual({ error: 'EACCES' })
+ })
+})
+
+describe('resolveOpenCodeTranscriptDbPath', () => {
+ function createDataHome(): string {
+ const dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-discover-'))
+ tempDirs.push(dataHome)
+ return dataHome
+ }
+
+ it('prefers the canonical opencode.db over stale opencode-*.db siblings', async () => {
+ const dataHome = createDataHome()
+ const dir = join(dataHome, 'opencode')
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, 'opencode-backup.db'), '')
+ writeFileSync(join(dir, 'opencode.db'), '')
+ vi.stubEnv('XDG_DATA_HOME', dataHome)
+ await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(join(dir, 'opencode.db'))
+ })
+
+ it('falls back to a sibling when no canonical DB exists', async () => {
+ const dataHome = createDataHome()
+ const dir = join(dataHome, 'opencode')
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, 'opencode-backup.db'), '')
+ vi.stubEnv('XDG_DATA_HOME', dataHome)
+ await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(join(dir, 'opencode-backup.db'))
+ })
+
+ it('keeps the canonical v1 DB when OpenCode 2 DBs coexist', async () => {
+ const dataHome = createDataHome()
+ const dir = join(dataHome, 'opencode')
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, 'opencode-next.db'), '')
+ writeFileSync(join(dir, 'opencode-local.db'), '')
+ writeFileSync(join(dir, 'opencode.db'), '')
+ vi.stubEnv('XDG_DATA_HOME', dataHome)
+ await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(join(dir, 'opencode.db'))
+ })
+
+ it('honors the OPENCODE_DB override and returns null with no DBs', async () => {
+ const dataHome = createDataHome()
+ const override = join(dataHome, 'custom.db')
+ writeFileSync(override, '')
+ vi.stubEnv('XDG_DATA_HOME', dataHome)
+ vi.stubEnv('OPENCODE_DB', override)
+ await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(override)
+
+ vi.stubEnv('OPENCODE_DB', '')
+ await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(null)
+ })
+})
diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts
new file mode 100644
index 000000000000..497635de87e1
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode.ts
@@ -0,0 +1,141 @@
+import {
+ OPENCODE_CAPTURE_RECORD_LIMIT,
+ OPENCODE_CAPTURE_TEXT_LIMIT
+} from '../ai-vault/opencode-transcript-capture-limits'
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+import { errorMessage } from '../ai-vault/session-scanner-values'
+import type { ReadTranscriptResult } from './transcript-reader'
+import {
+ readOpenCodeTranscriptPageViaWorker,
+ readOpenCodeTranscriptSignalViaWorker
+} from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn'
+import { discoverOpenCodeTranscriptDatabase } from './transcript-opencode-database'
+import type {
+ OpenCodeTranscriptPage,
+ OpenCodeTranscriptSignal
+} from './transcript-opencode-sqlite-query'
+import { DESKTOP_READ_WINDOW } from './transcript-watch-contract'
+import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit'
+
+// OpenCode SQLite reads use the same bounded worker as AI Vault.
+
+export type OpenCodeTranscriptDeps = {
+ resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise
+ readSignal?: (
+ dbPath: string,
+ sessionId: string,
+ signal?: AbortSignal
+ ) => Promise
+ readPage?: (
+ args: {
+ dbPath: string
+ sessionId: string
+ limit: number
+ beforeMessageRowId?: number
+ },
+ signal?: AbortSignal
+ ) => Promise
+}
+
+export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise {
+ return discoverOpenCodeTranscriptDatabase(sessionId)
+}
+
+export const openCodeTranscriptDefaultDeps: Required = {
+ resolveDbPath: discoverOpenCodeTranscriptDatabase,
+ readSignal: (dbPath, sessionId, signal) =>
+ readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal),
+ readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal)
+}
+
+export type OpenCodeTailResult =
+ | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number }
+ | { error: string; notFound?: true }
+
+export async function readOpenCodeNativeChatTranscriptTail(
+ args: { sessionId: string; limit: number; beforeOffset?: number },
+ deps: OpenCodeTranscriptDeps = {},
+ signal?: AbortSignal
+): Promise {
+ const limit = openCodeTranscriptPageLimit(args.limit > 0 ? args.limit : DESKTOP_READ_WINDOW)
+ try {
+ const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
+ args.sessionId,
+ signal
+ )
+ if (!dbPath) {
+ return { error: 'Transcript unavailable', notFound: true }
+ }
+ const page = await (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(
+ {
+ dbPath,
+ sessionId: args.sessionId,
+ limit,
+ ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {})
+ },
+ signal
+ )
+ if (!page) {
+ return { error: 'Transcript unavailable', notFound: true }
+ }
+ return {
+ messages: page.items.map((item) => item.message),
+ hasMore: page.hasMore,
+ beforeOffset: page.beforeMessageRowId ?? 0
+ }
+ } catch (err) {
+ return { error: errorMessage(err) }
+ }
+}
+
+export async function readOpenCodeNativeChatTranscriptFull(
+ sessionId: string,
+ deps: OpenCodeTranscriptDeps = {},
+ signal?: AbortSignal
+): Promise {
+ // Pages arrive newest-window first; reverse windows, preserving message order.
+ const pages: NativeChatMessage[][] = []
+ let records = 0
+ let bytes = 0
+ let cursor: number | undefined
+ try {
+ const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
+ sessionId,
+ signal
+ )
+ if (!dbPath) {
+ return { error: 'Transcript unavailable', notFound: true }
+ }
+ const readPage = deps.readPage ?? openCodeTranscriptDefaultDeps.readPage
+ for (;;) {
+ const page = await readPage(
+ {
+ dbPath,
+ sessionId,
+ limit: 500,
+ ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {})
+ },
+ signal
+ )
+ if (!page) {
+ if (cursor === undefined) {
+ return { error: 'Transcript unavailable', notFound: true }
+ }
+ break
+ }
+ pages.push(page.items.map((item) => item.message))
+ records += page.items.length
+ bytes += Buffer.byteLength(JSON.stringify(page.items))
+ if (records > OPENCODE_CAPTURE_RECORD_LIMIT || bytes > OPENCODE_CAPTURE_TEXT_LIMIT) {
+ throw new Error('OpenCode transcript exceeds its full read limit')
+ }
+ if (!page.hasMore || page.beforeMessageRowId == null || page.beforeMessageRowId === cursor) {
+ break
+ }
+ cursor = page.beforeMessageRowId
+ }
+ } catch (err) {
+ return { error: errorMessage(err) }
+ }
+ return { messages: pages.toReversed().flat() }
+}
diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts
new file mode 100644
index 000000000000..333788c82bbd
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts
@@ -0,0 +1,246 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows'
+import Database from '../sqlite/sync-database'
+import {
+ readOpenCodeTranscriptPage,
+ readOpenCodeTranscriptSignal
+} from './transcript-opencode-sqlite-query'
+import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe'
+
+const fixtures: { db: Database.Database; root: string }[] = []
+afterEach(() => {
+ for (const { db, root } of fixtures.splice(0)) {
+ db.close()
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+function fixture() {
+ const root = mkdtempSync(join(tmpdir(), 'orca-native-opencode2-'))
+ const path = join(root, 'opencode.db')
+ const db = new Database(path)
+ fixtures.push({ db, root })
+ db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY);
+ CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT,
+ seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER);
+ INSERT INTO session_v2 VALUES ('session');`)
+ let sequence = 0
+ const insert = (id: string, type: string, data: unknown, time = 1) =>
+ db
+ .prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(id, 'session', type, ++sequence, JSON.stringify(data), time, time)
+ return { db, path, insert }
+}
+
+describe('OpenCode 2 native transcript', () => {
+ it('paginates real v2 user/assistant records past bookkeeping without losing messages', () => {
+ const { path, insert } = fixture()
+ insert('one', 'user', { text: 'first prompt' })
+ insert('empty', 'idle', { outcome: 'succeeded' })
+ insert('two', 'assistant', { content: [{ type: 'text', text: 'first reply' }] })
+ insert('three', 'user', { text: 'second prompt' })
+ insert('last-empty', 'idle', { outcome: 'succeeded' })
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 })
+ expect(page?.items.map((item) => item.message.id)).toEqual(['opencode:two', 'opencode:three'])
+ expect(page?.hasMore).toBe(true)
+ const older = readOpenCodeTranscriptPage({
+ dbPath: path,
+ sessionId: 'session',
+ limit: 2,
+ beforeMessageRowId: page?.beforeMessageRowId ?? undefined
+ })
+ expect(older?.items.map((item) => item.message.id)).toEqual(['opencode:one'])
+ expect(older?.hasMore).toBe(false)
+ })
+
+ it('preserves tool identity, inputs, outputs and errors across in-place updates', () => {
+ const { db, path, insert } = fixture()
+ const content = [
+ {
+ type: 'tool',
+ id: 'call',
+ name: 'bash',
+ state: { status: 'running', input: { command: 'pwd' } }
+ }
+ ]
+ insert('tool', 'assistant', { content })
+ const before = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 })
+ expect(before?.items[0]?.message.blocks).toEqual([
+ {
+ type: 'tool-call',
+ callId: 'call',
+ name: 'bash',
+ state: 'running',
+ input: { command: 'pwd' }
+ }
+ ])
+ db.prepare('UPDATE session_message SET data = ?, time_updated = 2 WHERE id = ?').run(
+ JSON.stringify({
+ content: [
+ {
+ ...content[0],
+ state: {
+ status: 'error',
+ input: { command: 'pwd' },
+ error: { message: 'denied' },
+ content: []
+ }
+ }
+ ]
+ }),
+ 'tool'
+ )
+ const after = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 })
+ expect(after?.items[0]?.fingerprint).not.toBe(before?.items[0]?.fingerprint)
+ expect(after?.items[0]?.message.blocks[0]).toMatchObject({ state: 'failed', callId: 'call' })
+ expect(after?.items[0]?.message.blocks[1]).toMatchObject({ isError: true, callId: 'call' })
+ expect(readOpenCodeTranscriptSignal(path, 'session')?.maxPartTimeUpdated).toBe(2)
+ })
+
+ it('surfaces provider errors and explicit interrupted boundaries', () => {
+ const { path, insert } = fixture()
+ insert('error', 'assistant', { error: { type: 'provider', message: 'Rate limit exceeded' } })
+ insert('boundary', 'idle', { outcome: 'interrupted' })
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 })
+ expect(page?.items.map((item) => item.message.blocks)).toEqual([
+ [{ type: 'text', text: 'Rate limit exceeded' }],
+ [{ type: 'text', text: 'Conversation interrupted' }]
+ ])
+ })
+
+ it.each(['x', '😀'])(
+ 'omits oversized %s content with a stable cursor and time fingerprint',
+ (character) => {
+ const { db, path, insert } = fixture()
+ insert('before', 'user', { text: 'before' })
+ insert('large', 'user', {
+ text: '',
+ files: [{ mime: 'image/png', data: character.repeat(2 * 1024 * 1024) }]
+ })
+ insert('after', 'assistant', { text: 'after' })
+ const args = { dbPath: path, sessionId: 'session', limit: 2 }
+ const page = readOpenCodeTranscriptPage(args)
+ expect(page?.items.map((item) => item.message.id)).toEqual([
+ 'opencode:large',
+ 'opencode:after'
+ ])
+ expect(page?.items[0]).toMatchObject({
+ rowid: 2,
+ message: {
+ role: 'user',
+ timestamp: 1,
+ blocks: [{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }]
+ }
+ })
+ expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe(
+ page?.items[0]?.fingerprint
+ )
+ db.prepare("UPDATE session_message SET time_updated = 2 WHERE id = 'large'").run()
+ expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).not.toBe(
+ page?.items[0]?.fingerprint
+ )
+ expect(
+ readOpenCodeTranscriptPage({
+ ...args,
+ beforeMessageRowId: page?.beforeMessageRowId ?? undefined
+ })?.items.map((item) => item.message.id)
+ ).toEqual(['opencode:before'])
+ }
+ )
+
+ it('refuses excessive sparse scans instead of returning a partial transcript', () => {
+ const { db, path, insert } = fixture()
+ db.exec('BEGIN')
+ for (let index = 0; index < 10001; index++) {
+ insert(String(index), 'idle', {})
+ }
+ db.exec('COMMIT')
+ expect(() =>
+ readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 1 })
+ ).toThrow('read limit')
+ })
+
+ it('keeps the aggregate page byte budget when individual rows fit', () => {
+ const { db, path, insert } = fixture()
+ db.exec('BEGIN')
+ for (let index = 0; index < 18; index++) {
+ insert(String(index), 'user', { text: 'x'.repeat(1024 * 1024) })
+ }
+ db.exec('COMMIT')
+ expect(() =>
+ readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 18 })
+ ).toThrow('read limit')
+ })
+
+ it('reconciles live streaming updates and stops after unsubscribe', async () => {
+ const { db, path, insert } = fixture()
+ insert('reply', 'assistant', { content: [{ type: 'text', text: 'partial' }] })
+ const snapshots: unknown[] = []
+ const replacements: unknown[] = []
+ const subscription = subscribeOpenCodeNativeChatTranscript(
+ {
+ agent: 'opencode',
+ sessionId: 'session',
+ resolvePollIntervalMs: 5,
+ onAppend: () => {},
+ onInitialSnapshot: (messages) => snapshots.push(messages),
+ onReplace: (messages) => replacements.push(messages)
+ },
+ undefined,
+ {
+ resolveDbPath: async () => path,
+ readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId),
+ readPage: async (args) => readOpenCodeTranscriptPage(args)
+ }
+ )
+ try {
+ await expect.poll(() => snapshots.length).toBe(1)
+ db.prepare('UPDATE session_message SET data = ?, time_updated = 2').run(
+ JSON.stringify({ content: [{ type: 'text', text: 'complete' }] })
+ )
+ await expect.poll(() => replacements.length).toBe(1)
+ expect(replacements[0]).toMatchObject([{ blocks: [{ text: 'complete' }] }])
+ subscription.unsubscribe()
+ db.prepare('UPDATE session_message SET time_updated = 3').run()
+ await new Promise((resolve) => setTimeout(resolve, 25))
+ expect(replacements).toHaveLength(1)
+ } finally {
+ subscription.unsubscribe()
+ }
+ })
+})
+
+it('preserves real v2 user image files with or without accompanying text', () => {
+ const { path, insert } = fixture()
+ const file = { data: 'YQ==', mime: 'image/png', source: { type: 'inline' }, name: 'tiny.png' }
+ insert('text-image', 'user', { text: 'see this', files: [file] })
+ insert('image-only', 'user', { text: '', files: [file] })
+ const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 10 })
+ expect(page?.items.map((item) => item.message.blocks)).toEqual([
+ [
+ { type: 'text', text: 'see this' },
+ { type: 'image-ref', url: `data:${file.mime};base64,${file.data}`, alt: 'tiny.png' }
+ ],
+ [{ type: 'image-ref', url: `data:${file.mime};base64,${file.data}`, alt: 'tiny.png' }]
+ ])
+})
+
+it('prefers the migrated live v2 session over retained legacy tables for pages and signals', () => {
+ const { db, path, insert } = fixture()
+ db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY);
+ CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT);
+ CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT);
+ INSERT INTO session VALUES ('session');
+ INSERT INTO message VALUES ('old', 'session', 1, 1, '{"role":"user"}');
+ INSERT INTO part VALUES ('part', 'old', 'session', 1, '{"type":"text","text":"frozen legacy"}');`)
+ insert('imported', 'user', { text: 'migrated prompt' })
+ insert('live', 'assistant', { content: [{ type: 'text', text: 'live reply' }] })
+ expect(readOpenCodeTranscriptSignal(path, 'session')?.messageCount).toBe(2)
+ expect(
+ readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 10 })?.items.map(
+ (item) => item.message.id
+ )
+ ).toEqual(['opencode:imported', 'opencode:live'])
+})
diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts
new file mode 100644
index 000000000000..2ebfe950e10a
--- /dev/null
+++ b/src/main/native-chat/transcript-opencode2-sqlite.ts
@@ -0,0 +1,234 @@
+import { createHash } from 'node:crypto'
+import type SyncDatabase from '../sqlite/sync-database'
+import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit'
+import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
+import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values'
+import {
+ opencodeMessageBlocks,
+ OPENCODE_TRANSCRIPT_MAX_ROW_BYTES
+} from './transcript-opencode-part-blocks'
+import type {
+ OpenCodeTranscriptPage,
+ OpenCodeTranscriptSignal,
+ OpenCodeTranscriptItem
+} from './transcript-opencode-sqlite-query'
+
+const MAX_PAGE_BYTES = 16 * 1024 * 1024
+const MAX_SCAN_ROWS = 10_000
+
+function sessionExists(db: SyncDatabase, sessionId: string): boolean {
+ return (
+ tableExists(db, 'session_v2') &&
+ tableExists(db, 'session_message') &&
+ ['id', 'session_id', 'type', 'seq', 'data', 'time_created', 'time_updated'].every((column) =>
+ columnExists(db, 'session_message', column)
+ ) &&
+ db.prepare('SELECT 1 FROM session_v2 WHERE id = ?').get(sessionId) != null
+ )
+}
+
+export function readOpenCode2TranscriptSignal(
+ db: SyncDatabase,
+ sessionId: string
+): OpenCodeTranscriptSignal | null {
+ if (!sessionExists(db, sessionId)) {
+ return null
+ }
+ const row = asRecord(
+ db
+ .prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(seq), 0) AS latest,
+ COALESCE(MAX(time_updated), 0) AS updated FROM session_message WHERE session_id = ?`)
+ .get(sessionId)
+ )
+ if (
+ typeof row?.count !== 'number' ||
+ typeof row.latest !== 'number' ||
+ typeof row.updated !== 'number'
+ ) {
+ throw new Error('OpenCode transcript signal is invalid')
+ }
+ return {
+ messageCount: row.count,
+ partCount: 0,
+ maxMessageRowId: row.latest,
+ maxPartTimeUpdated: row.updated
+ }
+}
+
+function messageItem(value: unknown): OpenCodeTranscriptItem | null {
+ const row = asRecord(value)
+ if (
+ typeof row?.id !== 'string' ||
+ typeof row.cursor !== 'number' ||
+ typeof row.time_created !== 'number' ||
+ typeof row.time_updated !== 'number'
+ ) {
+ throw new Error('OpenCode transcript message is invalid')
+ }
+ if (
+ row.data === null ||
+ (typeof row.data === 'string' &&
+ Buffer.byteLength(row.data) > OPENCODE_TRANSCRIPT_MAX_ROW_BYTES)
+ ) {
+ return {
+ rowid: row.cursor,
+ fingerprint: `${row.time_updated}:omitted`,
+ message: {
+ id: `opencode:${row.id}`,
+ role: row.type === 'user' ? 'user' : row.type === 'assistant' ? 'assistant' : 'system',
+ blocks: opencodeMessageBlocks([
+ { message_id: row.id, time_updated: row.time_updated, data: null }
+ ]),
+ timestamp: row.time_created,
+ source: 'transcript'
+ }
+ }
+ }
+ if (typeof row.data !== 'string') {
+ throw new Error('OpenCode transcript message is invalid')
+ }
+ const messageId = row.id
+ const updatedAt = row.time_updated
+ const record = parseJsonObject(row.data)
+ if (!record) {
+ throw new Error('OpenCode transcript message contains invalid JSON')
+ }
+ const content = Array.isArray(record.content)
+ ? record.content
+ : typeof record.text === 'string'
+ ? [{ type: 'text', text: record.text }]
+ : []
+ if (row.type === 'user' && Array.isArray(record.files)) {
+ for (const file of record.files) {
+ const attachment = asRecord(file)
+ if (!attachment) {
+ continue
+ }
+ const url =
+ typeof attachment.uri === 'string'
+ ? attachment.uri
+ : typeof attachment.data === 'string' && typeof attachment.mime === 'string'
+ ? `data:${attachment.mime};base64,${attachment.data}`
+ : null
+ if (url) {
+ content.push({ type: 'file', url, mime: attachment.mime, filename: attachment.name })
+ }
+ }
+ }
+ const parts = content.flatMap((value) => {
+ const item = asRecord(value)
+ if (!item) {
+ return []
+ }
+ if (item.type !== 'tool') {
+ return [{ message_id: messageId, time_updated: updatedAt, data: JSON.stringify(item) }]
+ }
+ const state = asRecord(item.state)
+ const output = Array.isArray(state?.content)
+ ? state.content
+ .flatMap((value) => {
+ const part = asRecord(value)
+ return typeof part?.text === 'string' ? [part.text] : []
+ })
+ .join('\n')
+ : undefined
+ return [
+ {
+ message_id: messageId,
+ time_updated: updatedAt,
+ data: JSON.stringify({ ...item, tool: item.name, state: { ...state, output } })
+ }
+ ]
+ })
+ const blocks = opencodeMessageBlocks(parts)
+ const error = asRecord(record.error)
+ if (typeof error?.message === 'string') {
+ blocks.push({ type: 'text', text: error.message })
+ }
+ if (row.type === 'idle' && record.outcome === 'interrupted') {
+ blocks.push({ type: 'text', text: 'Conversation interrupted' })
+ }
+ if (
+ blocks.length === 0 ||
+ (row.type !== 'user' &&
+ row.type !== 'assistant' &&
+ row.type !== 'system' &&
+ row.type !== 'idle')
+ ) {
+ return null
+ }
+ return {
+ rowid: row.cursor,
+ fingerprint: `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}`,
+ message: {
+ id: `opencode:${row.id}`,
+ role: row.type === 'idle' ? 'system' : row.type,
+ blocks,
+ timestamp: row.time_created,
+ source: 'transcript'
+ }
+ }
+}
+
+export function readOpenCode2TranscriptPage(
+ db: SyncDatabase,
+ args: {
+ sessionId: string
+ limit: number
+ beforeMessageRowId?: number
+ }
+): OpenCodeTranscriptPage | null {
+ if (!sessionExists(db, args.sessionId)) {
+ return null
+ }
+ const limit = openCodeTranscriptPageLimit(args.limit)
+ const statement = db.prepare(`SELECT seq AS cursor, id, type, time_created, time_updated,
+ CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data
+ FROM session_message WHERE session_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?`)
+ const items: OpenCodeTranscriptItem[] = []
+ let cursor = args.beforeMessageRowId ?? Number.MAX_SAFE_INTEGER
+ let scannedRows = 0
+ let bytes = 0
+ let pageBytes = 0
+ let hasMore = false
+ while (items.length < limit) {
+ const rows = statement.all(args.sessionId, cursor, Math.min(limit + 1, 8))
+ hasMore = rows.length === Math.min(limit + 1, 8)
+ if (rows.length === 0) {
+ break
+ }
+ for (const value of rows) {
+ const row = asRecord(value)
+ if (typeof row?.cursor !== 'number') {
+ throw new Error('OpenCode transcript cursor is invalid')
+ }
+ bytes += Object.values(row).reduce(
+ (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0),
+ 0
+ )
+ if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) {
+ throw new Error('OpenCode transcript page exceeds its read limit')
+ }
+ const item = messageItem(value)
+ cursor = row.cursor
+ if (item) {
+ pageBytes += Buffer.byteLength(JSON.stringify(item))
+ if (pageBytes > MAX_PAGE_BYTES) {
+ throw new Error('OpenCode transcript page exceeds its read limit')
+ }
+ items.push(item)
+ }
+ if (items.length >= limit) {
+ hasMore =
+ db
+ .prepare('SELECT 1 FROM session_message WHERE session_id = ? AND seq < ? LIMIT 1')
+ .get(args.sessionId, cursor) != null
+ break
+ }
+ }
+ if (!hasMore) {
+ break
+ }
+ }
+ return { items: items.toReversed(), hasMore, beforeMessageRowId: scannedRows ? cursor : null }
+}
diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts
index 828521e2e3d5..7233f001de48 100644
--- a/src/main/native-chat/transcript-read-cache.ts
+++ b/src/main/native-chat/transcript-read-cache.ts
@@ -1,3 +1,5 @@
+import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
+import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode'
import type { AgentType } from '../../shared/native-chat-types'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
import { resolveSessionFilePath } from './session-file-resolver'
@@ -104,6 +106,9 @@ export async function readNativeChatTranscriptCached(
/** Hook-reported authoritative transcript path, preferred over the id glob. */
transcriptPath?: string
): Promise {
+ if (resolveNativeChatTranscriptAgent(agent) === 'opencode') {
+ return readOpenCodeNativeChatTranscriptFull(sessionId)
+ }
const epoch = cacheEpoch
let filePath: string | null
try {
diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts
index a4d3acb7465f..97ab76802d00 100644
--- a/src/main/native-chat/transcript-reader.ts
+++ b/src/main/native-chat/transcript-reader.ts
@@ -1,3 +1,4 @@
+import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode'
import type {
AgentType,
NativeChatMessage,
@@ -42,6 +43,9 @@ export async function readNativeChatTranscript(
sessionId: string,
options: ReadTranscriptOptions = {}
): Promise {
+ if (resolveNativeChatTranscriptAgent(agent) === 'opencode') {
+ return readOpenCodeNativeChatTranscriptFull(sessionId)
+ }
let filePath: string | null
try {
filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts
index b6fa9bafdb0a..ff0fcfeb72be 100644
--- a/src/main/native-chat/transcript-tail-reader.ts
+++ b/src/main/native-chat/transcript-tail-reader.ts
@@ -1,3 +1,4 @@
+import { readOpenCodeNativeChatTranscriptTail } from './transcript-opencode'
import type {
AgentType,
NativeChatMessage,
@@ -230,6 +231,9 @@ export async function readNativeChatTranscriptTail(
}
| { error: string; notFound?: true }
> {
+ if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') {
+ return readOpenCodeNativeChatTranscriptTail(args, {}, signal)
+ }
const decode = nativeChatLineDecoderForAgent(args.agent)
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
if (!decode) {
diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts
index 13463e524ef3..c264d9ab514c 100644
--- a/src/main/native-chat/transcript-watch-contract.ts
+++ b/src/main/native-chat/transcript-watch-contract.ts
@@ -1,3 +1,6 @@
+export const DESKTOP_READ_WINDOW = 300
+export const UNFLUSHED_SETTLE_MS = 1_500
+
import type {
AgentType,
NativeChatMessage,
diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts
index 97e7b75ec61a..a5090b4c8008 100644
--- a/src/main/native-chat/transcript-watch.ts
+++ b/src/main/native-chat/transcript-watch.ts
@@ -1,3 +1,6 @@
+import { UNFLUSHED_SETTLE_MS } from './transcript-watch-contract'
+import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe'
+import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { extname } from 'node:path'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import {
@@ -55,7 +58,6 @@ const FALLBACK_RESOLVE_POLL_MS = 5_000
// delay — a fresh session that has yet to be prompted never flushes, so the
// spinner is permanent. Long enough that a merely slow resolve still wins the
// race and paints history directly.
-const UNFLUSHED_SETTLE_MS = 1_500
function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null {
const path = args.transcriptPath?.trim()
@@ -264,6 +266,9 @@ export async function subscribeNativeChatTranscript(
setupSignal?: AbortSignal
): Promise {
setupSignal?.throwIfAborted()
+ if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') {
+ return subscribeOpenCodeNativeChatTranscript(args, setupSignal)
+ }
const decode = nativeChatLineDecoderForAgent(args.agent)
if (!decode) {
// Nothing watchable — return a no-op teardown so callers can unconditionally
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/qoder/hook-service.test.ts b/src/main/qoder/hook-service.test.ts
index fbb7d4058c01..24d5e9b7ac97 100644
--- a/src/main/qoder/hook-service.test.ts
+++ b/src/main/qoder/hook-service.test.ts
@@ -11,8 +11,9 @@ vi.mock('node:os', async (importOriginal) => ({
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/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-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts
new file mode 100644
index 000000000000..7aa9e911c602
--- /dev/null
+++ b/src/main/rate-limits/service-zcode-usage.test.ts
@@ -0,0 +1,189 @@
+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 { fetchZcodeRateLimits } from './zcode-usage-fetcher'
+import { hasZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
+import {
+ deferred,
+ okProvider,
+ resetRateLimitProviderMocks
+} 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('./antigravity-usage-fetcher', () => ({
+ fetchAntigravityRateLimits: vi.fn()
+}))
+
+vi.mock('./kimi-fetcher', () => ({
+ fetchKimiRateLimits: vi.fn()
+}))
+
+vi.mock('./opencode-go-usage-source-selection', () => ({
+ fetchOpenCodeGoUsage: vi.fn()
+}))
+
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn(),
+ hasZcodeCliPlanCredentials: vi.fn(() => false)
+}))
+
+vi.mock('./minimax/minimax-fetcher', () => ({
+ fetchMiniMaxRateLimits: vi.fn()
+}))
+
+vi.mock('./grok-fetcher', () => ({
+ fetchGrokRateLimits: vi.fn()
+}))
+
+vi.mock('./cursor-fetcher', () => ({
+ fetchCursorRateLimits: vi.fn()
+}))
+
+vi.mock('./cursor-auth', () => ({
+ readCursorAuthSession: vi.fn()
+}))
+
+vi.mock('./grok-auth', () => ({
+ readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
+}))
+
+vi.mock('../minimax/minimax-cookie-store', () => ({
+ hasMiniMaxSessionCookie: vi.fn(() => false)
+}))
+
+vi.mock('../minimax/minimax-api-key-store', () => ({
+ hasMiniMaxApiKey: vi.fn(() => false)
+}))
+
+vi.mock('../zcode/zcode-plan-api-key-store', () => ({
+ hasZcodePlanApiKey: vi.fn(() => false),
+ readZcodePlanApiKey: vi.fn(() => null),
+ saveZcodePlanApiKey: vi.fn(),
+ clearZcodePlanApiKey: vi.fn()
+}))
+
+describe('RateLimitService zcode plan credentials', () => {
+ beforeEach(() => {
+ resetRateLimitProviderMocks()
+ vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
+ vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
+ })
+
+ it('fetches zcode with the site-resolved plan credential when a resolver is set', async () => {
+ const service = new RateLimitService()
+ service.setZcodePlanConfigResolver(() => ({ site: 'bigmodel', apiKey: 'glm-key' }))
+ vi.mocked(hasZcodePlanApiKey).mockReturnValue(true)
+ vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 33, Date.now()))
+
+ await service.refresh()
+
+ expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1)
+ expect(fetchZcodeRateLimits).toHaveBeenCalledWith({
+ signal: expect.any(AbortSignal),
+ planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' }
+ })
+ const state = service.getState()
+ expect(state.zcode?.status).toBe('ok')
+ expect(state.zcode?.session?.usedPercent).toBe(33)
+ expect(state.zcodePlanApiKeyConfigured).toBe(true)
+ })
+
+ it('passes no plan credential while no key is saved and still fetches via the CLI config', async () => {
+ const service = new RateLimitService()
+ service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: '' }))
+ vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 12, Date.now()))
+
+ await service.refresh()
+
+ expect(fetchZcodeRateLimits).toHaveBeenCalledWith({
+ signal: expect.any(AbortSignal),
+ planCredential: null
+ })
+ expect(service.getState().zcode?.session?.usedPercent).toBe(12)
+ })
+
+ it('surfaces a resolver failure as a zcode-only error without fetching', async () => {
+ const service = new RateLimitService()
+ service.setZcodePlanConfigResolver(() => {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ })
+
+ await service.refresh()
+
+ expect(fetchZcodeRateLimits).not.toHaveBeenCalled()
+ const zcode = service.getState().zcode
+ expect(zcode?.status).toBe('error')
+ expect(zcode?.error).toContain('could not be decrypted')
+ expect(zcode?.usageMetadata?.failureKind).toBe('keychain-unavailable')
+ expect(service.getState().claude?.status).toBe('ok')
+ })
+
+ it('discards the previous zcode snapshot when the saved site changes', async () => {
+ const service = new RateLimitService()
+ let site: 'zai' | 'bigmodel' = 'zai'
+ service.setZcodePlanConfigResolver(() => ({ site, apiKey: 'glm-key' }))
+ vi.mocked(fetchZcodeRateLimits)
+ .mockResolvedValueOnce(okProvider('zcode', 40, Date.now()))
+ .mockRejectedValueOnce(new Error('Zcode quota request failed (401)'))
+
+ await service.refresh()
+ expect(service.getState().zcode?.session?.usedPercent).toBe(40)
+
+ site = 'bigmodel'
+ await service.refresh()
+
+ const state = service.getState()
+ expect(fetchZcodeRateLimits).toHaveBeenLastCalledWith({
+ signal: expect.any(AbortSignal),
+ planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' }
+ })
+ expect(state.zcode?.status).toBe('error')
+ expect(state.zcode?.session).toBeNull()
+ })
+
+ it('does not apply an in-flight zcode result after credential invalidation', async () => {
+ const service = new RateLimitService()
+ const firstZcode = deferred()
+ const secondZcode = deferred()
+ service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: 'glm-key' }))
+ vi.mocked(fetchZcodeRateLimits)
+ .mockImplementationOnce(() => firstZcode.promise)
+ .mockImplementationOnce(() => secondZcode.promise)
+
+ const firstRefresh = service.refresh()
+ await vi.waitFor(() => expect(service.getState().claude?.status).toBe('ok'))
+
+ service.invalidateZcodeCredentialState()
+ const queuedRefresh = service.refresh()
+ await Promise.resolve()
+
+ firstZcode.resolve(okProvider('zcode', 50, Date.now()))
+ await vi.waitFor(() => expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2))
+
+ expect(service.getState().zcode?.status).toBe('fetching')
+ expect(service.getState().zcode?.session).toBeNull()
+
+ secondZcode.resolve(okProvider('zcode', 10, Date.now()))
+ await firstRefresh
+ await queuedRefresh
+
+ const state = service.getState()
+ expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2)
+ expect(state.zcode?.session?.usedPercent).toBe(10)
+ })
+})
diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts
index ed8578e36940..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.
@@ -38,6 +51,15 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna
})
}
+ invalidateZcodeCredentialState(): void {
+ this.zcodeFetchGeneration += 1
+ // Why: saving/forgetting the plan key can race an in-flight fetch; clear the visible snapshot before any old-key result returns.
+ this.updateState({
+ ...this.state,
+ zcode: this.withFetchingStatus(null, 'zcode')
+ })
+ }
+
async refreshForCodexAccountChange(
outgoingAccountId?: string | null,
target?: CodexAccountSelectionTarget
diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts
index 7db5d24a4a75..fd9dcf1375c3 100644
--- a/src/main/rate-limits/service/service-configuration.ts
+++ b/src/main/rate-limits/service/service-configuration.ts
@@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron'
import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store'
import { hasMiniMaxApiKey } from '../../minimax/minimax-api-key-store'
+import { hasZcodePlanApiKey } from '../../zcode/zcode-plan-api-key-store'
import { RateLimitServiceAccountRefresh } from './service-account-refresh'
import {
type CodexAccountSelectionTarget,
@@ -11,6 +12,7 @@ import {
type OpenCodeGoRateLimitConfig,
type MiniMaxRateLimitConfig,
type AntigravityUsageEnabledResolver,
+ type ZcodePlanRateLimitConfig,
type GeminiCliOAuthEnabledResolver,
type InactiveCodexAccountInfo,
type InactiveClaudeAccountInfo,
@@ -41,14 +43,22 @@ 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 {
this.miniMaxConfigResolver = resolver
}
+ setZcodePlanConfigResolver(resolver: () => ZcodePlanRateLimitConfig): void {
+ this.zcodePlanConfigResolver = resolver
+ }
+
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
this.geminiCliOAuthEnabledResolver = resolver
}
@@ -130,6 +140,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
// Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads.
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
minimaxApiKeyConfigured: hasMiniMaxApiKey(),
+ zcodePlanApiKeyConfigured: hasZcodePlanApiKey(),
opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured,
grokAuthConfigured: this.grokAuthConfigured,
cursorAuthConfigured: this.cursorAuthConfigured,
diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts
index 12542f311db9..cfd4a6e9d58e 100644
--- a/src/main/rate-limits/service/service-fetch-policy.ts
+++ b/src/main/rate-limits/service/service-fetch-policy.ts
@@ -24,6 +24,19 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT
}
}
+ protected getZcodePlanCredentialError(message: string): ProviderRateLimits {
+ return {
+ provider: 'zcode',
+ session: null,
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now(),
+ error: message,
+ status: 'error',
+ usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' }
+ }
+ }
+
// Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive.
// A live post flips the snapshot back to ok, but the endpoint's Retry-After is still binding.
protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean {
diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts
index a24322a54712..3da1389d4830 100644
--- a/src/main/rate-limits/service/service-fetch-targets.ts
+++ b/src/main/rate-limits/service/service-fetch-targets.ts
@@ -6,6 +6,8 @@ import {
type ClaudeRuntimeAuthPreparation,
type CodexAccountSelectionTarget,
type MiniMaxResolvedConfig,
+ type ZcodePlanResolvedConfig,
+ type OpenCodeGoResolvedConfig,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
type ProviderRateLimits,
@@ -13,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
@@ -187,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 {
@@ -213,4 +244,16 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
}
}
}
+
+ protected resolveZcodePlanConfig(): ZcodePlanResolvedConfig {
+ try {
+ return {
+ config: this.zcodePlanConfigResolver?.() ?? { site: 'zai', apiKey: '' },
+ error: null
+ }
+ } catch (error) {
+ // Why: an undecryptable saved key must not abort every provider's refresh; surface it as ZCode-only state instead.
+ return { config: { site: 'zai', apiKey: '' }, error: toErrorMessage(error) }
+ }
+ }
}
diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts
index b6a9b40373ee..2731f882be5a 100644
--- a/src/main/rate-limits/service/service-full-cycle-application.ts
+++ b/src/main/rate-limits/service/service-full-cycle-application.ts
@@ -25,6 +25,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
opencodeGeneration,
miniMaxConfigChanged,
miniMaxGeneration,
+ zcodeConfigChanged,
+ zcodeGeneration,
claudeFetchGated,
results: [
claudeResult,
@@ -201,6 +203,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
const grok = settleSiblingProviderResult('grok', grokSettled)
const cursor = settleSiblingProviderResult('cursor', cursorSettled)
const zcode = settleSiblingProviderResult('zcode', zcodeSettled)
+ const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration
const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled)
// Why: the stale policy keeps a recent snapshot through a failed refresh, but
// a snapshot belonging to a different Cursor account must not survive the
@@ -221,16 +224,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
previousZcodeAccount === zcodeAccount
this.trackActiveFailureStreak('grok', grok)
this.trackActiveFailureStreak('cursor', cursor)
- this.trackActiveFailureStreak('zcode', zcode)
+ if (shouldApplyZcode) {
+ this.trackActiveFailureStreak('zcode', zcode)
+ }
this.trackActiveFailureStreak('antigravity', antigravity)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok),
cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor),
- zcode:
- zcode.status === 'error' && !sameZcodeAccount
+ zcode: !shouldApplyZcode
+ ? this.state.zcode
+ : zcodeConfigChanged
? zcode
- : this.applyStalePolicy(zcode, previousState.zcode),
+ : zcode.status === 'error' && !sameZcodeAccount
+ ? zcode
+ : this.applyStalePolicy(zcode, previousState.zcode),
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity)
})
}
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 405650ddf571..f847cbf15097 100644
--- a/src/main/rate-limits/service/service-full-cycle-preparation.ts
+++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts
@@ -8,6 +8,7 @@ import { readCursorAuthSession } from '../cursor-auth'
import { fetchZcodeRateLimits } from '../zcode-usage-fetcher'
import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher'
import { antigravityUsageDisabledSnapshot } from '../antigravity-usage-snapshot'
+import { ZCODE_PLAN_SITE_BASE_URLS } from '../../../shared/zcode-plan-sites'
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
import { createHash } from 'node:crypto'
import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection'
@@ -36,6 +37,8 @@ export type FetchAllCyclePrepared = {
opencodeGeneration: number
miniMaxConfigChanged: boolean
miniMaxGeneration: number
+ zcodeConfigChanged: boolean
+ zcodeGeneration: number
claudeFetchGated: boolean
results: [
PromiseSettledResult,
@@ -81,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
@@ -101,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
@@ -119,6 +124,25 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
const antigravityUsageEnabled = this.antigravityUsageEnabledResolver?.() ?? true
+ const zcodePlanConfigResult = this.resolveZcodePlanConfig()
+ const zcodePlanApiKey = zcodePlanConfigResult.config.apiKey
+ // Why digest, not the key: this string only has to change when the credential does.
+ const currentZcodeConfigHash = zcodePlanApiKey
+ ? `${zcodePlanConfigResult.config.site}|${createHash('sha256').update(zcodePlanApiKey).digest('hex')}`
+ : (zcodePlanConfigResult.error ?? '')
+ const zcodeConfigChanged = currentZcodeConfigHash !== this.lastZcodeConfigHash
+ if (zcodeConfigChanged) {
+ this.lastZcodeConfigHash = currentZcodeConfigHash
+ this.zcodeFetchGeneration += 1
+ }
+ const zcodeGeneration = this.zcodeFetchGeneration
+ const zcodePlanCredential = zcodePlanApiKey
+ ? {
+ apiKey: zcodePlanApiKey,
+ baseUrl: ZCODE_PLAN_SITE_BASE_URLS[zcodePlanConfigResult.config.site]
+ }
+ : null
+
// Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change).
this.updateState({
...previousState,
@@ -140,7 +164,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
: this.withFetchingStatus(previousState.minimax, 'minimax'),
grok: this.withFetchingStatus(previousState.grok, 'grok'),
cursor: this.withFetchingStatus(previousState.cursor, 'cursor'),
- zcode: this.withFetchingStatus(previousState.zcode, 'zcode')
+ zcode: zcodeConfigChanged
+ ? this.withFetchingStatus(null, 'zcode')
+ : this.withFetchingStatus(previousState.zcode, 'zcode')
})
// Why its own promise: the keychain read and the desktop state.vscdb read
@@ -155,7 +181,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
(reason) => ({ status: 'rejected', reason }) as const
)
- const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then(
+ const zcodeResultPromise = (
+ zcodePlanConfigResult.error
+ ? Promise.resolve(this.getZcodePlanCredentialError(zcodePlanConfigResult.error))
+ : fetchZcodeRateLimits({ signal, planCredential: zcodePlanCredential })
+ ).then(
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
)
@@ -208,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,
@@ -230,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,
@@ -245,6 +295,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
opencodeGeneration,
miniMaxConfigChanged,
miniMaxGeneration,
+ zcodeConfigChanged,
+ zcodeGeneration,
claudeFetchGated,
results: [
claudeResult,
diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts
index eb810f1e21bc..0ec13020a6e9 100644
--- a/src/main/rate-limits/service/service-state.ts
+++ b/src/main/rate-limits/service/service-state.ts
@@ -14,6 +14,7 @@ import {
type OpenCodeGoRateLimitConfig,
type MiniMaxRateLimitConfig,
type AntigravityUsageEnabledResolver,
+ type ZcodePlanRateLimitConfig,
type GeminiCliOAuthEnabledResolver,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
@@ -85,8 +86,10 @@ export abstract class RateLimitServiceState {
protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null
protected opencodeFetchGeneration = 0
protected minimaxFetchGeneration = 0
+ protected zcodeFetchGeneration = 0
protected lastOpencodeConfigHash = ''
protected lastMiniMaxConfigHash = ''
+ protected lastZcodeConfigHash = ''
protected codexHomePathResolver: CodexHomePathResolver | null = null
protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
runtime: 'host',
@@ -100,7 +103,9 @@ 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
protected antigravityUsageEnabledResolver: AntigravityUsageEnabledResolver | null = null
protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts
index c80f6bb68f14..5bae83b1449c 100644
--- a/src/main/rate-limits/service/service-types.ts
+++ b/src/main/rate-limits/service/service-types.ts
@@ -1,4 +1,5 @@
import type { ProviderRateLimits } from '../../../shared/rate-limit-types'
+import type { ZcodePlanSite } from '../../../shared/zcode-plan-sites'
import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service'
import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection'
import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home'
@@ -44,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 = {
@@ -61,6 +69,16 @@ export type MiniMaxResolvedConfig = {
error: string | null
}
+export type ZcodePlanRateLimitConfig = {
+ site: ZcodePlanSite
+ apiKey: string
+}
+
+export type ZcodePlanResolvedConfig = {
+ config: ZcodePlanRateLimitConfig
+ error: string | null
+}
+
export type GeminiCliOAuthEnabledResolver = () => boolean
/** Whether the user is actually showing Antigravity usage, so the `agy` probe is worth spawning. */
diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts
index b587d196be0f..aadca4c17a59 100644
--- a/src/main/rate-limits/zcode-usage-fetcher.test.ts
+++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
+import { fetchZcodeRateLimits, hasZcodeCliPlanCredentials } from './zcode-usage-fetcher'
let dir: string
let configPath: string
@@ -289,4 +289,112 @@ describe('fetchZcodeRateLimits', () => {
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
})
+
+ it('prefers the Orca-saved plan credential over the ZCode CLI config', async () => {
+ writeConfig()
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ success: true,
+ data: {
+ level: 'pro',
+ limits: [
+ { type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 30 },
+ { type: 'CREDIT_LIMIT', unit: 6, number: 1, percentage: 60 }
+ ]
+ }
+ })
+ )
+ )
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'orca-saved-key', baseUrl: 'https://api.z.ai' }
+ })
+
+ expect(result.status).toBe('ok')
+ const [url, init] = vi.mocked(fetch).mock.calls[0]
+ expect(String(url)).toBe('https://api.z.ai/api/monitor/usage/quota/limit')
+ expect(new Headers(init?.headers).get('Authorization')).toBe('orca-saved-key')
+ expect(result.usageMetadata?.credentialSource).toBe('orca-plan')
+ expect(JSON.stringify(result)).not.toContain('orca-saved-key')
+ })
+
+ it('uses the BigModel host for the mainland plan site', async () => {
+ writeFileSync(configPath, JSON.stringify({ provider: {} }))
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ success: true,
+ data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 10 }] }
+ })
+ )
+ )
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'bigmodel-key', baseUrl: 'https://open.bigmodel.cn' }
+ })
+
+ expect(result.status).toBe('ok')
+ expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe(
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit'
+ )
+ })
+
+ it('rejects a plan credential whose base URL is not a supported site', async () => {
+ vi.mocked(fetch).mockResolvedValue(new Response('{}'))
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'key', baseUrl: 'https://evil.example.com' }
+ })
+
+ expect(result.status).toBe('error')
+ expect(result.error).toBe('The saved GLM Coding Plan API key is unusable')
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it('reports an unusable plan credential instead of switching to the CLI config', async () => {
+ writeConfig()
+ vi.mocked(fetch).mockResolvedValue(new Response('{}'))
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'bad\r\nkey', baseUrl: 'https://api.z.ai' }
+ })
+
+ expect(result.status).toBe('error')
+ expect(result.error).toBe('The saved GLM Coding Plan API key is unusable')
+ expect(fetch).not.toHaveBeenCalled()
+ expect(result.usageMetadata?.credentialSource).toBeUndefined()
+ })
+})
+
+describe('hasZcodeCliPlanCredentials', () => {
+ it('detects a usable CLI config', async () => {
+ writeConfig()
+
+ expect(hasZcodeCliPlanCredentials(configPath)).toBe(true)
+ })
+
+ it('reports false without a config file', async () => {
+ expect(hasZcodeCliPlanCredentials(configPath)).toBe(false)
+ })
+})
+
+describe('quota credential privacy', () => {
+ it.each(['network', 'response'])('redacts a credential echoed by a %s error', async (kind) => {
+ writeConfig()
+ if (kind === 'network') {
+ vi.mocked(fetch).mockRejectedValue(new Error('Rejected test-secret'))
+ } else {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(JSON.stringify({ success: false, msg: 'Rejected test-secret' }))
+ )
+ }
+ const result = await fetchZcodeRateLimits({ configPath })
+ expect(result.error).toBe('Rejected [redacted]')
+ expect(JSON.stringify(result)).not.toContain('test-secret')
+ })
})
diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts
index 852def603e32..2eba29a4f98a 100644
--- a/src/main/rate-limits/zcode-usage-fetcher.ts
+++ b/src/main/rate-limits/zcode-usage-fetcher.ts
@@ -26,6 +26,14 @@ type ZcodeUsageCredentials = {
authProvenance: string
}
+/** A GLM Coding Plan key saved through Orca's AI Provider Accounts; takes priority over the ZCode CLI config. */
+export type ZcodePlanCredential = {
+ apiKey: string
+ baseUrl: string
+}
+
+export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan'
+
// Why readers and not casts: both JSON sources are outside our control — a user-edited
// config file and a remote response — so their shape is a guess until something checks it.
function isRecord(value: unknown): value is Record {
@@ -75,6 +83,10 @@ function failed(
}
}
+function redactCredential(error: string, apiKey: string): string {
+ return error.replaceAll(apiKey, '[redacted]')
+}
+
function readCredentials(configPath: string): ZcodeUsageCredentials | null {
let config: Record | null
try {
@@ -102,8 +114,20 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null {
) {
return null
}
+ return resolveUsageCredentials(apiKey, baseURL, mainProvider)
+}
+
+function resolveUsageCredentials(
+ key: string,
+ baseUrl: string,
+ identity: string
+): ZcodeUsageCredentials | null {
+ const apiKey = key.trim()
+ if (!apiKey || /[\r\n]/.test(apiKey)) {
+ return null
+ }
try {
- const parsed = new URL(baseURL)
+ const parsed = new URL(baseUrl)
if (
parsed.protocol !== 'https:' ||
!SUPPORTED_HOSTS.has(parsed.hostname) ||
@@ -112,10 +136,10 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null {
return null
}
return {
- apiKey: apiKey.trim(),
+ apiKey,
quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`,
authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY)
- .update(JSON.stringify([mainProvider, parsed.origin, apiKey.trim()]))
+ .update(JSON.stringify([identity, parsed.origin, apiKey]))
.digest('hex')
}
} catch {
@@ -123,6 +147,12 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null {
}
}
+export function hasZcodeCliPlanCredentials(
+ configPath = join(homedir(), '.zcode', 'cli', 'config.json')
+): boolean {
+ return readCredentials(configPath) !== null
+}
+
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
@@ -182,14 +212,28 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null {
export async function fetchZcodeRateLimits(
options: {
configPath?: string
+ planCredential?: ZcodePlanCredential | null
signal?: AbortSignal
} = {}
): Promise {
const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json')
- const credentials = readCredentials(configPath)
+ const planCredentials = options.planCredential
+ ? resolveUsageCredentials(
+ options.planCredential.apiKey,
+ options.planCredential.baseUrl,
+ ZCODE_PLAN_CREDENTIAL_SOURCE
+ )
+ : null
+ if (!planCredentials && options.planCredential) {
+ // Why: a saved-but-unusable key must surface as its own error; silently
+ // falling back to the CLI config would show a different account's quota.
+ return failed('The saved GLM Coding Plan API key is unusable', 'parse', '')
+ }
+ const credentials = planCredentials ?? readCredentials(configPath)
if (!credentials) {
return unavailable('ZCode Coding Plan credentials are not configured')
}
+ const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath
let response: Response
try {
@@ -208,7 +252,10 @@ export async function fetchZcodeRateLimits(
})
} catch (error) {
return failed(
- error instanceof Error ? error.message : 'ZCode quota request failed',
+ redactCredential(
+ error instanceof Error ? error.message : 'ZCode quota request failed',
+ credentials.apiKey
+ ),
'network',
credentials.authProvenance
)
@@ -239,7 +286,11 @@ export async function fetchZcodeRateLimits(
) {
const msg = payload?.msg
const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response'
- return failed(message, 'parse', credentials.authProvenance)
+ return failed(
+ redactCredential(message, credentials.apiKey),
+ 'parse',
+ credentials.authProvenance
+ )
}
const limits = reported.filter((value): value is QuotaLimit => isRecord(value))
@@ -270,7 +321,7 @@ export async function fetchZcodeRateLimits(
status: 'ok',
usageMetadata: {
source: 'web',
- credentialSource: configPath,
+ credentialSource,
authProvenance: credentials.authProvenance
}
}
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/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/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts
index d29b9afea1ee..a312fc66dbe5 100644
--- a/src/main/runtime/runtime-client-settings.ts
+++ b/src/main/runtime/runtime-client-settings.ts
@@ -48,6 +48,7 @@ export type RuntimeClientSettings = Pick<
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
+ | 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'artifactSharingEnabled'
| 'worktreeVisibilityDefaults'
@@ -84,6 +85,7 @@ export type RuntimeClientSettingsUpdate = Pick<
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
+ | 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'worktreeVisibilityDefaults'
| 'machineName'
@@ -132,6 +134,7 @@ export class RuntimeClientSettingsController {
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas',
+ zcodePlanSite: settings.zcodePlanSite ?? 'zai',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts
index 43fe59b34e1a..a7f9af2a9069 100644
--- a/src/main/runtime/runtime-store-contract.ts
+++ b/src/main/runtime/runtime-store-contract.ts
@@ -103,6 +103,7 @@ export type RuntimeStore = {
minimaxGroupId?: GlobalSettings['minimaxGroupId']
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
minimaxEndpoint?: GlobalSettings['minimaxEndpoint']
+ zcodePlanSite?: GlobalSettings['zcodePlanSite']
prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides']
artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled']
terminalQuickCommands?: GlobalSettings['terminalQuickCommands']
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/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
diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts
index b32c54e41546..63f32663f734 100644
--- a/src/main/startup/main-process-account-services.ts
+++ b/src/main/startup/main-process-account-services.ts
@@ -15,6 +15,12 @@ import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limi
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'
@@ -87,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 (
@@ -102,6 +117,17 @@ export function initializeMainProcessAccountServices(): void {
)
})
}
+ // Why: the site picks the GLM Coding Plan quota host, so a stale snapshot from
+ // the previous site would otherwise sit in the status bar until the next poll.
+ if ('zcodePlanSite' in updates) {
+ state.rateLimits?.invalidateZcodeCredentialState()
+ void state.rateLimits?.refresh().catch((error: unknown) => {
+ console.warn(
+ '[rate-limits] Failed to refresh GLM Coding Plan usage after a settings change:',
+ error
+ )
+ })
+ }
})
state.rateLimits.setClaudeAuthPreparationResolver((target) =>
state.claudeRuntimeAuth!.prepareForRateLimitFetch(target)
@@ -110,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() ?? ''
@@ -129,6 +159,10 @@ export function initializeMainProcessAccountServices(): void {
apiKey
}
})
+ state.rateLimits.setZcodePlanConfigResolver(() => ({
+ site: store.getSettings().zcodePlanSite ?? 'zai',
+ apiKey: readZcodePlanApiKey() ?? ''
+ }))
state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled)
// Reuse the meter switch so hidden Antigravity usage does not spawn agy.
state.rateLimits.setAntigravityUsageEnabledResolver(() =>
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/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts
new file mode 100644
index 000000000000..ed53cd28e7b3
--- /dev/null
+++ b/src/main/zcode/zcode-plan-api-key-store.test.ts
@@ -0,0 +1,206 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store'
+
+const safeStorageMock = vi.hoisted(() => ({
+ isEncryptionAvailable: vi.fn(() => true),
+ encryptString: vi.fn((value: string) => Buffer.from(value)),
+ decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
+}))
+
+const electronMock = vi.hoisted(() => ({
+ safeStorage: safeStorageMock
+}))
+
+vi.mock('electron', () => electronMock)
+
+const existsSyncMock = vi.fn()
+const readFileSyncMock = vi.fn()
+const rmSyncMock = vi.fn()
+const hardenExistingSecureFileMock = vi.fn()
+const writeSecureFileMock = vi.fn()
+const homedirMock = vi.fn(() => '/home/test')
+
+vi.mock('node:fs', () => ({
+ existsSync: existsSyncMock,
+ readFileSync: readFileSyncMock,
+ rmSync: rmSyncMock
+}))
+
+vi.mock('node:os', () => ({
+ homedir: homedirMock
+}))
+
+vi.mock('node:path', () => ({
+ join: (...parts: string[]) => parts.join('/')
+}))
+
+vi.mock('../../shared/secure-file', () => ({
+ hardenExistingSecureFile: hardenExistingSecureFileMock,
+ writeSecureFile: writeSecureFileMock
+}))
+
+const storePath = '/home/test/.orca/zcode-plan-api-key.enc'
+const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
+ `orca-zcode-plan-api-key:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
+
+async function loadStore(): Promise {
+ return await import('./zcode-plan-api-key-store')
+}
+
+describe('zcode-plan-api-key-store', () => {
+ beforeEach(() => {
+ existsSyncMock.mockReset()
+ readFileSyncMock.mockReset()
+ rmSyncMock.mockReset()
+ hardenExistingSecureFileMock.mockReset()
+ writeSecureFileMock.mockReset()
+ writeSecureFileMock.mockReturnValue(true)
+ safeStorageMock.isEncryptionAvailable.mockReset()
+ safeStorageMock.encryptString.mockReset()
+ safeStorageMock.decryptString.mockReset()
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
+ safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
+ safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
+ homedirMock.mockReturnValue('/home/test')
+ vi.resetModules()
+ })
+
+ it('reports unconfigured while no key file exists', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ expect(store.hasZcodePlanApiKey()).toBe(false)
+ expect(store.readZcodePlanApiKey()).toBeNull()
+ })
+
+ it('saves an encrypted envelope and reads it back through the cache', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ store.saveZcodePlanApiKey(' glm-secret ')
+
+ expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('encrypted', 'glm-secret'))
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret')))
+ expect(store.hasZcodePlanApiKey()).toBe(true)
+ expect(store.readZcodePlanApiKey()).toBe('glm-secret')
+ })
+
+ it('warns and writes plaintext when safeStorage is unavailable', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ store.saveZcodePlanApiKey('glm-secret')
+
+ expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', 'glm-secret'))
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
+ warn.mockRestore()
+ })
+
+ it('reads a plaintext envelope back without requiring safeStorage', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'glm-secret')))
+ const store = await loadStore()
+
+ expect(store.readZcodePlanApiKey()).toBe('glm-secret')
+ expect(safeStorageMock.decryptString).not.toHaveBeenCalled()
+ })
+
+ it.each(['encrypted', 'plaintext'] as const)(
+ 'reports %s protection without decrypting',
+ async (kind) => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope(kind, 'glm-secret')))
+ const store = await loadStore()
+ expect(store.getZcodePlanApiKeyProtection()).toBe(
+ kind === 'encrypted' ? 'sealed' : 'plaintext'
+ )
+ expect(safeStorageMock.decryptString).not.toHaveBeenCalled()
+ }
+ )
+
+ it('rejects saving an empty key', async () => {
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey(' ')).toThrow('GLM Coding Plan API key is required')
+ expect(writeSecureFileMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects a key with an interior newline instead of saving it', async () => {
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey('glm\r\nsecret')).toThrow('must be a single line')
+ expect(writeSecureFileMock).not.toHaveBeenCalled()
+ })
+
+ it('refuses to keep an unrestricted plaintext key when hardening fails', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ writeSecureFileMock.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey('glm-secret')).toThrow(
+ 'could not be stored securely on this device'
+ )
+ expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
+ warn.mockRestore()
+ })
+
+ it('restores the previous envelope when a plaintext replacement cannot be restricted', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ const previous = Buffer.from(envelope('encrypted', 'old-key'))
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(previous)
+ writeSecureFileMock.mockReturnValueOnce(false).mockReturnValueOnce(true)
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey('new-key')).toThrow(
+ 'could not be stored securely on this device'
+ )
+ // First write publishes the unrestricted replacement; the second restores
+ // the previous envelope so the user's old key survives the failed replace.
+ expect(writeSecureFileMock).toHaveBeenCalledTimes(2)
+ expect(writeSecureFileMock).toHaveBeenLastCalledWith(storePath, previous.toString('utf8'))
+ expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything())
+ warn.mockRestore()
+ })
+
+ it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret')))
+ const store = await loadStore()
+
+ expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted')
+ })
+
+ it('throws on an unreadable envelope instead of returning a partial key', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from('not-an-envelope'))
+ const store = await loadStore()
+
+ expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted')
+ })
+
+ it('clearing removes the file and resets the cached value', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ store.saveZcodePlanApiKey('glm-secret')
+
+ existsSyncMock.mockReturnValue(true)
+ store.clearZcodePlanApiKey()
+
+ expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
+ existsSyncMock.mockReturnValue(false)
+ expect(store.readZcodePlanApiKey()).toBeNull()
+ })
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts
new file mode 100644
index 000000000000..45c4ad315ac6
--- /dev/null
+++ b/src/main/zcode/zcode-plan-api-key-store.ts
@@ -0,0 +1,169 @@
+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 ZCODE_PLAN_API_KEY_FILE = 'zcode-plan-api-key.enc'
+const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:'
+let cachedZcodePlanApiKey: string | null = null
+let warnedZcodePlanApiKeyStatusHardenFailure = false
+
+type ZcodePlanApiKeyEnvelope = {
+ kind: 'encrypted' | 'plaintext'
+ payload: Buffer
+}
+
+function getZcodePlanApiKeyPath(): string {
+ return join(homedir(), '.orca', ZCODE_PLAN_API_KEY_FILE)
+}
+
+function encodeApiKeyEnvelope(kind: ZcodePlanApiKeyEnvelope['kind'], payload: Buffer): string {
+ return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
+}
+
+function decodeApiKeyEnvelope(raw: Buffer): ZcodePlanApiKeyEnvelope {
+ const text = raw.toString('utf8')
+ if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) {
+ throw new Error('GLM Coding Plan 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('GLM Coding Plan API key could not be decrypted')
+ }
+ const kind = rest.slice(0, separator)
+ if (kind !== 'encrypted' && kind !== 'plaintext') {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+ return {
+ kind,
+ payload: Buffer.from(rest.slice(separator + 1), 'base64')
+ }
+}
+
+function readEnvelope(envelope: ZcodePlanApiKeyEnvelope): string {
+ if (envelope.kind === 'plaintext') {
+ return envelope.payload.toString('utf8')
+ }
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+ return safeStorage.decryptString(envelope.payload)
+}
+
+export function hasZcodePlanApiKey(): boolean {
+ const keyPath = getZcodePlanApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return false
+ }
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ if (!warnedZcodePlanApiKeyStatusHardenFailure) {
+ warnedZcodePlanApiKeyStatusHardenFailure = true
+ console.warn(
+ '[zcode] Failed to harden GLM Coding Plan API key file while checking status',
+ error
+ )
+ }
+ }
+ return true
+}
+
+export function getZcodePlanApiKeyProtection(): SecretAtRestProtection | null {
+ const keyPath = getZcodePlanApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return null
+ }
+ try {
+ return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed'
+ } catch {
+ return null
+ }
+}
+
+export function saveZcodePlanApiKey(key: string): void {
+ const trimmed = key.trim()
+ if (!trimmed) {
+ throw new Error('GLM Coding Plan API key is required')
+ }
+ if (/[\r\n]/.test(trimmed)) {
+ throw new Error('GLM Coding Plan API key must be a single line')
+ }
+ if (safeStorage.isEncryptionAvailable()) {
+ writeSecureFile(
+ getZcodePlanApiKeyPath(),
+ encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed))
+ )
+ cachedZcodePlanApiKey = trimmed
+ return
+ }
+ console.warn(
+ '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext'
+ )
+ const keyPath = getZcodePlanApiKeyPath()
+ // Why: capture the previous envelope — writeSecureFile has already replaced
+ // the file by the time it reports that restriction failed, and deleting the
+ // result must not take the user's previous working key with it.
+ let previousEnvelope: Buffer | null = null
+ if (existsSync(keyPath)) {
+ try {
+ previousEnvelope = readFileSync(keyPath)
+ } catch {
+ previousEnvelope = null
+ }
+ }
+ const wroteRestricted = writeSecureFile(
+ keyPath,
+ encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
+ )
+ // Why: an unrestricted plaintext credential must never be reported as saved.
+ if (!wroteRestricted) {
+ if (!previousEnvelope) {
+ rmSync(keyPath, { force: true })
+ } else {
+ try {
+ writeSecureFile(keyPath, previousEnvelope.toString('utf8'))
+ } catch {
+ // Why: restriction is failing device-wide; the restored bytes keep the
+ // previous credential available instead of deleting it, and the thrown
+ // save error still tells the user the store is not secure.
+ }
+ }
+ throw new Error('GLM Coding Plan API key could not be stored securely on this device')
+ }
+ cachedZcodePlanApiKey = trimmed
+}
+
+export function readZcodePlanApiKey(): string | null {
+ if (cachedZcodePlanApiKey !== null) {
+ return cachedZcodePlanApiKey
+ }
+ const keyPath = getZcodePlanApiKeyPath()
+ 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 hasZcodePlanApiKey).
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ console.warn('[zcode] Failed to harden GLM Coding Plan API key file while reading', error)
+ }
+ try {
+ const raw = readFileSync(keyPath)
+ const envelope = decodeApiKeyEnvelope(raw)
+ cachedZcodePlanApiKey = readEnvelope(envelope)
+ return cachedZcodePlanApiKey
+ } catch (error) {
+ console.error('[zcode] failed to decode/decrypt GLM Coding Plan API key', error)
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+}
+
+export function clearZcodePlanApiKey(): void {
+ cachedZcodePlanApiKey = null
+ rmSync(getZcodePlanApiKeyPath(), { force: true })
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 220b90c18a6c..cf5060fab898 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -4,7 +4,8 @@ import type {
CodexConfigSyncApi,
CursorAccountsApi,
GrokAccountsApi,
- MinimaxCredentialsApi
+ MinimaxCredentialsApi,
+ ZcodePlanCredentialsApi
} from './api/agent-account-api'
import type { HooksApi } from './api/agent-hook-api'
import type { SkillsApi } from './api/agent-skill-api'
@@ -141,7 +142,13 @@ 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
cursorAccounts: CursorAccountsApi
ssh: SshApi
diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts
index 9c8087f4b916..052fcdf2edc3 100644
--- a/src/preload/api/agent-account-api.ts
+++ b/src/preload/api/agent-account-api.ts
@@ -5,6 +5,7 @@ import type {
} from '../../shared/managed-account-types'
import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types'
import type { CursorAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types'
+import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'
export type CodexAccountsApi = {
list: () => Promise
@@ -96,6 +97,15 @@ export type MinimaxCredentialsApi = {
}>
}
+export type ZcodePlanCredentialsApi = {
+ // Why: the GLM Coding Plan key lives in its own safeStorage file and the
+ // ZCode CLI's config is read-only, so the status reports both sources'
+ // presence; neither credential value ever crosses the IPC boundary.
+ getStatus: () => Promise
+ saveApiKey: (key: string) => Promise
+ clearApiKey: () => Promise
+}
+
export type CodexConfigSyncApi = {
status: () => Promise
}
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/api/zcode-plan-credentials-bridge.ts b/src/preload/api/zcode-plan-credentials-bridge.ts
new file mode 100644
index 000000000000..cee36fa7b870
--- /dev/null
+++ b/src/preload/api/zcode-plan-credentials-bridge.ts
@@ -0,0 +1,12 @@
+import { ipcRenderer } from 'electron'
+import type { PreloadApi } from '../api-types'
+import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'
+
+export const zcodePlanCredentialsApi = {
+ getStatus: (): Promise =>
+ ipcRenderer.invoke('zcodePlanCredentials:getStatus'),
+ saveApiKey: (key: string): Promise =>
+ ipcRenderer.invoke('zcodePlanCredentials:saveApiKey', key),
+ clearApiKey: (): Promise =>
+ ipcRenderer.invoke('zcodePlanCredentials:clearApiKey')
+} satisfies PreloadApi['zcodePlanCredentials']
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 42cf236c14db..5f64a1e2e351 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -75,7 +75,9 @@ 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'
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
import { sshApi } from './api/ssh-bridge'
@@ -174,7 +176,9 @@ const api = {
runtime: runtimeApi,
runtimeEnvironments: runtimeEnvironmentsApi,
rateLimits: rateLimitsApi,
+ opencodeGoCredentials: opencodeGoCredentialsApi,
minimaxCredentials: minimaxCredentialsApi,
+ zcodePlanCredentials: zcodePlanCredentialsApi,
grokAccounts: grokAccountsApi,
cursorAccounts: cursorAccountsApi,
ssh: sshApi,
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/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/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/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/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/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx
index 303ab2472c79..1f3ded16110c 100644
--- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx
@@ -39,7 +39,8 @@ const mocks = {
sendAnswer: vi.fn(),
sendRaw: vi.fn(),
cancelPending: vi.fn(),
- cancel: vi.fn()
+ cancel: vi.fn(),
+ cancelAsk: vi.fn()
}
function renderCard(canSend = true): ReturnType {
@@ -90,7 +91,8 @@ function CardHarness({
sendAnswer: mocks.sendAnswer,
sendRaw: mocks.sendRaw,
cancelPending: mocks.cancelPending,
- cancel: mocks.cancel
+ cancel: mocks.cancel,
+ cancelAsk: mocks.cancelAsk
}}
/>
)
@@ -135,6 +137,16 @@ function chooseSpacesAndSubmit(): void {
}
describe('NativeChatInteractiveCard answer lifecycle', () => {
+ it('routes question Cancel to rejection and releases the composer slot without Stop', () => {
+ const onShowingQuestionChange = vi.fn()
+ render(cardElement(true, undefined, onShowingQuestionChange))
+ expect(screen.getByTestId('native-chat-question-card-title')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(mocks.cancelAsk).toHaveBeenCalledOnce()
+ expect(mocks.cancel).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('native-chat-question-card-title')).not.toBeInTheDocument()
+ expect(onShowingQuestionChange).toHaveBeenLastCalledWith(false)
+ })
beforeEach(() => {
vi.clearAllMocks()
storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT
diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx
index 9d57a6ab5e10..6411bb1e9b1d 100644
--- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx
@@ -36,7 +36,7 @@ export function NativeChatInteractiveCard({
* a target while the composer is unmounted. */
answerInputRef?: React.RefObject
}): React.JSX.Element | null {
- const { sendAnswer, sendRaw, cancelPending, cancel } = send
+ const { sendAnswer, sendRaw, cancelPending, cancelAsk } = send
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState(null)
// A question answer is a paced multi-step write (body→Enter per question); keep
@@ -135,7 +135,7 @@ export function NativeChatInteractiveCard({
onCancel={() => {
clearDismissTimer()
setDismissedKey(cardKey)
- cancel()
+ cancelAsk()
}}
/>
)
diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts
index a08bc476d2f4..737d7d4a0774 100644
--- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts
@@ -117,19 +117,16 @@ describe('canToggleNativeChat', () => {
).toBe(true)
})
- it.each(['gemini', 'opencode'] as const)(
- 'rejects unsupported agent %s detected live',
- (agent) => {
- expect(
- canToggleNativeChat({
- experimentalNativeChatEnabled: true,
- contentType: 'terminal',
- launchAgent: null,
- detectedAgent: agent
- })
- ).toBe(false)
- }
- )
+ it.each(['gemini'] as const)('rejects unsupported agent %s detected live', (agent) => {
+ expect(
+ canToggleNativeChat({
+ experimentalNativeChatEnabled: true,
+ contentType: 'terminal',
+ launchAgent: null,
+ detectedAgent: agent
+ })
+ ).toBe(false)
+ })
it('accepts Grok when resolved from the title', () => {
expect(
diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts
index 3921d862526c..4cbc4c692eae 100644
--- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts
@@ -295,3 +295,10 @@ describe('hasAskAnswer', () => {
expect(hasAskAnswer(single(['A', 'B']), [])).toBe(false)
})
})
+
+for (const agent of ['opencode', 'opencode2']) {
+ it(`${agent} approves the captured default selector with Enter`, () => {
+ const card = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'shell' } }), agent)
+ expect(card?.options[0].send).toBe('\r')
+ })
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts
index 038ce1e7523b..6454dd8257b8 100644
--- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts
+++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts
@@ -1,3 +1,4 @@
+import { nativeChatApprovalAcceptKey } from '../../../../shared/native-chat-agent-support'
import { translate } from '@/i18n/i18n'
import type {
AgentJournalApprovalMatchedAskRule,
@@ -54,7 +55,8 @@ const ESCAPE = String.fromCharCode(27)
/** Parse the desktop-only approval envelope; question parsing stays cross-platform. */
export function parseApprovalFromStatus(
- interactivePrompt: string | undefined | null
+ interactivePrompt: string | undefined | null,
+ agent?: string
): ChatApproval | null {
if (!interactivePrompt) {
return null
@@ -83,7 +85,10 @@ export function parseApprovalFromStatus(
}),
detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined,
options: [
- { label: translate('components.native-chat.approval.allow', 'Allow'), send: '1' },
+ {
+ label: translate('components.native-chat.approval.allow', 'Allow'),
+ send: nativeChatApprovalAcceptKey(agent)
+ },
{ label: translate('components.native-chat.approval.deny', 'Deny'), send: ESCAPE }
]
}
@@ -91,12 +96,13 @@ export function parseApprovalFromStatus(
export function parseInteractivePrompt(
interactivePrompt: string | undefined | null,
- toolName?: string
+ toolName?: string,
+ agent?: string
): InteractivePromptCard {
const prompt = parseAskFromStatus(interactivePrompt, toolName)
if (prompt) {
return { kind: 'question', prompt }
}
- const approval = parseApprovalFromStatus(interactivePrompt)
+ const approval = parseApprovalFromStatus(interactivePrompt, agent)
return approval ? { kind: 'approval', approval } : null
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts
index 243fea0ac577..4f59534cf6eb 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts
@@ -33,8 +33,13 @@ export function useNativeChatInteractivePromptCard({
const entry = s.agentStatusByPaneKey[paneKey]
return entry?.interactivePrompt ? (entry.toolName ?? null) : null
})
+ const agent = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.agentType)
return useMemo(() => {
- const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined)
+ const statusCard = parseInteractivePrompt(
+ interactivePrompt,
+ interactiveToolName ?? undefined,
+ agent
+ )
if (statusCard?.kind === 'approval') {
return statusCard
}
@@ -44,5 +49,5 @@ export function useNativeChatInteractivePromptCard({
transcriptSettled
})
return prompt ? { kind: 'question' as const, prompt } : null
- }, [interactivePrompt, interactiveToolName, messages, transcriptSettled])
+ }, [interactivePrompt, interactiveToolName, agent, messages, transcriptSettled])
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
index 913e8fe9fea1..4fe977a22fb5 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
@@ -184,6 +184,58 @@ describe('useNativeChatInteractiveSend', () => {
)
})
+ it.each(['opencode', 'opencode2'] as const)(
+ 'paces two Escape writes for %s Stop and cancels them on rebind',
+ (agent) => {
+ const { result, rerender } = renderHook(
+ ({ ptyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, ptyId, agent),
+ { initialProps: { ptyId: 'pty-1' } }
+ )
+ act(() => result.current.cancel())
+ expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
+ { terminalTabId: 'tab-1' },
+ 'pty-1',
+ [{ raw: '\x1b' }, { raw: '\x1b' }]
+ )
+ rerender({ ptyId: 'pty-2' })
+ expect(mocks.cancel).toHaveBeenCalledOnce()
+ }
+ )
+
+ it.each(['opencode', 'opencode2'] as const)(
+ 'rejects a %s question with one Escape and no delayed Stop',
+ (agent) => {
+ const { result } = renderHook(() =>
+ useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent)
+ )
+ act(() => result.current.cancelAsk())
+ expect(mocks.sendRuntimePtyInput).toHaveBeenCalledExactlyOnceWith(
+ { terminalTabId: 'tab-1' },
+ 'pty-1',
+ '\x1b',
+ 'driving'
+ )
+ expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each(['opencode', 'opencode2'] as const)(
+ 'delivers a non-default %s answer through selector keys',
+ (agent) => {
+ const { result } = renderHook(() =>
+ useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent)
+ )
+ act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
+ expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
+ { terminalTabId: 'tab-1' },
+ 'pty-1',
+ [{ raw: '2' }],
+ expect.any(Function)
+ )
+ expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
+ }
+ )
+
it('can cancel delayed writes without interrupting the replacement prompt', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
index 3d270cde49bf..d0930253ab26 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
@@ -38,7 +38,9 @@ export type NativeChatInteractiveSend = {
sendRaw: (raw: string) => void
/** Stop delayed writes without interrupting the agent. */
cancelPending: () => void
- /** Send ESC to interrupt — cancels a question / denies an approval. */
+ /** Reject the active question without requesting session interruption. */
+ cancelAsk: () => void
+ /** Interrupt the active turn. */
cancel: () => void
}
@@ -46,7 +48,7 @@ export type NativeChatInteractiveSend = {
* Reuse the desktop composer's exact send path for the interactive cards:
* resolve this tab's live ptyId + runtime owner settings, then write bytes via
* `sendRuntimePtyInput` (which branches local pty:write vs remote runtime RPC,
- * so SSH panes work unchanged). Claude and Codex answers use their respective
+ * so SSH panes work unchanged). Selector answers use their respective
* selector keystrokes via `sendNativeChatAskAnswer`; other agents still go through
* `sendNativeChatMessage`. Control strings (option digits, ESC) are written raw.
*/
@@ -98,8 +100,7 @@ export function useNativeChatInteractiveSend(
// Cancel any prior in-flight answer before starting a new one.
cancelInFlight()
const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId)
- // Claude and Codex ignore pasted labels but have different selector state
- // machines; Grok commits pasted text. OpenClaude follows Claude's path.
+ // Selector TUIs ignore pasted labels; Codex uses a different key sequence.
const stepsAnswer = shouldStepNativeChatAskAnswer(agent)
const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex'
// Why: pin the answered question's baseline BEFORE delivery. A late settle
@@ -156,11 +157,24 @@ export function useNativeChatInteractiveSend(
[terminalTabId, paneKey, targetPtyId, agent, cancelInFlight]
)
- // Stop/cancel: drop any pending answer writes, then send ESC to interrupt.
- const cancel = useCallback(() => {
+ const cancelAsk = useCallback(() => {
cancelInFlight()
sendRaw(ESC)
}, [cancelInFlight, sendRaw])
- return { sendAnswer, sendRaw, cancelPending: cancelInFlight, cancel }
+ const cancel = useCallback(() => {
+ cancelInFlight()
+ if (resolveNativeChatTranscriptAgent(agent) === 'opencode' && targetPtyId) {
+ // OpenCode confirms interruption with a second Escape; pace writes like mobile Stop.
+ inFlightRef.current = sendNativeChatAskAnswer(
+ getSettingsForAgentTabRuntimeOwner(terminalTabId),
+ targetPtyId,
+ [{ raw: ESC }, { raw: ESC }]
+ )
+ return
+ }
+ sendRaw(ESC)
+ }, [agent, cancelInFlight, sendRaw, targetPtyId, terminalTabId])
+
+ return { sendAnswer, sendRaw, cancelPending: cancelInFlight, cancelAsk, cancel }
}
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-continuation.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts
index 28ccfbe056fe..b978d9ad2b84 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts
@@ -1,3 +1,7 @@
+import {
+ buildAgentSessionContinuationPrompt,
+ hasFullAgentSessionContext
+} from '@/lib/agent-session-continuation'
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
@@ -65,6 +69,33 @@ describe('AI Vault session continuation', () => {
expect(request.source.capturedText).toContain('assistant: The component tests still need work.')
})
+ it.each(['opencode', 'opencode2'] as const)(
+ 'uses bounded %s vault previews for plain and synthetic database paths',
+ (agent) => {
+ for (const filePath of [
+ '/data/opencode/opencode.db',
+ '/data/opencode/opencode.db#session',
+ '/data/private-account.db'
+ ]) {
+ const sourceSession = { ...session(agent), filePath }
+ const request = prepareAiVaultSessionContinuation({
+ session: sourceSession,
+ targetWorktreeId: 'folder:project',
+ targetWorkspacePath: '/project'
+ })
+ expect(hasFullAgentSessionContext(request.source)).toBe(false)
+ expect(buildAgentSessionContinuationPrompt(request.source, 'focused')).toContain(
+ 'The component tests still need work.'
+ )
+ expect(buildAgentSessionContinuationPrompt(request.source, 'focused')).not.toContain(
+ filePath
+ )
+ sourceSession.previewMessages = []
+ expect(canContinueAiVaultSessionInNewSession(sourceSession, 'folder:project')).toBe(false)
+ }
+ }
+ )
+
it('never treats a preview tool result as the user prompt', () => {
const sourceSession = session()
sourceSession.lastUserPrompt = null
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts
index 3baff2bf272c..26f2549ab74c 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts
@@ -7,7 +7,7 @@ export function canContinueAiVaultSessionInNewSession(
): boolean {
return Boolean(
targetWorktreeId &&
- (session.filePath.trim() || session.previewMessages.some((message) => message.text.trim()))
+ (vaultTranscriptPath(session) || session.previewMessages.some((message) => message.text.trim()))
)
}
@@ -23,7 +23,7 @@ export function prepareAiVaultSessionContinuation(args: {
sourceAgent: session.agent,
sourceTitle: session.title,
sourceWorkingDirectory: session.cwd,
- transcriptPath: session.filePath.trim() || null,
+ transcriptPath: vaultTranscriptPath(session),
// Why: preview user entries can be tool results or injected skill text; only provider-authenticated prompts are safe hints.
lastPrompt: session.lastUserPrompt ?? null,
lastAssistantMessage: latestAssistantPreview(session)
@@ -46,3 +46,11 @@ function previewTranscript(session: AiVaultSession): string {
.map((message) => `${message.role}: ${message.text.trim()}`)
.join('\n\n')
}
+
+function vaultTranscriptPath(session: AiVaultSession): string | null {
+ // OpenCode vault paths identify its database or storage metadata, not a full transcript.
+ if (session.agent === 'opencode' || session.agent === 'opencode2') {
+ return null
+ }
+ return session.filePath.trim() || null
+}
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/settings/AccountsPane.section-lifetime.test.tsx b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx
index ff46d9637327..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,9 +83,21 @@ 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 }))
},
+ zcodePlanCredentials: {
+ getStatus: vi.fn(async () => ({
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ }))
+ },
codexConfigSync: {
status: vi.fn(async () => ({
state: 'synced',
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 634131a3849c..e99453afabbe 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -25,7 +25,8 @@ import {
getAccountsLocationSearchEntries,
getAccountsMiniMaxSearchEntries,
getAccountsOpencodeSearchEntries,
- getAccountsPaneSearchEntries
+ getAccountsPaneSearchEntries,
+ getAccountsZcodePlanSearchEntries
} from './accounts-search'
import { getRemoteAccountsPaneScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
@@ -42,6 +43,7 @@ import { GrokAccountsSection } from './GrokAccountsSection'
import { AntigravityAccountsSection } from './AntigravityAccountsSection'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { CursorAccountsSection } from './CursorAccountsSection'
+import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection'
import type {
AccountsPaneProps,
AccountsPaneSectionModel,
@@ -406,6 +408,9 @@ export function AccountsPane({
) : null,
matchesSettingsSearch(searchQuery, getAccountsCursorSearchEntries()) ? (
+ ) : null,
+ matchesSettingsSearch(searchQuery, getAccountsZcodePlanSearchEntries()) ? (
+
) : null
]
diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx
index e18f9ed2d425..18c4ab37182d 100644
--- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx
+++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx
@@ -17,7 +17,9 @@ const EXPECTED_SUPPORTED_AGENTS = [
'openclaude',
'codex',
'grok',
- 'omp'
+ 'omp',
+ 'opencode',
+ 'opencode2'
] as const satisfies readonly TuiAgent[]
const SUPPORTED_AGENTS_LABEL_KEY = 'auto.components.settings.NativeChatSupportedAgents.label'
@@ -41,7 +43,7 @@ describe('NativeChatSupportedAgents', () => {
expect(NATIVE_CHAT_SUPPORTED_AGENT_LIST).toEqual(EXPECTED_SUPPORTED_AGENTS)
for (const entry of getAgentCatalog()) {
expect(isNativeChatSupportedAgent(entry.id), entry.id).toBe(
- EXPECTED_SUPPORTED_AGENTS.includes(entry.id as (typeof EXPECTED_SUPPORTED_AGENTS)[number])
+ EXPECTED_SUPPORTED_AGENTS.some((agent) => agent === entry.id)
)
}
})
@@ -58,15 +60,15 @@ describe('NativeChatSupportedAgents', () => {
}
})
- it('omits agents native chat cannot render, including OpenCode', () => {
+ it('omits agents outside the supported transcript contract', () => {
const rendered = getRenderedChips().map((chip) => chip.agent)
for (const entry of getAgentCatalog()) {
- if (!isNativeChatSupportedAgent(entry.id)) {
+ if (!EXPECTED_SUPPORTED_AGENTS.some((agent) => agent === entry.id)) {
expect(rendered).not.toContain(entry.id)
}
}
- expect(rendered).not.toContain('opencode')
+ expect(rendered).not.toContain('cursor')
})
it('keeps the label in the English catalog', () => {
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
new file mode 100644
index 000000000000..32c277b72ced
--- /dev/null
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
@@ -0,0 +1,282 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+
+import React from 'react'
+import type { ZcodePlanSite } from '../../../../shared/zcode-plan-sites'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => {
+ const settings: { zcodePlanSite?: ZcodePlanSite } = { zcodePlanSite: 'zai' }
+ return {
+ isWeb: vi.fn(() => false),
+ getStatus: vi.fn(),
+ saveApiKey: vi.fn(),
+ clearApiKey: vi.fn(),
+ refreshRateLimits: vi.fn(),
+ updateSettings: vi.fn(),
+ recordFeatureInteraction: vi.fn(),
+ zcodeUsage: vi.fn<() => unknown>(() => null),
+ settings
+ }
+})
+
+vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: mocks.isWeb }))
+
+vi.mock('@/lib/agent-catalog', () => ({
+ AgentIcon: () => React.createElement('span', { 'data-testid': 'zcode-icon' })
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string, values?: Record) => {
+ let result = fallback
+ for (const [key, value] of Object.entries(values ?? {})) {
+ result = result.replace(`{{${key}}}`, value)
+ }
+ return result
+ }
+}))
+
+vi.mock('../../store', () => ({
+ useAppStore: (selector: (state: Record) => unknown) =>
+ selector({
+ refreshRateLimits: mocks.refreshRateLimits,
+ updateSettings: mocks.updateSettings,
+ recordFeatureInteraction: mocks.recordFeatureInteraction,
+ settingsSearchQuery: '',
+ settings: mocks.settings,
+ rateLimits: { zcode: mocks.zcodeUsage() }
+ })
+}))
+
+import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection'
+
+describe('ZcodePlanAccountsSection', () => {
+ beforeEach(() => {
+ mocks.isWeb.mockReturnValue(false)
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
+ mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
+ mocks.refreshRateLimits.mockResolvedValue(undefined)
+ mocks.updateSettings.mockResolvedValue(undefined)
+ mocks.recordFeatureInteraction.mockReset()
+ mocks.zcodeUsage.mockReturnValue(null)
+ mocks.settings.zcodePlanSite = 'zai'
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ zcodePlanCredentials: {
+ getStatus: mocks.getStatus,
+ saveApiKey: mocks.saveApiKey,
+ clearApiKey: mocks.clearApiKey
+ }
+ }
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('disables site and secret editing on a paired web client', async () => {
+ mocks.isWeb.mockReturnValue(true)
+ mocks.settings.zcodePlanSite = 'bigmodel'
+ render( )
+ expect(
+ await screen.findByText(
+ 'Change the plan site and API key in the desktop app on the computer running Orca.'
+ )
+ ).toBeInTheDocument()
+ expect(screen.getByRole('combobox')).toBeDisabled()
+ expect(screen.getByRole('combobox')).toHaveTextContent('Zhipu · BigModel')
+ expect(screen.getByPlaceholderText('Paste your GLM Coding Plan API key')).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
+ })
+
+ it('keeps paired host credentials unknown beside successful quota', async () => {
+ mocks.isWeb.mockReturnValue(true)
+ mocks.zcodeUsage.mockReturnValue({
+ provider: 'zcode',
+ status: 'ok',
+ error: null,
+ planType: null,
+ session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null },
+ weekly: null,
+ monthly: null,
+ updatedAt: 1
+ })
+ render( )
+ expect(await screen.findByText('42%')).toBeInTheDocument()
+ expect(
+ screen.getByText('Plan credential details are only readable on the computer running Orca.')
+ ).toBeInTheDocument()
+ expect(screen.queryByText('No GLM Coding Plan linked')).not.toBeInTheDocument()
+ expect(screen.queryByText('Not saved')).not.toBeInTheDocument()
+ })
+
+ it('does not invent a site or console link for an older host', async () => {
+ mocks.isWeb.mockReturnValue(true)
+ delete mocks.settings.zcodePlanSite
+ render( )
+ expect(await screen.findByText('Host plan site unavailable')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'Get API key' })).not.toBeInTheDocument()
+ expect(screen.getByRole('combobox')).toBeDisabled()
+ })
+
+ it('shows the unlinked state when neither an API key nor a CLI config exists', async () => {
+ render( )
+
+ expect(await screen.findByText('No GLM Coding Plan linked')).toBeInTheDocument()
+ expect(screen.queryByText('Using the ZCode CLI sign-in')).not.toBeInTheDocument()
+ })
+
+ it('explains the CLI fallback when only the ZCode CLI config exists', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: true })
+
+ render( )
+
+ expect(await screen.findByText('Using the ZCode CLI sign-in')).toBeInTheDocument()
+ expect(screen.getByText(/~\/\.zcode\/cli\/config\.json/)).toBeInTheDocument()
+ })
+
+ it('saves a trimmed API key through the credential IPC', async () => {
+ render( )
+
+ const input = await screen.findByPlaceholderText('Paste your GLM Coding Plan API key')
+ fireEvent.change(input, { target: { value: ' glm-secret ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => {
+ expect(mocks.saveApiKey).toHaveBeenCalledWith('glm-secret')
+ })
+ expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking')
+ await screen.findByText('Replace')
+ })
+
+ it('requires a non-empty key before saving', async () => {
+ render( )
+
+ const save = await screen.findByRole('button', { name: 'Save' })
+ expect(save).toBeDisabled()
+ })
+
+ it('forgets a saved key through the credential IPC', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: true })
+
+ render( )
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Forget key' }))
+
+ await waitFor(() => {
+ expect(mocks.clearApiKey).toHaveBeenCalledTimes(1)
+ })
+ expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking')
+ })
+
+ it('labels the saved state with the selected site', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.settings.zcodePlanSite = 'bigmodel'
+
+ render( )
+
+ expect(
+ await screen.findByText('API key saved · Zhipu · BigModel (open.bigmodel.cn)')
+ ).toBeInTheDocument()
+ })
+
+ it('renders the live quota windows for a linked plan', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.zcodeUsage.mockReturnValue({
+ provider: 'zcode',
+ status: 'ok',
+ error: null,
+ planType: 'max',
+ session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null },
+ weekly: { usedPercent: 73, windowMinutes: 10080, resetsAt: null, resetDescription: null },
+ monthly: null,
+ updatedAt: Date.now()
+ })
+
+ render( )
+
+ expect(await screen.findByText('42%')).toBeInTheDocument()
+ expect(screen.getByText('73%')).toBeInTheDocument()
+ expect(screen.getByText('Plan: max')).toBeInTheDocument()
+ })
+
+ it('renders the reset countdown once, not doubled', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.zcodeUsage.mockReturnValue({
+ provider: 'zcode',
+ status: 'ok',
+ error: null,
+ planType: null,
+ // Why 47.5 minutes: the floor survives a minute-boundary crossing between
+ // mock setup and render, so the assertion stays deterministic.
+ session: {
+ usedPercent: 42,
+ windowMinutes: 300,
+ resetsAt: Date.now() + 47 * 60_000 + 30_000,
+ resetDescription: null
+ },
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now()
+ })
+
+ render( )
+
+ expect(await screen.findByText('42%')).toBeInTheDocument()
+ expect(screen.getByText(/5 hours — resets in 47m/)).toBeInTheDocument()
+ expect(screen.queryByText(/Resets in Resets/)).not.toBeInTheDocument()
+ })
+})
+
+describe('GLM credential status races', () => {
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('keeps the saved status when an earlier status read finishes late', async () => {
+ let finishRead: ((value: unknown) => void) | undefined
+ mocks.getStatus.mockResolvedValue({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: false,
+ apiKeyProtection: 'sealed'
+ })
+ mocks.getStatus.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRead = resolve
+ })
+ )
+ mocks.saveApiKey.mockResolvedValue({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: false,
+ apiKeyProtection: 'sealed'
+ })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ zcodePlanCredentials: {
+ getStatus: mocks.getStatus,
+ saveApiKey: mocks.saveApiKey,
+ clearApiKey: mocks.clearApiKey
+ }
+ }
+ })
+ render( )
+ fireEvent.change(screen.getByPlaceholderText('Paste your GLM Coding Plan API key'), {
+ target: { value: 'synthetic-key' }
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ await screen.findByRole('button', { name: 'Forget key' })
+ finishRead?.({ apiKeyConfigured: false, zcodeCliConfigured: false, apiKeyProtection: null })
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Forget key' })).toBeInTheDocument()
+ )
+ })
+})
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
new file mode 100644
index 000000000000..be3d5e1010ef
--- /dev/null
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
@@ -0,0 +1,363 @@
+import { useState } from 'react'
+import { isWebClientLocation } from '@/lib/web-client-location'
+import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react'
+import { AgentIcon } from '@/lib/agent-catalog'
+import { translate } from '@/i18n/i18n'
+import {
+ ZCODE_PLAN_SITE_CONSOLE_URLS,
+ type ZcodePlanSite
+} from '../../../../shared/zcode-plan-sites'
+import { cn } from '@/lib/utils'
+import { useAppStore } from '../../store'
+import { useNow } from '../../hooks/use-now'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { SearchableSetting } from './SearchableSetting'
+import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows'
+import { useZcodePlanCredentials } from './use-zcode-plan-credentials'
+import { UnsealedCredentialNotice } from './UnsealedCredentialNotice'
+
+const SEARCH_KEYWORDS = [
+ 'glm',
+ 'zai',
+ 'z.ai',
+ 'zhipu',
+ 'bigmodel',
+ 'coding plan',
+ 'usage',
+ 'rate limit',
+ 'zcode'
+]
+
+function siteLabel(site: ZcodePlanSite): string {
+ if (site === 'bigmodel') {
+ return translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.site.bigmodel',
+ 'Zhipu · BigModel (open.bigmodel.cn)'
+ )
+ }
+ return translate('auto.components.settings.ZcodePlanAccountsSection.site.zai', 'Z.AI (z.ai)')
+}
+
+export function ZcodePlanAccountsSection(): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const updateSettings = useAppStore((s) => s.updateSettings)
+ const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
+ const zcodeUsage = useAppStore((s) => s.rateLimits.zcode)
+ const { status, apiKeyDraft, setApiKeyDraft, credentialBusy, saveApiKey, clearApiKey } =
+ useZcodePlanCredentials(zcodeUsage?.updatedAt)
+ const [refreshing, setRefreshing] = useState(false)
+ const now = useNow(60_000)
+
+ const credentialEditable = !isWebClientLocation()
+ const site = settings?.zcodePlanSite ?? (credentialEditable ? 'zai' : undefined)
+ const detailsUnavailable = !credentialEditable || status?.detailsUnavailable === true
+ const consoleUrl = site ? ZCODE_PLAN_SITE_CONSOLE_URLS[site] : undefined
+ const apiKeyConfigured = status?.apiKeyConfigured === true
+
+ const handleSiteChange = (value: string): void => {
+ if (!credentialEditable || (value !== 'zai' && value !== 'bigmodel') || value === site) {
+ return
+ }
+ // Why: main invalidates and refreshes on this settings change, so no local refresh is needed.
+ void updateSettings({ zcodePlanSite: value })
+ }
+
+ const handleRefreshUsage = async (): Promise => {
+ setRefreshing(true)
+ try {
+ await refreshRateLimits()
+ } finally {
+ setRefreshing(false)
+ }
+ }
+
+ const usage = zcodeUsage ?? null
+ const usageWindows = collectZcodeUsageWindows(usage)
+ const staleUsageError = usage?.status === 'error' ? (usage.error ?? null) : null
+
+ return (
+
+
+
+
+
+
+ {detailsUnavailable ? (
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.detailsUnavailable',
+ 'Plan credential details are only readable on the computer running Orca.'
+ )}
+
+ ) : apiKeyConfigured ? (
+ <>
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyStored',
+ 'API key saved · {{value0}}',
+ { value0: siteLabel(site ?? 'zai') }
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyStoredHelp',
+ 'Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.'
+ )}
+
+ >
+ ) : status?.zcodeCliConfigured ? (
+ <>
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.usingCli',
+ 'Using the ZCode CLI sign-in'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.usingCliHelp',
+ 'Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.'
+ )}
+
+ >
+ ) : (
+ <>
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notConfigured',
+ 'No GLM Coding Plan linked'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notConfiguredHelp',
+ 'Save the plan API key below, or sign in with the ZCode CLI on this computer.'
+ )}
+
+ >
+ )}
+ {staleUsageError ?
{staleUsageError}
: null}
+
+
void handleRefreshUsage()}
+ className="shrink-0"
+ >
+ {refreshing ? (
+
+ ) : (
+
+ )}
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.refreshUsage',
+ 'Refresh usage'
+ )}
+
+
+
+ {!credentialEditable ? (
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.hostOnly',
+ 'Change the plan site and API key in the desktop app on the computer running Orca.'
+ )}
+
+ ) : null}
+
+
+
+ {translate('auto.components.settings.ZcodePlanAccountsSection.siteTitle', 'Plan site')}
+
+
+
+
+
+
+ {(['zai', 'bigmodel'] as const).map((option) => (
+
+ {siteLabel(option)}
+
+ ))}
+
+
+
+
+
+
+
+
+ {translate('auto.components.settings.ZcodePlanAccountsSection.keyTitle', 'API key')}
+
+ {!detailsUnavailable ? (
+
+ {apiKeyConfigured ? : }
+ {apiKeyConfigured
+ ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved')
+ : translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notSaved',
+ 'Not saved'
+ )}
+
+ ) : null}
+
+
+
+
+ setApiKeyDraft(e.target.value)}
+ placeholder={translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyPlaceholder',
+ 'Paste your GLM Coding Plan API key'
+ )}
+ spellCheck={false}
+ className="flex-1"
+ />
+ void saveApiKey()}
+ disabled={credentialBusy || !credentialEditable || !apiKeyDraft.trim()}
+ className="shrink-0"
+ >
+ {credentialBusy ? : null}
+ {apiKeyConfigured
+ ? translate('auto.components.settings.ZcodePlanAccountsSection.replace', 'Replace')
+ : translate('auto.components.settings.ZcodePlanAccountsSection.save', 'Save')}
+
+ {apiKeyConfigured ? (
+ void clearApiKey()}
+ disabled={credentialBusy || !credentialEditable}
+ className="shrink-0"
+ >
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.forgetKey',
+ 'Forget key'
+ )}
+
+ ) : null}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyHelp',
+ 'The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.'
+ )}
+
+
+
+ {usageWindows.length > 0 ? (
+
+
+ {usageWindows.map((row) => (
+
+ ))}
+ {usage?.planType ? (
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.planLevel',
+ 'Plan: {{value0}}',
+ { value0: usage.planType }
+ )}
+
+ ) : null}
+
+
+ ) : null}
+
+ )
+}
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')} />
{
+ it('keeps Antigravity and GLM plan settings discoverable in pane order', () => {
+ const entries = getAccountsPaneSearchEntries()
+ const titles = entries.map((entry) => entry.title)
+ expect(
+ titles.filter((title) =>
+ ['Antigravity Accounts', 'Cursor Usage', 'GLM Coding Plan'].includes(title)
+ )
+ ).toEqual(['Antigravity Accounts', 'Cursor Usage', 'GLM Coding Plan'])
+ expect(entries.find((entry) => entry.title === 'Antigravity Accounts')?.keywords).toEqual(
+ expect.arrayContaining(['antigravity', 'agy', 'google', 'accounts'])
+ )
+ expect(entries.find((entry) => entry.title === 'GLM Coding Plan')?.keywords).toEqual(
+ expect.arrayContaining(['glm', 'zai', 'zhipu', 'bigmodel', 'coding plan'])
+ )
+ })
+})
+
describe('getAccountsMiniMaxSearchEntries', () => {
it('returns a single entry that targets the MiniMax session cookie flow', () => {
const entries = getAccountsMiniMaxSearchEntries()
diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts
index 49296504e748..948f258323f3 100644
--- a/src/renderer/src/components/settings/accounts-search.ts
+++ b/src/renderer/src/components/settings/accounts-search.ts
@@ -267,6 +267,36 @@ export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [
}
])
+export const getAccountsZcodePlanSearchEntries = createLocalizedCatalog(() => [
+ {
+ title: translate('auto.components.settings.accounts.search.zcodePlan.title', 'GLM Coding Plan'),
+ description: translate(
+ 'auto.components.settings.accounts.search.zcodePlan.description',
+ 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.'
+ ),
+ keywords: [
+ ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.glm', 'glm'),
+ ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.zai', 'zai'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.zhipu',
+ 'zhipu'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.bigmodel',
+ 'bigmodel'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.codingPlan',
+ 'coding plan'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.rateLimit',
+ 'rate limit'
+ )
+ ]
+ }
+])
+
export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
...getAccountsLocationSearchEntries(),
...getAccountsClaudeSearchEntries(),
@@ -276,5 +306,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS
...getAccountsMiniMaxSearchEntries(),
...getAccountsGrokSearchEntries(),
...getAccountsAntigravitySearchEntries(),
- ...getAccountsCursorSearchEntries()
+ ...getAccountsCursorSearchEntries(),
+ ...getAccountsZcodePlanSearchEntries()
])
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/settings/use-zcode-plan-credentials.test.tsx b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
new file mode 100644
index 000000000000..b1c86c5da574
--- /dev/null
+++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
@@ -0,0 +1,102 @@
+// @vitest-environment happy-dom
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { ZcodePlanCredentialsStatus } from '../../../../shared/zcode-plan-sites'
+import { createZcodePlanCredentialsApi } from '../../web/preload-api/web-agent-accounts-api'
+import { useZcodePlanCredentials } from './use-zcode-plan-credentials'
+
+const mocks = vi.hoisted(() => ({ interaction: vi.fn(), success: vi.fn(), error: vi.fn() }))
+vi.mock('../../store', () => ({
+ useAppStore: (select: (s: { recordFeatureInteraction: typeof mocks.interaction }) => unknown) =>
+ select({ recordFeatureInteraction: mocks.interaction })
+}))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('sonner', () => ({ toast: { success: mocks.success, error: mocks.error } }))
+const unlinked: ZcodePlanCredentialsStatus = {
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+}
+const linked: ZcodePlanCredentialsStatus = {
+ ...unlinked,
+ apiKeyConfigured: true,
+ apiKeyProtection: 'sealed'
+}
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+describe('GLM credential mutation refresh races', () => {
+ it.each(['save', 'clear', 'failed-save', 'failed-clear'] as const)(
+ 'applies %s while quota changes and rereads after settling',
+ async (action) => {
+ let complete: ((status: ZcodePlanCredentialsStatus) => void) | undefined
+ let reject: ((error: Error) => void) | undefined
+ const pending = new Promise((resolve, fail) => {
+ complete = resolve
+ reject = fail
+ })
+ const initial = action.endsWith('clear') ? linked : unlinked
+ const final = action === 'save' ? linked : action === 'clear' ? unlinked : initial
+ let stored = initial
+ const api = {
+ getStatus: vi.fn(async () => stored),
+ saveApiKey: vi.fn(() => pending),
+ clearApiKey: vi.fn(() => pending)
+ }
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { zcodePlanCredentials: api }
+ })
+ const { result, rerender } = renderHook(({ time }) => useZcodePlanCredentials(time), {
+ initialProps: { time: 1 }
+ })
+ await waitFor(() => expect(result.current.status).toEqual(initial))
+ act(() => result.current.setApiKeyDraft('synthetic-key'))
+ let mutation: Promise | undefined
+ act(() => {
+ mutation = action.endsWith('clear')
+ ? result.current.clearApiKey()
+ : result.current.saveApiKey()
+ })
+ rerender({ time: 2 })
+ expect(api.getStatus).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ stored = final
+ if (action.startsWith('failed')) {
+ reject?.(new Error('Synthetic failure'))
+ } else {
+ complete?.(final)
+ }
+ await mutation
+ })
+ await waitFor(() => expect(result.current.status).toEqual(final))
+ expect(api.getStatus).toHaveBeenCalledTimes(2)
+ expect(result.current.credentialBusy).toBe(false)
+ if (action.startsWith('failed')) {
+ expect(result.current.apiKeyDraft).toBe('synthetic-key')
+ expect(mocks.error).toHaveBeenCalledTimes(1)
+ expect(mocks.success).not.toHaveBeenCalled()
+ } else {
+ expect(result.current.apiKeyDraft).toBe('')
+ }
+ }
+ )
+ it('does not claim a web save succeeded or discard the key draft', async () => {
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { zcodePlanCredentials: createZcodePlanCredentialsApi() }
+ })
+ const { result } = renderHook(() => useZcodePlanCredentials(1))
+ await waitFor(() =>
+ expect(result.current.status).toEqual({ ...unlinked, detailsUnavailable: true })
+ )
+ act(() => result.current.setApiKeyDraft('synthetic-web-key'))
+ await act(() => result.current.saveApiKey())
+ expect(result.current.apiKeyDraft).toBe('synthetic-web-key')
+ expect(mocks.success).not.toHaveBeenCalled()
+ expect(mocks.error).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/settings/use-zcode-plan-credentials.ts b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
new file mode 100644
index 000000000000..e01b30cccc18
--- /dev/null
+++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
@@ -0,0 +1,91 @@
+import { useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '../../store'
+import type { ZcodePlanCredentialsStatus } from '../../../../shared/zcode-plan-sites'
+
+export function useZcodePlanCredentials(updatedAt: number | undefined) {
+ const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
+ const [status, setStatus] = useState(null)
+ const [apiKeyDraft, setApiKeyDraft] = useState('')
+ const [credentialBusy, setCredentialBusy] = useState(false)
+ const readGeneration = useRef(0)
+ const mutationPending = useRef(false)
+
+ useEffect(() => {
+ if (mutationPending.current) {
+ return
+ }
+ const request = ++readGeneration.current
+ void window.api.zcodePlanCredentials.getStatus().then(
+ (next) => {
+ if (request === readGeneration.current) {
+ setStatus(next)
+ }
+ },
+ () => {
+ if (request === readGeneration.current) {
+ setStatus(null)
+ }
+ }
+ )
+ return () => {
+ readGeneration.current += 1
+ }
+ }, [updatedAt, credentialBusy])
+
+ const updateCredential = async (action: 'save' | 'clear'): Promise => {
+ if (mutationPending.current) {
+ return
+ }
+ readGeneration.current += 1
+ mutationPending.current = true
+ setCredentialBusy(true)
+ try {
+ const next =
+ action === 'save'
+ ? await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim())
+ : await window.api.zcodePlanCredentials.clearApiKey()
+ if (
+ !next ||
+ typeof next.apiKeyConfigured !== 'boolean' ||
+ typeof next.zcodeCliConfigured !== 'boolean'
+ ) {
+ throw new Error(
+ 'GLM Coding Plan keys can only be changed in the desktop app on the computer running Orca.'
+ )
+ }
+ setStatus(next)
+ setApiKeyDraft('')
+ recordFeatureInteraction('usage-tracking')
+ if (action === 'save') {
+ toast.success(
+ translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keySaved',
+ 'GLM Coding Plan API key saved.'
+ )
+ )
+ }
+ } catch (error) {
+ toast.error(
+ translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keySaveFailed',
+ 'GLM Coding Plan credential update failed.'
+ ),
+ { description: error instanceof Error ? error.message : String(error) }
+ )
+ } finally {
+ mutationPending.current = false
+ setCredentialBusy(false)
+ }
+ }
+
+ return {
+ status,
+ apiKeyDraft,
+ setApiKeyDraft,
+ credentialBusy,
+ saveApiKey: () => updateCredential('save'),
+ clearApiKey: () => updateCredential('clear')
+ }
+}
diff --git a/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx
new file mode 100644
index 000000000000..6701ee44adaa
--- /dev/null
+++ b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx
@@ -0,0 +1,72 @@
+import { translate } from '@/i18n/i18n'
+import { formatResetDuration } from '../../../../shared/rate-limit-reset-format'
+import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
+import { Badge } from '../ui/badge'
+
+export type ZcodeUsageWindowKind = 'session' | 'weekly' | 'monthly'
+
+export type ZcodeUsageWindowRow = {
+ kind: ZcodeUsageWindowKind
+ window: RateLimitWindow
+}
+
+function windowLabel(kind: ZcodeUsageWindowKind): string {
+ if (kind === 'session') {
+ return translate('auto.components.settings.ZcodePlanAccountsSection.window.session', '5 hours')
+ }
+ if (kind === 'weekly') {
+ return translate('auto.components.settings.ZcodePlanAccountsSection.window.weekly', 'Weekly')
+ }
+ return translate('auto.components.settings.ZcodePlanAccountsSection.window.mcp', 'MCP monthly')
+}
+
+function formatWindowReset(window: RateLimitWindow, now: number): string | null {
+ if (!window.resetsAt) {
+ return null
+ }
+ const remaining = window.resetsAt - now
+ return remaining > 0 ? formatResetDuration(remaining) : null
+}
+
+// Why: a window only renders when its data survived the fetcher's mapping, so
+// error snapshots and MCP-less plans show exactly the windows they reported.
+export function collectZcodeUsageWindows(usage: ProviderRateLimits | null): ZcodeUsageWindowRow[] {
+ const rows: ZcodeUsageWindowRow[] = []
+ if (usage?.session) {
+ rows.push({ kind: 'session', window: usage.session })
+ }
+ if (usage?.weekly) {
+ rows.push({ kind: 'weekly', window: usage.weekly })
+ }
+ if (usage?.monthly) {
+ rows.push({ kind: 'monthly', window: usage.monthly })
+ }
+ return rows
+}
+
+export function ZcodeUsageWindowView({
+ row,
+ now
+}: {
+ row: ZcodeUsageWindowRow
+ now: number
+}): React.JSX.Element {
+ const resetLabel = formatWindowReset(row.window, now)
+ return (
+
+
+ {Math.round(row.window.usedPercent)}%
+
+
+ {windowLabel(row.kind)}
+ {resetLabel
+ ? translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.resetIn',
+ ' — resets in {{value0}}',
+ { value0: resetLabel }
+ )
+ : ''}
+
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
index 8506bae6779c..c01e803b1518 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
@@ -78,11 +78,19 @@ function usageSettings(overrides: Partial = {}): UsagePro
opencodeGoApiKeyConfigured: false,
grokAuthConfigured: false,
cursorAuthConfigured: false,
+ zcodePlanApiKeyConfigured: false,
...overrides
}
}
describe('hasUsageProviderSettings', () => {
+ it('keeps a linked GLM plan visible without CLI detection or a first quota result', () => {
+ const settings = usageSettings({ zcodePlanApiKeyConfigured: true })
+ expect(hasUsageProviderSettings(settings)).toBe(true)
+ expect(getVisibleUsageProvider('zcode', null, settings)?.status).toBe('fetching')
+ expect(getVisibleUsageProvider('zcode', null, usageSettings())).toBeNull()
+ })
+
it('treats persisted managed accounts as configured usage providers', () => {
expect(
hasUsageProviderSettings(
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
index 98c54eb14b0f..4ade7294abf8 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
@@ -16,6 +16,9 @@ export type UsageProviderSettings = Pick<
// Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll.
minimaxCookieConfigured: boolean
minimaxApiKeyConfigured: boolean
+ // Why: the GLM Coding Plan key lives in its own safeStorage file; main
+ // reports presence so the ZCode bar survives reloads before the first poll.
+ zcodePlanApiKeyConfigured: boolean
// Why: the OpenCode Go key can live in OPENCODE_API_KEY or in OpenCode's own
// store, neither of which the renderer can see; main reports presence.
opencodeGoApiKeyConfigured: boolean
@@ -85,7 +88,8 @@ export function hasUsageProviderSettings(
settings?.minimaxCookieConfigured === true ||
settings?.minimaxApiKeyConfigured === true ||
settings?.grokAuthConfigured === true ||
- settings?.cursorAuthConfigured === true
+ settings?.cursorAuthConfigured === true ||
+ settings?.zcodePlanApiKeyConfigured === true
)
}
@@ -125,6 +129,9 @@ export function hasUsageProviderSettingsForProvider(
if (providerId === 'cursor') {
return settings.cursorAuthConfigured === true
}
+ if (providerId === 'zcode') {
+ return settings.zcodePlanApiKeyConfigured === true
+ }
return false
}
diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts
index 6977319fa152..074d4aeebc95 100644
--- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts
+++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts
@@ -9,11 +9,12 @@ describe('getUsageProviderAccountsSectionId', () => {
expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go')
expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax')
expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok')
+ expect(getUsageProviderAccountsSectionId('cursor')).toBe('accounts-cursor')
+ expect(getUsageProviderAccountsSectionId('zcode')).toBe('accounts-zcode')
})
it('does not invent an Accounts section for CLI-owned credentials', () => {
expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull()
expect(getUsageProviderAccountsSectionId('kimi')).toBeNull()
- expect(getUsageProviderAccountsSectionId('zcode')).toBeNull()
})
})
diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
index 5a86e98ec350..ec9b79ca82b9 100644
--- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
+++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
@@ -20,10 +20,10 @@ export function getUsageProviderAccountsSectionId(
return 'accounts-cursor'
case 'antigravity':
case 'kimi':
- case 'zcode':
// Why: Orca must not mutate Kimi's CLI-owned credential lifecycle.
- // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json.
// Antigravity credentials live in the agy CLI; quota is fetched directly via agy.
return null
+ case 'zcode':
+ return 'accounts-zcode'
}
}
diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts
index 8c94bc0ccc33..1c98e3e2f47f 100644
--- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts
+++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts
@@ -107,7 +107,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) {
minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured,
opencodeGoApiKeyConfigured: rateLimits.opencodeGoApiKeyConfigured,
grokAuthConfigured: rateLimits.grokAuthConfigured,
- cursorAuthConfigured: rateLimits.cursorAuthConfigured
+ cursorAuthConfigured: rateLimits.cursorAuthConfigured,
+ zcodePlanApiKeyConfigured: rateLimits.zcodePlanApiKeyConfigured
}
const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings)
const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings)
@@ -147,10 +148,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) {
// Why: a Cursor session can come from the IDE alone, so PATH detection of
// cursor-agent would hide a real meter from IDE-only users.
const showCursor = visibleCursor !== null && statusBarItems.includes('cursor')
+ // Why: a saved Coding Plan key is site-auth, not a CLI on PATH — a subscriber
+ // without the ZCode CLI must still earn the meter (same exemption as MiniMax/Cursor).
const showZcode =
visibleZcode !== null &&
statusBarItems.includes('zcode') &&
- isStatusBarItemAvailable('zcode', detectedAgentIds)
+ (rateLimits.zcodePlanApiKeyConfigured || isStatusBarItemAvailable('zcode', detectedAgentIds))
// Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply.
const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings)
const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go')
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/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
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts
index 5b1a415219e3..803ec298ed57 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
-import { buildAgentSessionContinuationPrompt } from '@/lib/agent-session-continuation'
+import {
+ buildAgentSessionContinuationPrompt,
+ hasFullAgentSessionContext
+} from '@/lib/agent-session-continuation'
import { prepareAgentSessionContinuationFromPane } from './terminal-agent-session-continuation'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -64,6 +67,30 @@ describe('buildAgentSessionContinuationPrompt', () => {
)
expect(buildAgentSessionContinuationPrompt(source, 'full')).toBeNull()
})
+ it.each(['opencode', 'opencode2'] as const)(
+ 'uses captured context for %s SQLite references',
+ (sourceAgent) => {
+ const source = {
+ sourceAgent,
+ transcriptPath: '/home/u/.local/share/opencode/opencode.db#ses_proof',
+ capturedText: 'user: fix the parser\nassistant: tests remain'
+ }
+ expect(hasFullAgentSessionContext(source)).toBe(false)
+ expect(buildAgentSessionContinuationPrompt(source, 'focused')).toContain('tests remain')
+ expect(buildAgentSessionContinuationPrompt(source, 'focused')).not.toContain('opencode.db#')
+ expect(buildAgentSessionContinuationPrompt(source, 'full')).toBeNull()
+ }
+ )
+
+ it('preserves real transcript paths containing a hash', () => {
+ const source = {
+ sourceAgent: 'opencode' as const,
+ transcriptPath: '/home/u/project#one/session.jsonl',
+ capturedText: 'fallback'
+ }
+ expect(hasFullAgentSessionContext(source)).toBe(true)
+ expect(buildAgentSessionContinuationPrompt(source, 'full')).toContain(source.transcriptPath)
+ })
})
describe('prepareAgentSessionContinuationFromPane', () => {
@@ -104,6 +131,30 @@ describe('prepareAgentSessionContinuationFromPane', () => {
})
})
+ it.each(['opencode', 'opencode2'] as const)(
+ 'captures scrollback for a %s SQLite session',
+ (sourceAgent) => {
+ store.agentStatusByPaneKey[`tab-1:${LEAF_ID}`] = {
+ agentType: sourceAgent,
+ providerSession: { transcriptPath: '/tmp/opencode.db#ses_proof' }
+ }
+ const pane = makePane('latest terminal context')
+ const request = prepareAgentSessionContinuationFromPane({
+ pane,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ groupId: null,
+ workspacePath: '/repo',
+ initialCwd: '/repo'
+ })
+ expect(pane.serializeAddon.serialize).toHaveBeenCalledWith({ scrollback: 800 })
+ expect(request?.source.capturedText).toBe('latest terminal context')
+ expect(buildAgentSessionContinuationPrompt(request!.source, 'focused')).toContain(
+ 'latest terminal context'
+ )
+ }
+ )
+
it('falls back to terminal capture when the provider transcript path is blank', () => {
store.agentStatusByPaneKey[`tab-1:${LEAF_ID}`]!.providerSession = { transcriptPath: ' ' }
const pane = makePane('latest terminal context')
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts
index 806197d5323b..c71c27d9dbd6 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts
@@ -2,6 +2,7 @@ import { toast } from 'sonner'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import {
buildAgentSessionContinuationPrompt,
+ resolveAgentSessionContinuationTranscriptPath,
type AgentSessionContinuationRequest
} from '@/lib/agent-session-continuation'
import { useAppStore } from '@/store'
@@ -53,7 +54,10 @@ export function prepareAgentSessionContinuationFromPane({
const paneKey = makePaneKey(tabId, pane.leafId)
const status = state.agentStatusByPaneKey[paneKey]
const sourceAgent = resolveSourceAgent({ pane, tabId, worktreeId })
- const transcriptPath = status?.providerSession?.transcriptPath?.trim() || null
+ const transcriptPath = resolveAgentSessionContinuationTranscriptPath({
+ sourceAgent,
+ transcriptPath: status?.providerSession?.transcriptPath
+ })
const capturedText = transcriptPath ? '' : pane.serializeAddon.serialize({ scrollback: 800 })
const source = {
// Why: prefer the same-host transcript so opening the dialog does not serialize large scrollback.
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/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index ff1d4e95cac3..cfd3ac80e0fb 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -1567,6 +1567,9 @@
"7c3bb36706": "remove",
"e2b0ee267f": "stale"
},
+ "ZcodePlanAccountsSection": {
+ "keyRequired": "GLM Coding Plan API key is required."
+ },
"accounts": {
"search": {
"d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching."
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c8b184072518..5406a7e8a335 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": {
@@ -9152,6 +9153,18 @@
"rateLimit": "rate limit",
"statusBar": "status bar"
}
+ },
+ "zcodePlan": {
+ "title": "GLM Coding Plan",
+ "description": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.",
+ "kw": {
+ "glm": "glm",
+ "zai": "zai",
+ "zhipu": "zhipu",
+ "bigmodel": "bigmodel",
+ "codingPlan": "coding plan",
+ "rateLimit": "rate limit"
+ }
}
}
},
@@ -12266,6 +12279,48 @@
},
"UnsealedCredentialNotice": {
"body": "{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it."
+ },
+ "ZcodePlanAccountsSection": {
+ "site": {
+ "bigmodel": "Zhipu · BigModel (open.bigmodel.cn)",
+ "zai": "Z.AI (z.ai)"
+ },
+ "window": {
+ "session": "5 hours",
+ "weekly": "Weekly",
+ "mcp": "MCP monthly"
+ },
+ "resetIn": " — resets in {{value0}}",
+ "keyRequired": "GLM Coding Plan API key is required.",
+ "keySaved": "GLM Coding Plan API key saved.",
+ "keySaveFailed": "GLM Coding Plan credential update failed.",
+ "title": "GLM Coding Plan",
+ "subtitle": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage in the status bar. Save the plan API key here — no ZCode CLI setup needed.",
+ "consoleLink": "Get API key",
+ "keyStored": "API key saved · {{value0}}",
+ "keyStoredHelp": "Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.",
+ "usingCli": "Using the ZCode CLI sign-in",
+ "usingCliHelp": "Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.",
+ "notConfigured": "No GLM Coding Plan linked",
+ "notConfiguredHelp": "Save the plan API key below, or sign in with the ZCode CLI on this computer.",
+ "refreshUsage": "Refresh usage",
+ "siteTitle": "Plan site",
+ "siteDescription": "Pick the console your Coding Plan belongs to: Z.AI for the international site, Zhipu BigModel for the mainland site.",
+ "keyTitle": "API key",
+ "keyDescription": "Paste the API key from the selected console’s API Keys page. Stored locally, encrypted when the OS supports it, and sent only to that site for usage refreshes.",
+ "saved": "Saved",
+ "notSaved": "Not saved",
+ "keyPlaceholder": "Paste your GLM Coding Plan API key",
+ "replace": "Replace",
+ "save": "Save",
+ "forgetKey": "Forget key",
+ "keyHelp": "The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.",
+ "usageTitle": "Plan usage",
+ "usageDescription": "Live quota windows for the linked Coding Plan, refreshed with the status bar usage cycle.",
+ "planLevel": "Plan: {{value0}}",
+ "hostOnly": "Change the plan site and API key in the desktop app on the computer running Orca.",
+ "detailsUnavailable": "Plan credential details are only readable on the computer running Orca.",
+ "siteUnavailable": "Host plan site unavailable"
}
},
"right": {
@@ -18643,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/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 0f452b5617f1..b14ef912ea7e 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -8899,6 +8899,18 @@
"usage": "用量"
},
"title": "Cursor 用量"
+ },
+ "zcodePlan": {
+ "title": "GLM Coding Plan 用量",
+ "description": "追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 的用量。选择站点并保存套餐 API key。",
+ "kw": {
+ "glm": "glm",
+ "zai": "zai",
+ "zhipu": "zhipu",
+ "bigmodel": "bigmodel",
+ "codingPlan": "coding plan",
+ "rateLimit": "rate limit"
+ }
}
}
},
@@ -12059,6 +12071,46 @@
"usageDescription": "Cursor 按两个随账单周期重置的额度池计费,用完后按需计费。",
"usageLabel": "用量",
"usageTitle": "每月套餐用量"
+ },
+ "ZcodePlanAccountsSection": {
+ "site": {
+ "bigmodel": "智谱 · BigModel(open.bigmodel.cn)",
+ "zai": "Z.AI(z.ai)"
+ },
+ "window": {
+ "session": "5 小时",
+ "weekly": "每周",
+ "mcp": "MCP 月度"
+ },
+ "resetIn": " — {{value0}}后重置",
+ "keyRequired": "请填写 GLM Coding Plan API key。",
+ "keySaved": "GLM Coding Plan API key 已保存。",
+ "keySaveFailed": "GLM Coding Plan 凭据更新失败。",
+ "title": "GLM Coding Plan",
+ "subtitle": "在状态栏追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 用量。在此保存套餐 API key,无需安装 ZCode CLI。",
+ "consoleLink": "获取 API key",
+ "keyStored": "API key 已保存 · {{value0}}",
+ "keyStoredHelp": "仅保存在本地,并只发送到所选站点用于用量刷新。优先级高于 ZCode CLI 登录。",
+ "usingCli": "正在使用 ZCode CLI 登录",
+ "usingCliHelp": "Orca 从 ~/.zcode/cli/config.json 读取 Coding Plan key。在下方保存 API key 即可改为在此关联套餐。",
+ "notConfigured": "尚未关联 GLM Coding Plan",
+ "notConfiguredHelp": "在下方保存套餐 API key,或在这台电脑上通过 ZCode CLI 登录。",
+ "refreshUsage": "刷新用量",
+ "siteTitle": "套餐站点",
+ "siteDescription": "选择你的 Coding Plan 所属控制台:国际站选 Z.AI,国内站选智谱 BigModel。",
+ "keyTitle": "API key",
+ "keyDescription": "粘贴所选控制台 API Keys 页面中的 API key。保存在本地,系统支持时加密存储,并只发送到该站点用于用量刷新。",
+ "saved": "已保存",
+ "notSaved": "未保存",
+ "keyPlaceholder": "粘贴你的 GLM Coding Plan API key",
+ "replace": "替换",
+ "save": "保存",
+ "forgetKey": "清除 key",
+ "keyHelp": "与编码工具使用同一把套餐 key(例如 Claude Code 将 ANTHROPIC_BASE_URL 指向对应站点)。切换上方站点会变更接收该 key 的主机。",
+ "usageTitle": "套餐用量",
+ "usageDescription": "已关联 Coding Plan 的实时配额窗口,随状态栏用量周期刷新。",
+ "planLevel": "套餐:{{value0}}",
+ "hostOnly": "请在运行 Orca 的电脑上的桌面应用中更改套餐站点和 API 密钥。"
}
},
"right": {
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-session-continuation.ts b/src/renderer/src/lib/agent-session-continuation.ts
index 3e910fd5d360..41c5c72bd30f 100644
--- a/src/renderer/src/lib/agent-session-continuation.ts
+++ b/src/renderer/src/lib/agent-session-continuation.ts
@@ -1,3 +1,4 @@
+import { splitOpenCodeSqliteCandidate } from '../../../shared/opencode-sqlite-session-path'
import { buildBoundedSessionTranscript } from '@/lib/agent-session-fork-context'
import type { LaunchSource } from '../../../shared/telemetry-events'
import type { TuiAgent } from '../../../shared/tui-agent'
@@ -31,14 +32,31 @@ function markdownFenceFor(value: string): string {
}
export function hasFullAgentSessionContext(source: AgentSessionContinuationSource): boolean {
- return Boolean(source.transcriptPath?.trim())
+ return resolveAgentSessionContinuationTranscriptPath(source) !== null
+}
+
+export function resolveAgentSessionContinuationTranscriptPath(
+ source: Pick
+): string | null {
+ const path = source.transcriptPath?.trim() || null
+ if (!path) {
+ return null
+ }
+ const agent = source.sourceAgent
+ if (
+ (agent === 'opencode' || agent === 'opencode2') &&
+ splitOpenCodeSqliteCandidate(path, agent)
+ ) {
+ return null
+ }
+ return path
}
export function buildAgentSessionContinuationPrompt(
source: AgentSessionContinuationSource,
mode: AgentSessionContinuationContextMode
): string | null {
- const transcriptPath = source.transcriptPath?.trim() || null
+ const transcriptPath = resolveAgentSessionContinuationTranscriptPath(source)
const capturedTranscript = transcriptPath
? null
: buildBoundedSessionTranscript(source.capturedText)
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/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/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts
index 957e9b62ce20..52385894c034 100644
--- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts
+++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts
@@ -47,18 +47,15 @@ describe('decideInitialAgentTabViewMode', () => {
).toBeUndefined()
})
- it.each(['gemini', 'opencode'] as const)(
- 'keeps unsupported agent %s in terminal view',
- (agent) => {
- expect(
- decideInitialAgentTabViewMode({
- experimentalNativeChat: true,
- openAgentTabsInChatByDefault: true,
- agent
- })
- ).toBeUndefined()
- }
- )
+ it.each(['gemini'] as const)('keeps unsupported agent %s in terminal view', (agent) => {
+ expect(
+ decideInitialAgentTabViewMode({
+ experimentalNativeChat: true,
+ openAgentTabsInChatByDefault: true,
+ agent
+ })
+ ).toBeUndefined()
+ })
it.each([
['local', null],
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/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts
index 60b98274d319..89fdef9d0c14 100644
--- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts
+++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts
@@ -23,6 +23,26 @@ export function createMiniMaxCredentialsApi(): NonNullable<
}
}
+export function createZcodePlanCredentialsApi(): PreloadApi['zcodePlanCredentials'] {
+ const status = {
+ detailsUnavailable: true,
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ }
+ const unsupported = () =>
+ Promise.reject(
+ new Error(
+ 'GLM Coding Plan keys can only be changed in the desktop app on the computer running Orca.'
+ )
+ )
+ return {
+ getStatus: () => Promise.resolve(status),
+ saveApiKey: unsupported,
+ clearApiKey: unsupported
+ }
+}
+
export function createCursorAccountsApi(): NonNullable['cursorAccounts']> {
// Why an explanation and not a bare `signedIn: false`: Cursor's session lives on
// the machine running Orca, and this bridge cannot read it. The host may well be
@@ -45,6 +65,16 @@ export function createCursorAccountsApi(): NonNullable['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/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/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts
index f42c31d628a1..0f96d27144da 100644
--- a/src/renderer/src/web/preload-api/web-preferences-store.ts
+++ b/src/renderer/src/web/preload-api/web-preferences-store.ts
@@ -23,6 +23,7 @@ import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment'
import { mergeSettings, mergeWebUIState } from './web-preference-normalization'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session'
+import { zcodePlanSiteOwner, settingsForZcodePlanSiteOwner } from './web-zcode-plan-site'
import { SETTINGS_STORAGE_KEY, UI_STORAGE_KEY, readJson, writeJson } from './web-storage'
export type WebSettingsApi = NonNullable
@@ -74,7 +75,7 @@ export function getStoredSettings(): GlobalSettings {
// Keep readJson's invalid-JSON fallback non-destructive.
}
}
- return mergeSettings(
+ const settings = mergeSettings(
{
...defaults,
floatingTerminalEnabled: false,
@@ -83,6 +84,8 @@ export function getStoredSettings(): GlobalSettings {
},
migratedStored
)
+ delete settings.zcodePlanSite
+ return settings
}
export function writeStoredSettings(
@@ -106,6 +109,7 @@ export function writeStoredSettings(
export async function getRuntimeBackedStoredSettings(): Promise {
const local = getStoredSettings()
const requestedEnvironment = requireActiveEnvironmentOrNull()
+ const requestedSiteOwner = zcodePlanSiteOwner(requestedEnvironment)
if (!requestedEnvironment) {
return local
}
@@ -139,6 +143,12 @@ export async function getRuntimeBackedStoredSettings(): Promise
if (typeof result.settings.minimaxUsageModels === 'string') {
runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels
}
+ if (zcodePlanSiteOwner(currentEnvironment) === requestedSiteOwner) {
+ webRuntimeState.zcodePlanSiteRuntimeOwner = requestedSiteOwner
+ const site = result.settings.zcodePlanSite
+ webRuntimeState.zcodePlanSiteRuntimeValue =
+ site === 'zai' || site === 'bigmodel' ? site : null
+ }
if (
result.settings.minimaxEndpoint === 'overseas' ||
result.settings.minimaxEndpoint === 'cn'
@@ -169,19 +179,20 @@ export async function getRuntimeBackedStoredSettings(): Promise
export function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings {
const environment = requireActiveEnvironmentOrNull()
+ const ownedSettings = settingsForZcodePlanSiteOwner(settings, environment)
if (!environment) {
- return settings
+ return ownedSettings
}
if (
environment.id === webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId &&
webRuntimeState.worktreeVisibilityDefaultsRuntimeValue
) {
return {
- ...settings,
+ ...ownedSettings,
worktreeVisibilityDefaults: webRuntimeState.worktreeVisibilityDefaultsRuntimeValue
}
}
- const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings
+ const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = ownedSettings
return supportedSettings as GlobalSettings
}
diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts
index 15b6cb63ee75..a50f182e4211 100644
--- a/src/renderer/src/web/preload-api/web-runtime-session.ts
+++ b/src/renderer/src/web/preload-api/web-runtime-session.ts
@@ -2,6 +2,7 @@ import type {
RuntimeHostStatusSnapshot,
RuntimeHostStatusResponse
} from '../../../../shared/runtime-host-status'
+import type { ZcodePlanSite } from '../../../../shared/zcode-plan-sites'
import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types'
import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue'
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
@@ -18,6 +19,8 @@ import { translate } from '@/i18n/i18n'
export const webRuntimeState: {
activeEnvironment: StoredWebRuntimeEnvironment | null
+ zcodePlanSiteRuntimeOwner: string | null
+ zcodePlanSiteRuntimeValue: ZcodePlanSite | null
worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null
worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null
activeClient: WebRuntimeClient | null
@@ -26,6 +29,8 @@ export const webRuntimeState: {
cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null
} = {
activeEnvironment: readStoredWebRuntimeEnvironment(),
+ zcodePlanSiteRuntimeOwner: null,
+ zcodePlanSiteRuntimeValue: null,
worktreeVisibilityDefaultsRuntimeEnvironmentId: null,
worktreeVisibilityDefaultsRuntimeValue: null,
activeClient: null,
diff --git a/src/renderer/src/web/preload-api/web-zcode-plan-site.ts b/src/renderer/src/web/preload-api/web-zcode-plan-site.ts
new file mode 100644
index 000000000000..37f358a70d4e
--- /dev/null
+++ b/src/renderer/src/web/preload-api/web-zcode-plan-site.ts
@@ -0,0 +1,23 @@
+import type { GlobalSettings } from '../../../../shared/global-settings-types'
+import type { StoredWebRuntimeEnvironment } from '../web-runtime-environment'
+import { webRuntimeState } from './web-runtime-session'
+
+export function zcodePlanSiteOwner(environment: StoredWebRuntimeEnvironment | null): string | null {
+ return environment
+ ? JSON.stringify([environment.id, environment.pairingRevision ?? environment.createdAt])
+ : null
+}
+
+export function settingsForZcodePlanSiteOwner(
+ settings: GlobalSettings,
+ environment: StoredWebRuntimeEnvironment | null
+): GlobalSettings {
+ const owner = zcodePlanSiteOwner(environment)
+ return {
+ ...settings,
+ zcodePlanSite:
+ owner && owner === webRuntimeState.zcodePlanSiteRuntimeOwner
+ ? (webRuntimeState.zcodePlanSiteRuntimeValue ?? undefined)
+ : undefined
+ }
+}
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 fc119f88258b..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,7 +53,9 @@ describe('web preload API composition', () => {
'preflight',
'notifications',
'rateLimits',
+ 'opencodeGoCredentials',
'minimaxCredentials',
+ 'zcodePlanCredentials',
'grokAccounts',
'cursorAccounts',
'codexAccounts',
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 17f77ded803c..635e6dd924e5 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -6,6 +6,8 @@ import {
createCodexAccountsApi,
createCursorAccountsApi,
createGrokAccountsApi,
+ createZcodePlanCredentialsApi,
+ createOpenCodeGoCredentialsApi,
createMiniMaxCredentialsApi
} from './preload-api/web-agent-accounts-api'
import { createWebAgentStatusApi } from './preload-api/web-agent-status-api'
@@ -106,7 +108,9 @@ function createWebPreloadApi(): Partial {
preflight: createPreflightApi(),
notifications: createNotificationsApi(),
rateLimits: createRateLimitsApi(),
+ opencodeGoCredentials: createOpenCodeGoCredentialsApi(),
minimaxCredentials: createMiniMaxCredentialsApi(),
+ zcodePlanCredentials: createZcodePlanCredentialsApi(),
grokAccounts: createGrokAccountsApi(),
cursorAccounts: createCursorAccountsApi(),
codexAccounts: createCodexAccountsApi(),
diff --git a/src/renderer/src/web/web-zcode-plan-settings.test.ts b/src/renderer/src/web/web-zcode-plan-settings.test.ts
new file mode 100644
index 000000000000..dea9b84c985c
--- /dev/null
+++ b/src/renderer/src/web/web-zcode-plan-settings.test.ts
@@ -0,0 +1,111 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
+import {
+ installBrowserGlobals,
+ writeStoredRuntimeEnvironment
+} from './web-preload-api-test-harness'
+
+describe('web GLM host site', () => {
+ beforeEach(() => vi.resetModules())
+ afterEach(() => vi.unstubAllGlobals())
+ it('reads the host GLM site without sending site or secret changes back', async () => {
+ const runtimeCalls: { method: string; params: unknown }[] = []
+ vi.doMock('./web-runtime-client', () => ({
+ WebRuntimeClient: class {
+ call(method: string, params?: unknown): Promise> {
+ runtimeCalls.push({ method, params })
+ return Promise.resolve({
+ id: 'site-read',
+ ok: true,
+ result: { settings: { zcodePlanSite: 'bigmodel' } },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ }
+ close(): void {}
+ }
+ }))
+ const globals = installBrowserGlobals('Linux')
+ writeStoredRuntimeEnvironment(globals.storage)
+ const { installWebPreloadApi } = await import('./web-preload-api')
+ installWebPreloadApi()
+ expect((await globals.window.api.settings.get()).zcodePlanSite).toBe('bigmodel')
+ expect((await globals.window.api.settings.set({ zcodePlanSite: 'zai' })).zcodePlanSite).toBe(
+ 'bigmodel'
+ )
+ await expect(
+ globals.window.api.zcodePlanCredentials.saveApiKey('synthetic-key')
+ ).rejects.toThrow()
+ expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
+ })
+})
+
+describe('old host GLM settings', () => {
+ beforeEach(() => vi.resetModules())
+ afterEach(() => vi.unstubAllGlobals())
+
+ it.each([undefined, 'bigmodel', 'zai'])(
+ 'does not promote browser site %s to omitted host site',
+ async (cachedSite) => {
+ vi.doMock('./web-runtime-client', () => ({
+ WebRuntimeClient: class {
+ call(): Promise> {
+ return Promise.resolve({
+ id: 'old-host',
+ ok: true,
+ result: { settings: {} },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ }
+ close(): void {}
+ }
+ }))
+ const globals = installBrowserGlobals('Linux')
+ globals.storage.setItem('orca.web.settings.v1', JSON.stringify({ zcodePlanSite: cachedSite }))
+ writeStoredRuntimeEnvironment(globals.storage)
+ const { installWebPreloadApi } = await import('./web-preload-api')
+ installWebPreloadApi()
+ expect((await globals.window.api.settings.get()).zcodePlanSite).toBeUndefined()
+ expect(await globals.window.api.zcodePlanCredentials.getStatus()).toMatchObject({
+ detailsUnavailable: true
+ })
+ }
+ )
+})
+
+describe('GLM site host attestation lifetime', () => {
+ beforeEach(() => vi.resetModules())
+ afterEach(() => vi.unstubAllGlobals())
+ it('drops site attestation when the same host later omits the field or is repaired', async () => {
+ let site: string | undefined = 'bigmodel'
+ vi.doMock('./web-runtime-client', () => ({
+ WebRuntimeClient: class {
+ call(): Promise> {
+ return Promise.resolve({
+ id: 'site',
+ ok: true,
+ result: { settings: site ? { zcodePlanSite: site } : {} },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ }
+ close(): void {}
+ }
+ }))
+ const globals = installBrowserGlobals('Linux')
+ writeStoredRuntimeEnvironment(globals.storage)
+ const { installWebPreloadApi } = await import('./web-preload-api')
+ installWebPreloadApi()
+ expect((await globals.window.api.settings.get()).zcodePlanSite).toBe('bigmodel')
+ expect((await globals.window.api.settings.set({ uiLanguage: 'en' })).zcodePlanSite).toBe(
+ 'bigmodel'
+ )
+ const { webRuntimeState } = await import('./preload-api/web-runtime-session')
+ const environment = webRuntimeState.activeEnvironment
+ if (!environment) {
+ throw new Error('Missing synthetic paired host')
+ }
+ environment.pairingRevision = (environment.pairingRevision ?? environment.createdAt) + 1
+ expect(globals.window.api.settings.getSync?.()?.zcodePlanSite).toBeUndefined()
+ site = undefined
+ expect((await globals.window.api.settings.get()).zcodePlanSite).toBeUndefined()
+ })
+})
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 000000000000..42c952ed33a1
Binary files /dev/null and b/src/shared/agent-icons/qoder-cn.png differ
diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts
index 3fb112e61149..7b5ad97896fd 100644
--- a/src/shared/agent-kind.ts
+++ b/src/shared/agent-kind.ts
@@ -27,6 +27,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
omp: 'omp',
'prime-agent': 'prime-agent',
qoder: 'qoder',
+ 'qoder-cn': 'qoder-cn',
gemini: 'gemini',
antigravity: 'antigravity',
aider: 'aider',
diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts
index a8262eb4760e..481b5c5baf36 100644
--- a/src/shared/agent-process-recognition.ts
+++ b/src/shared/agent-process-recognition.ts
@@ -74,6 +74,9 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined {
return PROCESS_TO_AGENT.get('codex')
}
// Qoder's launcher resolves to a versioned native binary.
+ if (/^(?:qoderclicn|qodercn)-\d/.test(normalized)) {
+ return PROCESS_TO_AGENT.get('qoderclicn')
+ }
if (/^qodercli-\d/.test(normalized)) {
return PROCESS_TO_AGENT.get('qodercli')
}
@@ -162,6 +165,8 @@ export function isExpectedAgentProcess(
return (
normalizedProcess === normalizedExpected ||
normalizedProcess.startsWith(`${normalizedExpected}.`) ||
+ (['qoderclicn', 'qodercn'].includes(normalizedExpected) &&
+ /^(?:qoderclicn|qodercn)(?:-\d.*)?$/.test(normalizedProcess)) ||
(normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) ||
(normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-'))
)
diff --git a/src/shared/agent-session-resume-runtime-capabilities.ts b/src/shared/agent-session-resume-runtime-capabilities.ts
new file mode 100644
index 000000000000..b3f3f2ace44d
--- /dev/null
+++ b/src/shared/agent-session-resume-runtime-capabilities.ts
@@ -0,0 +1,33 @@
+// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
+// older host answers the unknown member with invalid_argument — a code the launch fallback does
+// not retry on — so clients must probe before taking the host-authority path.
+export const AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.cursor-resume.v1' as const
+export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const
+export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.opencode2-resume.v1' as const
+export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
+export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const
+export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.codebuddy-resume.v1' as const
+export const AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.qoder-cn-resume.v1' as const
+export const AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.qwen-code-resume.v1' as const
+export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.qoder-resume.v1' as const
+export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY =
+ 'agent-session.zcode-resume.v1' as const
+
+export const AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES = [
+ AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_DSH_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_ZCODE_RESUME_RUNTIME_CAPABILITY
+] as const
diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts
index fb2dd302975a..6d591db91e88 100644
--- a/src/shared/agent-session-resume.ts
+++ b/src/shared/agent-session-resume.ts
@@ -8,6 +8,8 @@ export const RESUMABLE_TUI_AGENTS = [
'codebuddy',
'codex',
'qoder',
+ 'qoder-cn',
+ 'qwen-code',
'gemini',
'antigravity',
'opencode',
@@ -197,6 +199,8 @@ export function extractAgentProviderSession(
// Native-chat agents: also capture the hook's authoritative transcript_path,
// since recent Claude Code names the transcript file with a UUID that differs
// from the hook session_id (so the id-based glob no longer finds it).
+ case 'qoder-cn':
+ case 'qwen-code':
case 'qoder':
case 'codebuddy':
case 'claude':
@@ -291,6 +295,10 @@ export function getAgentResumeArgv(
return providerSession.key === 'conversation_id' ? ['cursor-agent', '--resume', id] : null
case 'codex':
return providerSession.key === 'session_id' ? ['codex', 'resume', id] : null
+ case 'qoder-cn':
+ return providerSession.key === 'session_id' ? ['qoderclicn', '--resume', id] : null
+ case 'qwen-code':
+ return providerSession.key === 'session_id' ? ['qwen', '--resume', id] : null
case 'qoder':
return providerSession.key === 'session_id' ? ['qodercli', '--resume', id] : null
case 'gemini':
diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts
index 69d1531fb9e0..fe623188a5bc 100644
--- a/src/shared/agent-title-identity.ts
+++ b/src/shared/agent-title-identity.ts
@@ -74,7 +74,7 @@ function computeAgentLabel(title: string): string | null {
return piStateBrand
}
if (isQoderTerminalTitle(title)) {
- return 'Qoder CLI'
+ return title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts
index 7cd7a2c292cd..5dbd8e4a876a 100644
--- a/src/shared/agent-title-status.ts
+++ b/src/shared/agent-title-status.ts
@@ -132,7 +132,8 @@ export function createAgentStatusTracker(
export function normalizeTerminalTitle(title: string): string {
const qoderStatus = qoderTitleStatus(title)
if (qoderStatus) {
- return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} Qoder CLI`
+ const label = title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI'
+ return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} ${label}`
}
if (!title) {
return title
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/default-global-settings.ts b/src/shared/default-global-settings.ts
index 3c6a9d2aa591..80aa334b21c3 100644
--- a/src/shared/default-global-settings.ts
+++ b/src/shared/default-global-settings.ts
@@ -211,10 +211,10 @@ export function buildDefaultSettings(args: {
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
- opencodeGoApiKey: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
minimaxEndpoint: 'overseas',
+ zcodePlanSite: 'zai',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS },
diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts
index 2a3fac5d5459..c078070d298a 100644
--- a/src/shared/global-settings-types.ts
+++ b/src/shared/global-settings-types.ts
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from './execution-host'
+import type { OrcaWorkspaceLayout } from './orca-workspace-layout'
import type { GitHubProjectSettings } from './github/project-types'
import type { VoiceSettings } from './speech-types'
import type { AiVaultSearchSettings } from './ai-vault-search-settings'
@@ -26,6 +27,7 @@ import type { CtrlTabOrderMode } from './tab-types'
import type { TerminalColorOverrides } from './terminal-color-overrides'
import type { TerminalQuickCommand } from './terminal-quick-command-types'
import type { TuiAgent } from './tui-agent'
+import type { ZcodePlanSite } from './zcode-plan-sites'
import type {
AgentDashboardMode,
BranchPrefixStrategy,
@@ -392,14 +394,14 @@ 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. */
minimaxUsageModels: string
/** MiniMax account region; defaults to overseas for existing users. */
minimaxEndpoint: MiniMaxEndpoint
+ /** GLM Coding Plan site whose API key is saved in AI Provider Accounts; defaults to the international Z.AI console. */
+ zcodePlanSite?: ZcodePlanSite
/** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */
geminiCliOAuthEnabled: boolean
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
@@ -537,11 +539,7 @@ export type GlobalSettings = {
aiVaultSearch?: AiVaultSearchSettings
}
-export type OrcaWorkspaceLayout = {
- path: string
- nestWorkspaces: boolean
-}
-
// Re-exported so existing importers keep one entry point; the shape lives in its
// own file because this one is at the max-lines ceiling.
export type { GhosttyImportPreview } from './ghostty-import-preview'
+export type { OrcaWorkspaceLayout } from './orca-workspace-layout'
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(
diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts
index 82fe88e9ac3b..386584c1279b 100644
--- a/src/shared/native-chat-agent-profiles.ts
+++ b/src/shared/native-chat-agent-profiles.ts
@@ -31,6 +31,12 @@ const NATIVE_CHAT_AGENT_PROFILES: Partial {
})
it('passes codex, grok and omp through and rejects everything else', () => {
+ expect(resolveNativeChatTranscriptAgent('opencode')).toBe('opencode')
+ expect(resolveNativeChatTranscriptAgent('opencode2')).toBe('opencode')
expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex')
expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok')
expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp')
@@ -24,6 +26,8 @@ describe('resolveNativeChatTranscriptAgent', () => {
describe('isNativeChatSupportedAgent', () => {
it('recognizes the parseable agents and rejects unknown / nullish input', () => {
+ expect(isNativeChatSupportedAgent('opencode')).toBe(true)
+ expect(isNativeChatSupportedAgent('opencode2')).toBe(true)
expect(isNativeChatSupportedAgent('claude')).toBe(true)
expect(isNativeChatSupportedAgent('openclaude')).toBe(true)
expect(isNativeChatSupportedAgent('omp')).toBe(true)
@@ -37,6 +41,8 @@ describe('nativeChatRequiresLocalTranscript', () => {
it('covers the agents whose hook discloses no transcript path', () => {
// Claude/Codex report `transcript_path`; Grok and omp report only an id, so
// native chat has to find their file on a disk this process can read.
+ expect(nativeChatRequiresLocalTranscript('opencode')).toBe(true)
+ expect(nativeChatRequiresLocalTranscript('opencode2')).toBe(true)
expect(nativeChatRequiresLocalTranscript('grok')).toBe(true)
expect(nativeChatRequiresLocalTranscript('omp')).toBe(true)
expect(nativeChatRequiresLocalTranscript('claude')).toBe(false)
@@ -55,6 +61,8 @@ describe('shouldStepNativeChatAskAnswer', () => {
// Codex 0.145's request_user_input card ignores typed labels and commits on
// the highlighted row, so pasted answers misdeliver like STA-1860.
expect(shouldStepNativeChatAskAnswer('codex')).toBe(true)
+ expect(shouldStepNativeChatAskAnswer('opencode')).toBe(true)
+ expect(shouldStepNativeChatAskAnswer('opencode2')).toBe(true)
})
it('does not step other or unknown agents', () => {
diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts
index 70fa92a92bf6..e2bb9c40cacc 100644
--- a/src/shared/native-chat-agent-support.ts
+++ b/src/shared/native-chat-agent-support.ts
@@ -1,6 +1,6 @@
import type { TuiAgent } from './tui-agent'
-export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp'
+export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' | 'opencode'
/** Agents whose transcripts the native chat view can parse and render, in the
* order the settings pane advertises them. */
@@ -9,7 +9,9 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [
'openclaude',
'codex',
'grok',
- 'omp'
+ 'omp',
+ 'opencode',
+ 'opencode2'
]
export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set(
@@ -24,17 +26,15 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo
* does not establish owning-host reads, so OMP remains gated even with metadata. */
export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean {
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
- return transcriptAgent === 'grok' || transcriptAgent === 'omp'
+ return transcriptAgent === 'grok' || transcriptAgent === 'omp' || transcriptAgent === 'opencode'
}
-/** True when the agent renders a digit-commit question selector that ignores
- * typed label text (pasting "Blue" + Enter commits the highlighted FIRST
- * option — STA-1860): Claude's AskUserQuestion and Codex 0.145's
- * request_user_input card both behave this way, so answers must be delivered
- * as per-option keystrokes. Other agents commit a pasted answer. */
+/** Selector TUIs require key steps rather than pasted option labels. */
export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean {
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
- return transcriptAgent === 'claude' || transcriptAgent === 'codex'
+ return (
+ transcriptAgent === 'claude' || transcriptAgent === 'codex' || transcriptAgent === 'opencode'
+ )
}
export function resolveNativeChatTranscriptAgent(
@@ -45,8 +45,15 @@ export function resolveNativeChatTranscriptAgent(
if (agent === 'claude' || agent === 'openclaude') {
return 'claude'
}
+ if (agent === 'opencode' || agent === 'opencode2') {
+ return 'opencode'
+ }
if (agent === 'codex' || agent === 'grok' || agent === 'omp') {
return agent
}
return null
}
+
+export function nativeChatApprovalAcceptKey(agent: string | null | undefined): string {
+ return resolveNativeChatTranscriptAgent(agent) === 'opencode' ? '\r' : '1'
+}
diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts
index 29220967157d..0c9ff11753ee 100644
--- a/src/shared/native-chat-ask.test.ts
+++ b/src/shared/native-chat-ask.test.ts
@@ -168,6 +168,26 @@ describe('extractPendingAsk', () => {
})
describe('parseAskFromStatus', () => {
+ it('preserves OpenCode question envelope multiple selection on live status and replay', () => {
+ const input = {
+ id: 'question',
+ sessionID: 'session',
+ questions: [
+ {
+ header: 'Choice',
+ question: 'Pick both?',
+ multiple: true,
+ options: [
+ { label: 'One', description: 'first' },
+ { label: 'Two', description: 'second' }
+ ]
+ }
+ ]
+ }
+ const prompt = parseAskFromStatus(JSON.stringify(input), 'AskUserQuestion')
+ expect(prompt?.questions[0]).toMatchObject({ multiSelect: true, header: 'Choice' })
+ expect(extractPendingAsk([message('m1', [call('question', input)])])).toEqual(prompt)
+ })
it('accepts the canonical shape from any tool name and rejects broken JSON', () => {
expect(
parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT), 'SomeNewTool')?.questions
diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts
index f166de48c1fc..44ee53c6c7de 100644
--- a/src/shared/native-chat-ask.ts
+++ b/src/shared/native-chat-ask.ts
@@ -20,10 +20,10 @@ export function registerQuestionTool(toolName: string, parser: InteractiveQuesti
}
function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null {
- if (!input || typeof input !== 'object') {
+ if (!input || typeof input !== 'object' || !('questions' in input)) {
return null
}
- const rawQuestions = (input as { questions?: unknown }).questions
+ const rawQuestions = input.questions
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
return null
}
@@ -32,14 +32,16 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null {
if (!raw || typeof raw !== 'object') {
continue
}
- const question = raw as Record
- const text = typeof question.question === 'string' ? question.question : ''
- const options = parseOptions(question.options)
+ const text = 'question' in raw && typeof raw.question === 'string' ? raw.question : ''
+ const options = parseOptions('options' in raw ? raw.options : undefined)
if (text || options.length > 0) {
questions.push({
question: text,
- header: typeof question.header === 'string' ? question.header : undefined,
- multiSelect: question.multiSelect === true,
+ header: 'header' in raw && typeof raw.header === 'string' ? raw.header : undefined,
+ multiSelect:
+ 'multiSelect' in raw
+ ? raw.multiSelect === true
+ : 'multiple' in raw && raw.multiple === true,
options
})
}
@@ -59,12 +61,15 @@ function parseOptions(raw: unknown): AskOption[] {
if (
option &&
typeof option === 'object' &&
- typeof (option as { label?: unknown }).label === 'string'
+ 'label' in option &&
+ typeof option.label === 'string'
) {
- const value = option as { label: string; description?: unknown }
return {
- label: value.label,
- description: typeof value.description === 'string' ? value.description : undefined
+ label: option.label,
+ description:
+ 'description' in option && typeof option.description === 'string'
+ ? option.description
+ : undefined
}
}
return null
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/opencode-sqlite-session-path.ts b/src/shared/opencode-sqlite-session-path.ts
new file mode 100644
index 000000000000..c89dbd4119f8
--- /dev/null
+++ b/src/shared/opencode-sqlite-session-path.ts
@@ -0,0 +1,29 @@
+const OPENCODE_SQLITE_PATH_SEPARATOR = '#'
+
+export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string {
+ return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}`
+}
+
+// A database row reference is metadata, never a transcript file path.
+export function splitOpenCodeSqliteCandidate(
+ candidatePath: string,
+ agent: 'opencode' | 'opencode2' | 'zcode' = 'opencode'
+): { dbPath: string; sessionId: string } | null {
+ const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR)
+ if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) {
+ return null
+ }
+ const dbPath = candidatePath.slice(0, separatorIndex)
+ const sessionId = candidatePath.slice(separatorIndex + 1)
+ // Host paths may use either separator when consumed by a browser client.
+ const databaseName = dbPath.split(/[\\/]/).at(-1) ?? ''
+ const validName =
+ agent === 'zcode'
+ ? databaseName.toLowerCase() === 'db.sqlite'
+ : /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(databaseName)
+ return validName ? { dbPath, sessionId } : null
+}
+
+export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean {
+ return splitOpenCodeSqliteCandidate(candidatePath) !== null
+}
diff --git a/src/shared/opencode-transcript-page-limit.ts b/src/shared/opencode-transcript-page-limit.ts
new file mode 100644
index 000000000000..4f589c22f838
--- /dev/null
+++ b/src/shared/opencode-transcript-page-limit.ts
@@ -0,0 +1,7 @@
+export const OPENCODE_TRANSCRIPT_MAX_WINDOW = 2400
+
+export function openCodeTranscriptPageLimit(limit: number): number {
+ return Number.isFinite(limit)
+ ? Math.min(OPENCODE_TRANSCRIPT_MAX_WINDOW, Math.max(1, Math.floor(limit)))
+ : 300
+}
diff --git a/src/shared/orca-workspace-layout.ts b/src/shared/orca-workspace-layout.ts
new file mode 100644
index 000000000000..8019ee0919e9
--- /dev/null
+++ b/src/shared/orca-workspace-layout.ts
@@ -0,0 +1,4 @@
+export type OrcaWorkspaceLayout = {
+ path: string
+ nestWorkspaces: boolean
+}
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index 650bc85b449d..2b7211d5a253 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -1,3 +1,16 @@
+import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities'
+export {
+ AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
+ AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY
+} from './agent-session-resume-runtime-capabilities'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities'
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
@@ -246,22 +259,6 @@ export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY =
// stop capability above, which a client can advertise while predating this.
export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY =
'agent-session.background-task-row-stop.v1' as const
-// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
-// older host answers the unknown member with invalid_argument — a code the launch fallback does
-// not retry on — so clients must probe before taking the host-authority path.
-export const AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY =
- 'agent-session.cursor-resume.v1' as const
-export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const
-export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
- 'agent-session.opencode2-resume.v1' as const
-export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
-export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const
-export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY =
- 'agent-session.codebuddy-resume.v1' as const
-export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY =
- 'agent-session.qoder-resume.v1' as const
-export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY =
- 'agent-session.zcode-resume.v1' as const
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
@@ -414,14 +411,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_TURN_ITEM_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
- AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
- AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
+ ...AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY,
diff --git a/src/shared/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts
index 8db665e72094..f7600368a102 100644
--- a/src/shared/qoder-agent.test.ts
+++ b/src/shared/qoder-agent.test.ts
@@ -158,3 +158,46 @@ it('settles manual compaction without interrupting automatic compaction', () =>
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/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts
index 494f56e4dff1..b9af7fe1b68c 100644
--- a/src/shared/rate-limit-state-factory.ts
+++ b/src/shared/rate-limit-state-factory.ts
@@ -18,6 +18,7 @@ export function createEmptyRateLimitState(overrides: Partial = {
opencodeGoApiKeyConfigured: false,
grokAuthConfigured: false,
cursorAuthConfigured: false,
+ zcodePlanApiKeyConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts
index 6b06f09d10e9..7f69b33264ba 100644
--- a/src/shared/rate-limit-types.ts
+++ b/src/shared/rate-limit-types.ts
@@ -170,6 +170,12 @@ export type RateLimitState = {
* stored login. The token itself never leaves main.
*/
cursorAuthConfigured: boolean
+ /**
+ * True when a GLM Coding Plan API key is saved in Orca's AI Provider
+ * Accounts. The key itself never leaves main; the status bar uses this to
+ * keep the ZCode bar visible across reloads between snapshot refreshes.
+ */
+ zcodePlanApiKeyConfigured?: boolean
claudeTarget: RateLimitRuntimeTarget
codexTarget: RateLimitRuntimeTarget
inactiveClaudeAccounts: InactiveAccountUsage[]
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/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts
index fd0c2f986a93..6574c89a8bb7 100644
--- a/src/shared/rpc-contract/client-settings-params.ts
+++ b/src/shared/rpc-contract/client-settings-params.ts
@@ -115,6 +115,7 @@ export const SettingsUpdate = z
minimaxGroupId: z.string().optional(),
minimaxUsageModels: z.string().optional(),
minimaxEndpoint: z.enum(['overseas', 'cn']).optional(),
+ zcodePlanSite: z.enum(['zai', 'bigmodel']).optional(),
githubProjects: GitHubProjectSettings.optional(),
prBotAuthorOverrides: z
.unknown()
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/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
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/src/shared/zcode-plan-sites.ts b/src/shared/zcode-plan-sites.ts
new file mode 100644
index 000000000000..ba1770cf9562
--- /dev/null
+++ b/src/shared/zcode-plan-sites.ts
@@ -0,0 +1,32 @@
+import type { SecretAtRestProtection } from './secret-at-rest-protection'
+
+/**
+ * GLM Coding Plan site table shared by the zcode credential store, the usage
+ * fetcher, and the Accounts settings section. The provider id stays `zcode`;
+ * the two sites are the international Z.AI console and Zhipu's mainland
+ * BigModel platform.
+ */
+export type ZcodePlanSite = 'zai' | 'bigmodel'
+
+export const ZCODE_PLAN_SITES: readonly ZcodePlanSite[] = ['zai', 'bigmodel']
+
+export function isZcodePlanSite(value: unknown): value is ZcodePlanSite {
+ return value === 'zai' || value === 'bigmodel'
+}
+
+export const ZCODE_PLAN_SITE_BASE_URLS: Record = {
+ zai: 'https://api.z.ai',
+ bigmodel: 'https://open.bigmodel.cn'
+}
+
+export const ZCODE_PLAN_SITE_CONSOLE_URLS: Record = {
+ zai: 'https://z.ai/manage-apikey',
+ bigmodel: 'https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys'
+}
+
+export type ZcodePlanCredentialsStatus = {
+ detailsUnavailable?: boolean
+ apiKeyConfigured: boolean
+ zcodeCliConfigured: boolean
+ apiKeyProtection: SecretAtRestProtection | null
+}
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
+)