diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs
index 002a84360f6..a73e9d5e3cc 100644
--- a/config/scripts/verify-localization-catalog.mjs
+++ b/config/scripts/verify-localization-catalog.mjs
@@ -11,7 +11,12 @@ import { repairTranslatedValue } from './locale-translation-policy.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
-const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain', 'translateSearchKeyword'])
+const LOCALIZATION_FUNCTION_NAMES = new Set([
+ 't',
+ 'translate',
+ 'translateMain',
+ 'translateSearchKeyword'
+])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
export const LOCALIZATION_SOURCE_ROOTS = [
diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts
index 4430c60115a..5a959b870bf 100644
--- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts
+++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts
@@ -41,6 +41,7 @@ const MODEL_DESCRIPTOR: SessionOptionDescriptor = {
]
},
valueSource: 'reported',
+ transport: 'catalog',
settable: true
}
@@ -57,6 +58,7 @@ const EFFORT_DESCRIPTOR: SessionOptionDescriptor = {
]
},
valueSource: 'dispatched',
+ transport: 'catalog',
settable: true
}
@@ -66,6 +68,7 @@ const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = {
category: 'mode',
kind: { type: 'boolean', currentValue: false },
valueSource: 'reported',
+ transport: 'catalog',
settable: true
}
@@ -227,6 +230,7 @@ describe('MobileNativeChatSessionOptionPickers', () => {
...MODEL_DESCRIPTOR,
kind: { type: 'select', choices: [] },
valueSource: 'unknown',
+ transport: 'catalog',
action: { type: 'agent-picker' }
}
])
@@ -236,6 +240,46 @@ describe('MobileNativeChatSessionOptionPickers', () => {
expect(invokeAction).toHaveBeenCalledWith('model')
})
+ // The terminal transport can only learn the outcome by parsing the screen back,
+ // so the sheet admits the value is unconfirmed; the structured transport reports
+ // it every turn, which makes the same caption noise there.
+ it.each([
+ { transport: 'catalog' as const, caption: true },
+ { transport: 'agent-session' as const, caption: false }
+ ])('captions a dispatched value only on the terminal transport', async (scenario) => {
+ mount([
+ MODEL_DESCRIPTOR,
+ { ...EFFORT_DESCRIPTOR, valueSource: 'dispatched', transport: scenario.transport }
+ ])
+ await act(async () => pill('Model').props.onPress())
+ await act(async () => rowByText('Effort').props.onPress())
+ const captions = renderer!.root
+ .findAll((node) => node.type === 'Text')
+ .filter(
+ (node) =>
+ (node.props as { children?: unknown }).children === 'Sent to the agent — not confirmed'
+ )
+ expect(captions.length > 0).toBe(scenario.caption)
+ })
+
+ it.each(['catalog', 'agent-session'] as const)(
+ 'does not caption a reported value on the %s transport',
+ async (transport) => {
+ mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, valueSource: 'reported', transport }])
+ await act(async () => pill('Model').props.onPress())
+ await act(async () => rowByText('Effort').props.onPress())
+ expect(
+ renderer!.root
+ .findAll((node) => node.type === 'Text')
+ .some(
+ (node) =>
+ (node.props as { children?: unknown }).children ===
+ 'Sent to the agent — not confirmed'
+ )
+ ).toBe(false)
+ }
+ )
+
it('locks the pills while the agent is working', () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
expect(pill('Model').props).toMatchObject({ disabled: true })
diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
index bfa5244a398..c9d641f74ec 100644
--- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
+++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
@@ -3,9 +3,10 @@ import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from '
import { ChevronLeft, X } from 'lucide-react-native'
import { BottomDrawer } from '../components/BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
-import type {
- SessionOptionDescriptor,
- SessionOptionValue
+import {
+ sessionOptionDispatchUnconfirmed,
+ type SessionOptionDescriptor,
+ type SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
mobileModelPillLabel,
@@ -119,7 +120,7 @@ export function MobileNativeChatSessionOptionPickers({
) : null}
- {activeDescriptor.valueSource === 'dispatched' ? (
+ {sessionOptionDispatchUnconfirmed(activeDescriptor) ? (
Sent to the agent — not confirmed
) : null}
{reason ? {reason} : null}
diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts
index 66a929aeb56..6acdf8b3750 100644
--- a/mobile/src/session/use-mobile-native-chat-session-options.ts
+++ b/mobile/src/session/use-mobile-native-chat-session-options.ts
@@ -168,7 +168,8 @@ export function useMobileNativeChatSessionOptions(args: {
models: activeModels(catalog, record),
record,
mode: 'live',
- modelLabel: 'Model'
+ modelLabel: 'Model',
+ liveTransport: 'catalog'
})
}, [agent, catalog, scopeKey, version])
diff --git a/package.json b/package.json
index 58f4805d937..c519d7ea6b1 100644
--- a/package.json
+++ b/package.json
@@ -153,6 +153,7 @@
"repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs"
},
"dependencies": {
+ "@anthropic-ai/claude-agent-sdk": "0.3.251",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@floating-ui/dom": "1.7.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d7481f2e556..6b59d23e026 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -122,6 +122,9 @@ importers:
.:
dependencies:
+ '@anthropic-ai/claude-agent-sdk':
+ specifier: 0.3.251
+ version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@43.4.1(supports-color@7.2.0))
@@ -535,6 +538,23 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+ '@anthropic-ai/claude-agent-sdk@0.3.251':
+ resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ '@anthropic-ai/sdk': '>=0.93.0'
+ '@modelcontextprotocol/sdk': ^1.29.0
+ zod: ^4.0.0
+
+ '@anthropic-ai/sdk@0.122.0':
+ resolution: {integrity: sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==}
+ hasBin: true
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -2628,6 +2648,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
+ '@stablelib/base64@1.0.1':
+ resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
+
'@stablyai/playwright-base@2.1.14':
resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==}
engines: {node: '>=18'}
@@ -4493,6 +4516,9 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
+ fast-sha256@1.3.0:
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
+
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
@@ -5039,6 +5065,10 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+ json-schema-to-ts@3.1.1:
+ resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
+ engines: {node: '>=16'}
+
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -6425,6 +6455,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ standardwebhooks@1.1.1:
+ resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
+
stat-mode@1.0.0:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'}
@@ -6608,6 +6641,9 @@ packages:
truncate-utf8-bytes@1.0.2:
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
+ ts-algebra@2.0.0:
+ resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+
ts-dedent@2.2.0:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
@@ -7007,6 +7043,16 @@ packages:
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+ignoredOptionalDependencies:
+ - '@anthropic-ai/claude-agent-sdk-darwin-arm64'
+ - '@anthropic-ai/claude-agent-sdk-darwin-x64'
+ - '@anthropic-ai/claude-agent-sdk-linux-arm64'
+ - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl'
+ - '@anthropic-ai/claude-agent-sdk-linux-x64'
+ - '@anthropic-ai/claude-agent-sdk-linux-x64-musl'
+ - '@anthropic-ai/claude-agent-sdk-win32-arm64'
+ - '@anthropic-ai/claude-agent-sdk-win32-x64'
+
snapshots:
'@adobe/css-tools@4.5.0': {}
@@ -7016,6 +7062,19 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.2
+ '@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)':
+ dependencies:
+ '@anthropic-ai/sdk': 0.122.0(zod@4.5.4)
+ '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.5.4)
+ zod: 4.5.4
+
+ '@anthropic-ai/sdk@0.122.0(zod@4.5.4)':
+ dependencies:
+ json-schema-to-ts: 3.1.1
+ standardwebhooks: 1.1.1
+ optionalDependencies:
+ zod: 4.5.4
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -7669,6 +7728,28 @@ snapshots:
dependencies:
'@chevrotain/types': 11.1.2
+ '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4)':
+ dependencies:
+ '@hono/node-server': 2.1.0(hono@4.13.0)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1(ajv@8.20.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.0.8
+ express: 5.2.1(supports-color@7.2.0)
+ express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
+ hono: 4.13.0
+ jose: 6.2.3
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.5.4
+ zod-to-json-schema: 3.25.2(zod@4.5.4)
+ transitivePeerDependencies:
+ - supports-color
+
'@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 2.1.0(hono@4.13.0)
@@ -7679,8 +7760,8 @@ snapshots:
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.8
- express: 5.2.1
- express-rate-limit: 8.5.2(express@5.2.1)
+ express: 5.2.1(supports-color@7.2.0)
+ express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
hono: 4.13.0
jose: 6.2.3
json-schema-typed: 8.0.2
@@ -8917,6 +8998,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
+ '@stablelib/base64@1.0.1': {}
+
'@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)':
dependencies:
'@playwright/test': 1.59.1
@@ -9923,7 +10006,7 @@ snapshots:
bluebird@3.7.2: {}
- body-parser@2.3.0:
+ body-parser@2.3.0(supports-color@7.2.0):
dependencies:
bytes: 3.1.2
content-type: 2.0.0
@@ -10779,15 +10862,15 @@ snapshots:
exponential-backoff@3.1.3: {}
- express-rate-limit@8.5.2(express@5.2.1):
+ express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)):
dependencies:
- express: 5.2.1
+ express: 5.2.1(supports-color@7.2.0)
ip-address: 10.4.0
- express@5.2.1:
+ express@5.2.1(supports-color@7.2.0):
dependencies:
accepts: 2.0.0
- body-parser: 2.3.0
+ body-parser: 2.3.0(supports-color@7.2.0)
content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
@@ -10797,7 +10880,7 @@ snapshots:
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
- finalhandler: 2.1.1
+ finalhandler: 2.1.1(supports-color@7.2.0)
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
@@ -10808,9 +10891,9 @@ snapshots:
proxy-addr: 2.0.7
qs: 6.15.2
range-parser: 1.2.1
- router: 2.2.0
- send: 1.2.1
- serve-static: 2.2.1
+ router: 2.2.0(supports-color@7.2.0)
+ send: 1.2.1(supports-color@7.2.0)
+ serve-static: 2.2.1(supports-color@7.2.0)
statuses: 2.0.2
type-is: 2.1.0
vary: 1.1.2
@@ -10831,6 +10914,8 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
+ fast-sha256@1.3.0: {}
+
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
@@ -10871,7 +10956,7 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
- finalhandler@2.1.1:
+ finalhandler@2.1.1(supports-color@7.2.0):
dependencies:
debug: 4.4.3(supports-color@7.2.0)
encodeurl: 2.0.0
@@ -11445,6 +11530,11 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
+ json-schema-to-ts@3.1.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ ts-algebra: 2.0.0
+
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
@@ -13037,7 +13127,7 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
- router@2.2.0:
+ router@2.2.0(supports-color@7.2.0):
dependencies:
debug: 4.4.3(supports-color@7.2.0)
depd: 2.0.0
@@ -13080,7 +13170,7 @@ snapshots:
semver@7.8.1: {}
- send@1.2.1:
+ send@1.2.1(supports-color@7.2.0):
dependencies:
debug: 4.4.3(supports-color@7.2.0)
encodeurl: 2.0.0
@@ -13107,12 +13197,12 @@ snapshots:
transitivePeerDependencies:
- typescript
- serve-static@2.2.1:
+ serve-static@2.2.1(supports-color@7.2.0):
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
- send: 1.2.1
+ send: 1.2.1(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -13267,6 +13357,11 @@ snapshots:
stackback@0.0.2: {}
+ standardwebhooks@1.1.1:
+ dependencies:
+ '@stablelib/base64': 1.0.1
+ fast-sha256: 1.3.0
+
stat-mode@1.0.0: {}
state-local@1.0.7: {}
@@ -13437,6 +13532,8 @@ snapshots:
dependencies:
utf8-byte-length: 1.0.5
+ ts-algebra@2.0.0: {}
+
ts-dedent@2.2.0: {}
ts-morph@26.0.0:
@@ -13786,6 +13883,10 @@ snapshots:
dependencies:
zod: 3.25.76
+ zod-to-json-schema@3.25.2(zod@4.5.4):
+ dependencies:
+ zod: 4.5.4
+
zod@3.25.76: {}
zod@4.5.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d241459f884..97920f087c5 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -12,6 +12,20 @@ minimumReleaseAgeExclude:
- zod@4.5.4
shamefullyHoist: true
+# Orca always launches the user's own resolved Claude CLI via
+# pathToClaudeCodeExecutable, so the SDK's bundled ~95 MB-per-platform CLI
+# binaries must never be installed. Excluding them is what makes the path
+# override mandatory rather than merely preferred.
+ignoredOptionalDependencies:
+ - '@anthropic-ai/claude-agent-sdk-darwin-arm64'
+ - '@anthropic-ai/claude-agent-sdk-darwin-x64'
+ - '@anthropic-ai/claude-agent-sdk-linux-arm64'
+ - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl'
+ - '@anthropic-ai/claude-agent-sdk-linux-x64'
+ - '@anthropic-ai/claude-agent-sdk-linux-x64-musl'
+ - '@anthropic-ai/claude-agent-sdk-win32-arm64'
+ - '@anthropic-ai/claude-agent-sdk-win32-x64'
+
supportedArchitectures:
os:
- current
diff --git a/src/main/claude-accounts/claude-structured-auth-policy.test.ts b/src/main/claude-accounts/claude-structured-auth-policy.test.ts
new file mode 100644
index 00000000000..a2d7c7d5da8
--- /dev/null
+++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from 'vitest'
+import type { GlobalSettings } from '../../shared/global-settings-types'
+import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
+import {
+ CLAUDE_AUTH_ENV_VARS,
+ hasClaudeAuthEnvConflict,
+ shouldStripClaudeAuthEnvForAccount
+} from './environment'
+import {
+ normalizeTuiAgentEnvRecord,
+ resolveTuiAgentLaunchEnv
+} from '../../shared/tui-agent-launch-defaults'
+import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy'
+
+const HOST_ACCOUNT = { id: 'host-a', managedAuthRuntime: 'host' } as ClaudeManagedAccount
+const WSL_ACCOUNT = { id: 'wsl-b', managedAuthRuntime: 'wsl' } as ClaudeManagedAccount
+const LEGACY_ACCOUNT = { id: 'legacy-c' } as ClaudeManagedAccount
+
+function settings(
+ overrides: Partial<
+ Pick<
+ GlobalSettings,
+ | 'claudeManagedAccounts'
+ | 'activeClaudeManagedAccountId'
+ | 'activeClaudeManagedAccountIdsByRuntime'
+ >
+ >
+): Parameters[0] {
+ return {
+ claudeManagedAccounts: [HOST_ACCOUNT, WSL_ACCOUNT, LEGACY_ACCOUNT],
+ activeClaudeManagedAccountId: null,
+ ...overrides
+ } as Parameters[0]
+}
+
+// The predicate now backs BOTH transports (runtime-auth-preparation.ts and the
+// structured wiring), so it needs a test of its own: forcing it to a constant used
+// to leave ~1000 tests green.
+describe('shouldStripClaudeAuthEnvForAccount', () => {
+ it('does not strip when no managed account is selected', () => {
+ expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], null)).toBe(false)
+ expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], undefined)).toBe(false)
+ expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], '')).toBe(false)
+ })
+
+ it('strips for a host-managed account', () => {
+ expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true)
+ })
+
+ it('strips for an account with no explicit runtime (the legacy host shape)', () => {
+ expect(shouldStripClaudeAuthEnvForAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true)
+ })
+
+ it('does not strip for a WSL-managed account, matching runtime-auth-preparation', () => {
+ expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false)
+ })
+
+ it('strips for a selected id no account list explains', () => {
+ // Fail-safe: an id we cannot resolve is treated as a pinned account, never as
+ // "no account", so an unreadable settings blob cannot open the strip.
+ expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true)
+ expect(shouldStripClaudeAuthEnvForAccount(undefined, 'deleted-d')).toBe(true)
+ expect(shouldStripClaudeAuthEnvForAccount([], 'deleted-d')).toBe(true)
+ })
+})
+
+describe('claudeStructuredAuthPolicyForSettings', () => {
+ it('reads the host runtime selection, not the legacy flat field alone', () => {
+ expect(
+ claudeStructuredAuthPolicyForSettings(
+ settings({
+ activeClaudeManagedAccountId: 'host-a',
+ activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }
+ })
+ )
+ ).toEqual({ stripAuthEnv: true })
+ })
+
+ it('strips when a host account is pinned by runtime selection', () => {
+ expect(
+ claudeStructuredAuthPolicyForSettings(
+ settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } })
+ )
+ ).toEqual({ stripAuthEnv: true })
+ })
+
+ it('does not strip for system auth, so an API-key-only user keeps their sign-in', () => {
+ expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ stripAuthEnv: false })
+ })
+
+ it('ignores a WSL-only selection: the structured child is always a native host process', () => {
+ expect(
+ claudeStructuredAuthPolicyForSettings(
+ settings({
+ activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } }
+ })
+ )
+ ).toEqual({ stripAuthEnv: false })
+ })
+})
+
+describe('the strip vocabulary the policy governs', () => {
+ it('covers every Anthropic auth variable the terminal path knows about', () => {
+ // A new auth var added to the list without a matching refusal/strip path is the
+ // shape of the leak this lane already shipped once.
+ expect([...CLAUDE_AUTH_ENV_VARS]).toEqual([
+ 'ANTHROPIC_API_KEY',
+ 'ANTHROPIC_AUTH_TOKEN',
+ 'CLAUDE_CODE_OAUTH_TOKEN',
+ 'AWS_BEARER_TOKEN_BEDROCK'
+ ])
+ })
+})
+
+// The refusal has to cover exactly what the strip removes. Anything narrower lets an
+// override reach the child that applyClaudeEnvPatch would have deleted.
+describe('hasClaudeAuthEnvConflict matches the strip it guards', () => {
+ it('refuses each Anthropic auth variable', () => {
+ for (const key of CLAUDE_AUTH_ENV_VARS) {
+ expect(hasClaudeAuthEnvConflict({ [key]: 'v' }, 'linux')).toBe(true)
+ }
+ })
+
+ // `ANTHROPIC_API_KEY=` in the agent env box is how a user blanks a variable, and the
+ // settings pipeline preserves the empty value (agent-default-env-draft.ts assigns
+ // everything after the `=`; normalizeTuiAgentEnvRecord drops empty KEYS only). An
+ // empty value cannot beat the pinned account and the strip removes the name anyway,
+ // so refusing it would break a terminal launch that works today for no security gain.
+ it('admits an override whose value is empty, the documented way to blank a variable', () => {
+ expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: '' }, 'linux')).toBe(false)
+ expect(hasClaudeAuthEnvConflict({ anthropic_api_key: '' }, 'win32')).toBe(false)
+ expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: '' }, 'linux')).toBe(false)
+ })
+
+ it('still refuses the same names once they carry a value', () => {
+ expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: 'sk-ant' }, 'linux')).toBe(true)
+ })
+
+ // The end-to-end shape the regression actually took: settings text -> normalized
+ // record -> launch env -> the predicate the terminal preflight gates on.
+ it('admits a blanked variable all the way from the settings record', () => {
+ const configured = normalizeTuiAgentEnvRecord({ claude: { ANTHROPIC_API_KEY: '' } })
+ const launchEnv = resolveTuiAgentLaunchEnv('claude', configured)
+
+ expect(launchEnv).toEqual({ ANTHROPIC_API_KEY: '' })
+ expect(hasClaudeAuthEnvConflict(launchEnv, 'linux')).toBe(false)
+ })
+
+ it('folds case on win32, where the OS does', () => {
+ expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'win32')).toBe(true)
+ expect(hasClaudeAuthEnvConflict({ Anthropic_Custom_Headers: 'x-api-key: v' }, 'win32')).toBe(
+ true
+ )
+ })
+
+ it('keeps env names case-sensitive off win32', () => {
+ expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'linux')).toBe(false)
+ })
+
+ it('admits non-auth Anthropic settings on both platforms', () => {
+ expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'linux')).toBe(false)
+ expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'win32')).toBe(false)
+ expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: 1' }, 'linux')).toBe(
+ false
+ )
+ expect(hasClaudeAuthEnvConflict(undefined, 'linux')).toBe(false)
+ })
+})
diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts
new file mode 100644
index 00000000000..c30cd69b827
--- /dev/null
+++ b/src/main/claude-accounts/claude-structured-auth-policy.ts
@@ -0,0 +1,37 @@
+import type { GlobalSettings } from '../../shared/global-settings-types'
+import { shouldStripClaudeAuthEnvForAccount } from './environment'
+import { getSelectedClaudeAccountIdForTarget } from './runtime-selection'
+
+/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result:
+ * the one field a launch resolution needs from the managed-account state. */
+export type ClaudeStructuredAuthPolicy = {
+ stripAuthEnv: boolean
+}
+
+/**
+ * The only supported way to build a structured launch's auth policy.
+ *
+ * It exists as a named function rather than an inline object at the wiring site so
+ * that the settings-to-policy mapping is testable on its own: the one production
+ * wiring lives in a `@ts-nocheck` file, where neither the compiler nor a type test
+ * can see a dropped field.
+ *
+ * Structured Claude always spawns a native local-host child — the launch resolver
+ * refuses any record with a remote execution host or a WSL distro — so the host
+ * selection, not the platform default target, owns its auth.
+ */
+export function claudeStructuredAuthPolicyForSettings(
+ settings: Pick<
+ GlobalSettings,
+ | 'claudeManagedAccounts'
+ | 'activeClaudeManagedAccountId'
+ | 'activeClaudeManagedAccountIdsByRuntime'
+ >
+): ClaudeStructuredAuthPolicy {
+ return {
+ stripAuthEnv: shouldStripClaudeAuthEnvForAccount(
+ settings.claudeManagedAccounts,
+ getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })
+ )
+ }
+}
diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts
index 83fe3b40209..b85dd60a854 100644
--- a/src/main/claude-accounts/environment.ts
+++ b/src/main/claude-accounts/environment.ts
@@ -1,3 +1,5 @@
+import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
+
export const CLAUDE_AUTH_ENV_VARS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
@@ -13,14 +15,21 @@ export type ClaudeEnvPatch = {
export function applyClaudeEnvPatch(
baseEnv: Record,
patch: ClaudeEnvPatch,
- options?: { stripAuthEnv?: boolean }
+ options?: { stripAuthEnv?: boolean; platform?: NodeJS.Platform }
): Record {
if (options?.stripAuthEnv) {
for (const key of CLAUDE_AUTH_ENV_VARS) {
delete baseEnv[key]
}
- if (isAuthLikeCustomHeaders(baseEnv.ANTHROPIC_CUSTOM_HEADERS)) {
- delete baseEnv.ANTHROPIC_CUSTOM_HEADERS
+ const platform = options.platform ?? process.platform
+ for (const key of Object.keys(baseEnv)) {
+ const normalized = platform === 'win32' ? key.toUpperCase() : key
+ if (
+ (platform === 'win32' && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) ||
+ (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(baseEnv[key]))
+ ) {
+ delete baseEnv[key]
+ }
}
}
@@ -34,16 +43,94 @@ export function applyClaudeEnvPatch(
return baseEnv
}
-export function hasClaudeAuthEnvConflict(env: Record | undefined): boolean {
- if (!env) {
+/** One string for every transport, so a terminal launch and a structured launch
+ * cannot drift into telling the user two different things about one refusal. */
+export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE =
+ 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
+
+export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE =
+ 'A Claude account switch is in progress. Try again after it finishes.'
+
+/**
+ * Whether a launch on the host runtime must drop inherited Anthropic auth.
+ *
+ * Only a pinned host-managed account owns the credential, so only it may strip:
+ * with no managed account the user's own `ANTHROPIC_*` is their sign-in, and
+ * removing it signs them out of a CLI that would otherwise have worked.
+ */
+export function shouldStripClaudeAuthEnvForAccount(
+ accounts: readonly ClaudeManagedAccount[] | undefined,
+ activeAccountId: string | null | undefined
+): boolean {
+ if (!activeAccountId) {
return false
}
return (
- CLAUDE_AUTH_ENV_VARS.some((key) => Boolean(env[key])) ||
- isAuthLikeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS)
+ (accounts ?? []).find((account) => account.id === activeAccountId)?.managedAuthRuntime !== 'wsl'
)
}
+/**
+ * Whether a launch's explicit env carries Anthropic auth a managed account must own.
+ *
+ * The key comparison mirrors applyClaudeEnvPatch's strip exactly: case-insensitive on
+ * win32, where the OS folds env names so `anthropic_api_key` is an effective
+ * `ANTHROPIC_API_KEY`, and case-sensitive elsewhere. A refusal narrower than the strip
+ * lets an override through that the strip would have removed.
+ *
+ * A non-empty value is what makes it a conflict. `ANTHROPIC_API_KEY=` in the agent env
+ * box is how a user blanks a variable — the settings pipeline preserves that empty value
+ * (normalizeTuiAgentEnvRecord drops empty KEYS only) — and an empty override can neither
+ * authenticate nor beat the pinned account, while the strip removes the name regardless.
+ * Refusing it would break a terminal launch that works today for no security gain.
+ */
+/**
+ * The inherited Anthropic auth a non-stripping launch has to carry forward explicitly.
+ *
+ * applyClaudeEnvPatch always strips the inherited half of a child env, and the
+ * configured half is what overrides it — so a system-auth user's own key only survives
+ * if the caller puts it back deliberately. Returns the exact keys present, so a
+ * win32 `anthropic_api_key` is carried under the name the OS actually has.
+ */
+export function claudeAuthEnvCarriedForward(
+ inherited: NodeJS.ProcessEnv,
+ platform: NodeJS.Platform = process.platform
+): Record {
+ const carried: Record = {}
+ for (const [key, value] of Object.entries(inherited)) {
+ if (value === undefined) {
+ continue
+ }
+ const normalized = platform === 'win32' ? key.toUpperCase() : key
+ if (
+ CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized) ||
+ (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value))
+ ) {
+ carried[key] = value
+ }
+ }
+ return carried
+}
+
+export function hasClaudeAuthEnvConflict(
+ env: Record | undefined,
+ platform: NodeJS.Platform = process.platform
+): boolean {
+ if (!env) {
+ return false
+ }
+ for (const [key, value] of Object.entries(env)) {
+ const normalized = platform === 'win32' ? key.toUpperCase() : key
+ if (value && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) {
+ return true
+ }
+ if (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) {
+ return true
+ }
+ }
+ return false
+}
+
function isAuthLikeCustomHeaders(value: string | undefined): boolean {
if (!value) {
return false
diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts
index 9e30b621924..caab66c3430 100644
--- a/src/main/claude-accounts/live-pty-gate.ts
+++ b/src/main/claude-accounts/live-pty-gate.ts
@@ -5,6 +5,13 @@ const liveClaudePtyIds = new Set()
// survived the app restart inside the daemon.
const seededUnconfirmedPtyIds = new Set()
let switchInProgress = false
+// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the
+// swap out instead of refusing. See whenClaudeAuthSwitchSettles.
+const switchSettledListeners = new Set<() => void>()
+
+/** A managed account swap is a credential-file rewrite, not a network round trip;
+ * anything past this is a wedged switch, and refusing beats waiting forever. */
+export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000
export type ClaudeLivePtyPersistence = {
addClaudeLivePtySessionId(sessionId: string): void
@@ -81,6 +88,35 @@ export function markClaudePtyExited(ptyId: string): void {
notifyDrainedOnTransition(hadLivePtys)
}
+/**
+ * Register a structured Claude child with the same gate the terminal path uses.
+ *
+ * The gate is what makes the managed OAuth refresh defer instead of rotating a
+ * single-use refresh token out from under a running Claude (runtime-auth-sync.ts).
+ * A structured session's child is as much a live Claude as a PTY's is, so it has to
+ * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an
+ * identical terminal session is protected.
+ *
+ * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct
+ * children of this process and cannot survive a restart, so seeding them back on the
+ * next launch would hold the gate closed for a process that is provably gone.
+ */
+export function markClaudeStructuredChildSpawned(childKey: string): void {
+ liveClaudePtyIds.add(structuredChildGateId(childKey))
+}
+
+export function markClaudeStructuredChildExited(childKey: string): void {
+ const hadLivePtys = liveClaudePtyIds.size > 0
+ liveClaudePtyIds.delete(structuredChildGateId(childKey))
+ notifyDrainedOnTransition(hadLivePtys)
+}
+
+// Namespaced so a structured child can never collide with a daemon PTY session id,
+// which confirmSeededClaudeLivePtys reconciles against the daemon's own list.
+function structuredChildGateId(childKey: string): string {
+ return `claude-structured:${childKey}`
+}
+
export function hasLiveClaudePtys(): boolean {
return liveClaudePtyIds.size > 0
}
@@ -93,7 +129,44 @@ export function beginClaudeAuthSwitch(): void {
}
export function endClaudeAuthSwitch(): void {
+ const wasInProgress = switchInProgress
switchInProgress = false
+ if (!wasInProgress) {
+ return
+ }
+ // Each listener removes itself as it settles; Set iteration is defined over that.
+ for (const listener of switchSettledListeners) {
+ listener()
+ }
+}
+
+/**
+ * Resolves `true` once no account switch is running, `false` if one is still running
+ * at the deadline.
+ *
+ * Exists for callers that have already done irreversible work — a structured acquire
+ * has closed the old child by the time it resolves its launch, so turning a switch
+ * into a refusal there strands the user with a dead session and no replacement.
+ * Waiting for the swap and then launching against it is the recoverable answer;
+ * refusing is only correct when nothing has been torn down yet.
+ */
+export function whenClaudeAuthSwitchSettles(
+ timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS
+): Promise {
+ if (!switchInProgress) {
+ return Promise.resolve(true)
+ }
+ return new Promise((resolve) => {
+ const settle = (settled: boolean): void => {
+ switchSettledListeners.delete(listener)
+ clearTimeout(timer)
+ resolve(settled)
+ }
+ const listener = (): void => settle(true)
+ switchSettledListeners.add(listener)
+ const timer = setTimeout(() => settle(false), timeoutMs)
+ timer.unref?.()
+ })
}
export function isClaudeAuthSwitchInProgress(): boolean {
diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts
index ae79c4c7bbb..dabcd9d472f 100644
--- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts
+++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts
@@ -2,6 +2,7 @@ import { join } from 'node:path'
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime'
import { parseWslUncPath } from '../../../shared/wsl-paths'
+import { shouldStripClaudeAuthEnvForAccount } from '../environment'
import { getDefaultWslDistro, getWslHome } from '../../wsl'
import {
getSelectedClaudeAccountIdForTarget,
@@ -69,7 +70,10 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh
wslDistro: null,
wslLinuxConfigDir: null,
envPatch: paths.envPatch,
- stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'),
+ stripAuthEnv: shouldStripClaudeAuthEnvForAccount(
+ settings.claudeManagedAccounts,
+ activeAccountId
+ ),
managedRefreshDeferredByLivePty: Boolean(
activeAccountId &&
activeAccount?.managedAuthRuntime !== 'wsl' &&
diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs
new file mode 100644
index 00000000000..4f2a09425fd
--- /dev/null
+++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs
@@ -0,0 +1,144 @@
+// Scripted stand-in for the Claude Code CLI, driven by the SDK contract-pin
+// tests. It speaks just enough stream-json to satisfy the SDK: it answers every
+// inbound control_request with a success control_response, records everything it
+// observes to a report file, and plays back the steps listed in a scenario file.
+//
+// Env contract (set by the test):
+// ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file
+// { steps: Step[], controlResponses?: { [subtype]: } } where a Step is
+// { emit: } | { awaitUserMessage: true } | { stderr: } |
+// { awaitControlResponse: } | { delayMs: } | { exit: }
+// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written
+// ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close
+// ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer
+// ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid
+import { spawn } from 'node:child_process'
+import { readFileSync, writeFileSync } from 'node:fs'
+import { createInterface } from 'node:readline'
+
+const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH
+const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH
+
+const report = {
+ argv: process.argv.slice(1),
+ execPath: process.execPath,
+ controlRequests: [],
+ controlResponses: [],
+ userMessages: [],
+ descendantPid: null
+}
+const writeReport = () => {
+ if (reportPath) {
+ writeFileSync(reportPath, JSON.stringify(report))
+ }
+}
+// Written immediately so a test can prove which script the SDK executed even if
+// the session dies before the scenario completes.
+writeReport()
+
+const scenario = scenarioPath ? JSON.parse(readFileSync(scenarioPath, 'utf8')) : { steps: [] }
+
+if (process.env.ORCA_SDK_CONTRACT_IGNORE_SIGTERM) {
+ process.on('SIGTERM', () => {})
+ process.on('SIGINT', () => {})
+ setInterval(() => {}, 1_000_000)
+}
+if (process.env.ORCA_SDK_CONTRACT_DESCENDANT) {
+ const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000000)'], {
+ stdio: 'ignore'
+ })
+ descendant.unref()
+ report.descendantPid = descendant.pid ?? null
+ writeReport()
+}
+
+const emit = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`)
+
+const waiters = []
+const settle = (kind, requestId) => {
+ for (let i = waiters.length - 1; i >= 0; i--) {
+ const waiter = waiters[i]
+ if (
+ waiter.kind === kind &&
+ (waiter.requestId === undefined || waiter.requestId === requestId)
+ ) {
+ waiters.splice(i, 1)
+ waiter.resolve()
+ }
+ }
+}
+const waitFor = (kind, requestId) => {
+ if (kind === 'user' && report.userMessages.length > 0) {
+ return Promise.resolve()
+ }
+ if (
+ kind === 'control_response' &&
+ report.controlResponses.some((frame) => frame.response?.request_id === requestId)
+ ) {
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => waiters.push({ kind, requestId, resolve }))
+}
+
+createInterface({ input: process.stdin }).on('line', (line) => {
+ let frame
+ try {
+ frame = JSON.parse(line)
+ } catch {
+ return
+ }
+ if (frame.type === 'control_request') {
+ report.controlRequests.push(frame)
+ writeReport()
+ if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) {
+ return
+ }
+ emit({
+ type: 'control_response',
+ response: {
+ subtype: 'success',
+ request_id: frame.request_id,
+ response: scenario.controlResponses?.[frame.request?.subtype] ?? {
+ commands: [],
+ models: []
+ }
+ }
+ })
+ return
+ }
+ if (frame.type === 'control_response') {
+ report.controlResponses.push(frame)
+ writeReport()
+ settle('control_response', frame.response?.request_id)
+ return
+ }
+ if (frame.type === 'user') {
+ report.userMessages.push(frame)
+ writeReport()
+ settle('user')
+ }
+})
+
+// Never outlive a wedged test: the readline subscription would otherwise hold
+// this process open forever if the SDK side stops driving the scenario.
+setTimeout(() => process.exit(3), 20_000).unref()
+
+for (const step of scenario.steps) {
+ if (step.emit) {
+ emit(step.emit)
+ } else if (step.stderr !== undefined) {
+ process.stderr.write(step.stderr)
+ } else if (step.awaitUserMessage) {
+ await waitFor('user')
+ } else if (step.awaitControlResponse !== undefined) {
+ await waitFor('control_response', step.awaitControlResponse)
+ } else if (step.delayMs) {
+ await new Promise((resolve) => setTimeout(resolve, step.delayMs))
+ } else if (step.exit !== undefined) {
+ // A CLI that refuses to start: leave with its own status, stderr already written.
+ writeReport()
+ process.exit(step.exit)
+ }
+}
+writeReport()
+process.exit(0)
diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts
new file mode 100644
index 00000000000..46bcb7219d3
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts
@@ -0,0 +1,519 @@
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import {
+ query,
+ type CanUseTool,
+ type Options,
+ type SDKUserMessage,
+ type SpawnedProcess as SdkSpawnedProcess,
+ type SpawnOptions as SdkSpawnOptions
+} from '@anthropic-ai/claude-agent-sdk'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { spawnProcess } from '../../shared/child-process/run-process'
+import type { AgentSessionRecord } from '../../shared/agent-session-record'
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
+import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests'
+import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
+
+// Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK
+// driving a scripted fake CLI (never the real Claude binary). These tests exist
+// to catch a future SDK version drifting under Orca: unknown-frame pass-through,
+// spawner env fidelity, argument parity with the pre-SDK argv,
+// permission-callback semantics, and executable-path override.
+
+const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
+const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f'
+const LEAF_UUID = 'ad0f7c9e-1b2c-4d3e-8f90-abc123def456'
+const PINNED_SDK_VERSION = '0.3.251'
+const SDK_PLATFORM_PACKAGE_BASENAMES = [
+ 'claude-agent-sdk-darwin-arm64',
+ 'claude-agent-sdk-darwin-x64',
+ 'claude-agent-sdk-linux-arm64',
+ 'claude-agent-sdk-linux-arm64-musl',
+ 'claude-agent-sdk-linux-x64',
+ 'claude-agent-sdk-linux-x64-musl',
+ 'claude-agent-sdk-win32-arm64',
+ 'claude-agent-sdk-win32-x64'
+]
+
+/**
+ * The exact argv the hand-rolled transport built before the SDK swap. Frozen here
+ * as the parity oracle: CLAUDE_STRUCTURED_BASE_OPTIONS has to keep producing it.
+ */
+const PRE_SDK_ARGV = [
+ '-p',
+ '--input-format',
+ 'stream-json',
+ '--output-format',
+ 'stream-json',
+ '--include-partial-messages',
+ '--verbose',
+ '--replay-user-messages',
+ '--permission-prompt-tool',
+ 'stdio',
+ '--setting-sources',
+ 'user,project,local'
+]
+
+const RESULT_FRAME = {
+ type: 'result',
+ subtype: 'success',
+ is_error: false,
+ duration_ms: 1,
+ duration_api_ms: 1,
+ num_turns: 1,
+ result: 'ok',
+ session_id: SESSION_ID,
+ total_cost_usd: 0,
+ usage: { input_tokens: 1, output_tokens: 1 },
+ uuid: 'uuid-result-1'
+}
+
+type ScenarioStep = Record
+type SpawnSeen = {
+ command: string
+ args: string[]
+ cwd: string | undefined
+ env: Record
+}
+type ScriptedCliReport = {
+ argv: string[]
+ execPath: string
+ controlRequests: { request_id: string; request: { subtype: string } }[]
+ controlResponses: { response: { request_id: string; response?: Record } }[]
+ userMessages: Record[]
+}
+
+const scratchDirs: string[] = []
+afterEach(() => {
+ vi.unstubAllEnvs()
+ for (const dir of scratchDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+function scriptScenario(
+ steps: ScenarioStep[],
+ controlResponses: Record = {}
+): {
+ scenarioPath: string
+ reportPath: string
+ cwd: string
+ readReport: () => ScriptedCliReport
+} {
+ const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-contract-'))
+ scratchDirs.push(dir)
+ const scenarioPath = join(dir, 'scenario.json')
+ const reportPath = join(dir, 'report.json')
+ writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses }))
+ return {
+ scenarioPath,
+ reportPath,
+ cwd: dir,
+ readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport
+ }
+}
+
+function scenarioEnv(scenario: { scenarioPath: string; reportPath: string }) {
+ return {
+ PATH: process.env.PATH,
+ ORCA_SDK_CONTRACT_SCENARIO_PATH: scenario.scenarioPath,
+ ORCA_SDK_CONTRACT_REPORT_PATH: scenario.reportPath
+ }
+}
+
+function recordingSpawner(spawns: SpawnSeen[]) {
+ return (opts: SdkSpawnOptions): SdkSpawnedProcess => {
+ spawns.push({
+ command: opts.command,
+ args: [...opts.args],
+ cwd: opts.cwd,
+ env: { ...opts.env }
+ })
+ return spawnProcess({
+ program: opts.command,
+ args: opts.args,
+ cwd: opts.cwd,
+ env: opts.env as NodeJS.ProcessEnv,
+ signal: opts.signal
+ }) as unknown as SdkSpawnedProcess
+ }
+}
+
+function resolvedLaunch(launchArgs: string[]) {
+ const record = {
+ sessionId: 'contract-pin-session',
+ provider: 'claude',
+ location: {
+ executionHostId: LOCAL_EXECUTION_HOST_ID,
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'folder'
+ },
+ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
+ providerHandleChain: [],
+ launchArgs
+ } as unknown as AgentSessionRecord
+ return createClaudeStructuredLaunchResolver({
+ store: { getRecord: () => record } as unknown as AgentSessionRecordStore,
+ resolveWorkspacePath: async () => '/repos/workspace-1',
+ resolveCommand: () => FAKE_CLI,
+ resolveAuthPolicy: () => ({ stripAuthEnv: true })
+ })({ identity: { sessionId: record.sessionId } as never })
+}
+
+function singleUserTurn(): AsyncIterable {
+ return (async function* () {
+ yield {
+ type: 'user',
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+ parent_tool_use_id: null,
+ session_id: SESSION_ID
+ } as SDKUserMessage
+ // Hold input open; the stream ends when the scripted CLI exits, and an
+ // unresolved bare promise does not keep the event loop alive.
+ await new Promise(() => {})
+ })()
+}
+
+async function drainQuery(options: Options): Promise[]> {
+ const messages: Record[] = []
+ for await (const message of query({ prompt: singleUserTurn(), options })) {
+ messages.push(message as unknown as Record)
+ }
+ return messages
+}
+
+/** Expand `--flag=value` argv entries so both SDK spellings compare equal. */
+function normalizeArgv(args: string[]): string[] {
+ return args.flatMap((arg) => {
+ if (!arg.startsWith('--')) {
+ return [arg]
+ }
+ const eq = arg.indexOf('=')
+ return eq === -1 ? [arg] : [arg.slice(0, eq), arg.slice(eq + 1)]
+ })
+}
+
+/** Group the pre-SDK argv into flag/value pairs. */
+function flagTable(args: readonly string[]): { flag: string; value: string | null }[] {
+ const table: { flag: string; value: string | null }[] = []
+ for (let i = 0; i < args.length; i++) {
+ const flag = args[i]!
+ const next = args[i + 1]
+ if (next !== undefined && !next.startsWith('-')) {
+ table.push({ flag, value: next })
+ i++
+ } else {
+ table.push({ flag, value: null })
+ }
+ }
+ return table
+}
+
+describe('Claude Agent SDK contract pins', () => {
+ it('yields unknown types, unknown fields and unknown content blocks verbatim, and consumes keep_alive', async () => {
+ const unknownTopLevel = {
+ type: 'message_kind_from_the_future',
+ session_id: SESSION_ID,
+ uuid: 'uuid-unknown-1',
+ payload: { alpha: 1, nested: { flags: ['a', 'b'] } }
+ }
+ const assistantWithUnknowns = {
+ type: 'assistant',
+ message: {
+ id: 'msg-1',
+ type: 'message',
+ role: 'assistant',
+ model: 'claude-x',
+ content: [
+ { type: 'text', text: 'hello back' },
+ { type: 'content_block_from_the_future', payload: { depth: 3 } }
+ ],
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 1, output_tokens: 2 }
+ },
+ parent_tool_use_id: null,
+ uuid: 'uuid-assistant-1',
+ session_id: SESSION_ID,
+ field_from_the_future: 'preserved'
+ }
+ const scenario = scriptScenario([
+ { awaitUserMessage: true },
+ { emit: { type: 'keep_alive' } },
+ { emit: unknownTopLevel },
+ { emit: assistantWithUnknowns },
+ { emit: RESULT_FRAME }
+ ])
+ const spawns: SpawnSeen[] = []
+ const messages = await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario),
+ spawnClaudeCodeProcess: recordingSpawner(spawns)
+ })
+
+ expect(messages.find((m) => m.uuid === 'uuid-unknown-1')).toEqual(unknownTopLevel)
+ expect(messages.find((m) => m.uuid === 'uuid-assistant-1')).toEqual(assistantWithUnknowns)
+ // The SDK intercepts keep_alive internally — a liveness signal must never
+ // be derived from it reaching the consumer, because it does not.
+ expect(messages.some((m) => m.type === 'keep_alive')).toBe(false)
+ expect(messages.some((m) => m.type === 'result')).toBe(true)
+ })
+
+ it('hands the custom spawner exactly the caller-supplied env, plus the two pinned SDK mutations', async () => {
+ vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key-must-not-leak')
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ const spawns: SpawnSeen[] = []
+ await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: {
+ ...scenarioEnv(scenario),
+ CLAUDE_CONFIG_DIR: '/pinned/claude-config',
+ ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-token-1',
+ NODE_OPTIONS: '--max-old-space-size=64'
+ },
+ spawnClaudeCodeProcess: recordingSpawner(spawns)
+ })
+
+ const env = spawns[0]!.env
+ // Supplied values arrive verbatim: the config-dir pin and spawn token are
+ // observable at this boundary, so Orca's auth scrubbing stays assertable.
+ expect(env.CLAUDE_CONFIG_DIR).toBe('/pinned/claude-config')
+ expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-token-1')
+ // Ambient process.env is NOT merged in when env is supplied.
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined()
+ // The SDK's two documented mutations, pinned so a change is noticed.
+ expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts')
+ expect('NODE_OPTIONS' in env).toBe(false)
+ })
+
+ it('inherits process.env into the child when env is omitted — the ambient-auth sharp edge', async () => {
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ vi.stubEnv('ORCA_SDK_CONTRACT_SCENARIO_PATH', scenario.scenarioPath)
+ vi.stubEnv('ORCA_SDK_CONTRACT_REPORT_PATH', scenario.reportPath)
+ vi.stubEnv('ORCA_SDK_CONTRACT_AMBIENT_CANARY', 'inherited-from-process-env')
+ const spawns: SpawnSeen[] = []
+ await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ spawnClaudeCodeProcess: recordingSpawner(spawns)
+ })
+
+ // Omitting env reproduces the ambient-auth-leak failure mode: the child
+ // sees everything in process.env. Orca must therefore always pass an
+ // explicit, fully-constructed env.
+ expect(spawns[0]!.env.ORCA_SDK_CONTRACT_AMBIENT_CANARY).toBe('inherited-from-process-env')
+ })
+
+ it('emits --replay-user-messages only through extraArgs, never on its own', async () => {
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ const bareSpawns: SpawnSeen[] = []
+ await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario),
+ spawnClaudeCodeProcess: recordingSpawner(bareSpawns)
+ })
+ expect(bareSpawns[0]!.args).not.toContain('--replay-user-messages')
+
+ const replayScenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ const replaySpawns: SpawnSeen[] = []
+ await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: replayScenario.cwd,
+ env: scenarioEnv(replayScenario),
+ extraArgs: { 'replay-user-messages': null },
+ spawnClaudeCodeProcess: recordingSpawner(replaySpawns)
+ })
+ const replayArgs = replaySpawns[0]!.args
+ expect(replayArgs.filter((arg) => arg === '--replay-user-messages')).toHaveLength(1)
+ })
+
+ it('produces a matching CLI flag for every pre-SDK argv entry', async () => {
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ const spawns: SpawnSeen[] = []
+ // Driven by the real resolver, so the argv walk covers the durable-launchArgs
+ // translation and its merge order, not a hand-written options literal.
+ const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high'])
+ await drainQuery({
+ ...launch.options,
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario),
+ canUseTool: (async () => ({ behavior: 'deny', message: 'unused' })) as CanUseTool,
+ spawnClaudeCodeProcess: recordingSpawner(spawns)
+ })
+
+ expect(spawns).toHaveLength(1)
+ const argv = normalizeArgv(spawns[0]!.args)
+ // Typed-first translation must not also spell the flag through extraArgs.
+ for (const flag of ['--model', '--effort']) {
+ expect(
+ argv.filter((arg) => arg === flag),
+ `${flag} occurrences`
+ ).toHaveLength(1)
+ }
+ expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5')
+ expect(argv[argv.indexOf('--effort') + 1]).toBe('high')
+ // Headless print mode is the SDK's only mode; `query()` never passes `-p`,
+ // and if the SDK ever started passing it this pin would notice.
+ const impliedByHeadlessQuery = new Set(['-p'])
+ for (const entry of flagTable(PRE_SDK_ARGV)) {
+ if (impliedByHeadlessQuery.has(entry.flag)) {
+ expect(argv, `${entry.flag} is implied, never spelled`).not.toContain(entry.flag)
+ continue
+ }
+ const at = argv.indexOf(entry.flag)
+ expect(at, `SDK argv is missing ${entry.flag}`).toBeGreaterThanOrEqual(0)
+ if (entry.value !== null) {
+ expect(argv[at + 1], `value of ${entry.flag}`).toBe(entry.value)
+ }
+ }
+ // The launch resolver always carries one of --session-id / --resume.
+ const sessionAt = argv.indexOf('--session-id')
+ expect(sessionAt).toBeGreaterThanOrEqual(0)
+ expect(argv[sessionAt + 1]).toBe(launch.providerSessionId)
+ })
+
+ it('still exposes the runtime get_settings reader the auth diagnostic depends on', async () => {
+ // 0.3.251 ships getSettings() but redacts it from the Query declaration. This pin
+ // is the drift alarm: if a bump drops or reshapes it, the diagnostic degrades and
+ // this test says so instead of the degradation shipping silently.
+ const settings = { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } }
+ const scenario = scriptScenario([{ delayMs: 3_000 }], { get_settings: settings })
+ const session = query({
+ prompt: singleUserTurn(),
+ options: {
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario)
+ }
+ })
+ try {
+ const read = claudeQuerySettingsReader(session)
+ expect(read, 'the SDK no longer exposes get_settings at runtime').not.toBeNull()
+ await expect(read?.()).resolves.toEqual(settings)
+ } finally {
+ await session.return(undefined)
+ }
+ })
+
+ it('maps resume identity to --resume and --resume-session-at', async () => {
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ const spawns: SpawnSeen[] = []
+ await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario),
+ resume: SESSION_ID,
+ resumeSessionAt: LEAF_UUID,
+ spawnClaudeCodeProcess: recordingSpawner(spawns)
+ })
+
+ const argv = normalizeArgv(spawns[0]!.args)
+ const resumeAt = argv.indexOf('--resume')
+ expect(resumeAt).toBeGreaterThanOrEqual(0)
+ expect(argv[resumeAt + 1]).toBe(SESSION_ID)
+ const leafAt = argv.indexOf('--resume-session-at')
+ expect(leafAt).toBeGreaterThanOrEqual(0)
+ expect(argv[leafAt + 1]).toBe(LEAF_UUID)
+ })
+
+ it('gives canUseTool the wire request_id and fires its abort signal on control_cancel_request', async () => {
+ const scenario = scriptScenario([
+ { awaitUserMessage: true },
+ {
+ emit: {
+ type: 'control_request',
+ request_id: 'perm-421',
+ request: {
+ subtype: 'can_use_tool',
+ tool_name: 'Bash',
+ input: { command: 'echo hi' },
+ tool_use_id: 'tool-use-9'
+ }
+ }
+ },
+ { delayMs: 120 },
+ { emit: { type: 'control_cancel_request', request_id: 'perm-421' } },
+ { awaitControlResponse: 'perm-421' },
+ { emit: RESULT_FRAME }
+ ])
+ const seen: { toolName: string; requestId: string; toolUseID: string }[] = []
+ let abortFired = false
+ const canUseTool: CanUseTool = (toolName, _input, { signal, requestId, toolUseID }) => {
+ seen.push({ toolName, requestId, toolUseID })
+ return new Promise((resolve) => {
+ signal.addEventListener('abort', () => {
+ abortFired = true
+ resolve({ behavior: 'deny', message: 'cancelled by test' })
+ })
+ })
+ }
+ const spawns: SpawnSeen[] = []
+ await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario),
+ canUseTool,
+ spawnClaudeCodeProcess: recordingSpawner(spawns)
+ })
+
+ expect(seen).toEqual([{ toolName: 'Bash', requestId: 'perm-421', toolUseID: 'tool-use-9' }])
+ expect(abortFired).toBe(true)
+ // The callback's settlement is written back onto the wire against the same id.
+ const settled = scenario
+ .readReport()
+ .controlResponses.find((frame) => frame.response.request_id === 'perm-421')
+ expect(settled?.response.response?.behavior).toBe('deny')
+ // Exactly one process spawn per query, control traffic included.
+ expect(spawns).toHaveLength(1)
+ })
+
+ it('runs the executable given via pathToClaudeCodeExecutable under the default spawner', async () => {
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
+ const messages = await drainQuery({
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ cwd: scenario.cwd,
+ env: scenarioEnv(scenario)
+ })
+
+ expect(messages.some((m) => m.type === 'result')).toBe(true)
+ const report = scenario.readReport()
+ // The SDK executed exactly the script we pointed it at — no bundled binary.
+ expect(report.argv[0]).toBe(FAKE_CLI)
+ expect(report.execPath).toContain('node')
+ // And the streaming handshake went to it: the SDK sent its initialize
+ // control request to our script.
+ expect(report.controlRequests.some((frame) => frame.request.subtype === 'initialize')).toBe(
+ true
+ )
+ })
+
+ it('pins the SDK version the contract was verified against', () => {
+ const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk')
+ const manifest = JSON.parse(readFileSync(join(dirname(sdkEntry), 'package.json'), 'utf8')) as {
+ version: string
+ }
+ expect(manifest.version).toBe(PINNED_SDK_VERSION)
+ })
+
+ it('keeps the eight bundled CLI platform binaries out of the install', () => {
+ const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk')
+ // The SDK's own scoped directory is where pnpm would link its optional
+ // platform packages; ignoredOptionalDependencies must keep them all absent.
+ const scopeDir = dirname(dirname(sdkEntry))
+ for (const basename of SDK_PLATFORM_PACKAGE_BASENAMES) {
+ expect(
+ existsSync(join(scopeDir, basename, 'package.json')),
+ `${basename} must not be installed`
+ ).toBe(false)
+ }
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts
new file mode 100644
index 00000000000..6bd396413fa
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-control-requests.ts
@@ -0,0 +1,154 @@
+import type {
+ PermissionMode,
+ Query,
+ SDKControlInterruptResponse
+} from '@anthropic-ai/claude-agent-sdk'
+
+export class ClaudeControlRequestError extends Error {
+ constructor(
+ readonly subtype: string,
+ message: string
+ ) {
+ super(message)
+ this.name = 'ClaudeControlRequestError'
+ }
+}
+
+export const CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS = 30_000
+
+/** The SDK closes a query out from under an in-flight control request with this exact message. */
+const QUERY_CLOSED_MESSAGE = 'Query closed before response received'
+
+/** 0.3.251 ships getSettings() but redacts it from the Query declaration; the typeof guard below is its degradation path. */
+type ClaudeQuerySettingsReader = { getSettings?: () => Promise }
+
+export function claudeQuerySettingsReader(query: Query): (() => Promise) | null {
+ const reader = (query as unknown as ClaudeQuerySettingsReader).getSettings
+ return typeof reader === 'function' ? reader.bind(query) : null
+}
+
+/**
+ * cancel_async_message is a runtime Query method the shipped 0.3.251 declaration omits;
+ * it withdraws a single still-queued async user message by uuid so an interrupted turn
+ * cannot spawn a later unexpected turn. The typeof guard is its degradation path.
+ */
+type ClaudeQueryAsyncCanceller = { cancelAsyncMessage?: (uuid: string) => Promise }
+
+export function claudeQueryAsyncCanceller(
+ query: Query
+): ((uuid: string) => Promise) | null {
+ const cancel = (query as unknown as ClaudeQueryAsyncCanceller).cancelAsyncMessage
+ return typeof cancel === 'function' ? cancel.bind(query) : null
+}
+
+export type ClaudeControlOptions = { timeoutMs?: number }
+
+/**
+ * Run one native Query control method under Orca's deadline and error classification.
+ *
+ * The SDK owns correlation but applies no deadline, so the timeout stays here — and its
+ * message is load-bearing: the init proof matches on `claude initialize request timed out`.
+ * A closed query is a transport failure, not the CLI rejecting the request, so only the
+ * latter is re-thrown as a `ClaudeControlRequestError` a caller may surface as a rejection.
+ */
+export function runClaudeControl(
+ subtype: string,
+ run: () => Promise,
+ timeoutMs: number = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS
+): Promise {
+ let timer: ReturnType | null = null
+ const deadline = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(new Error(`claude ${subtype} request timed out`)), timeoutMs)
+ timer.unref?.()
+ })
+ return Promise.race([
+ Promise.resolve()
+ .then(run)
+ .catch((error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error)
+ if (error instanceof ClaudeControlRequestError || message === QUERY_CLOSED_MESSAGE) {
+ throw error
+ }
+ throw new ClaudeControlRequestError(subtype, message)
+ }),
+ deadline
+ ]).finally(() => {
+ if (timer) {
+ clearTimeout(timer)
+ }
+ })
+}
+
+/** The native control surface Orca drives, one method per Query control request. */
+export type ClaudeControlSurface = {
+ interrupt: (
+ options?: ClaudeControlOptions & { cancelQueued?: boolean }
+ ) => Promise
+ cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise
+ setModel: (model: string | undefined, options?: ClaudeControlOptions) => Promise
+ setPermissionMode: (mode: PermissionMode, options?: ClaudeControlOptions) => Promise
+ applyFlagSettings: (
+ settings: Parameters[0],
+ options?: ClaudeControlOptions
+ ) => Promise
+ supportedModels: (options?: ClaudeControlOptions) => Promise
+ initializationResult: (options?: ClaudeControlOptions) => Promise
+ getSettings: (options?: ClaudeControlOptions) => Promise
+}
+
+type InterruptingQuery = {
+ interrupt: (options?: {
+ cancelQueued?: boolean
+ }) => Promise
+}
+
+export function createClaudeControlSurface(query: Query): ClaudeControlSurface {
+ return {
+ interrupt: (options) =>
+ runClaudeControl(
+ 'interrupt',
+ () =>
+ (query as unknown as InterruptingQuery).interrupt(
+ options?.cancelQueued ? { cancelQueued: true } : undefined
+ ),
+ options?.timeoutMs
+ ),
+ cancelAsyncMessage: (uuid, options) => {
+ const cancel = claudeQueryAsyncCanceller(query)
+ return cancel
+ ? runClaudeControl('cancel_async_message', () => cancel(uuid), options?.timeoutMs).then(
+ () => {}
+ )
+ : Promise.resolve()
+ },
+ setModel: (model, options) =>
+ runClaudeControl('set_model', () => query.setModel(model), options?.timeoutMs).then(() => {}),
+ setPermissionMode: (mode, options) =>
+ runClaudeControl(
+ 'set_permission_mode',
+ () => query.setPermissionMode(mode),
+ options?.timeoutMs
+ ).then(() => {}),
+ applyFlagSettings: (settings, options) =>
+ runClaudeControl(
+ 'apply_flag_settings',
+ () => query.applyFlagSettings(settings),
+ options?.timeoutMs
+ ).then(() => {}),
+ supportedModels: (options) =>
+ runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs),
+ initializationResult: (options) =>
+ runClaudeControl('initialize', () => query.initializationResult(), options?.timeoutMs),
+ getSettings: (options) => {
+ const read = claudeQuerySettingsReader(query)
+ return read
+ ? runClaudeControl('get_settings', read, options?.timeoutMs)
+ : Promise.reject(
+ new ClaudeControlRequestError(
+ 'get_settings',
+ 'this SDK exposes no get_settings request'
+ )
+ )
+ }
+ }
+}
diff --git a/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts
new file mode 100644
index 00000000000..04d58067353
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { DescendantSnapshot } from '../pty-descendant-termination'
+import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
+import { collectDescendantRows } from '../pty-descendant-termination'
+import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
+import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot'
+
+function posixSnapshot(capturedAtMs: number): DescendantSnapshot {
+ return {
+ root: { pid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' },
+ rootPgid: 100,
+ descendants: [{ pid: 200, ppid: 100, pgid: 100, startedAt: 'Mon Jan 1 00:00:01 2026' }],
+ capturedAtMs
+ }
+}
+
+function windowsSnapshot(): WindowsDescendantSnapshot {
+ return {
+ root: { pid: 100, creationTimeMs: 5 },
+ descendants: [{ pid: 200, creationTimeMs: 7 }],
+ unidentifiedCount: 0,
+ capturedAtMs: 1
+ }
+}
+
+describe('Claude child root identity', () => {
+ it('keeps a retained row boundary when a refresh observes no new descendants', () => {
+ const previous = posixSnapshot(1_700_000_000_900)
+ const next = posixSnapshot(1_700_000_002_100)
+
+ expect(
+ mergeClaudeCapturedTrees(
+ { platform: 'posix', tree: previous },
+ { platform: 'posix', tree: next }
+ )
+ ).toEqual({
+ platform: 'posix',
+ tree: { ...next, capturedAtMsByPid: { '200': previous.capturedAtMs } }
+ })
+ })
+
+ it('keeps the descendant verdict when a POSIX root probe is unavailable', async () => {
+ const child = { pid: 100, kill: vi.fn(() => true) }
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants: vi.fn(async () => posixSnapshot(1)),
+ terminateDescendants,
+ verifyRootIdentity: vi.fn(async () => false)
+ })
+
+ // POSIX runs no bare-pid root operation, so a declined probe withholds
+ // nothing: the handle kill still lands and the verification still speaks.
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(terminateDescendants).toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('rejects mixed old and recycled root rows instead of making the tree killable', async () => {
+ const child = { pid: 100, kill: vi.fn(() => true) }
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants: vi.fn(async () =>
+ collectDescendantRows(
+ 100,
+ [
+ { pid: 100, ppid: 1, pgid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' },
+ { pid: 100, ppid: 1, pgid: 101, startedAt: 'Mon Jan 1 00:00:01 2026' },
+ { pid: 200, ppid: 100, pgid: 200, startedAt: 'Mon Jan 1 00:00:00 2026' }
+ ],
+ 1
+ )
+ ),
+ terminateDescendants,
+ verifyRootIdentity: vi.fn(async () => true)
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ // No admissible snapshot means no row may be signalled from its number, but
+ // the root still leaves through the handle Node owns.
+ expect(terminateDescendants).not.toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('fails closed when Windows root identity revalidation is unavailable', async () => {
+ const child = { pid: 100, kill: vi.fn(() => true) }
+ const terminateWindowsTree = vi.fn(async () => {})
+ const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureWindowsDescendants: vi.fn(async () => windowsSnapshot()),
+ terminateWindowsTree,
+ terminateWindowsDescendants,
+ verifyRootIdentity: vi.fn(async () => false)
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ // taskkill /T /F addresses a bare pid and stays gated; the handle does not.
+ expect(terminateWindowsTree).not.toHaveBeenCalled()
+ expect(terminateWindowsDescendants).not.toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts
new file mode 100644
index 00000000000..3f15f8e8fca
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts
@@ -0,0 +1,934 @@
+import { execFileSync } from 'node:child_process'
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import { describe, expect, it, vi } from 'vitest'
+import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process'
+import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
+import type { DescendantSnapshot } from '../pty-descendant-termination'
+import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
+import {
+ createClaudeChildTreeReaper as createClaudeChildTreeReaperImpl,
+ proveClaudeChildExit,
+ type ClaudeChildTreeReaper
+} from './claude-agent-sdk-exit-proof'
+
+// The descendant models an MCP server: it either cooperates or, when it traps
+// SIGTERM, only a forced, verified sweep can reach it. The root either traps
+// SIGTERM too, or leaves promptly on stdin end the way a healthy CLI does —
+// which is the path that used to skip descendant proof entirely.
+function childWithDescendantScript(input: {
+ rootTrapsSigterm: boolean
+ descendantTrapsSigterm: boolean
+}): string {
+ const descendantScript = `${input.descendantTrapsSigterm ? 'process.on("SIGTERM", () => {}); ' : ''}setInterval(() => {}, 1000000)`
+ const rootBehaviour = input.rootTrapsSigterm
+ ? `process.on('SIGTERM', () => {})
+process.on('SIGINT', () => {})
+setInterval(() => {}, 1000000)`
+ : `process.stdin.on('end', () => process.exit(0))
+process.stdin.resume()`
+ return `
+const descendant = require('node:child_process').spawn(
+ process.execPath,
+ ['-e', ${JSON.stringify(descendantScript)}],
+ { stdio: 'ignore' }
+)
+descendant.unref()
+process.stdout.write(JSON.stringify({ descendantPid: descendant.pid }) + '\\n')
+${rootBehaviour}
+`
+}
+
+const COOPERATIVE_CHILD = `
+process.stdin.on('end', () => process.exit(0))
+process.stdin.resume()
+process.stdout.write('ready\\n')
+`
+
+/**
+ * Sampled synchronously so it reads the exact moment the close boundary is
+ * crossed. A zombie has exited (its parent just has not reaped it yet), so a
+ * kill(pid, 0) probe would misreport it as running.
+ */
+function descendantState(pid: number): 'running' | 'exited' {
+ let state: string
+ try {
+ state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], {
+ encoding: 'utf8',
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C' }
+ }).trim()
+ } catch (error) {
+ // ps exits 1 when no process matches; anything else is a failed probe, not an answer.
+ if ((error as { status?: number }).status !== 1) {
+ throw error
+ }
+ return 'exited'
+ }
+ return state.startsWith('Z') ? 'exited' : 'running'
+}
+
+/**
+ * ps lstart is second-resolution, so the identity-safe sweep only SIGKILLs a row
+ * born strictly before the second the snapshot was captured in. The snapshot is
+ * armed the moment close begins, so a descendant born in that same second can
+ * only be asked, never forced — the same bound an MCP server spawned within a
+ * second of the user closing the chat would hit.
+ */
+function ageDescendantPastTheCaptureSecond(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 1_000 - (Date.now() % 1_000) + 20))
+}
+
+/**
+ * The close ladder as production drives it: `closeProcessRegistry` retries an
+ * unproven close, and each retry re-verifies the retained snapshot. A loaded
+ * host can spend one attempt's whole window inside `ps`, and reporting false
+ * there is the honest verdict — the requirement is that TRUE never outruns the
+ * observation, which the caller asserts at whichever boundary returns it.
+ */
+async function proveExitWithRetries(
+ input: Parameters[0],
+ attempts = 3
+): Promise {
+ for (let attempt = 1; attempt < attempts; attempt += 1) {
+ if (await proveClaudeChildExit(input)) {
+ return true
+ }
+ }
+ return proveClaudeChildExit(input)
+}
+
+function spawnScript(script: string): ReturnType {
+ return spawnProcess({
+ program: process.execPath,
+ args: ['-e', script],
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+}
+
+function firstStdoutLine(child: ReturnType): Promise {
+ return new Promise((resolve) => {
+ child.stdout.setEncoding('utf8').once('data', (chunk: string) => resolve(chunk.trim()))
+ })
+}
+
+function observeExit(child: EventEmitter): { exitPromise: Promise; exited: () => boolean } {
+ let exited = false
+ const exitPromise = new Promise((resolve) => {
+ child.once('exit', () => {
+ exited = true
+ resolve()
+ })
+ })
+ return { exitPromise, exited: () => exited }
+}
+
+/** `null` models a spawn that failed before a pid existed. */
+function mockChild(
+ pid: number | null = 424242
+): EventEmitter &
+ Pick & { kill: ReturnType } {
+ const child = new EventEmitter()
+ return Object.assign(child, {
+ pid: pid ?? undefined,
+ stdin: new PassThrough(),
+ kill: vi.fn(() => true)
+ }) as never
+}
+
+/** A tree whose verdict is scripted per reap, recording when it was armed. */
+function mockTree(verdicts: DescendantTreeVerdict[]): ClaudeChildTreeReaper & {
+ capture: ReturnType
+ reap: ReturnType
+} {
+ let treeVerdict: DescendantTreeVerdict = 'unverifiable'
+ return {
+ capture: vi.fn(async () => {}),
+ reap: vi.fn(async () => {
+ treeVerdict = verdicts.shift() ?? treeVerdict
+ return treeVerdict
+ }),
+ get treeVerdict() {
+ return treeVerdict
+ }
+ }
+}
+
+function windowsSnapshotOf(descendantPid: number): WindowsDescendantSnapshot {
+ return {
+ root: { pid: 424242, creationTimeMs: 1_700_000_000_001 },
+ descendants: [{ pid: descendantPid, creationTimeMs: 1_700_000_000_000 }],
+ unidentifiedCount: 0,
+ capturedAtMs: 1
+ }
+}
+
+function snapshotOf(descendantPid: number): DescendantSnapshot {
+ return {
+ root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' },
+ rootPgid: 1,
+ descendants: [
+ { pid: descendantPid, ppid: 424242, pgid: 1, startedAt: 'Mon Jan 1 00:00:00 2026' }
+ ],
+ capturedAtMs: 1
+ }
+}
+
+// Unit tests use synthetic process ids; production always supplies the fresh
+// identity probe, so the harness explicitly models a matching probe.
+function createClaudeChildTreeReaper(
+ child: Parameters[0],
+ deps: Parameters[1] = {}
+): ReturnType {
+ return createClaudeChildTreeReaperImpl(child, {
+ verifyRootIdentity: async () => true,
+ ...deps
+ })
+}
+
+describe('claude child exit proof', () => {
+ it.runIf(process.platform !== 'win32')(
+ 'reports a proven exit only once a SIGTERM-resistant descendant is gone at the close boundary',
+ async () => {
+ const child = spawnScript(
+ childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: true })
+ )
+ const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as {
+ descendantPid: number
+ }
+ expect(descendantState(descendantPid)).toBe('running')
+ await ageDescendantPastTheCaptureSecond()
+
+ try {
+ const proven = await proveExitWithRetries({ child, ...observeExit(child) })
+ // Evaluated AT the boundary, not by polling until a deferred sweep timer
+ // wins: true releases the lease, so a descendant still running here is
+ // exactly the orphan the proof exists to prevent. False would be the
+ // honest verdict for a tree that outlived the bounded ladder.
+ expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
+ proven: true,
+ descendant: 'exited'
+ })
+ } finally {
+ // Failure-safe only: the assertion above owns the requirement, this just
+ // stops a failing run from leaking a process.
+ try {
+ process.kill(descendantPid, 'SIGKILL')
+ } catch {
+ // Already gone.
+ }
+ }
+ },
+ 20_000
+ )
+
+ it.runIf(process.platform !== 'win32')(
+ 'proves a promptly exiting root only once its stubborn descendant is gone too',
+ async () => {
+ // The ordinary healthy close: the root leaves on stdin end within the graceful
+ // window. Its descendant must still be proven gone, not assumed gone with it.
+ const child = spawnScript(
+ childWithDescendantScript({ rootTrapsSigterm: false, descendantTrapsSigterm: true })
+ )
+ const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as {
+ descendantPid: number
+ }
+ expect(descendantState(descendantPid)).toBe('running')
+ await ageDescendantPastTheCaptureSecond()
+
+ try {
+ const proven = await proveExitWithRetries({ child, ...observeExit(child) })
+ expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
+ proven: true,
+ descendant: 'exited'
+ })
+ } finally {
+ try {
+ process.kill(descendantPid, 'SIGKILL')
+ } catch {
+ // Already gone.
+ }
+ }
+ },
+ 20_000
+ )
+
+ it.runIf(process.platform !== 'win32')(
+ 'still proves a stubborn child whose descendant honours SIGTERM',
+ async () => {
+ const child = spawnScript(
+ childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: false })
+ )
+ const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as {
+ descendantPid: number
+ }
+ try {
+ const proven = await proveExitWithRetries({ child, ...observeExit(child) })
+ expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
+ proven: true,
+ descendant: 'exited'
+ })
+ } finally {
+ try {
+ process.kill(descendantPid, 'SIGKILL')
+ } catch {
+ // Already gone.
+ }
+ }
+ },
+ 20_000
+ )
+
+ it('arms the snapshot before stdin closes and verifies it after a clean exit', async () => {
+ const child = spawnScript(COOPERATIVE_CHILD)
+ expect(await firstStdoutLine(child)).toBe('ready')
+ const exit = observeExit(child)
+ const tree = mockTree(['exited'])
+ let exitedWhenArmed: boolean | null = null
+ tree.capture.mockImplementation(async () => {
+ exitedWhenArmed = exit.exited()
+ })
+
+ await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(true)
+ // The snapshot is the only proof that survives the root: taken while it lived,
+ // verified once it left. A reap before the exit would have been the forced ladder.
+ expect(exitedWhenArmed).toBe(false)
+ expect(tree.reap).toHaveBeenCalledTimes(1)
+ expect(exit.exited()).toBe(true)
+ }, 20_000)
+
+ it('proves a clean close of a childless root with one snapshot and no signal', async () => {
+ const child = spawnScript(COOPERATIVE_CHILD)
+ expect(await firstStdoutLine(child)).toBe('ready')
+
+ await expect(proveClaudeChildExit({ child, ...observeExit(child) })).resolves.toBe(true)
+ }, 20_000)
+
+ it('reports an unprovable exit as false rather than assuming the child died', async () => {
+ const child = mockChild()
+ const tree = mockTree(['exited'])
+
+ await expect(
+ proveClaudeChildExit({
+ child,
+ exitPromise: new Promise(() => {}),
+ exited: () => false,
+ tree
+ })
+ ).resolves.toBe(false)
+ expect(tree.reap).toHaveBeenCalledTimes(1)
+ }, 20_000)
+
+ it('reports false when the root exit was observed but a descendant was seen alive', async () => {
+ const child = mockChild()
+ const exit = observeExit(child)
+ const tree = mockTree(['live'])
+ tree.reap.mockImplementation(async () => {
+ child.emit('exit', null, 'SIGKILL')
+ return 'live'
+ })
+
+ await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(false)
+ expect(exit.exited()).toBe(true)
+ // One verification per attempt: the retried close re-verifies, this one does not.
+ expect(tree.reap).toHaveBeenCalledTimes(1)
+ }, 20_000)
+
+ it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => {
+ const child = mockChild()
+ const tree = mockTree(['exited'])
+
+ await expect(
+ proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree })
+ ).resolves.toBe(true)
+ expect(tree.reap).toHaveBeenCalledTimes(1)
+ })
+
+ it('stays unproven for a root that left before any snapshot could be armed', async () => {
+ const child = mockChild()
+ const captureDescendants = vi.fn(async () => snapshotOf(4243))
+ const terminateDescendants = vi.fn()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'darwin',
+ exited: () => true,
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await expect(
+ proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree })
+ ).resolves.toBe(false)
+ // A dead root's descendants have reparented: walking its pid now could only
+ // sweep a stranger, so no walk is attempted and nothing is proven.
+ expect(captureDescendants).not.toHaveBeenCalled()
+ expect(terminateDescendants).not.toHaveBeenCalled()
+ expect(tree.treeVerdict).toBe('unverifiable')
+ })
+})
+
+describe('claude child tree reaper', () => {
+ it('kills the root while verification runs and never stops it first', async () => {
+ const child = mockChild()
+ const release = Promise.withResolvers()
+ const terminateDescendants = vi.fn(() => release.promise)
+ const captureDescendants = vi.fn(async () => snapshotOf(4243))
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'darwin',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ const first = tree.reap()
+ const second = tree.reap()
+ await vi.waitFor(() => expect(terminateDescendants).toHaveBeenCalledTimes(1))
+ // A stopped root cannot verify: its killed children stay zombie rows in ps.
+ expect(child.kill.mock.calls).toEqual([['SIGKILL']])
+ expect(tree.treeVerdict).toBe('unverifiable')
+
+ release.resolve('exited')
+ await expect(Promise.all([first, second])).resolves.toEqual(['exited', 'exited'])
+ expect(captureDescendants).toHaveBeenCalledTimes(1)
+ expect(tree.treeVerdict).toBe('exited')
+ })
+
+ it('re-verifies the retained snapshot on a later reap rather than re-walking a dead root', async () => {
+ const child = mockChild()
+ const captureDescendants = vi.fn(async () => snapshotOf(4243))
+ const terminateDescendants = vi
+ .fn()
+ .mockResolvedValueOnce('live')
+ .mockResolvedValueOnce('exited')
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await expect(tree.reap()).resolves.toBe('live')
+ expect(tree.treeVerdict).toBe('live')
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(captureDescendants).toHaveBeenCalledTimes(1)
+ expect(terminateDescendants).toHaveBeenNthCalledWith(2, snapshotOf(4243))
+ expect(tree.treeVerdict).toBe('exited')
+ })
+
+ it('keeps an observed exit when a later re-read cannot see the table', async () => {
+ const child = mockChild()
+ const terminateDescendants = vi
+ .fn()
+ .mockResolvedValueOnce('exited')
+ .mockResolvedValueOnce('unverifiable')
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants: vi.fn(async () => snapshotOf(4243)),
+ terminateDescendants
+ })
+
+ await expect(tree.reap()).resolves.toBe('exited')
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(tree.treeVerdict).toBe('exited')
+ })
+
+ it('keeps an observed live descendant when a later re-read cannot see the table', async () => {
+ const child = mockChild()
+ // Reap #1 completed and saw a descendant alive at its deadline; the root then
+ // left on its own and the re-verification on a loaded host could not read the
+ // table. "Could not look" must not erase "was seen alive": the lease release
+ // gate is exactly the pair this distinguishes.
+ const terminateDescendants = vi
+ .fn()
+ .mockResolvedValueOnce('live')
+ .mockResolvedValueOnce('unverifiable')
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants: vi.fn(async () => snapshotOf(4243)),
+ terminateDescendants
+ })
+
+ await expect(tree.reap()).resolves.toBe('live')
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(tree.treeVerdict).toBe('live')
+ })
+
+ it('treats an unreadable process table as unproven and re-walks the live root', async () => {
+ const child = mockChild()
+ // A loaded host can miss the table's deadline; while the root still lives
+ // that is a retryable read, not evidence that it has no descendants.
+ const captureDescendants = vi
+ .fn()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(snapshotOf(4243))
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(terminateDescendants).not.toHaveBeenCalled()
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(captureDescendants).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not latch a missing root while it is still live', async () => {
+ const child = mockChild()
+ const captureDescendants = vi
+ .fn()
+ .mockResolvedValueOnce({ rootPgid: null, descendants: [], capturedAtMs: 1 })
+ .mockResolvedValueOnce(snapshotOf(4243))
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await tree.capture()
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(captureDescendants).toHaveBeenCalledTimes(2)
+ expect(terminateDescendants).toHaveBeenCalledWith(snapshotOf(4243))
+ })
+
+ it('refreshes the live snapshot at close time so late descendants are included', async () => {
+ const child = mockChild()
+ const first = snapshotOf(4243)
+ const second = {
+ ...first,
+ descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }]
+ }
+ const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second)
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+ await tree.reap()
+
+ expect(captureDescendants).toHaveBeenCalledTimes(2)
+ expect(terminateDescendants).toHaveBeenCalledWith(second)
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('keeps the original capture boundary for retained POSIX rows', async () => {
+ const child = mockChild()
+ const first = {
+ ...snapshotOf(4243),
+ capturedAtMs: 1_700_000_000_900
+ }
+ const refreshed = {
+ ...first,
+ capturedAtMs: 1_700_000_002_100,
+ descendants: [
+ ...first.descendants,
+ {
+ pid: 4244,
+ ppid: 424242,
+ pgid: 1,
+ startedAt: 'Tue Jan 2 00:00:00 2026'
+ }
+ ]
+ }
+ const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(refreshed)
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+ await tree.reap()
+
+ expect(terminateDescendants).toHaveBeenCalledWith({
+ ...refreshed,
+ // The retained 4243 row was first observed in the earlier displayed
+ // second. Its per-row boundary must not advance with the refresh.
+ capturedAtMsByPid: {
+ '4243': first.capturedAtMs,
+ '4244': refreshed.capturedAtMs
+ }
+ })
+ })
+
+ it('fails closed when a POSIX refresh reuses a PID with a new identity', async () => {
+ const child = mockChild()
+ const first = snapshotOf(4243)
+ const replacement = {
+ ...first,
+ descendants: [
+ {
+ ...first.descendants[0],
+ pgid: 9,
+ startedAt: 'Tue Jan 2 00:00:00 2026'
+ }
+ ]
+ }
+ const captureDescendants = vi
+ .fn()
+ .mockResolvedValueOnce(first)
+ .mockResolvedValueOnce(replacement)
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ // The descendant evidence is discarded; the root's identity never was in doubt.
+ expect(terminateDescendants).not.toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('fails closed when a Windows refresh reuses a PID with a new creation time', async () => {
+ const child = mockChild()
+ const first = windowsSnapshotOf(4243)
+ const replacement = {
+ ...first,
+ descendants: [{ pid: 4243, creationTimeMs: first.descendants[0].creationTimeMs + 1 }]
+ }
+ const captureWindowsDescendants = vi
+ .fn()
+ .mockResolvedValueOnce(first)
+ .mockResolvedValueOnce(replacement)
+ const terminateWindowsTree = vi.fn(async () => {})
+ const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureWindowsDescendants,
+ terminateWindowsTree,
+ terminateWindowsDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(terminateWindowsTree).not.toHaveBeenCalled()
+ expect(terminateWindowsDescendants).not.toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('queues a fresh boundary behind an output-triggered capture already in flight', async () => {
+ const child = mockChild()
+ const firstDone = Promise.withResolvers()
+ const first = snapshotOf(4243)
+ const second = {
+ ...first,
+ descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }]
+ }
+ const captureDescendants = vi
+ .fn()
+ .mockImplementationOnce(async () => {
+ await firstDone.promise
+ return first
+ })
+ .mockResolvedValueOnce(second)
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ const outputCapture = tree.refresh!()
+ await vi.waitFor(() => expect(captureDescendants).toHaveBeenCalledTimes(1))
+ const closeCapture = tree.refresh!()
+ await Promise.resolve()
+ expect(captureDescendants).toHaveBeenCalledTimes(1)
+
+ firstDone.resolve()
+ await closeCapture
+ await tree.reap()
+
+ expect(captureDescendants).toHaveBeenCalledTimes(2)
+ expect(terminateDescendants).toHaveBeenCalledWith(second)
+ await outputCapture
+ })
+
+ it('retains a replacement descendant when the prior identity exited', async () => {
+ const child = mockChild()
+ const first = snapshotOf(4243)
+ const replacement = snapshotOf(4244)
+ const captureDescendants = vi
+ .fn()
+ .mockResolvedValueOnce(first)
+ .mockResolvedValueOnce(replacement)
+ const terminateDescendants = vi.fn(async (snapshot: DescendantSnapshot) =>
+ snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const)
+ )
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+ await expect(tree.reap()).resolves.toBe('live')
+
+ expect(terminateDescendants).toHaveBeenCalledWith({
+ ...replacement,
+ descendants: [...first.descendants, ...replacement.descendants]
+ })
+ })
+
+ it('retains a Windows replacement descendant while preserving unidentified rows', async () => {
+ const child = mockChild()
+ const first = windowsSnapshotOf(4243)
+ const replacement = {
+ ...windowsSnapshotOf(4244),
+ unidentifiedCount: 0
+ }
+ const captureWindowsDescendants = vi
+ .fn()
+ .mockResolvedValueOnce({ ...first, unidentifiedCount: 1 })
+ .mockResolvedValueOnce(replacement)
+ const terminateWindowsTree = vi.fn(async () => {})
+ const terminateWindowsDescendants = vi.fn(async (snapshot: WindowsDescendantSnapshot) =>
+ snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const)
+ )
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureWindowsDescendants,
+ terminateWindowsTree,
+ terminateWindowsDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+ await expect(tree.reap()).resolves.toBe('live')
+
+ expect(terminateWindowsDescendants).toHaveBeenCalledWith({
+ ...replacement,
+ descendants: [...first.descendants, ...replacement.descendants],
+ unidentifiedCount: 1
+ })
+ })
+
+ it('retains the prior identity-safe snapshot when a refresh is partial', async () => {
+ const child = mockChild()
+ const first = {
+ ...snapshotOf(4243),
+ descendants: [
+ ...snapshotOf(4243).descendants,
+ { ...snapshotOf(4243).descendants[0], pid: 4244 }
+ ]
+ }
+ const captureDescendants = vi
+ .fn()
+ .mockResolvedValueOnce(first)
+ .mockResolvedValueOnce({
+ ...first,
+ descendants: first.descendants.slice(0, 1)
+ })
+ const terminateDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+ await tree.reap()
+
+ expect(terminateDescendants).toHaveBeenCalledWith(first)
+ })
+
+ it('stops re-walking once the root is gone, however the table behaved', async () => {
+ const child = mockChild()
+ let exited = false
+ const captureDescendants = vi.fn(async () => null)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => exited,
+ captureDescendants,
+ terminateDescendants: vi.fn()
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ // An unreadable table costs the snapshot, never the kill on the live root.
+ expect(child.kill).toHaveBeenCalledTimes(1)
+ exited = true
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(captureDescendants).toHaveBeenCalledTimes(1)
+ // The second attempt observes a dead root: Node has dropped the handle, so
+ // there is nothing left to signal and no recycled pid to reach.
+ expect(child.kill).toHaveBeenCalledTimes(1)
+ })
+
+ it('discards a walk that found no root instead of proving an empty tree', async () => {
+ const child = mockChild()
+ const captureDescendants = vi.fn(async () => ({
+ rootPgid: null,
+ descendants: [],
+ capturedAtMs: 1
+ }))
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants,
+ terminateDescendants: vi.fn()
+ })
+
+ await tree.capture()
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ // A vacuous walk remains retryable while the root is live; no empty-tree
+ // verdict is latched from a missing root row.
+ expect(captureDescendants).toHaveBeenCalledTimes(2)
+ })
+
+ it('discards a walk that raced the root exit instead of proving an empty tree', async () => {
+ const child = mockChild()
+ let exited = false
+ const captureDescendants = vi.fn(async () => {
+ exited = true
+ return { rootPgid: 1, descendants: [], capturedAtMs: 1 }
+ })
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => exited,
+ captureDescendants,
+ terminateDescendants: vi.fn()
+ })
+
+ await tree.capture()
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ })
+
+ it('proves a childless snapshot without signalling anything', async () => {
+ const child = mockChild()
+ const terminateDescendants = vi.fn()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ captureDescendants: vi.fn(async () => ({
+ root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' },
+ rootPgid: 1,
+ descendants: [],
+ capturedAtMs: 1
+ })),
+ terminateDescendants
+ })
+
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(terminateDescendants).not.toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('waits for the Windows tree kill before releasing the root', async () => {
+ const child = mockChild()
+ const release = Promise.withResolvers()
+ const terminateWindowsTree = vi.fn(() => release.promise)
+ const captureDescendants = vi.fn()
+ const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureDescendants,
+ captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
+ terminateWindowsTree,
+ terminateWindowsDescendants
+ })
+
+ const reap = tree.reap()
+ await vi.waitFor(() =>
+ expect(terminateWindowsTree).toHaveBeenCalledWith({
+ pid: 424242,
+ creationTimeMs: 1_700_000_000_001
+ })
+ )
+ expect(child.kill).not.toHaveBeenCalled()
+ expect(terminateWindowsDescendants).not.toHaveBeenCalled()
+ release.resolve()
+ await expect(reap).resolves.toBe('exited')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243))
+ expect(captureDescendants).not.toHaveBeenCalled()
+ })
+
+ it('stays unproven on Windows when taskkill fails and a descendant is still observed', async () => {
+ const child = mockChild()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
+ terminateWindowsTree: vi.fn(async () => {
+ throw new Error('taskkill: access denied')
+ }),
+ terminateWindowsDescendants: vi.fn(async () => 'live' as const)
+ })
+
+ // taskkill's own outcome is not the proof; the table read after it is.
+ await expect(tree.reap()).resolves.toBe('live')
+ expect(tree.treeVerdict).toBe('live')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('stays unproven on Windows when taskkill resolves but a descendant survives it', async () => {
+ const child = mockChild()
+ const terminateWindowsTree = vi.fn(async () => {})
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
+ terminateWindowsTree,
+ terminateWindowsDescendants: vi.fn(async () => 'live' as const)
+ })
+
+ await expect(tree.reap()).resolves.toBe('live')
+ expect(terminateWindowsTree).toHaveBeenCalledTimes(1)
+ expect(tree.treeVerdict).toBe('live')
+ })
+
+ it('never taskkills a Windows root that already exited, but still verifies its snapshot', async () => {
+ const child = mockChild()
+ let exited = false
+ const terminateWindowsTree = vi.fn(async () => {})
+ const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ exited: () => exited,
+ captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
+ terminateWindowsTree,
+ terminateWindowsDescendants
+ })
+
+ await tree.capture()
+ exited = true
+ await expect(tree.reap()).resolves.toBe('exited')
+ // A dead root's pid may already belong to a stranger: taskkill /T /F on it
+ // would take down an unrelated tree.
+ expect(terminateWindowsTree).not.toHaveBeenCalled()
+ expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243))
+ })
+
+ it('treats an unreadable Windows table as unproven', async () => {
+ const child = mockChild()
+ const terminateWindowsDescendants = vi.fn()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ captureWindowsDescendants: vi.fn(async () => null),
+ terminateWindowsTree: vi.fn(async () => {}),
+ terminateWindowsDescendants
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(terminateWindowsDescendants).not.toHaveBeenCalled()
+ // A host that cannot supply creation times blocks taskkill, not the root kill.
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('has nothing to reap for a child that never spawned', async () => {
+ const child = mockChild(null)
+ const captureDescendants = vi.fn()
+ const tree = createClaudeChildTreeReaper(child, { platform: 'linux', captureDescendants })
+
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(captureDescendants).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts
new file mode 100644
index 00000000000..17533f87a70
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-exit-proof.ts
@@ -0,0 +1,366 @@
+import type { SpawnedProcess } from '../../shared/child-process/run-process'
+import {
+ terminateDescendantSnapshotWithVerdict,
+ type DescendantTreeVerdict
+} from '../pty-descendant-exit-verification'
+import {
+ captureDescendantSnapshot,
+ type DescendantSnapshot,
+ type PosixProcessIdentity
+} from '../pty-descendant-termination'
+import {
+ captureWindowsDescendantSnapshot,
+ terminateIdentifiedWindowsProcessTree,
+ verifyWindowsDescendantSnapshotExit,
+ verifyWindowsProcessIdentity,
+ type WindowsDescendantSnapshot,
+ type WindowsProcessIdentity
+} from '../windows-descendant-exit-verification'
+import { mergeClaudeCapturedTrees, type ClaudeCapturedTree } from './claude-child-tree-snapshot'
+import { terminateClaudeRoot, terminateClaudeWindowsRoot } from './claude-child-root-termination'
+import {
+ proveClaudeChildExitWithReaper,
+ type ClaudeChildExitProofInput
+} from './claude-child-exit-proof-ladder'
+
+/**
+ * A later reap may only raise the latched verdict. An observed exit is final, and
+ * a descendant seen alive at a deadline is never forgotten by a later look that
+ * could not read the table: the lease gate discriminates on exactly that pair.
+ */
+const TREE_VERDICT_TRUST: Record = {
+ unverifiable: 0,
+ live: 1,
+ exited: 2
+}
+
+type ReapableChild = Pick
+
+/**
+ * A walk is only admissible while the root it walked was alive. A POSIX walk
+ * that found no root says so with a null pgid; either platform's walk can also
+ * have raced the root's death. Both can only have missed descendants that
+ * already reparented away, so neither is evidence about the tree.
+ */
+function admissibleTree(
+ captured: DescendantSnapshot | WindowsDescendantSnapshot | null,
+ platform: NodeJS.Platform,
+ exited: boolean
+): ClaudeCapturedTree | null {
+ if (!captured || exited) {
+ return null
+ }
+ if (platform === 'win32') {
+ return { platform: 'win32', tree: captured as WindowsDescendantSnapshot }
+ }
+ const tree = captured as DescendantSnapshot
+ return tree.rootPgid === null ? null : { platform: 'posix', tree }
+}
+
+export type ClaudeChildTreeReaperDeps = {
+ platform?: NodeJS.Platform
+ /** Whether the root's exit has been observed; only a live root can be walked. */
+ exited?: () => boolean
+ captureDescendants?: (rootPid: number) => Promise
+ terminateDescendants?: (snapshot: DescendantSnapshot) => Promise
+ terminateWindowsTree?: (root: WindowsProcessIdentity) => Promise
+ captureWindowsDescendants?: (rootPid: number) => Promise
+ terminateWindowsDescendants?: (
+ snapshot: WindowsDescendantSnapshot
+ ) => Promise
+ /** Identity probe for the bare-pid tree kill; only Windows has one to gate. */
+ verifyRootIdentity?: (root: PosixProcessIdentity | WindowsProcessIdentity) => Promise
+}
+
+export type ClaudeChildTreeReaper = {
+ /**
+ * Snapshot the root's live descendants. The moment the root dies they reparent
+ * and no table walk can find them again, so this has to run before anything
+ * gives the root a reason to leave. Held once; later calls are no-ops.
+ */
+ capture(): Promise
+ /** Refresh a live root's snapshot at the close boundary; a failed refresh keeps the prior proof. */
+ refresh?: () => Promise
+ /**
+ * Kill the child's whole tree and report what the bounded verification
+ * observed. Concurrent calls share one reap, and a later call re-verifies the
+ * same snapshot rather than trusting a root that has since died on its own.
+ */
+ reap(): Promise
+ /**
+ * `unverifiable` until a reap observes otherwise. `exited` is the only verdict
+ * that lets a close release the lease; `live` names a descendant that was seen
+ * still running, which no later caller may collapse into "unknown".
+ */
+ readonly treeVerdict: DescendantTreeVerdict
+}
+
+/**
+ * The same shared primitives the Codex structured provider composes: a raw
+ * pipe child owns no PTY job, so there is nothing for the PTY job sweep to
+ * terminate on Windows and no unref'd timer is allowed to outlive the proof.
+ *
+ * The proof is unproven by default. `treeVerdict` is assigned in exactly one
+ * place, from the verdict of `judgeTree`, so a code path that never reaches a
+ * verification cannot report the tree gone by omission.
+ */
+export function createClaudeChildTreeReaper(
+ child: ReapableChild,
+ deps: ClaudeChildTreeReaperDeps = {}
+): ClaudeChildTreeReaper {
+ const platform = deps.platform ?? process.platform
+ const exited = deps.exited ?? (() => false)
+ // Undefined until captured; null when no admissible snapshot exists — the root
+ // was already gone, or the table could not be read while it was alive — which
+ // no later read can make up for.
+ let snapshot: ClaudeCapturedTree | null | undefined
+ let capturing: Promise | null = null
+ let refreshing: Promise | null = null
+ let queuedRefresh: Promise | null = null
+ let inFlight: Promise | null = null
+ let treeVerdict: DescendantTreeVerdict = 'unverifiable'
+
+ // Consulted only on win32: POSIX signals descendants by revalidated identity
+ // and reaches the root solely through Node's handle, so neither needs a probe.
+ const verifyRoot =
+ deps.verifyRootIdentity ??
+ ((root: PosixProcessIdentity | WindowsProcessIdentity) =>
+ verifyWindowsProcessIdentity(root as WindowsProcessIdentity))
+
+ function captureOnce(): Promise {
+ if (refreshing) {
+ const pending = refreshing
+ return pending.then(() => queuedRefresh ?? undefined)
+ }
+ if (snapshot !== undefined) {
+ return Promise.resolve()
+ }
+ if (capturing) {
+ const pending = capturing
+ return pending.then(() => queuedRefresh ?? undefined)
+ }
+ const rootPid = child.pid
+ if (!rootPid || exited()) {
+ // Only the root's death makes a missing snapshot final: its descendants
+ // have reparented, and no later walk can reach them.
+ snapshot = exited() ? null : snapshot
+ return Promise.resolve()
+ }
+ const capture =
+ platform === 'win32'
+ ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot)
+ : (deps.captureDescendants ?? captureDescendantSnapshot)
+ capturing = capture(rootPid)
+ .catch(() => null)
+ .then((captured) => {
+ // A walk that found no root, or that raced the root's death, can only
+ // have missed descendants that already reparented away. A table that
+ // could not be read in time is not an answer at all: while the root
+ // still lives the walk is simply retried, rather than latching a failed
+ // read as proof that there was nothing to find.
+ const rootExited = exited()
+ const tree = admissibleTree(captured, platform, rootExited)
+ if (tree) {
+ snapshot = tree
+ } else if (rootExited) {
+ // Once the root has exited its descendants may have reparented; no
+ // later table read can make an absent snapshot safe to signal.
+ snapshot = null
+ } else {
+ // A failed read or a walk that did not observe the live root is
+ // retryable while the root remains alive. Never latch a vacuous null.
+ snapshot = undefined
+ }
+ })
+ .finally(() => {
+ capturing = null
+ })
+ return capturing
+ }
+
+ function startRefresh(): Promise {
+ if (exited()) {
+ return Promise.resolve()
+ }
+ const rootPid = child.pid
+ if (!rootPid) {
+ return Promise.resolve()
+ }
+ const capture =
+ platform === 'win32'
+ ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot)
+ : (deps.captureDescendants ?? captureDescendantSnapshot)
+ const operation = (async () => {
+ const captured = await capture(rootPid).catch(() => null)
+ if (exited()) {
+ return
+ }
+ const tree = admissibleTree(captured, platform, false)
+ if (!tree) {
+ return
+ }
+ if (snapshot === undefined) {
+ snapshot = tree
+ return
+ }
+ if (snapshot !== null) {
+ // A merge that returns null saw a same-PID identity change: a
+ // recycle/replace decision, not an absent descendant, so no row here may
+ // be signalled from its number. Only the descendant evidence is lost —
+ // the root still leaves through the handle no recycled pid can reach.
+ snapshot = mergeClaudeCapturedTrees(snapshot, tree)
+ }
+ // Keep an earlier admissible snapshot when this close-boundary read fails;
+ // it remains the only identity-safe evidence after root exit.
+ })()
+ refreshing = operation
+ const clearRefreshing = (): void => {
+ if (refreshing === operation) {
+ refreshing = null
+ }
+ }
+ void operation.then(clearRefreshing, clearRefreshing)
+ return operation
+ }
+
+ function queueRefreshAfter(pending: Promise): Promise {
+ if (queuedRefresh) {
+ return queuedRefresh
+ }
+ const operation = pending.then(() => {
+ if (exited()) {
+ return
+ }
+ return startRefresh()
+ })
+ queuedRefresh = operation
+ const clearQueuedRefresh = (): void => {
+ if (queuedRefresh === operation) {
+ queuedRefresh = null
+ }
+ }
+ void operation.then(clearQueuedRefresh, clearQueuedRefresh)
+ return operation
+ }
+
+ async function refresh(): Promise {
+ const pending = capturing ?? refreshing
+ if (pending) {
+ await queueRefreshAfter(pending)
+ return
+ }
+ if (queuedRefresh) {
+ await queuedRefresh
+ return
+ }
+ try {
+ await startRefresh()
+ } catch {
+ // A refresh is advisory; capture failures leave the prior proof intact.
+ }
+ }
+
+ /** The only source of a tree verdict: every `exited` here is an observation. */
+ async function judgeTree(): Promise {
+ const killRoot = (): boolean => terminateClaudeRoot({ child, exited })
+ const rootPid = child.pid
+ if (!rootPid) {
+ // Never spawned, so the OS never created a tree to orphan.
+ return 'exited'
+ }
+ await captureOnce()
+ if (platform === 'win32') {
+ // Why taskkill's own outcome is never the verdict: it resolves identically
+ // on a timeout, an access denial, a recycled root and a real kill.
+ const { rootVerified } = await terminateClaudeWindowsRoot({
+ snapshot: snapshot?.platform === 'win32' ? snapshot.tree : null,
+ exited,
+ verifyRoot: (root) => verifyRoot(root),
+ terminateTree: (root) =>
+ deps.terminateWindowsTree
+ ? deps.terminateWindowsTree(root)
+ : terminateIdentifiedWindowsProcessTree(root, {
+ ownsRoot: () => !exited()
+ }).then(() => undefined),
+ killRoot
+ })
+ if (!rootVerified && !exited()) {
+ return 'unverifiable'
+ }
+ return snapshot?.platform === 'win32'
+ ? await (deps.terminateWindowsDescendants ?? verifyWindowsDescendantSnapshotExit)(
+ snapshot.tree
+ )
+ : 'unverifiable'
+ }
+ if (snapshot?.platform !== 'posix') {
+ killRoot()
+ return 'unverifiable'
+ }
+ if (snapshot.tree.descendants.length === 0) {
+ // Read while the root was alive and childless: a later table read has no
+ // row it could match, so it would add nothing to this observation.
+ killRoot()
+ return 'exited'
+ }
+ // Why the root is killed while verification is already running, and never
+ // SIGSTOPped first the way the Codex non-group path does: measured on macOS, a
+ // killed child of a stopped parent stays a zombie row in ps with its lstart
+ // and pgid intact, so verification cannot pass until the root is dead. The
+ // descendants are signalled by the verifier as soon as it revalidates their
+ // identities; the root's death then reparents any zombies to init, which
+ // reaps them. After a root exit the kill is a no-op: Node drops the handle
+ // on exit and never signals a possibly recycled pid.
+ const verdictPromise = deps.terminateDescendants
+ ? deps.terminateDescendants(snapshot.tree)
+ : terminateDescendantSnapshotWithVerdict(snapshot.tree, {
+ requireIdentityBeforeSignal: true
+ })
+ killRoot()
+ // What the verification observed is the verdict: a kill that reports no
+ // signal means the handle was already gone, never that the tree survived.
+ return verdictPromise
+ }
+
+ return {
+ capture: captureOnce,
+ refresh,
+ reap() {
+ if (inFlight) {
+ return inFlight
+ }
+ const attempt = judgeTree()
+ .catch((): DescendantTreeVerdict => 'unverifiable')
+ .then((verdict) => {
+ treeVerdict =
+ TREE_VERDICT_TRUST[verdict] > TREE_VERDICT_TRUST[treeVerdict] ? verdict : treeVerdict
+ return verdict
+ })
+ inFlight = attempt
+ void attempt.finally(() => {
+ if (inFlight === attempt) {
+ inFlight = null
+ }
+ })
+ return attempt
+ },
+ get treeVerdict() {
+ return treeVerdict
+ }
+ }
+}
+
+/**
+ * Orca's own shutdown ladder on the child it spawned, kept because the SDK's
+ * close path returns no proof and Orca never releases a lease on an assumed exit.
+ *
+ * Resolves true only after the child actually emitted exit and its snapshotted
+ * descendants were observed gone; false is unproven. A root that left on its
+ * own before a snapshot could be armed stays unproven: its descendants had
+ * already reparented out of reach when the ladder first looked.
+ */
+export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise {
+ return proveClaudeChildExitWithReaper(input, () =>
+ createClaudeChildTreeReaper(input.child, { exited: input.exited })
+ )
+}
diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts
new file mode 100644
index 00000000000..f1a38466d41
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts
@@ -0,0 +1,154 @@
+import { existsSync, readFileSync, statSync } from 'node:fs'
+import { dirname, join, relative, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { spawnProcess } from '../../shared/child-process/run-process'
+
+/**
+ * Keep the agent SDK on the structured-Claude side of the toggle.
+ *
+ * A user who never leaves the terminal/TUI Claude path must not pay for the SDK:
+ * importing it evaluates a package that rewrites
+ * `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves
+ * executables for every later subprocess, and a missing or incompatible install
+ * would take normal runtime startup down with it. The ordinary
+ * `OrcaRuntimeService` graph reaches the Claude transport module, so only a
+ * deferred import keeps that boundary — and only a walk of the real import graph
+ * keeps the next static import from quietly restoring it.
+ */
+const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk'
+const REPO_ROOT = resolve(__dirname, '..', '..', '..')
+
+/** The Electron main entry: everything the app loads before any session exists. */
+const ROOT = 'src/main/index.ts'
+/** Proof the walk goes all the way into the Claude transport rather than stopping short. */
+const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts'
+
+/**
+ * Static, value-carrying specifiers only, read statement by statement so a
+ * multi-line `import { ... } from '...'` counts. `import type` is erased before
+ * the module ever loads and a bare `import(...)` is the deferral this guards, so
+ * neither is an edge the runtime traverses at load time.
+ */
+const STATEMENT_START = /^\s*(?:import|export)\b/
+const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/
+const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/
+const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/
+/** An import statement never spans more lines than its longest specifier list. */
+const MAX_STATEMENT_LINES = 60
+
+function readSpecifiers(source: string): string[] {
+ const lines = source.split('\n')
+ const found: string[] = []
+ for (let index = 0; index < lines.length; index += 1) {
+ const first = lines[index] as string
+ if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) {
+ continue
+ }
+ const sideEffect = SIDE_EFFECT_IMPORT.exec(first)
+ if (sideEffect) {
+ found.push(sideEffect[1] as string)
+ continue
+ }
+ for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) {
+ if (scan > index && STATEMENT_START.test(lines[scan] as string)) {
+ break
+ }
+ const specifier = FROM_SPECIFIER.exec(lines[scan] as string)
+ if (specifier) {
+ found.push(specifier[1] as string)
+ break
+ }
+ }
+ }
+ return found
+}
+
+/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */
+function resolveRelative(fromFile: string, specifier: string): string | null {
+ const base = join(dirname(fromFile), specifier)
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) {
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
+ return candidate
+ }
+ }
+ return null
+}
+
+function walkStaticImports(rootFile: string): { visited: Set; sdkImporters: string[] } {
+ const visited = new Set()
+ const sdkImporters: string[] = []
+ const queue = [resolve(REPO_ROOT, rootFile)]
+ while (queue.length > 0) {
+ const file = queue.pop() as string
+ const key = relative(REPO_ROOT, file).split('\\').join('/')
+ if (visited.has(key)) {
+ continue
+ }
+ visited.add(key)
+ for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) {
+ if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) {
+ sdkImporters.push(key)
+ continue
+ }
+ if (!specifier.startsWith('.')) {
+ continue
+ }
+ const target = resolveRelative(file, specifier)
+ if (target) {
+ queue.push(target)
+ }
+ }
+ }
+ return { visited, sdkImporters }
+}
+
+describe('claude agent SDK import boundary', () => {
+ const walk = walkStaticImports(ROOT)
+
+ it('walks a graph deep enough to reach the Claude transport', () => {
+ // Without this the guard passes for the wrong reason the moment the walk breaks.
+ expect(walk.visited.size).toBeGreaterThan(500)
+ expect([...walk.visited]).toContain(TRANSPORT_MODULE)
+ })
+
+ it('never reaches the SDK through a static import from the main entry', () => {
+ expect(
+ walk.sdkImporters,
+ `${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.`
+ ).toEqual([])
+ })
+
+ it('leaves the Windows executable-search environment alone when the runtime loads', async () => {
+ // A vitest file runs in its own fork, so this is a clean process; the ambient
+ // value is cleared first because the developer's own shell may carry one.
+ delete process.env.NoDefaultCurrentDirectoryInExePath
+ await import('../runtime/structured-agent-session-runtime')
+
+ expect(process.env.NoDefaultCurrentDirectoryInExePath).toBeUndefined()
+ })
+
+ it('still lets the SDK set it, so the guard above is not measuring nothing', async () => {
+ // A separate process, not this fork: the assertion has to be about a first
+ // evaluation of the package, which a cached module registry cannot give.
+ const { NoDefaultCurrentDirectoryInExePath: _cleared, ...env } = process.env
+ const probe = spawnProcess({
+ program: process.execPath,
+ args: [
+ '-e',
+ `import(${JSON.stringify(SDK_PACKAGE)}).then(() => console.log(String(process.env.NoDefaultCurrentDirectoryInExePath)))`
+ ],
+ cwd: REPO_ROOT,
+ env: env as Record,
+ stdio: ['ignore', 'pipe', 'ignore']
+ })
+ const observed = await new Promise((settle) => {
+ let output = ''
+ probe.stdout?.setEncoding('utf8').on('data', (chunk: string) => {
+ output += chunk
+ })
+ probe.once('close', () => settle(output.trim()))
+ })
+
+ expect(observed).toBe('1')
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts
new file mode 100644
index 00000000000..cd3520cf6d5
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts
@@ -0,0 +1,107 @@
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import { describe, expect, it, vi } from 'vitest'
+import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk'
+import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process'
+import type { ProcessSpec } from '../../shared/child-process/process-spec'
+import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn'
+
+type FakeChild = EventEmitter & {
+ pid: number
+ stdin: PassThrough
+ stdout: PassThrough
+ stderr: PassThrough
+ kill: ReturnType
+}
+
+function fakeSpawn() {
+ const child = new EventEmitter() as FakeChild
+ child.pid = 4321
+ child.stdin = new PassThrough()
+ child.stdout = new PassThrough()
+ child.stderr = new PassThrough()
+ child.kill = vi.fn(() => true)
+ const specs: ProcessSpec[] = []
+ const spawnImpl = ((spec: ProcessSpec) => {
+ specs.push(spec)
+ return child
+ }) as unknown as typeof spawnProcess
+ return { child, spawnImpl, specs }
+}
+
+function sdkOptions(overrides: Partial = {}): SdkSpawnOptions {
+ return {
+ command: '/usr/local/bin/claude',
+ args: ['--output-format', 'stream-json'],
+ cwd: '/work/repo',
+ env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one', UNSET: undefined },
+ signal: new AbortController().signal,
+ ...overrides
+ }
+}
+
+describe('claude agent SDK process spawn', () => {
+ it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => {
+ const process = fakeSpawn()
+ const spawn = createClaudeCodeProcessSpawn(process.spawnImpl)
+
+ expect(spawn.pid).toBeUndefined()
+ expect(spawn.child).toBeNull()
+ const child = spawn.spawn(sdkOptions())
+
+ expect(child).toBe(process.child)
+ expect(spawn.child).toBe(process.child)
+ expect(spawn.pid).toBe(4321)
+ expect(process.specs[0]).toEqual({
+ program: '/usr/local/bin/claude',
+ args: ['--output-format', 'stream-json'],
+ cwd: '/work/repo',
+ env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' },
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+ })
+
+ it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => {
+ const process = fakeSpawn()
+ const controller = new AbortController()
+ createClaudeCodeProcessSpawn(process.spawnImpl).spawn(sdkOptions({ signal: controller.signal }))
+
+ // Node's spawn({signal}) kills the child on abort; Orca's ladder must be the
+ // only thing that can end this process, or close() would report an assumed exit.
+ expect(process.specs[0]).not.toHaveProperty('signal')
+ })
+
+ it('drains stderr into a bounded tail so an exit error still carries it', async () => {
+ const process = fakeSpawn()
+ const spawn = createClaudeCodeProcessSpawn(process.spawnImpl)
+ spawn.spawn(sdkOptions())
+
+ process.child.stderr.write('x'.repeat(9000))
+ process.child.stderr.write('claude: not signed in')
+ await new Promise((resolve) => setImmediate(resolve))
+
+ expect(spawn.stderrTail).toMatch(/claude: not signed in$/)
+ expect(spawn.stderrTail.length).toBe(8192)
+ })
+
+ it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => {
+ const process = fakeSpawn()
+ createClaudeCodeProcessSpawn(process.spawnImpl).spawn(
+ sdkOptions({
+ command: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd',
+ args: ['--setting-sources=user,project,local', '--session-id', 'a b&c']
+ })
+ )
+
+ // The spec the spawner builds is what Orca's Windows branch encodes; the SDK's
+ // own spawn would hand `.cmd` straight to Node and mangle the argument.
+ const resolved = resolveSpawn(process.specs[0] as ProcessSpec, 'win32')
+ expect(resolved.file.toLowerCase()).toContain('cmd.exe')
+ expect(resolved.options.windowsVerbatimArguments).toBe(true)
+ expect(resolved.args).toHaveLength(1)
+ // `/v:off` plus the quoted argument is what keeps `&` from splitting the line.
+ expect(resolved.args[0]).toContain('/v:off')
+ expect(resolved.args[0]).toContain('"a b&c"')
+ expect(resolved.args[0]).toContain('"--setting-sources=user,project,local"')
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts
new file mode 100644
index 00000000000..a2b1ad7f158
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-process-spawn.ts
@@ -0,0 +1,69 @@
+import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk'
+import { spawnProcess } from '../../shared/child-process/run-process'
+
+/** Derived rather than imported: only src/shared/child-process may name node:child_process. */
+type ClaudeCodeChild = ReturnType
+
+const STDERR_TAIL_MAX_BYTES = 8192
+
+export type ClaudeCodeProcessSpawn = {
+ /** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */
+ spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild
+ /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */
+ readonly child: ClaudeCodeChild | null
+ /** Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn token. */
+ readonly pid: number | undefined
+ readonly stderrTail: string
+}
+
+function definedEnv(env: Record): Record {
+ const next: Record = {}
+ for (const [key, value] of Object.entries(env)) {
+ if (value !== undefined) {
+ next[key] = value
+ }
+ }
+ return next
+}
+
+/**
+ * Orca supplies the Claude Code child rather than letting the SDK spawn it.
+ *
+ * Two independent reasons: the SDK's `SpawnedProcess` has no pid, and Orca's
+ * spawner is the only path that encodes `.cmd` arguments safely on Windows.
+ */
+export function createClaudeCodeProcessSpawn(
+ spawnImpl: typeof spawnProcess = spawnProcess
+): ClaudeCodeProcessSpawn {
+ let child: ClaudeCodeChild | null = null
+ let stderrTail = ''
+ return {
+ spawn: (options) => {
+ // Why `options.signal` is dropped: it would let the SDK kill the child outside
+ // Orca's ladder, and close() may never report an exit it did not observe.
+ const spawned = spawnImpl({
+ program: options.command,
+ args: [...options.args],
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
+ env: definedEnv(options.env),
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+ child = spawned
+ // The SDK drains stderr only for its own local spawn, so a custom spawner must:
+ // otherwise the child blocks on a full pipe and exit errors lose their tail.
+ spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => {
+ stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
+ })
+ return spawned
+ },
+ get child() {
+ return child
+ },
+ get pid() {
+ return child?.pid
+ },
+ get stderrTail() {
+ return stderrTail
+ }
+ }
+}
diff --git a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts
new file mode 100644
index 00000000000..9f843527e30
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts
@@ -0,0 +1,190 @@
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import { describe, expect, it, vi } from 'vitest'
+import type { SpawnedProcess } from '../../shared/child-process/run-process'
+import type { DescendantSnapshot } from '../pty-descendant-termination'
+import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
+import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
+import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot'
+
+const ROOT_PID = 424242
+const ROOT_STARTED_AT = 'Mon Jan 1 00:00:00 2026'
+const ROOT_FORK_MS = Date.parse(ROOT_STARTED_AT)
+
+function mockChild(): EventEmitter &
+ Pick & { kill: ReturnType } {
+ return Object.assign(new EventEmitter(), {
+ pid: ROOT_PID,
+ stdin: new PassThrough(),
+ kill: vi.fn(() => true)
+ }) as never
+}
+
+function posixSnapshot(input: {
+ capturedAtMs: number
+ descendants?: DescendantSnapshot['descendants']
+}): DescendantSnapshot {
+ return {
+ root: { pid: ROOT_PID, startedAt: ROOT_STARTED_AT },
+ rootPgid: ROOT_PID,
+ descendants: input.descendants ?? [],
+ capturedAtMs: input.capturedAtMs
+ }
+}
+
+function windowsSnapshot(capturedAtMs = 1): WindowsDescendantSnapshot {
+ return {
+ root: { pid: ROOT_PID, creationTimeMs: 1_700_000_000_001 },
+ descendants: [{ pid: 4243, creationTimeMs: 1_700_000_000_000 }],
+ unidentifiedCount: 0,
+ capturedAtMs
+ }
+}
+
+describe('Claude root kill fallback', () => {
+ it('kills the root when the first capture landed in the fork second', async () => {
+ // The production POSIX verifier declines a root born in its capture second,
+ // and that verdict must not cost the tree the kill on Node's own handle.
+ const child = mockChild()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => false,
+ captureDescendants: vi.fn(async () => posixSnapshot({ capturedAtMs: ROOT_FORK_MS + 300 }))
+ })
+
+ await expect(tree.reap()).resolves.toBe('exited')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('kills the root after a recycled descendant pid voided the snapshot', async () => {
+ const child = mockChild()
+ const captureDescendants = vi
+ .fn()
+ .mockResolvedValueOnce(
+ posixSnapshot({
+ capturedAtMs: ROOT_FORK_MS + 5_000,
+ descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }]
+ })
+ )
+ .mockResolvedValueOnce(
+ posixSnapshot({
+ capturedAtMs: ROOT_FORK_MS + 6_000,
+ descendants: [
+ { pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: 'Mon Jan 1 00:00:30 2026' }
+ ]
+ })
+ )
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => false,
+ captureDescendants,
+ terminateDescendants: vi.fn(async () => 'exited' as const),
+ verifyRootIdentity: vi.fn(async () => true)
+ })
+
+ await tree.capture()
+ await tree.refresh?.()
+ // The descendant evidence is rightly discarded; the root's never was in doubt.
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('keeps an observed live descendant when the root identity probe declined', async () => {
+ const child = mockChild()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => false,
+ captureDescendants: vi.fn(async () =>
+ posixSnapshot({
+ capturedAtMs: ROOT_FORK_MS + 5_000,
+ descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }]
+ })
+ ),
+ terminateDescendants: vi.fn(async () => 'live' as const),
+ verifyRootIdentity: vi.fn(async () => false)
+ })
+
+ await expect(tree.reap()).resolves.toBe('live')
+ expect(tree.treeVerdict).toBe('live')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('reports a Windows taskkill that worked as exited, not unverifiable', async () => {
+ const child = mockChild()
+ // Probe 1 gates taskkill; a later probe correctly finds the root already dead.
+ const verifyRootIdentity = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false)
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ exited: () => false,
+ captureWindowsDescendants: vi.fn(async () => windowsSnapshot()),
+ terminateWindowsTree: vi.fn(async () => {}),
+ terminateWindowsDescendants: vi.fn(async () => 'exited' as const),
+ verifyRootIdentity
+ })
+
+ await expect(tree.reap()).resolves.toBe('exited')
+ })
+
+ it('kills the root when no POSIX snapshot could be read', async () => {
+ const child = mockChild()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => false,
+ captureDescendants: vi.fn(async () => null),
+ terminateDescendants: vi.fn()
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('kills the root when the Windows process table is unreadable', async () => {
+ const child = mockChild()
+ const terminateWindowsTree = vi.fn(async () => {})
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'win32',
+ exited: () => false,
+ captureWindowsDescendants: vi.fn(async () => null),
+ terminateWindowsTree,
+ terminateWindowsDescendants: vi.fn()
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ // No identity means no bare-pid tree kill, but the owned handle is still ours.
+ expect(terminateWindowsTree).not.toHaveBeenCalled()
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ it('never signals a root the reaper already saw exit', async () => {
+ const child = mockChild()
+ const tree = createClaudeChildTreeReaper(child, {
+ platform: 'linux',
+ exited: () => true,
+ captureDescendants: vi.fn(async () => null)
+ })
+
+ await expect(tree.reap()).resolves.toBe('unverifiable')
+ expect(child.kill).not.toHaveBeenCalled()
+ })
+
+ it('chains per-pid Windows boundaries across a second merge', async () => {
+ const first = windowsSnapshot(1_000)
+ const second: WindowsDescendantSnapshot = {
+ ...windowsSnapshot(2_000),
+ descendants: [
+ { pid: 4243, creationTimeMs: 1_700_000_000_000 },
+ { pid: 4244, creationTimeMs: 1_700_000_000_002 }
+ ]
+ }
+ const third: WindowsDescendantSnapshot = { ...second, capturedAtMs: 3_000 }
+
+ const merged = mergeClaudeCapturedTrees(
+ { platform: 'win32', tree: first },
+ { platform: 'win32', tree: second }
+ )
+ expect(merged?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 })
+ const rechained = mergeClaudeCapturedTrees(merged!, { platform: 'win32', tree: third })
+
+ expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 })
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.test.ts b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts
new file mode 100644
index 00000000000..62fbd8cf203
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts
@@ -0,0 +1,65 @@
+import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
+import { describe, expect, it } from 'vitest'
+import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue'
+
+/**
+ * The SDK's input pump is `for await (const frame of prompt) { await transport.write(frame) }`.
+ * A rejected write — or an abort — ends that loop abruptly, which calls the
+ * generator's `return()`. Everything below drives that exact shape, because the
+ * frame the pump already pulled is the one nothing else can reach.
+ */
+const frame = (text: string): SDKUserMessage =>
+ ({
+ type: 'user',
+ message: { role: 'user', content: [{ type: 'text', text }] }
+ }) as unknown as SDKUserMessage
+
+const settled = (promise: Promise): Promise<'settled' | 'pending'> =>
+ Promise.race([
+ promise.then(
+ () => 'settled' as const,
+ () => 'settled' as const
+ ),
+ new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 100))
+ ])
+
+describe('claude user message queue', () => {
+ it('rejects the frame the SDK pulled but abandoned without writing', async () => {
+ const queue = createClaudeUserMessageQueue()
+ const pump = queue.messages[Symbol.asyncIterator]()
+ const sent = queue.push(frame('hello'))
+
+ await pump.next()
+ await pump.return?.(undefined)
+
+ await expect(settled(sent)).resolves.toBe('settled')
+ await expect(sent).rejects.toThrow(
+ 'claude stream-json input ended before the frame was written'
+ )
+ })
+
+ it('rejects an in-flight frame from fail() when the SDK never resumes the pump', async () => {
+ const queue = createClaudeUserMessageQueue()
+ const pump = queue.messages[Symbol.asyncIterator]()
+ const sent = queue.push(frame('hello'))
+
+ await pump.next()
+ queue.fail(new Error('claude stream-json exited: child died'))
+
+ await expect(settled(sent)).resolves.toBe('settled')
+ await expect(sent).rejects.toThrow('claude stream-json exited: child died')
+ })
+
+ it('still settles a written frame only once the pump asks for the next one', async () => {
+ const queue = createClaudeUserMessageQueue()
+ const pump = queue.messages[Symbol.asyncIterator]()
+ const sent = queue.push(frame('hello'))
+
+ const pulled = await pump.next()
+ expect(pulled.value).toMatchObject({ type: 'user' })
+ // The write proof is the pump coming back for more, exactly as before.
+ await expect(settled(sent)).resolves.toBe('pending')
+ void pump.next()
+ await expect(sent).resolves.toBeUndefined()
+ })
+})
diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.ts b/src/main/claude/claude-agent-sdk-user-message-queue.ts
new file mode 100644
index 00000000000..87fa6660159
--- /dev/null
+++ b/src/main/claude/claude-agent-sdk-user-message-queue.ts
@@ -0,0 +1,100 @@
+import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
+
+type QueuedMessage = {
+ message: SDKUserMessage
+ resolve: () => void
+ reject: (error: Error) => void
+}
+
+export type ClaudeUserMessageQueue = {
+ /** The SDK's streaming-input prompt; it stays open until `end`. */
+ messages: AsyncIterable
+ /** Resolves once the SDK has finished writing the frame to the child. */
+ push: (message: SDKUserMessage) => Promise
+ /** Reject every unwritten frame, in-flight included; a caller waiting on a send must not hang past the exit. */
+ fail: (error: Error) => void
+ end: () => void
+}
+
+/** The rejection an abandoned frame carries when nothing else has named a cause yet. */
+const UNWRITTEN_FRAME_MESSAGE = 'claude stream-json input ended before the frame was written'
+
+export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue {
+ const queued: QueuedMessage[] = []
+ // The frame the SDK has taken but not yet acknowledged. It is out of `queued`,
+ // so it is unreachable from anywhere else and would otherwise never settle.
+ let inFlight: QueuedMessage | null = null
+ let wake: (() => void) | null = null
+ let ended = false
+ let failure: Error | null = null
+ const notify = (): void => {
+ wake?.()
+ wake = null
+ }
+ const rejectInFlight = (error: Error): void => {
+ const abandoned = inFlight
+ inFlight = null
+ abandoned?.reject(error)
+ }
+
+ async function* drain(): AsyncGenerator {
+ for (;;) {
+ const next = queued.shift()
+ if (next) {
+ inFlight = next
+ let written = false
+ try {
+ yield next.message
+ written = true
+ } finally {
+ // The SDK's input pump abandons this iterator when its
+ // `await transport.write(...)` rejects or the query aborts, and the code
+ // after a `yield` never runs on that path. Settling here is the only
+ // place a frame it already took can be reached.
+ if (written) {
+ inFlight = null
+ // Resumed only after the SDK's `await transport.write(...)` settled, so this
+ // is the same "the frame reached the child" proof the hand-rolled write gave.
+ next.resolve()
+ } else {
+ rejectInFlight(failure ?? new Error(UNWRITTEN_FRAME_MESSAGE))
+ }
+ }
+ continue
+ }
+ if (ended || failure) {
+ return
+ }
+ await new Promise((resolve) => {
+ wake = resolve
+ })
+ }
+ }
+
+ return {
+ messages: drain(),
+ push: (message) =>
+ new Promise((resolve, reject) => {
+ if (failure) {
+ reject(failure)
+ return
+ }
+ queued.push({ message, resolve, reject })
+ notify()
+ }),
+ fail: (error) => {
+ failure ??= error
+ for (const entry of queued.splice(0)) {
+ entry.reject(error)
+ }
+ // A pump that never resumes cannot run the generator's cleanup, so the
+ // exit path has to reach the in-flight frame itself.
+ rejectInFlight(error)
+ notify()
+ },
+ end: () => {
+ ended = true
+ notify()
+ }
+ }
+}
diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts
new file mode 100644
index 00000000000..85ed629f1b9
--- /dev/null
+++ b/src/main/claude/claude-child-exit-proof-ladder.ts
@@ -0,0 +1,41 @@
+import type { SpawnedProcess } from '../../shared/child-process/run-process'
+import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline'
+import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
+
+const GRACEFUL_EXIT_MS = 1_500
+const FORCED_EXIT_MS = 1_000
+
+export type ClaudeChildExitProofInput = {
+ child: Pick
+ exitPromise: Promise
+ exited: () => boolean
+ tree?: ClaudeChildTreeReaper
+}
+
+export async function proveClaudeChildExitWithReaper(
+ input: ClaudeChildExitProofInput,
+ createTree: () => ClaudeChildTreeReaper
+): Promise {
+ const tree = input.tree ?? createTree()
+ // Arm before stdin closes: only a live root can identify its descendants.
+ await tree.capture()
+ try {
+ input.child.stdin?.end()
+ } catch {
+ // The reap below still owns the process.
+ }
+ let reaped = false
+ if (!input.exited()) {
+ await waitForProcessExitUntil(input.exitPromise, GRACEFUL_EXIT_MS)
+ if (!input.exited()) {
+ reaped = true
+ await tree.refresh?.()
+ await tree.reap()
+ await waitForProcessExitUntil(input.exitPromise, FORCED_EXIT_MS)
+ }
+ }
+ if (!reaped && input.exited() && tree.treeVerdict !== 'exited') {
+ await tree.reap()
+ }
+ return input.exited() && tree.treeVerdict === 'exited'
+}
diff --git a/src/main/claude/claude-child-process-environment.test.ts b/src/main/claude/claude-child-process-environment.test.ts
new file mode 100644
index 00000000000..8299fdcdd61
--- /dev/null
+++ b/src/main/claude/claude-child-process-environment.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import { applyClaudeEnvPatch } from '../claude-accounts/environment'
+import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
+
+describe('Claude child process environment', () => {
+ it('strips case-insensitive auth headers through the shared env patch on Windows', () => {
+ expect(
+ applyClaudeEnvPatch(
+ {
+ anthropic_api_key: 'inherited-key',
+ Anthropic_Custom_Headers: 'Authorization: inherited',
+ SAFE_VALUE: 'preserved'
+ },
+ {},
+ { stripAuthEnv: true, platform: 'win32' }
+ )
+ ).toEqual({ SAFE_VALUE: 'preserved' })
+ })
+
+ it('strips case-insensitive inherited auth and session stamps on Windows', () => {
+ const env = buildClaudeChildProcessEnv(
+ {
+ ANTHROPIC_AUTH_TOKEN: 'configured-token',
+ Claude_Code_Session_Id: 'configured-session'
+ },
+ {
+ platform: 'win32',
+ inheritedEnv: {
+ anthropic_api_key: 'inherited-key',
+ Anthropic_Custom_Headers: 'Authorization: inherited',
+ claude_code_child_session: '1',
+ CLAUDE_CODE_SESSION_ID: 'inherited-session',
+ SAFE_VALUE: 'preserved'
+ }
+ }
+ )
+
+ expect(env).toEqual({
+ ANTHROPIC_AUTH_TOKEN: 'configured-token',
+ Claude_Code_Session_Id: 'configured-session',
+ SAFE_VALUE: 'preserved'
+ })
+ })
+
+ it('can strip child-session stamps reintroduced by a full SDK launch overlay', () => {
+ expect(
+ buildClaudeChildProcessEnv(
+ {
+ CLAUDE_CODE_CHILD_SESSION: 'configured-child-session',
+ CLAUDE_CODE_SESSION_ID: 'configured-session',
+ CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session'
+ },
+ {
+ scrubConfiguredChildSessionStamps: true,
+ inheritedEnv: {
+ CLAUDE_CODE_CHILD_SESSION: 'inherited-child-session',
+ SAFE_VALUE: 'preserved'
+ }
+ }
+ )
+ ).toEqual({ SAFE_VALUE: 'preserved' })
+ })
+})
diff --git a/src/main/claude/claude-child-process-environment.ts b/src/main/claude/claude-child-process-environment.ts
new file mode 100644
index 00000000000..58f00c5c1b8
--- /dev/null
+++ b/src/main/claude/claude-child-process-environment.ts
@@ -0,0 +1,69 @@
+import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment'
+
+const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [
+ 'CLAUDE_CODE_CHILD_SESSION',
+ 'CLAUDE_CODE_SESSION_ID',
+ 'CLAUDE_CODE_BRIDGE_SESSION_ID'
+] as const
+
+function cloneProcessEnv(source: NodeJS.ProcessEnv): Record {
+ const env: Record = {}
+ for (const [key, value] of Object.entries(source)) {
+ if (value !== undefined) {
+ env[key] = value
+ }
+ }
+ return env
+}
+
+function stripClaudeChildSessionStamps(
+ env: Record,
+ platform: NodeJS.Platform
+): Record {
+ for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) {
+ for (const envKey of Object.keys(env)) {
+ if (envKey === key || (platform === 'win32' && envKey.toUpperCase() === key)) {
+ delete env[envKey]
+ }
+ }
+ }
+ return env
+}
+
+export function buildClaudeChildProcessEnv(
+ configuredEnv: Record = {},
+ options: {
+ inheritedEnv?: NodeJS.ProcessEnv
+ platform?: NodeJS.Platform
+ scrubConfiguredChildSessionStamps?: boolean
+ } = {}
+): Record {
+ const inheritedEnv = options.inheritedEnv ?? process.env
+ const platform = options.platform ?? process.platform
+ const env = applyClaudeEnvPatch(
+ cloneProcessEnv(inheritedEnv),
+ {},
+ {
+ stripAuthEnv: true,
+ platform
+ }
+ )
+ if (platform === 'win32') {
+ const authKeys = new Set(CLAUDE_AUTH_ENV_VARS.map((key) => key.toUpperCase()))
+ for (const [key, value] of Object.entries(env)) {
+ const normalized = key.toUpperCase()
+ if (
+ authKeys.has(normalized) ||
+ (normalized === 'ANTHROPIC_CUSTOM_HEADERS' &&
+ /authorization|x-api-key|api-key|bearer/i.test(value))
+ ) {
+ delete env[key]
+ }
+ }
+ }
+ if (options.scrubConfiguredChildSessionStamps) {
+ return stripClaudeChildSessionStamps({ ...env, ...configuredEnv }, platform)
+ }
+ stripClaudeChildSessionStamps(env, platform)
+ return { ...env, ...configuredEnv }
+}
diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts
new file mode 100644
index 00000000000..bed422532e1
--- /dev/null
+++ b/src/main/claude/claude-child-root-termination.ts
@@ -0,0 +1,54 @@
+import type { SpawnedProcess } from '../../shared/child-process/run-process'
+import type { PosixProcessIdentity } from '../pty-descendant-termination'
+import type {
+ WindowsDescendantSnapshot,
+ WindowsProcessIdentity
+} from '../windows-descendant-exit-verification'
+
+export type ClaudeRootIdentity = PosixProcessIdentity | WindowsProcessIdentity
+
+type RootTerminationInput = {
+ child: Pick
+ exited: () => boolean
+}
+
+/**
+ * Kills the root through the handle Node owns rather than through its pid, which
+ * is why no identity probe gates it: libuv drops that handle in the same turn it
+ * reaps, so the signal either reaches the process Orca spawned or reaches
+ * nothing. A probe here could only let an unreadable process table cost the tree
+ * the one fallback that still works once every table read has failed.
+ *
+ * False means no signal was sent, because the root had already left.
+ */
+export function terminateClaudeRoot(input: RootTerminationInput): boolean {
+ return input.exited() ? false : input.child.kill('SIGKILL')
+}
+
+type WindowsRootTerminationInput = {
+ snapshot: WindowsDescendantSnapshot | null
+ exited: () => boolean
+ verifyRoot: (root: WindowsProcessIdentity) => Promise
+ terminateTree: (root: WindowsProcessIdentity) => Promise
+ killRoot: () => boolean
+}
+
+/**
+ * `taskkill /T /F` addresses a bare pid, so a dead root's pid may already belong
+ * to a stranger whose whole tree it would take down: that one is identity-gated.
+ * The direct root kill after it runs however the probe decided.
+ */
+export async function terminateClaudeWindowsRoot(
+ input: WindowsRootTerminationInput
+): Promise<{ rootVerified: boolean }> {
+ const { snapshot, exited, verifyRoot, terminateTree, killRoot } = input
+ let rootVerified = false
+ if (!exited() && snapshot) {
+ rootVerified = await verifyRoot(snapshot.root).catch(() => false)
+ if (rootVerified && !exited()) {
+ await terminateTree(snapshot.root).catch(() => {})
+ }
+ }
+ killRoot()
+ return { rootVerified }
+}
diff --git a/src/main/claude/claude-child-tree-snapshot.ts b/src/main/claude/claude-child-tree-snapshot.ts
new file mode 100644
index 00000000000..e0955648b02
--- /dev/null
+++ b/src/main/claude/claude-child-tree-snapshot.ts
@@ -0,0 +1,128 @@
+import type { DescendantSnapshot } from '../pty-descendant-termination'
+import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
+
+/** One platform's descendant tree, tagged so neither verifier can be handed the other's rows. */
+export type ClaudeCapturedTree =
+ | { platform: 'posix'; tree: DescendantSnapshot }
+ | { platform: 'win32'; tree: WindowsDescendantSnapshot }
+
+/**
+ * Process-table reads are not atomic: a refresh can omit a still-live row, but
+ * it can also observe a new process after the old row exited. Retain rows absent
+ * from the refresh, but reject a PID whose identity changed between reads.
+ */
+function mergeRowsByPid(
+ previous: readonly Row[],
+ next: readonly Row[],
+ sameIdentity: (previous: Row, next: Row) => boolean,
+ previousBoundary: (row: Row) => number,
+ nextBoundary: (row: Row) => number,
+ refreshBoundary: number
+): { rows: Row[]; capturedAtMsByPid?: Readonly> } | null {
+ const merged = new Map()
+ const capturedAtMsByPid: Record = {}
+ for (const row of previous) {
+ const prior = merged.get(row.pid)
+ if (prior && !sameIdentity(prior, row)) {
+ return null
+ }
+ merged.set(row.pid, row)
+ capturedAtMsByPid[String(row.pid)] = previousBoundary(row)
+ }
+ for (const row of next) {
+ const prior = merged.get(row.pid)
+ if (prior && !sameIdentity(prior, row)) {
+ return null
+ }
+ if (!prior) {
+ capturedAtMsByPid[String(row.pid)] = nextBoundary(row)
+ }
+ merged.set(row.pid, row)
+ }
+ const boundaries = Object.values(capturedAtMsByPid)
+ const needsBoundaryMap =
+ new Set(boundaries).size > 1 || boundaries.some((boundary) => boundary !== refreshBoundary)
+ return {
+ rows: [...merged.values()],
+ ...(needsBoundaryMap ? { capturedAtMsByPid } : {})
+ }
+}
+
+export function mergeClaudeCapturedTrees(
+ previous: ClaudeCapturedTree,
+ next: ClaudeCapturedTree
+): ClaudeCapturedTree | null {
+ if (previous.platform !== next.platform) {
+ return null
+ }
+ if (previous.platform === 'posix' && next.platform === 'posix') {
+ if (previous.tree.rootPgid !== next.tree.rootPgid) {
+ return null
+ }
+ // A refresh cannot repair an earlier capture that lacked root identity;
+ // retaining those rows would permit a later numeric-pid kill without proof.
+ if (!previous.tree.root || !next.tree.root) {
+ return null
+ }
+ if (
+ previous.tree.root.pid !== next.tree.root.pid ||
+ previous.tree.root.startedAt !== next.tree.root.startedAt
+ ) {
+ return null
+ }
+ const descendants = mergeRowsByPid(
+ previous.tree.descendants,
+ next.tree.descendants,
+ (left, right) => left.pgid === right.pgid && left.startedAt === right.startedAt,
+ (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs,
+ (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs,
+ next.tree.capturedAtMs
+ )
+ if (!descendants) {
+ return null
+ }
+ return {
+ platform: 'posix',
+ tree: {
+ ...next.tree,
+ // Retained rows keep their earlier boundary; new rows use the refresh
+ // boundary. The scalar remains the latest scan for legacy consumers.
+ descendants: descendants.rows,
+ ...(descendants.capturedAtMsByPid
+ ? { capturedAtMsByPid: descendants.capturedAtMsByPid }
+ : {})
+ }
+ }
+ }
+ if (previous.platform === 'win32' && next.platform === 'win32') {
+ if (
+ previous.tree.root.pid !== next.tree.root.pid ||
+ previous.tree.root.creationTimeMs !== next.tree.root.creationTimeMs
+ ) {
+ return null
+ }
+ const descendants = mergeRowsByPid(
+ previous.tree.descendants,
+ next.tree.descendants,
+ (left, right) => left.creationTimeMs === right.creationTimeMs,
+ (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs,
+ (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs,
+ next.tree.capturedAtMs
+ )
+ if (!descendants) {
+ return null
+ }
+ return {
+ platform: 'win32',
+ tree: {
+ ...next.tree,
+ descendants: descendants.rows,
+ ...(descendants.capturedAtMsByPid
+ ? { capturedAtMsByPid: descendants.capturedAtMsByPid }
+ : {}),
+ unidentifiedCount: Math.max(previous.tree.unidentifiedCount, next.tree.unidentifiedCount)
+ }
+ }
+ }
+ return null
+}
diff --git a/src/main/claude/claude-command-lifecycle-frames.test.ts b/src/main/claude/claude-command-lifecycle-frames.test.ts
new file mode 100644
index 00000000000..8126a546ed8
--- /dev/null
+++ b/src/main/claude/claude-command-lifecycle-frames.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it, vi } from 'vitest'
+import type {
+ AgentJournalItemBody,
+ AgentJournalItemIdentity
+} from '../../shared/agent-session-journal-types'
+import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
+import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
+
+function sinkState() {
+ const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
+ const sink: StructuredAgentSessionEventSink = {
+ appendItem: (identity, body) => items.push({ identity, body }),
+ appendTombstone: () => {},
+ publish: vi.fn()
+ }
+ return { sink, items }
+}
+
+function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
+ return items.flatMap((item) =>
+ item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
+ )
+}
+
+/**
+ * The queue-bookkeeping frame Claude Code 2.1.258 emits for every uuid-stamped
+ * command: `command_uuid` plus a state, and no content of its own. Shape and
+ * states taken from the CLI's own emission sites.
+ */
+function commandLifecycle(state: 'started' | 'completed' | 'cancelled', uuid: string) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ message: {
+ type: 'command_lifecycle',
+ command_uuid: 'command-1',
+ state,
+ uuid,
+ session_id: 'claude-session'
+ }
+ }
+}
+
+function userTurn(uuid: string, text: string) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ startsTurn: true as const,
+ message: {
+ type: 'user',
+ uuid,
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ isReplay: true,
+ message: { role: 'user', content: text }
+ }
+ }
+}
+
+function assistantReply(uuid: string, text: string) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ message: {
+ type: 'assistant',
+ uuid,
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ message: { role: 'assistant', content: [{ type: 'text', text }] }
+ }
+ }
+}
+
+describe('Claude command_lifecycle frames', () => {
+ it('keeps queue bookkeeping off the transcript for a whole turn', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.'))
+ translator.handle(commandLifecycle('started', 'lifecycle-1'))
+ translator.handle(assistantReply('assistant-1', 'PROBE_OK'))
+ translator.handle(commandLifecycle('completed', 'lifecycle-2'))
+ translator.handle(commandLifecycle('completed', 'lifecycle-3'))
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: {
+ type: 'result',
+ subtype: 'success',
+ uuid: 'result-1',
+ session_id: 'claude-session',
+ is_error: false,
+ result: 'PROBE_OK',
+ terminal_reason: 'completed'
+ }
+ })
+
+ expect(providerFrameKinds(state.items)).toEqual([])
+ // The turn's real content is untouched.
+ expect(
+ state.items.flatMap((item) =>
+ item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : []
+ )
+ ).toEqual([[{ type: 'text', text: 'PROBE_OK' }]])
+ })
+
+ it('keeps a cancelled command off the transcript too', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(commandLifecycle('cancelled', 'lifecycle-4'))
+
+ expect(providerFrameKinds(state.items)).toEqual([])
+ })
+})
diff --git a/src/main/claude/claude-config-dir-pin.test.ts b/src/main/claude/claude-config-dir-pin.test.ts
new file mode 100644
index 00000000000..c7be40a6f38
--- /dev/null
+++ b/src/main/claude/claude-config-dir-pin.test.ts
@@ -0,0 +1,34 @@
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { claudeConfigDirEnvPatch, defaultClaudeConfigDir } from './claude-config-dir-pin'
+
+describe('claude config dir pin', () => {
+ it('does not pin the CLI default home, so the macOS Keychain stays reachable', () => {
+ expect(claudeConfigDirEnvPatch(join(homedir(), '.claude'), { env: {} })).toEqual({})
+ expect(claudeConfigDirEnvPatch(`${join(homedir(), '.claude')}/`, { env: {} })).toEqual({})
+ expect(claudeConfigDirEnvPatch(' ', { env: {} })).toEqual({})
+ })
+
+ it('pins a managed account home the CLI would not find on its own', () => {
+ expect(claudeConfigDirEnvPatch('/accounts/claude/managed', { env: {} })).toEqual({
+ CLAUDE_CONFIG_DIR: '/accounts/claude/managed'
+ })
+ })
+
+ it('treats an inherited CLAUDE_CONFIG_DIR as the default the CLI already resolves', () => {
+ const env = { CLAUDE_CONFIG_DIR: '/inherited/home' }
+ expect(defaultClaudeConfigDir(env)).toBe('/inherited/home')
+ expect(claudeConfigDirEnvPatch('/inherited/home', { env })).toEqual({})
+ expect(claudeConfigDirEnvPatch('/other/home', { env })).toEqual({
+ CLAUDE_CONFIG_DIR: '/other/home'
+ })
+ })
+
+ it('compares Windows homes case-insensitively', () => {
+ const env = { CLAUDE_CONFIG_DIR: 'C:\\Users\\Work\\.claude' }
+ expect(claudeConfigDirEnvPatch('c:\\users\\work\\.claude', { env, platform: 'win32' })).toEqual(
+ {}
+ )
+ })
+})
diff --git a/src/main/claude/claude-config-dir-pin.ts b/src/main/claude/claude-config-dir-pin.ts
new file mode 100644
index 00000000000..d5cc0b8b186
--- /dev/null
+++ b/src/main/claude/claude-config-dir-pin.ts
@@ -0,0 +1,37 @@
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+/** The config dir the Claude CLI resolves for itself when nothing pins one. */
+export function defaultClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
+ return env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
+}
+
+function samePath(a: string, b: string, platform: NodeJS.Platform): boolean {
+ const left = resolve(a)
+ const right = resolve(b)
+ return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
+}
+
+/**
+ * An explicit CLAUDE_CONFIG_DIR moves the Claude CLI off the default Keychain item onto
+ * one derived from the pinned path, so a claude.ai OAuth login stops working even when
+ * the pin names the CLI's own default. Pin only a home the CLI would not find on its
+ * own — the same rule the legacy PTY path applies via `ClaudeRuntimePathResolver`.
+ *
+ * The pinned value is the account home verbatim: the CLI keys its credential lookup on
+ * the literal string, so re-spelling an equivalent path (absolute vs `~`, trailing
+ * separator) selects a different identity. Normalization here is for the equality test
+ * only and must never reach the env.
+ */
+export function claudeConfigDirEnvPatch(
+ accountHome: string,
+ options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
+): { CLAUDE_CONFIG_DIR?: string } {
+ const env = options.env ?? process.env
+ const platform = options.platform ?? process.platform
+ const resolved = accountHome.trim()
+ if (!resolved || samePath(resolved, defaultClaudeConfigDir(env), platform)) {
+ return {}
+ }
+ return { CLAUDE_CONFIG_DIR: resolved }
+}
diff --git a/src/main/claude/claude-descendant-escalation-boundary.test.ts b/src/main/claude/claude-descendant-escalation-boundary.test.ts
new file mode 100644
index 00000000000..55459df0c7f
--- /dev/null
+++ b/src/main/claude/claude-descendant-escalation-boundary.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it, vi } from 'vitest'
+import { terminateDescendantSnapshotWithVerdict } from '../pty-descendant-exit-verification'
+import {
+ collectDescendantRows,
+ type DescendantSnapshot,
+ type ProcessTableRow
+} from '../pty-descendant-termination'
+import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
+
+const ROOT_PID = 500
+const ORCA_PGID = 400
+const ROOT_STARTED_AT = 'Thu Sep 3 18:04:50 2026'
+/** The second both close-time walks land in. */
+const WALK_SECOND = 'Thu Sep 3 18:05:04 2026'
+const WALK_MS = Date.parse(WALK_SECOND)
+const EARLIER_SECOND = 'Thu Sep 3 18:05:03 2026'
+
+/** The measured split: `s20` at :03.946 died, `s21` at :04.042 leaked. */
+const EARLIER_BORN = [700, 701, 702]
+const WALK_SECOND_BORN = [721, 722, 723, 724]
+
+type Cohort = { pids: number[]; startedAt: string }
+
+const LIVE_TREE: Cohort[] = [
+ { pids: EARLIER_BORN, startedAt: EARLIER_SECOND },
+ { pids: WALK_SECOND_BORN, startedAt: WALK_SECOND }
+]
+
+function rowsFor(cohorts: Cohort[]): ProcessTableRow[] {
+ return [
+ { pid: ROOT_PID, ppid: 1, pgid: ORCA_PGID, startedAt: ROOT_STARTED_AT },
+ ...cohorts.flatMap((cohort) =>
+ cohort.pids.map((pid) => ({
+ pid,
+ ppid: ROOT_PID,
+ pgid: ORCA_PGID,
+ startedAt: cohort.startedAt
+ }))
+ )
+ ]
+}
+
+/** A real ppid walk from the root, exactly as production captures one. */
+function walk(capturedAtMs: number, cohorts: Cohort[] = LIVE_TREE): DescendantSnapshot {
+ return collectDescendantRows(ROOT_PID, rowsFor(cohorts), capturedAtMs)
+}
+
+function killedPids(calls: [number, NodeJS.Signals][]): number[] {
+ return calls.flatMap(([pid, signal]) => (signal === 'SIGKILL' ? [pid] : [])).sort((a, b) => a - b)
+}
+
+function signalledPids(calls: [number, NodeJS.Signals][]): number[] {
+ return calls.flatMap(([pid, signal]) => (signal === 'SIGTERM' ? [pid] : [])).sort((a, b) => a - b)
+}
+
+/**
+ * Drives the real reaper and the real verifier against a process table where
+ * every descendant traps SIGTERM, so only a forced sweep can end them. The root
+ * is alive for both walks and gone by the sweep, which is the measured teardown.
+ */
+async function sweep(
+ captures: DescendantSnapshot[],
+ liveTree: Cohort[] = LIVE_TREE
+): Promise<[number, NodeJS.Signals][]> {
+ const calls: [number, NodeJS.Signals][] = []
+ const captureDescendants = vi.fn()
+ for (const capture of captures) {
+ captureDescendants.mockResolvedValueOnce(capture)
+ }
+ const tree = createClaudeChildTreeReaper(
+ { pid: ROOT_PID, kill: vi.fn(() => true) },
+ {
+ platform: 'linux',
+ exited: () => false,
+ captureDescendants,
+ terminateDescendants: (snapshot) =>
+ terminateDescendantSnapshotWithVerdict(snapshot, {
+ requireIdentityBeforeSignal: true,
+ graceMs: 0,
+ verifyMs: 120,
+ sendSignal: (pid, signal) => calls.push([pid, signal]),
+ readTable: async () => ({ rows: rowsFor(liveTree), capturedAtMs: Date.now() })
+ })
+ }
+ )
+ // The close ladder's shape: arm, then re-walk the live root at the boundary.
+ await tree.capture()
+ await tree.refresh?.()
+ await tree.reap()
+ return calls
+}
+
+describe('Claude descendant forced-sweep fence', () => {
+ it('escalates a descendant forked in the same second as both close walks', async () => {
+ // Both walks land inside second :04, one ps duration apart, and the root is
+ // gone before a third could run. A descendant born at :04.042 is no less
+ // ours than its sibling born 96ms earlier at :03.946.
+ const calls = await sweep([walk(WALK_MS + 42), walk(WALK_MS + 140)])
+
+ expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN])
+ expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN])
+ })
+
+ it('still escalates descendants born before the walk that first saw them', async () => {
+ const onlyEarlier = [{ pids: EARLIER_BORN, startedAt: EARLIER_SECOND }]
+ const calls = await sweep([walk(WALK_MS + 42, onlyEarlier)], onlyEarlier)
+
+ expect(killedPids(calls)).toEqual(EARLIER_BORN)
+ })
+
+ it('withholds the sweep from a row no walk re-derived, on its start second alone', async () => {
+ // 900 was seen once, in its own birth second, and the refresh did not find
+ // it. The merge retains the row, but nothing re-proved it belongs to us, so
+ // the second-resolution fence is all there is and it still says no.
+ const retained = { pids: [900], startedAt: WALK_SECOND }
+ const firstWalk = walk(WALK_MS + 42, [...LIVE_TREE, retained])
+ const refresh = walk(WALK_MS + 140)
+
+ const calls = await sweep([firstWalk, refresh], [...LIVE_TREE, retained])
+
+ expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN, 900])
+ expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN])
+ })
+})
diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts
new file mode 100644
index 00000000000..e824139a776
--- /dev/null
+++ b/src/main/claude/claude-stream-json-connection-close.test.ts
@@ -0,0 +1,126 @@
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import type { ChildProcessWithoutNullStreams } from 'node:child_process'
+import { describe, expect, it, vi } from 'vitest'
+import type { query } from '@anthropic-ai/claude-agent-sdk'
+import {
+ openClaudeStreamJsonConnection,
+ type ClaudeStreamJsonLaunch
+} from './claude-stream-json-connection'
+
+const mocks = vi.hoisted(() => {
+ const refresh = vi.fn()
+ const proveClaudeChildExit = vi.fn()
+ const tree = {
+ capture: vi.fn(async () => {}),
+ refresh: (...args: unknown[]) => refresh(...args),
+ reap: vi.fn(async () => 'exited' as const),
+ treeVerdict: 'unverifiable' as const
+ }
+ return { proveClaudeChildExit, refresh, tree }
+})
+
+vi.mock('./claude-agent-sdk-exit-proof', () => ({
+ createClaudeChildTreeReaper: vi.fn(() => mocks.tree),
+ proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args)
+}))
+
+function fakeChild(): ChildProcessWithoutNullStreams {
+ const child = new EventEmitter()
+ return Object.assign(child, {
+ pid: 424242,
+ stdin: new PassThrough(),
+ stdout: new PassThrough(),
+ stderr: new PassThrough(),
+ kill: vi.fn()
+ }) as unknown as ChildProcessWithoutNullStreams
+}
+
+describe('Claude stream-json close ordering', () => {
+ it('waits for the live tree refresh before ending stdin', async () => {
+ const refreshDone = Promise.withResolvers()
+ mocks.refresh.mockReturnValueOnce(refreshDone.promise)
+ mocks.proveClaudeChildExit.mockResolvedValueOnce(true)
+ const child = fakeChild()
+ const launch: ClaudeStreamJsonLaunch = {
+ pathToClaudeCodeExecutable: 'claude',
+ options: {},
+ cwd: '/work/repo'
+ }
+ const queryImpl = ((params: Parameters[0]) => {
+ if (!params.options) {
+ throw new Error('missing SDK options')
+ }
+ params.options.spawnClaudeCodeProcess?.({
+ command: 'claude',
+ args: [],
+ env: {},
+ signal: new AbortController().signal
+ })
+ void (async () => {
+ for await (const _message of params.prompt) {
+ // The SDK owns the transport write; the close test only needs its EOF boundary.
+ }
+ child.stdin.end()
+ })()
+ return (async function* () {})()
+ }) as typeof query
+ const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl)
+
+ const closing = connection.close()
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(child.stdin.writableEnded).toBe(false)
+
+ refreshDone.resolve()
+ await expect(closing).resolves.toBe(true)
+ expect(child.stdin.writableEnded).toBe(true)
+ })
+
+ it('requests a fresh close boundary after an output capture starts', async () => {
+ mocks.refresh.mockReset()
+ mocks.proveClaudeChildExit.mockReset()
+ const outputCapture = Promise.withResolvers()
+ const closeCapture = Promise.withResolvers()
+ mocks.refresh
+ .mockReturnValueOnce(outputCapture.promise)
+ .mockReturnValueOnce(closeCapture.promise)
+ mocks.proveClaudeChildExit.mockResolvedValueOnce(true)
+ const child = fakeChild()
+ const launch: ClaudeStreamJsonLaunch = {
+ pathToClaudeCodeExecutable: 'claude',
+ options: {},
+ cwd: '/work/repo'
+ }
+ const queryImpl = ((params: Parameters[0]) => {
+ params.options?.spawnClaudeCodeProcess?.({
+ command: 'claude',
+ args: [],
+ env: {},
+ signal: new AbortController().signal
+ })
+ void (async () => {
+ for await (const _message of params.prompt) {
+ // The SDK owns the transport write; the close test only needs its EOF boundary.
+ }
+ child.stdin.end()
+ })()
+ return (async function* () {})()
+ }) as typeof query
+ const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl)
+
+ child.stderr.emit('data', 'output')
+ await vi.waitFor(() => expect(mocks.refresh).toHaveBeenCalledTimes(1))
+ const closing = connection.close()
+ await Promise.resolve()
+
+ expect(mocks.refresh).toHaveBeenCalledTimes(2)
+ expect(child.stdin.writableEnded).toBe(false)
+
+ outputCapture.resolve()
+ await Promise.resolve()
+ expect(child.stdin.writableEnded).toBe(false)
+ closeCapture.resolve()
+ await expect(closing).resolves.toBe(true)
+ expect(child.stdin.writableEnded).toBe(true)
+ })
+})
diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts
new file mode 100644
index 00000000000..c4f1f9a6fca
--- /dev/null
+++ b/src/main/claude/claude-stream-json-connection.test.ts
@@ -0,0 +1,768 @@
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process'
+import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate'
+import type { ProcessSpec } from '../../shared/child-process/process-spec'
+import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk'
+import {
+ openClaudeStreamJsonConnection,
+ type ClaudeStreamJsonConnection,
+ type ClaudeStreamJsonLaunch
+} from './claude-stream-json-connection'
+import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
+import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
+import { claudeAuthDiagnostic } from './claude-structured-init-proof'
+import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
+import { readClaudeStructuredSessionOptions } from './claude-structured-session-options'
+import type { ClaudeSession } from './claude-structured-session-state'
+import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
+
+// These drive the real SDK against the scripted fake CLI, so every assertion is
+// about the environment, argv and frames a real child actually saw.
+const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
+const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f'
+const HOLD_OPEN = { delayMs: 10_000 }
+
+type ScriptedCliReport = {
+ argv: string[]
+ controlRequests: { request_id: string; request: { subtype: string } }[]
+ controlResponses: { response: { request_id: string; response?: unknown } }[]
+ userMessages: Record[]
+ descendantPid: number | null
+}
+
+const scratchDirs: string[] = []
+const openConnections: ClaudeStreamJsonConnection[] = []
+
+afterEach(async () => {
+ for (const connection of openConnections.splice(0)) {
+ await connection.close()
+ }
+ for (const dir of scratchDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ spawned.splice(0)
+ spawnedChildren.splice(0)
+ vi.unstubAllEnvs()
+})
+
+function scriptScenario(
+ steps: Record[],
+ controlResponses: Record = {}
+) {
+ const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-connection-'))
+ scratchDirs.push(dir)
+ const scenarioPath = join(dir, 'scenario.json')
+ const reportPath = join(dir, 'report.json')
+ writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses }))
+ return {
+ cwd: dir,
+ env: {
+ PATH: process.env.PATH ?? '',
+ ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath,
+ ORCA_SDK_CONTRACT_REPORT_PATH: reportPath
+ },
+ readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport
+ }
+}
+
+function launchFor(
+ scenario: { cwd: string; env: Record },
+ env: Record = {}
+): ClaudeStreamJsonLaunch {
+ return {
+ pathToClaudeCodeExecutable: FAKE_CLI,
+ options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: SESSION_ID },
+ cwd: scenario.cwd,
+ env: { ...scenario.env, ...env }
+ }
+}
+
+/** The derived child environment, captured where Orca actually hands it to the OS. */
+const spawned: ProcessSpec[] = []
+/** The retained child, so a test can end it the way a crashing CLI would. */
+const spawnedChildren: SpawnedProcess[] = []
+
+async function open(
+ launch: ClaudeStreamJsonLaunch,
+ handlers: Parameters[1] = {},
+ queryImpl?: typeof query
+): Promise {
+ const connection = await openClaudeStreamJsonConnection(
+ launch,
+ handlers,
+ (spec) => {
+ spawned.push(spec)
+ const child = spawnProcess(spec)
+ spawnedChildren.push(child)
+ return child
+ },
+ queryImpl
+ )
+ openConnections.push(connection)
+ return connection
+}
+
+function childEnv(): Record {
+ return (spawned.at(-1)?.env ?? {}) as Record
+}
+
+async function until(read: () => T | null | undefined, label: string): Promise {
+ for (let attempt = 0; attempt < 400; attempt++) {
+ const value = read()
+ if (value !== null && value !== undefined) {
+ return value
+ }
+ await new Promise((resolve) => setTimeout(resolve, 25))
+ }
+ throw new Error(`timed out waiting for ${label}`)
+}
+
+function readReportSafely(scenario: { readReport: () => ScriptedCliReport }) {
+ try {
+ return scenario.readReport()
+ } catch {
+ return null
+ }
+}
+
+function processState(pid: number): 'running' | 'exited' {
+ try {
+ const state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], {
+ encoding: 'utf8',
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C' }
+ }).trim()
+ return state.startsWith('Z') ? 'exited' : 'running'
+ } catch (error) {
+ if ((error as { status?: number }).status === 1) {
+ return 'exited'
+ }
+ throw error
+ }
+}
+
+describe('Claude stream-json connection', () => {
+ it('passes the Claude Code system-prompt preset through to SDK query', async () => {
+ const scenario = scriptScenario([HOLD_OPEN])
+ let captured: Options | undefined
+ await open(launchFor(scenario), {}, (params) => {
+ captured = params.options
+ return query(params)
+ })
+
+ expect(captured?.systemPrompt).toEqual({ type: 'preset', preset: 'claude_code' })
+ })
+
+ it('hands the child a derived environment, the resolved CLI path, and keeps the pid', async () => {
+ vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK')
+ vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '1')
+ vi.stubEnv('NODE_OPTIONS', '--require=/tmp/inject.js')
+ // An inherited value wins over the SDK's default, so clear it to pin the default.
+ vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined)
+ vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited')
+ const scenario = scriptScenario([HOLD_OPEN])
+ const connection = await open(
+ launchFor(scenario, {
+ CLAUDE_CONFIG_DIR: '/accounts/managed/home',
+ ANTHROPIC_AUTH_TOKEN: 'configured-token',
+ ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9',
+ CLAUDE_CODE_CHILD_SESSION: 'configured-child-session',
+ CLAUDE_CODE_SESSION_ID: 'configured-session',
+ CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session'
+ })
+ )
+
+ // Ownership proof: the pid is a real live process, not a value the SDK reported.
+ expect(connection.pid).toEqual(expect.any(Number))
+ expect(() => process.kill(connection.pid as number, 0)).not.toThrow()
+ const env = childEnv()
+ // The managed home is pinned verbatim: the CLI keys credential lookup on the literal string.
+ expect(env.CLAUDE_CONFIG_DIR).toBe('/accounts/managed/home')
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token')
+ expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9')
+ expect(env.ORCA_CONNECTION_MARKER).toBe('inherited')
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined()
+ expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined()
+ expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
+ expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined()
+ // Two SDK mutations of the child env, pinned so a bump cannot change them unseen.
+ expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts')
+ expect(env.NODE_OPTIONS).toBeUndefined()
+ // The bundled binary is excluded from the install, so the resolved path is mandatory.
+ const report = await until(() => readReportSafely(scenario), 'the scripted CLI report')
+ expect(report.argv[0]).toBe(FAKE_CLI)
+ // The .mjs fixture makes the SDK run it under node; a real CLI path is the program
+ // itself. Either way the resolved path is what Orca's spawner is asked to execute.
+ expect([spawned.at(-1)?.program, ...(spawned.at(-1)?.args ?? [])]).toContain(FAKE_CLI)
+ expect(report.argv).toContain('--replay-user-messages')
+ expect(report.argv).toContain(`--session-id=${SESSION_ID}`)
+ })
+
+ it('leaves the default CLI home unpinned so macOS Keychain OAuth keeps working', async () => {
+ const scenario = scriptScenario([HOLD_OPEN])
+ await open(launchFor(scenario))
+
+ await until(() => readReportSafely(scenario), 'the scripted CLI report')
+ expect(childEnv().CLAUDE_CONFIG_DIR).toBeUndefined()
+ })
+
+ it('settles a send only once the frame reached the child, and replays reach onMessage', async () => {
+ const replay = {
+ type: 'user',
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+ parent_tool_use_id: null,
+ isReplay: true,
+ session_id: SESSION_ID,
+ uuid: 'uuid-replay-1'
+ }
+ const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: replay }, HOLD_OPEN])
+ const messages: Record[] = []
+ const connection = await open(launchFor(scenario), {
+ onMessage: (message) => messages.push(message)
+ })
+
+ await connection.send({
+ type: 'user',
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+ parent_tool_use_id: null,
+ session_id: SESSION_ID
+ })
+ // The report exists from the child's first line of work, so poll for the frame
+ // itself: `send` settles on the SDK's completed write, and the child still has
+ // to read that line before it can record it.
+ const report = await until(
+ () => (readReportSafely(scenario)?.userMessages.length ? readReportSafely(scenario) : null),
+ 'the user frame recorded by the child'
+ )
+ expect(report.userMessages).toHaveLength(1)
+
+ await until(() => messages.find((message) => message.uuid === 'uuid-replay-1'), 'the replay')
+ // The replay is delivered verbatim, so the dispatch acknowledgement still binds on it.
+ expect(messages.find((message) => message.uuid === 'uuid-replay-1')).toEqual(replay)
+ })
+
+ it('rejects a send the SDK pulled but could not write to a terminated child', async () => {
+ const scenario = scriptScenario([{ awaitUserMessage: true }, HOLD_OPEN])
+ const connection = await open(launchFor(scenario))
+ const child = spawnedChildren.at(-1)
+
+ // Same tick as the send, so the liveness guard still passes and the frame
+ // reaches the SDK's input pump: its `transport.write` is what fails, which is
+ // the window a child crashing mid-send actually opens.
+ child?.kill('SIGKILL')
+ const sent = connection.send({
+ type: 'user',
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+ parent_tool_use_id: null,
+ session_id: SESSION_ID
+ })
+
+ await expect(sent).rejects.toThrow()
+ expect(readReportSafely(scenario)?.userMessages ?? []).toHaveLength(0)
+ })
+
+ it('delivers an unmodeled frame verbatim so the provider-fallback row survives', async () => {
+ const unknown = {
+ type: 'frame_kind_from_the_future',
+ session_id: SESSION_ID,
+ uuid: 'uuid-unknown-1',
+ payload: { nested: { flags: ['a', 'b'] } }
+ }
+ const scenario = scriptScenario([{ emit: unknown }, HOLD_OPEN])
+ const messages: Record[] = []
+ await open(launchFor(scenario), { onMessage: (message) => messages.push(message) })
+
+ await until(() => messages.find((message) => message.uuid === 'uuid-unknown-1'), 'the frame')
+ expect(messages.find((message) => message.uuid === 'uuid-unknown-1')).toEqual(unknown)
+ })
+
+ it('commits the real partial-message cadence as one assistant item through the translator', async () => {
+ // The frame order and per-frame uuids are the ones Claude Code 2.1.258 emits
+ // under --include-partial-messages: every stream_event and the block's final
+ // assistant frame each carry their own uuid; only message.id ties them.
+ const stream = (uuid: string, event: Record) => ({
+ type: 'stream_event',
+ uuid,
+ session_id: SESSION_ID,
+ parent_tool_use_id: null,
+ event
+ })
+ const frames = [
+ stream('uuid-message-start', {
+ type: 'message_start',
+ message: { id: 'msg_01', role: 'assistant', content: [] }
+ }),
+ stream('uuid-block-start', {
+ type: 'content_block_start',
+ index: 0,
+ content_block: { type: 'text', text: '' }
+ }),
+ stream('uuid-delta-1', {
+ type: 'content_block_delta',
+ index: 0,
+ delta: { type: 'text_delta', text: 'ST' }
+ }),
+ stream('uuid-delta-2', {
+ type: 'content_block_delta',
+ index: 0,
+ delta: { type: 'text_delta', text: 'REAMOK_ELEC_64E632' }
+ }),
+ {
+ type: 'assistant',
+ uuid: 'uuid-assistant-final',
+ session_id: SESSION_ID,
+ parent_tool_use_id: null,
+ message: {
+ id: 'msg_01',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }],
+ stop_reason: null
+ }
+ },
+ stream('uuid-block-stop', { type: 'content_block_stop', index: 0 }),
+ stream('uuid-message-delta', { type: 'message_delta', delta: { stop_reason: 'end_turn' } }),
+ stream('uuid-message-stop', { type: 'message_stop' }),
+ {
+ type: 'result',
+ subtype: 'success',
+ is_error: false,
+ duration_ms: 1,
+ duration_api_ms: 1,
+ num_turns: 1,
+ result: 'STREAMOK_ELEC_64E632',
+ stop_reason: 'end_turn',
+ session_id: SESSION_ID,
+ uuid: 'uuid-result'
+ }
+ ]
+ const scenario = scriptScenario([...frames.map((frame) => ({ emit: frame })), HOLD_OPEN])
+ const journal = await openAgentSessionJournal({
+ identity: {
+ sessionId: 'session-1',
+ workspaceId: 'workspace-1',
+ hostId: 'host-1',
+ agent: 'claude',
+ providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' }
+ },
+ journalDir: join(scenario.cwd, 'journal'),
+ now: () => 1_700_000_000_000,
+ mintEpoch: () => 'epoch-1'
+ })
+ const deferred = createDeferredStructuredAgentSessionEventSink()
+ deferred.bind({ journal, fence: 1, publish: vi.fn() })
+ const translator = createClaudeJournalTranslator({ sink: deferred.sink })
+ let settled = false
+ await open(launchFor(scenario), {
+ onMessage: (message) => {
+ translator.handle({ type: 'message', sessionId: 'session-1', message })
+ settled ||= message.type === 'result'
+ }
+ })
+
+ await until(() => (settled ? true : null), 'the result frame')
+ await deferred.drained()
+ const items = journal.snapshot().items
+ const assistant = items.filter(
+ (item) => item.body.kind === 'message' && item.body.role === 'assistant'
+ )
+ expect(assistant.map((item) => item.body)).toEqual([
+ {
+ kind: 'message',
+ role: 'assistant',
+ blocks: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }]
+ }
+ ])
+ expect(assistant.map((item) => item.itemId)).toEqual([`claude:${SESSION_ID}:uuid-block-start`])
+ expect(
+ items.flatMap((item) =>
+ item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
+ )
+ ).toEqual([])
+ // The journal owns a SQLite connection now; afterEach removes this temp root and an open
+ // handle blocks that on Windows.
+ await journal.close()
+ })
+
+ it('feeds an inbound permission request to canUseTool and writes its answer back on the same id', async () => {
+ const scenario = scriptScenario([
+ {
+ emit: {
+ type: 'control_request',
+ request_id: 'perm-421',
+ request: {
+ subtype: 'can_use_tool',
+ tool_name: 'Bash',
+ input: { command: 'ls' },
+ tool_use_id: 'toolu_1',
+ permission_suggestions: [{ type: 'addRules' }]
+ }
+ }
+ },
+ { awaitControlResponse: 'perm-421' },
+ HOLD_OPEN
+ ])
+ const seen: { toolName: string; requestId: string; toolUseID: string; suggestions: unknown }[] =
+ []
+ const canUseTool: CanUseTool = (toolName, _input, options) => {
+ seen.push({
+ toolName,
+ requestId: options.requestId,
+ toolUseID: options.toolUseID,
+ suggestions: options.suggestions
+ })
+ return Promise.resolve({ behavior: 'deny', message: 'No', toolUseID: options.toolUseID })
+ }
+ await open(launchFor(scenario), { canUseTool })
+
+ await until(() => (seen.length > 0 ? seen : null), 'the inbound permission request')
+ expect(seen).toEqual([
+ {
+ toolName: 'Bash',
+ requestId: 'perm-421',
+ toolUseID: 'toolu_1',
+ suggestions: [{ type: 'addRules' }]
+ }
+ ])
+ const written = await until(
+ () =>
+ readReportSafely(scenario)?.controlResponses.find(
+ (frame) => frame.response.request_id === 'perm-421'
+ ),
+ 'the permission answer'
+ )
+ expect(written.response.response).toMatchObject({ behavior: 'deny', message: 'No' })
+ })
+
+ it('drives Orca control methods onto the SDK and times out with the init proof message', async () => {
+ const scenario = scriptScenario([HOLD_OPEN], {
+ initialize: { models: [{ value: 'sonnet' }], account: { tokenSource: 'oauth' } },
+ get_settings: { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } }
+ })
+ const connection = await open(launchFor(scenario))
+
+ await expect(connection.initializationResult()).resolves.toMatchObject({
+ models: [{ value: 'sonnet' }]
+ })
+ await expect(connection.getSettings()).resolves.toEqual({
+ env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' }
+ })
+ await expect(connection.setModel('opus')).resolves.toBeUndefined()
+ const requests = await until(
+ () =>
+ readReportSafely(scenario)?.controlRequests.find(
+ (frame) => frame.request.subtype === 'set_model'
+ ),
+ 'the set_model control request'
+ )
+ expect(requests.request.subtype).toBe('set_model')
+ })
+
+ it('reads supportedModels from the catalog the running CLI reported', async () => {
+ const scenario = scriptScenario([HOLD_OPEN], {
+ initialize: {
+ models: [
+ { value: 'default', resolvedModel: 'claude-opus-5' },
+ {
+ value: 'opus',
+ displayName: 'Opus 5',
+ description: 'The live row, not the seed',
+ resolvedModel: 'claude-opus-5',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'high']
+ }
+ ]
+ }
+ })
+ const connection = await open(launchFor(scenario))
+
+ await expect(connection.supportedModels()).resolves.toMatchObject([
+ { value: 'default', resolvedModel: 'claude-opus-5' },
+ { value: 'opus', displayName: 'Opus 5', supportedEffortLevels: ['low', 'high'] }
+ ])
+ })
+
+ it('serves the picker the live catalog rather than falling back to the static seed', async () => {
+ const scenario = scriptScenario([HOLD_OPEN], {
+ initialize: {
+ models: [
+ { value: 'default', resolvedModel: 'claude-opus-5' },
+ {
+ value: 'opus',
+ displayName: 'Opus 5',
+ description: 'The live row, not the seed',
+ resolvedModel: 'claude-opus-5',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'high']
+ }
+ ]
+ }
+ })
+ const connection = await open(launchFor(scenario))
+ const session = {
+ connection,
+ options: new Map(),
+ reportedOptions: {}
+ } as unknown as ClaudeSession
+
+ const options = await readClaudeStructuredSessionOptions(session, 5_000)
+
+ // The seed carries neither this description nor a two-level effort list, so
+ // both can only have come from the child.
+ expect(options.models).toContainEqual({
+ id: 'opus',
+ label: 'Opus 5',
+ description: 'The live row, not the seed',
+ isDefault: true,
+ efforts: [
+ { value: 'low', label: 'Low' },
+ { value: 'high', label: 'High' }
+ ]
+ })
+ expect(options.current.model).toBe('opus')
+ })
+
+ it('feeds the auth diagnostic from the settings the running child reports', async () => {
+ for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) {
+ vi.stubEnv(key, undefined)
+ }
+ const scenario = scriptScenario([HOLD_OPEN], {
+ get_settings: {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://settings.example.test',
+ ANTHROPIC_AUTH_TOKEN: 'secret'
+ }
+ }
+ })
+ const connection = await open(launchFor(scenario))
+ const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} }
+
+ // With no ambient auth, every true below can only have come from the CLI's settings.
+ expect(claudeAuthDiagnostic(init, null)).toMatchObject({
+ baseUrlConfigured: false,
+ authTokenConfigured: false
+ })
+ const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings())
+ expect(diagnostic).toMatchObject({
+ baseUrlConfigured: true,
+ authTokenConfigured: true,
+ apiKeyConfigured: false
+ })
+ expect(JSON.stringify(diagnostic)).not.toContain('secret')
+ })
+
+ it('reports an unauthenticated start through the init deadline instead of hanging', async () => {
+ // The scripted CLI never answers, which is the shape of a silently unauthenticated CLI.
+ const scenario = scriptScenario([HOLD_OPEN])
+ const connection = await open({
+ ...launchFor(scenario),
+ env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS: '1' }
+ })
+
+ await expect(connection.initializationResult({ timeoutMs: 200 })).rejects.toThrow(
+ 'claude initialize request timed out'
+ )
+ })
+
+ it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => {
+ const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }])
+ let exit: Error | null = null
+ const connection = await open(launchFor(scenario), {
+ onExit: (error) => {
+ exit = error
+ }
+ })
+
+ await until(() => exit, 'the exit error')
+ // The status and stderr are the only diagnostic a refused start leaves behind.
+ expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/)
+ expect(connection.closed).toBe(true)
+ // The root's exit is first-hand, but it left before a descendant snapshot
+ // could be armed, so close() has no tree proof to offer and says so.
+ await expect(connection.close()).resolves.toBe(false)
+ expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' })
+ })
+
+ it.runIf(process.platform !== 'win32')(
+ 'proves a natural SDK exit and cleans up its descendant before recovery',
+ async () => {
+ const scenario = scriptScenario([
+ { stderr: 'claude: natural exit\n' },
+ { delayMs: 500 },
+ { exit: 1 }
+ ])
+ let exit: Error | null = null
+ const connection = await open(
+ {
+ ...launchFor(scenario),
+ env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_DESCENDANT: '1' }
+ },
+ { onExit: (error) => (exit = error) }
+ )
+ const report = await until(() => {
+ const current = readReportSafely(scenario)
+ return current?.descendantPid ? current : null
+ }, 'the descendant report')
+ await until(() => exit, 'the natural exit error')
+ try {
+ await expect(connection.close()).resolves.toBe(true)
+ expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'exited' })
+ expect(processState(report.descendantPid as number)).toBe('exited')
+ } finally {
+ try {
+ process.kill(report.descendantPid as number, 'SIGKILL')
+ } catch {
+ // Already gone.
+ }
+ }
+ },
+ 20_000
+ )
+
+ it('settles a spawn error followed by close as processless and closes idempotently', async () => {
+ const scenario = scriptScenario([HOLD_OPEN])
+ const missingCli = join(scenario.cwd, 'claude-that-does-not-exist')
+ let fault: Error | null = null
+ let exit: Error | null = null
+ const connection = await open(
+ { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli },
+ {
+ onFault: (error) => {
+ fault = error
+ },
+ onExit: (error) => {
+ exit = error
+ }
+ }
+ )
+
+ await until(
+ () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null),
+ 'the processless spawn settlement'
+ )
+ expect(connection.pid).toBeUndefined()
+ expect(fault).toBeInstanceOf(Error)
+ expect(exit).toBeNull()
+ await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([
+ true,
+ true
+ ])
+ await expect(connection.close()).resolves.toBe(true)
+ expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' })
+ })
+
+ it('does not treat a child error event as first-hand root exit proof', async () => {
+ const scenario = scriptScenario([HOLD_OPEN])
+ let exit: Error | null = null
+ const connection = await open(launchFor(scenario), {
+ onExit: (error) => {
+ exit = error
+ }
+ })
+ const child = spawnedChildren.at(-1)
+ expect(child).toBeDefined()
+
+ child?.emit('error', new Error('child transport fault'))
+
+ expect(exit).toBeNull()
+ expect(connection.exitVerdict.root).toBe('live')
+ await until(() => exit, 'the distinct child exit')
+ expect(connection.exitVerdict.root).toBe('exited')
+ })
+
+ it('proves the exit of a child that ignores a graceful shutdown', async () => {
+ const scenario = scriptScenario([HOLD_OPEN])
+ const connection = await open({
+ ...launchFor(scenario),
+ env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_SIGTERM: '1' }
+ })
+
+ // Keep the lstart capture boundary outside the child's displayed start second.
+ await new Promise((resolve) => setTimeout(resolve, 1_100))
+ await expect(connection.close()).resolves.toBe(true)
+ }, 20_000)
+})
+
+// A structured Claude child owns the account's credentials while it runs, exactly as
+// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth
+// refresh instead of rotating the single-use token out from under a live session, and
+// structured sessions used to be invisible to it.
+describe('the managed-auth live gate', () => {
+ it('holds while a structured child runs and releases when it ends', async () => {
+ // The gate is a process-wide singleton and a sibling test's release lands on its
+ // child's 'close' event, which can settle after that test's close() resolved.
+ await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate')
+ const scenario = scriptScenario([
+ { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } },
+ { wait: HOLD_OPEN }
+ ])
+ const connection = await open(launchFor(scenario))
+
+ expect(hasLiveClaudePtys()).toBe(true)
+
+ await connection.close()
+
+ await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain')
+ expect(hasLiveClaudePtys()).toBe(false)
+ }, 30_000)
+
+ it('releases when the child dies on its own rather than through close()', async () => {
+ await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate')
+ const scenario = scriptScenario([
+ { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } },
+ { wait: HOLD_OPEN }
+ ])
+ await open(launchFor(scenario))
+ expect(hasLiveClaudePtys()).toBe(true)
+
+ spawnedChildren.at(-1)?.kill('SIGKILL')
+
+ await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain')
+ expect(hasLiveClaudePtys()).toBe(false)
+ }, 30_000)
+
+ // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never
+ // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of
+ // the process. Entering the gate only after the release handlers are attached makes
+ // that unreachable regardless of what the setup in between does.
+ it('leaks no gate entry when setup throws between spawn and handler attachment', async () => {
+ await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate')
+ const scenario = scriptScenario([
+ { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } },
+ { wait: HOLD_OPEN }
+ ])
+ let started: SpawnedProcess | null = null
+
+ try {
+ await expect(
+ openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => {
+ const child = spawnProcess(spec)
+ started = child
+ const attach = child.stderr.on.bind(child.stderr)
+ // Measured attach order: the SDK binds stderr 'data' from inside query(),
+ // before the child is even assigned. The SECOND bind is this connection's own
+ // armTreeOnOutput — the first statement that runs after the child exists and
+ // before its 'exit'/'close' release handlers. Throwing on the first is
+ // vacuous: it escapes before any gate entry could have happened.
+ let dataAttaches = 0
+ child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => {
+ if (event === 'data') {
+ dataAttaches += 1
+ if (dataAttaches === 2) {
+ throw new Error('stderr listener attach failed')
+ }
+ }
+ return attach(event, listener)
+ }) as typeof child.stderr.on
+ return child
+ })
+ ).rejects.toThrow('stderr listener attach failed')
+
+ expect(hasLiveClaudePtys()).toBe(false)
+ } finally {
+ ;(started as SpawnedProcess | null)?.kill('SIGKILL')
+ }
+ }, 30_000)
+})
diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts
new file mode 100644
index 00000000000..dd6bbc8a5eb
--- /dev/null
+++ b/src/main/claude/claude-stream-json-connection.ts
@@ -0,0 +1,283 @@
+import { randomUUID } from 'node:crypto'
+import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk'
+import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
+import { spawnProcess } from '../../shared/child-process/run-process'
+import {
+ markClaudeStructuredChildExited,
+ markClaudeStructuredChildSpawned
+} from '../claude-accounts/live-pty-gate'
+import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
+import {
+ ClaudeControlRequestError,
+ createClaudeControlSurface,
+ type ClaudeControlSurface
+} from './claude-agent-sdk-control-requests'
+import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof'
+import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
+import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn'
+import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue'
+import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution'
+
+export { ClaudeControlRequestError }
+
+/**
+ * The SDK is loaded at the structured-Claude boundary rather than by this module's
+ * import. The ordinary runtime's class graph statically reaches this file, and the
+ * SDK sets `process.env.NoDefaultCurrentDirectoryInExePath` at import time — a
+ * Windows executable-search change that a user who never leaves the terminal/TUI
+ * path never opted into, and a missing SDK would fail runtime startup. Memoized,
+ * so a session pays the import once per process rather than once per connection.
+ */
+let claudeAgentSdk: Promise | null = null
+
+function loadClaudeAgentSdk(): Promise {
+ claudeAgentSdk ??= import('@anthropic-ai/claude-agent-sdk')
+ return claudeAgentSdk
+}
+
+export type ClaudeStreamJsonLaunch = {
+ /** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */
+ pathToClaudeCodeExecutable: string
+ options: ClaudeStructuredSdkOptions
+ cwd: string
+ env?: Record
+}
+
+export type ClaudeStreamJsonConnectionHandlers = {
+ onMessage?: (message: Record) => void
+ /**
+ * The SDK owns inbound permission control: it hands `can_use_tool` to this callback with
+ * a stable requestId and an abort signal, dedups duplicate delivery, and matches the
+ * response by request_id itself. Setting it makes the SDK pass `--permission-prompt-tool
+ * stdio` automatically; it must not be paired with `permissionPromptToolName`.
+ */
+ canUseTool?: CanUseTool
+ /** `request_user_dialog` control; the CLI only emits kinds declared in `supportedDialogKinds`. */
+ onUserDialog?: OnUserDialog
+ /** A transport/process fault that is not itself first-hand root exit proof. */
+ onFault?: (error: Error) => void
+ onExit?: (error: Error) => void
+}
+
+/**
+ * Two questions with their own evidence. The root's verdict is first-hand: Orca's
+ * own child handle reported exit, or reported error then close before it ever had
+ * a pid. The tree's comes from bounded descendant verification, and `unverifiable`
+ * is never collapsed into either neighbour.
+ */
+export type ClaudeChildExitVerdict = {
+ root: 'exited' | 'live' | 'processless'
+ tree: DescendantTreeVerdict
+}
+
+export type ClaudeStreamJsonConnection = ClaudeControlSurface & {
+ readonly pid: number | undefined
+ readonly closed: boolean
+ /** What the ladder has observed so far; read after a `close()` that returned false. */
+ readonly exitVerdict: ClaudeChildExitVerdict
+ send: (message: Record) => Promise
+ /** Resolves true after processless settlement, or root exit plus observed tree exit. */
+ close: () => Promise
+}
+
+type ExitStatus = { code: number | null; signal: NodeJS.Signals | null }
+
+function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error): Error {
+ const detail = stderrTail.trim()
+ // The status is the diagnostic a signed-out or refused start leaves behind;
+ // it has to survive every wrapper between here and the user.
+ const how =
+ status?.signal !== null && status?.signal !== undefined
+ ? ` (signal ${status.signal})`
+ : status?.code !== null && status?.code !== undefined
+ ? ` (code ${status.code})`
+ : ''
+ const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}`
+ return cause ? new Error(message, { cause }) : new Error(message)
+}
+
+export async function openClaudeStreamJsonConnection(
+ launch: ClaudeStreamJsonLaunch,
+ handlers: ClaudeStreamJsonConnectionHandlers = {},
+ spawnImpl: typeof spawnProcess = spawnProcess,
+ queryImpl?: typeof ClaudeAgentSdk.query
+): Promise {
+ const { query } = await loadClaudeAgentSdk()
+ const spawner = createClaudeCodeProcessSpawn(spawnImpl)
+ const inbox = createClaudeUserMessageQueue()
+ const session = (queryImpl ?? query)({
+ prompt: inbox.messages,
+ options: {
+ ...launch.options,
+ cwd: launch.cwd,
+ // Why env is never omitted: the SDK inherits process.env when it is, which is
+ // exactly the ambient ANTHROPIC_* auth leak this lane already shipped once.
+ env: buildClaudeChildProcessEnv(launch.env, { scrubConfiguredChildSessionStamps: true }),
+ pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable,
+ spawnClaudeCodeProcess: spawner.spawn,
+ ...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}),
+ ...(handlers.onUserDialog ? { onUserDialog: handlers.onUserDialog } : {})
+ }
+ })
+ const child = spawner.child
+ if (!child) {
+ throw new Error('the claude agent SDK returned without spawning a child')
+ }
+ // This child owns the account's credentials for as long as it runs, exactly as a
+ // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate
+ // the single-use token out from under it mid-turn. Entered below, once a release
+ // path exists.
+ const authGateKey = randomUUID()
+ const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey)
+ let exited = false
+ let exitStatus: ExitStatus | null = null
+ let closing = false
+ let processless = false
+ let prePidSpawnError = false
+ let terminalError: Error | null = null
+ let faultReported = false
+ let exitReported = false
+ let closePromise: Promise | null = null
+ // One reaper per child: every close attempt and error-path reap shares its proof.
+ const rootSettled = (): boolean => exited || processless
+ const tree = createClaudeChildTreeReaper(child, { exited: rootSettled })
+
+ // Arm lazily on actual child output instead of issuing a process-table scan for
+ // every session at startup. A natural SDK exit can race a later close, while
+ // output-triggered observation still catches the usual live-child window.
+ let outputObservationArmed = false
+ const armTreeOnOutput = (): void => {
+ if (outputObservationArmed) {
+ return
+ }
+ outputObservationArmed = true
+ void (tree.refresh?.() ?? tree.capture())
+ }
+ child.stderr.on('data', armTreeOnOutput)
+ // The SDK may synchronously spawn the CLI and consume an early stderr chunk
+ // before this connection can attach its listener; the bounded tail preserves
+ // that observation for the same lazy arm.
+ if (spawner.stderrTail.length > 0) {
+ armTreeOnOutput()
+ }
+
+ let settleExit = (): void => {}
+ const exitPromise = new Promise((resolve) => {
+ settleExit = resolve
+ })
+ const markExited = (): void => {
+ exited = true
+ releaseAuthGate()
+ settleExit()
+ }
+ child.on('exit', (code, signal) => {
+ exitStatus = { code, signal }
+ markExited()
+ handleUnexpectedEnd()
+ })
+
+ const handleUnexpectedEnd = (cause?: Error): void => {
+ terminalError ??= exitError(spawner.stderrTail, exitStatus, cause)
+ inbox.fail(terminalError)
+ if (!closing && !faultReported) {
+ faultReported = true
+ handlers.onFault?.(terminalError)
+ }
+ if (!closing && exited && !exitReported) {
+ exitReported = true
+ handlers.onExit?.(terminalError)
+ }
+ }
+
+ void (async () => {
+ for await (const message of session) {
+ handlers.onMessage?.(message as unknown as Record)
+ }
+ })().catch((error: unknown) => {
+ // The SDK ends its generator in error when the child dies or the transport
+ // fails; a transport failure with a live child still has to reap the tree.
+ if (!closing && !exited) {
+ void tree.reap()
+ }
+ handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error)))
+ })
+
+ child.on('error', (error) => {
+ if (spawner.pid === undefined) {
+ prePidSpawnError = true
+ }
+ if (!closing && !exited) {
+ void tree.reap()
+ }
+ handleUnexpectedEnd(error)
+ })
+ child.on('close', () => {
+ // Covers the spawn-failure path too, where no 'exit' ever arrives.
+ releaseAuthGate()
+ if (prePidSpawnError && spawner.pid === undefined) {
+ processless = true
+ settleExit()
+ }
+ handleUnexpectedEnd()
+ })
+ child.stdin.on('error', (error) => {
+ if (!closing) {
+ void tree.reap()
+ handleUnexpectedEnd(error)
+ }
+ })
+ // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so
+ // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the
+ // managed OAuth refresh for the life of the process. Entering only after 'exit' and
+ // 'close' are attached makes that unreachable — any later throw still leaves a
+ // listener that releases. Nothing between spawn and here can yield, so the child
+ // cannot end before the gate is entered.
+ markClaudeStructuredChildSpawned(authGateKey)
+
+ const send = (message: Record): Promise => {
+ if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) {
+ return Promise.reject(terminalError ?? new Error('claude stream-json connection is closed'))
+ }
+ return inbox.push(message as unknown as SDKUserMessage)
+ }
+
+ const close = (): Promise => {
+ closePromise ??= (async () => {
+ closing = true
+ // Arm the descendant proof before ending stdin. The SDK may exit the root
+ // immediately; a post-exit walk cannot recover descendants that reparented.
+ await (tree.refresh?.() ?? tree.capture())
+ inbox.end()
+ const proven = await proveClaudeChildExit({
+ child,
+ exitPromise,
+ exited: rootSettled,
+ tree
+ })
+ inbox.fail(new Error('claude stream-json connection closed'))
+ if (!proven) {
+ closePromise = null
+ }
+ return proven
+ })()
+ return closePromise
+ }
+
+ return {
+ ...createClaudeControlSurface(session),
+ get pid() {
+ return spawner.pid
+ },
+ get closed() {
+ return closing || exited || terminalError !== null
+ },
+ get exitVerdict() {
+ return {
+ root: processless ? 'processless' : exited ? 'exited' : 'live',
+ tree: tree.treeVerdict
+ } as const
+ },
+ send,
+ close
+ }
+}
diff --git a/src/main/claude/claude-streamed-block-identity.ts b/src/main/claude/claude-streamed-block-identity.ts
new file mode 100644
index 00000000000..5cbf6674159
--- /dev/null
+++ b/src/main/claude/claude-streamed-block-identity.ts
@@ -0,0 +1,110 @@
+import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
+import { claudeRecord, claudeText } from './claude-structured-item-translation'
+
+// Under --include-partial-messages every stream_event frame carries its own
+// uuid, and the block's final `assistant` frame carries yet another; only
+// `message.id` ties them together. The block's first stream frame mints the
+// journal identity, and the final frame lands on it in block order instead of
+// appending a duplicate under its own uuid.
+
+export type ClaudeStreamedTextDelta = { identity: AgentJournalItemIdentity; text: string }
+
+type StreamedMessage = {
+ messageId: string | null
+ blocks: Map
+ /** Streamed text blocks whose final assistant frame has not arrived, in block order. */
+ awaitingFinal: AgentJournalItemIdentity[]
+}
+
+export type ClaudeStreamedBlockRegistry = {
+ /** Text a stream_event frame appends to its block, or null when it carries none. */
+ observe: (frame: Record) => ClaudeStreamedTextDelta | null
+ /** The streamed identity a final assistant frame reconciles onto, if its block streamed. */
+ reconcile: (frame: {
+ sessionId: string
+ parentToolUseId: string | null
+ messageId: string | null
+ }) => AgentJournalItemIdentity | null
+ clear: () => void
+}
+
+function scopeKey(sessionId: string, parentToolUseId: string | null): string {
+ return `${sessionId}/${parentToolUseId ?? ''}`
+}
+
+export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry {
+ const messages = new Map()
+
+ const messageFor = (scope: string): StreamedMessage => {
+ let streamed = messages.get(scope)
+ if (!streamed) {
+ streamed = { messageId: null, blocks: new Map(), awaitingFinal: [] }
+ messages.set(scope, streamed)
+ }
+ return streamed
+ }
+
+ const mint = (
+ streamed: StreamedMessage,
+ sessionId: string,
+ index: number,
+ uuid: string
+ ): AgentJournalItemIdentity => {
+ const identity: AgentJournalItemIdentity = { provider: 'claude', sessionId, uuid }
+ streamed.blocks.set(index, identity)
+ streamed.awaitingFinal.push(identity)
+ return identity
+ }
+
+ return {
+ observe: (frame) => {
+ const event = claudeRecord(frame.event)
+ const sessionId = claudeText(frame.session_id)
+ const uuid = claudeText(frame.uuid)
+ if (frame.type !== 'stream_event' || !event || !sessionId || !uuid) {
+ return null
+ }
+ const scope = scopeKey(sessionId, claudeText(frame.parent_tool_use_id))
+ if (event.type === 'message_start') {
+ messages.set(scope, {
+ messageId: claudeText(claudeRecord(event.message)?.id),
+ blocks: new Map(),
+ awaitingFinal: []
+ })
+ return null
+ }
+ const index = typeof event.index === 'number' ? event.index : 0
+ if (event.type === 'content_block_start') {
+ const block = claudeRecord(event.content_block)
+ if (block?.type !== 'text') {
+ return null
+ }
+ const identity = mint(messageFor(scope), sessionId, index, uuid)
+ const text = claudeText(block.text)
+ return text ? { identity, text } : null
+ }
+ if (event.type !== 'content_block_delta') {
+ return null
+ }
+ const delta = claudeRecord(event.delta)
+ const text = delta?.type === 'text_delta' ? claudeText(delta.text) : null
+ if (!text) {
+ return null
+ }
+ const streamed = messageFor(scope)
+ const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid)
+ return { identity, text }
+ },
+ reconcile: (frame) => {
+ const streamed = messages.get(scopeKey(frame.sessionId, frame.parentToolUseId))
+ if (
+ !streamed ||
+ (frame.messageId && streamed.messageId && frame.messageId !== streamed.messageId)
+ ) {
+ return null
+ }
+ return streamed.awaitingFinal.shift() ?? null
+ },
+ clear: () => messages.clear()
+ }
+}
diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts
new file mode 100644
index 00000000000..00a0bc0edd6
--- /dev/null
+++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
+import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
+
+function identityOf(uuid: string): AgentJournalItemIdentity {
+ return { provider: 'claude', sessionId: 'claude-session', uuid }
+}
+
+function checkpoints() {
+ const rows: { uuid: string; text: string }[] = []
+ let scheduled: (() => void) | null = null
+ const store = createClaudeStreamedTextCheckpoints({
+ persist: (identity, text) => {
+ rows.push({ uuid: 'uuid' in identity ? identity.uuid : '', text })
+ },
+ schedule: (run) => {
+ scheduled = run
+ return () => {
+ scheduled = null
+ }
+ }
+ })
+ return {
+ store,
+ rows,
+ runWindow: () => {
+ const run = scheduled as (() => void) | null
+ run?.()
+ }
+ }
+}
+
+describe('claude streamed text checkpoints', () => {
+ it('rewrites a block row with the full text accumulated so far', () => {
+ const { store, rows, runWindow } = checkpoints()
+
+ store.append(identityOf('block-1'), 'hel')
+ store.append(identityOf('block-1'), 'lo')
+ runWindow()
+
+ expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }])
+ expect(store.pending).toBe(1)
+ })
+
+ it('drops every block still awaiting its final frame at settlement', () => {
+ const { store, rows, runWindow } = checkpoints()
+
+ store.append(identityOf('block-1'), 'partial answer')
+ runWindow()
+ store.settle()
+
+ expect(store.pending).toBe(0)
+ // The row written before settlement stays; nothing is rewritten afterwards.
+ store.flush()
+ expect(rows).toEqual([{ uuid: 'block-1', text: 'partial answer' }])
+ })
+
+ it('keeps a block whose final frame arrived out of the settlement sweep', () => {
+ const { store } = checkpoints()
+
+ store.append(identityOf('block-1'), 'one')
+ store.append(identityOf('block-2'), 'two')
+ store.forget('claude:claude-session:block-1')
+
+ expect(store.pending).toBe(1)
+ store.settle()
+ expect(store.pending).toBe(0)
+ })
+
+ it('flushes text the widening checkpoint interval has not written yet', () => {
+ const { store, rows } = checkpoints()
+
+ store.append(identityOf('block-1'), 'x')
+ store.flush()
+
+ expect(rows).toEqual([{ uuid: 'block-1', text: 'x' }])
+ // Already at the row's length: a second flush has nothing to write.
+ store.flush()
+ expect(rows).toHaveLength(1)
+ })
+
+ it('stops persisting once disposed', () => {
+ const { store, rows, runWindow } = checkpoints()
+
+ store.append(identityOf('block-1'), 'text')
+ store.dispose()
+ runWindow()
+ store.flush()
+
+ expect(rows).toEqual([])
+ expect(store.pending).toBe(0)
+ })
+})
diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts
new file mode 100644
index 00000000000..348ecd99558
--- /dev/null
+++ b/src/main/claude/claude-streamed-text-checkpoints.ts
@@ -0,0 +1,105 @@
+import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
+import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
+import {
+ createAgentSessionDeltaCoalescer,
+ type AgentSessionDeltaCoalescerDeps
+} from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
+
+export type ClaudeStreamedTextCheckpointDeps = {
+ /** Rewrites the block's journal row with the text accumulated so far. */
+ persist: (identity: AgentJournalItemIdentity, text: string) => void
+ coalesceMs?: number
+ schedule?: AgentSessionDeltaCoalescerDeps['schedule']
+}
+
+export type ClaudeStreamedTextCheckpoints = {
+ /** Accumulate a delta; the row is rewritten on the coalescer's own cadence. */
+ append: (identity: AgentJournalItemIdentity, text: string) => void
+ /** Write every block whose row is behind the text received for it. */
+ flush: () => void
+ /** Drop one block's state, for a block whose final frame has now landed. */
+ forget: (key: string) => void
+ /**
+ * Drop every block still awaiting its final frame, at turn settlement. Their
+ * text is already journaled by the flush that precedes settlement; keeping it
+ * live would grow with every interrupted turn for the life of the session.
+ */
+ settle: () => void
+ /** Blocks still awaiting a final frame. A settled turn must leave none. */
+ readonly pending: number
+ dispose: () => void
+}
+
+/**
+ * Growth of a streamed block's row between its deltas and its final frame.
+ *
+ * The row is rewritten on a widening interval rather than per delta: a 200-line
+ * reply would otherwise rewrite the same journal row once per token.
+ */
+export function createClaudeStreamedTextCheckpoints(
+ deps: ClaudeStreamedTextCheckpointDeps
+): ClaudeStreamedTextCheckpoints {
+ const identities = new Map()
+ const latestText = new Map()
+ const checkpointLengths = new Map()
+
+ const persist = (key: string, text: string, force: boolean): void => {
+ latestText.set(key, text)
+ const checkpointLength = checkpointLengths.get(key) ?? 0
+ const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125))
+ if (!force && checkpointLength > 0 && text.length < nextLength) {
+ return
+ }
+ const identity = identities.get(key)
+ if (!identity) {
+ return
+ }
+ checkpointLengths.set(key, text.length)
+ deps.persist(identity, text)
+ }
+
+ const coalescer = createAgentSessionDeltaCoalescer({
+ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }),
+ ...(deps.schedule ? { schedule: deps.schedule } : {}),
+ emit: (key, text) => persist(key, text, false)
+ })
+
+ const drop = (key: string): void => {
+ coalescer.forget(key)
+ identities.delete(key)
+ latestText.delete(key)
+ checkpointLengths.delete(key)
+ }
+
+ return {
+ append: (identity, text) => {
+ const key = agentJournalItemKey(identity)
+ identities.set(key, identity)
+ coalescer.append(key, text)
+ },
+ flush: () => {
+ coalescer.flushAll()
+ for (const [key, text] of latestText) {
+ if (checkpointLengths.get(key) !== text.length) {
+ persist(key, text, true)
+ }
+ }
+ },
+ forget: drop,
+ settle: () => {
+ // Map iteration tolerates deletion of the entry just visited.
+ for (const key of identities.keys()) {
+ drop(key)
+ }
+ },
+ get pending() {
+ return identities.size
+ },
+ dispose: () => {
+ coalescer.dispose()
+ identities.clear()
+ latestText.clear()
+ checkpointLengths.clear()
+ }
+ }
+}
diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts
new file mode 100644
index 00000000000..1b633553e89
--- /dev/null
+++ b/src/main/claude/claude-structured-acquisition-release.ts
@@ -0,0 +1,43 @@
+import {
+ closeClaudeSession,
+ claudeAcquisitionCleanupError
+} from './claude-structured-session-close'
+import type {
+ ClaudeAcquisitionRegistry,
+ ClaudeSession,
+ ClaudeSessionExit,
+ ClaudeStructuredSessionAdapterDeps
+} from './claude-structured-session-state'
+
+/**
+ * Cleanup for an acquisition the host could not commit or prove. A session that
+ * a first-hand exit already removed is not an absence to report as proven: the
+ * ladder on its connection still answers, and that answer is classified exactly
+ * as a start-time failure would be.
+ */
+export async function releaseClaudeAcquisition(input: {
+ sessionId: string
+ sessions: Map
+ acquisitions: ClaudeAcquisitionRegistry
+ exits: Map
+ onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise
+ persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
+ onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent']
+}): Promise {
+ const exit = input.exits.get(input.sessionId)
+ if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) {
+ return closeClaudeSession(input)
+ }
+ const firstProof = exit.closePromise ? await exit.closePromise : false
+ // A failed exit-path proof is retained as evidence, not as a terminal result;
+ // a release retry must drive a fresh tree verification on the same connection.
+ const retriedProof = firstProof || (await exit.connection.close())
+ if (retriedProof) {
+ await input.onExitProven?.(input.sessionId, exit)
+ // Keep the first-hand exit evidence indexed until the tree proof succeeds;
+ // a failed close must be retryable and cannot look like an absent session.
+ input.exits.delete(input.sessionId)
+ return true
+ }
+ throw claudeAcquisitionCleanupError(exit.connection, exit.error)
+}
diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts
new file mode 100644
index 00000000000..ddc69366aad
--- /dev/null
+++ b/src/main/claude/claude-structured-auth-parity.test.ts
@@ -0,0 +1,235 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import type { AgentSessionRecord } from '../../shared/agent-session-record'
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate'
+import {
+ CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
+ CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE
+} from '../claude-accounts/environment'
+import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
+import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
+import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter'
+import {
+ PROVIDER_SESSION_ID,
+ adapterFor,
+ fakeClaude,
+ identityFor
+} from './claude-structured-session-test-support'
+
+const SESSION_ID = 'orca-session-auth'
+const IDENTITY = { sessionId: SESSION_ID } as Parameters<
+ ReturnType
+>[0]['identity']
+
+function record(): AgentSessionRecord {
+ return {
+ sessionId: SESSION_ID,
+ provider: 'claude',
+ location: {
+ executionHostId: LOCAL_EXECUTION_HOST_ID,
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'folder'
+ },
+ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
+ providerHandleChain: []
+ } as unknown as AgentSessionRecord
+}
+
+function resolverFor(options: {
+ stripAuthEnv: boolean
+ overlay?: Record
+ authSwitchSettleTimeoutMs?: number
+}): ReturnType {
+ return createClaudeStructuredLaunchResolver({
+ store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
+ resolveWorkspacePath: async (id) => `/repos/${id}`,
+ resolveCommand: () => '/usr/local/bin/claude',
+ resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }),
+ authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20,
+ ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {})
+ })
+}
+
+/**
+ * An adapter driven by the REAL launch resolver, not the stub in the shared test
+ * support — the stub has no auth guard at all, so a teardown-window test built on it
+ * would pass whatever the guard did.
+ */
+function realResolverAdapter(
+ claude: ReturnType,
+ authSwitchSettleTimeoutMs: number
+): ClaudeStructuredSessionAdapter {
+ const resumable = {
+ ...record(),
+ providerHandleChain: [
+ { handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }
+ ]
+ } as unknown as AgentSessionRecord
+ return new ClaudeStructuredSessionAdapter({
+ resolveLaunch: createClaudeStructuredLaunchResolver({
+ store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore,
+ resolveWorkspacePath: async (id) => `/repos/${id}`,
+ resolveCommand: () => '/usr/local/bin/claude',
+ resolveAuthPolicy: () => ({ stripAuthEnv: false }),
+ authSwitchSettleTimeoutMs
+ }),
+ openConnection: claude.openConnection,
+ readProcessStartTime: async () => 1_700_000_000_000,
+ now: () => 1_700_000_000_500,
+ persistHandle: async () => {}
+ })
+}
+
+function withAmbientAuth(value: string, run: () => Promise): Promise {
+ const restore = process.env.ANTHROPIC_API_KEY
+ process.env.ANTHROPIC_API_KEY = value
+ return run().finally(() => {
+ if (restore === undefined) {
+ delete process.env.ANTHROPIC_API_KEY
+ } else {
+ process.env.ANTHROPIC_API_KEY = restore
+ }
+ })
+}
+
+describe('claude structured auth parity with the terminal preflight', () => {
+ afterEach(() => {
+ endClaudeAuthSwitch()
+ })
+
+ // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and
+ // runtime/spawn-preflight.ts:139; the structured path used to let the override win.
+ it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => {
+ await expect(
+ resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({
+ identity: IDENTITY
+ })
+ ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
+ })
+
+ it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => {
+ await expect(
+ resolverFor({
+ stripAuthEnv: true,
+ overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' }
+ })({ identity: IDENTITY })
+ ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
+ })
+
+ it('still admits a non-auth env overlay under a managed account', async () => {
+ const launch = await resolverFor({
+ stripAuthEnv: true,
+ overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' }
+ })({ identity: IDENTITY })
+
+ expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test')
+ })
+
+ // Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a
+ // system-auth user's own shell key is their sign-in and must survive.
+ it('passes an ambient Anthropic key through when no managed account is active', async () => {
+ await withAmbientAuth('sk-ant-SHELL', async () => {
+ const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY })
+
+ expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL')
+ })
+ })
+
+ it('lets an explicit overlay override the ambient key when no managed account is active', async () => {
+ await withAmbientAuth('sk-ant-SHELL', async () => {
+ const launch = await resolverFor({
+ stripAuthEnv: false,
+ overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }
+ })({ identity: IDENTITY })
+
+ expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED')
+ })
+ })
+
+ it('still strips the ambient Anthropic key when a managed account is pinned', async () => {
+ await withAmbientAuth('sk-ant-SHELL', async () => {
+ const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY })
+
+ expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined()
+ })
+ })
+
+ // Task 3 — the terminal preflight guards this at four sites; the structured path had none.
+ it('refuses launch resolution when an account switch never settles', async () => {
+ beginClaudeAuthSwitch()
+
+ await expect(
+ resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY })
+ ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
+ })
+
+ it('waits a settling account switch out rather than refusing a resolved launch', async () => {
+ beginClaudeAuthSwitch()
+ setTimeout(() => endClaudeAuthSwitch(), 20)
+
+ const launch = await resolverFor({
+ stripAuthEnv: true,
+ authSwitchSettleTimeoutMs: 5_000
+ })({ identity: IDENTITY })
+
+ expect(launch.claudeConfigDir).toBe('/home/work/.claude')
+ })
+
+ it('refuses an acquire before it tears the previous session down', async () => {
+ const claude = fakeClaude()
+ const adapter = adapterFor(claude)
+ beginClaudeAuthSwitch()
+
+ await expect(
+ adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
+ ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
+ // Nothing was spawned, so the refusal must not have opened a connection.
+ expect(claude.connections).toHaveLength(0)
+ })
+
+ // The teardown between the entry guard and launch resolution closes the live child
+ // and proves its tree — seconds, not milliseconds. A switch that begins inside it
+ // has already cost the user their session, so refusing there produces exactly the
+ // outcome the entry guard advertises against: a dead chat and no replacement.
+ it('replaces the session when a switch begins inside the acquire teardown', async () => {
+ const claude = fakeClaude()
+ const adapter = realResolverAdapter(claude, 5_000)
+ await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
+ const live = claude.connections[0]!
+ const closeWithSwitch = live.close
+ live.close = async () => {
+ beginClaudeAuthSwitch()
+ setTimeout(() => endClaudeAuthSwitch(), 20)
+ return closeWithSwitch()
+ }
+
+ await expect(
+ adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' })
+ ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } })
+ expect(live.closed).toBe(true)
+ // The replacement child exists: the user's chat came back.
+ expect(claude.connections).toHaveLength(2)
+ expect(claude.connections[1]!.closed).toBe(false)
+ await adapter.closeAll()
+ })
+
+ it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => {
+ const claude = fakeClaude()
+ const adapter = realResolverAdapter(claude, 20)
+ await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
+ const live = claude.connections[0]!
+ const closeWithSwitch = live.close
+ live.close = async () => {
+ beginClaudeAuthSwitch()
+ return closeWithSwitch()
+ }
+
+ await expect(
+ adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' })
+ ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
+ // No replacement child was opened, so nothing is left running unowned.
+ expect(claude.connections).toHaveLength(1)
+ await adapter.closeAll()
+ })
+})
diff --git a/src/main/claude/claude-structured-content-parts.test.ts b/src/main/claude/claude-structured-content-parts.test.ts
new file mode 100644
index 00000000000..d2142150937
--- /dev/null
+++ b/src/main/claude/claude-structured-content-parts.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it, vi } from 'vitest'
+import type {
+ AgentJournalItemBody,
+ AgentJournalItemIdentity
+} from '../../shared/agent-session-journal-types'
+import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
+import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
+
+function sinkState() {
+ const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
+ const sink: StructuredAgentSessionEventSink = {
+ appendItem: (identity, body) => items.push({ identity, body }),
+ appendTombstone: () => {},
+ publish: vi.fn()
+ }
+ return { sink, items }
+}
+
+function providerRows(items: { body: AgentJournalItemBody }[]) {
+ return items.flatMap((item) =>
+ item.body.kind === 'status' && item.body.providerFrame
+ ? [{ kind: item.body.providerFrame.kind, text: item.body.text }]
+ : []
+ )
+}
+
+function userMessageWith(part: unknown) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ startsTurn: true as const,
+ message: {
+ type: 'user',
+ uuid: 'user-1',
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ isReplay: true,
+ message: { role: 'user', content: [{ type: 'text', text: 'look at this' }, part] }
+ }
+ }
+}
+
+/** Exactly what claudeDispatchMessageContent sends for a local attachment. */
+const BASE64_IMAGE = {
+ type: 'image',
+ source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUg==' }
+}
+
+describe('Claude message content parts', () => {
+ it('does not leak a wire kind for a locally attached image', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(userMessageWith(BASE64_IMAGE))
+
+ expect(providerRows(state.items)).toEqual([])
+ })
+
+ it('still renders an image the CLI sends by url', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(
+ userMessageWith({ type: 'image', source: { type: 'url', url: 'https://x.test/a.png' } })
+ )
+
+ expect(providerRows(state.items)).toEqual([])
+ expect(
+ state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : []))
+ ).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' })
+ })
+
+ it('says what is true for a content part it cannot render, not the wire kind', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } }))
+
+ const rows = providerRows(state.items)
+ expect(rows).toHaveLength(1)
+ // The kind stays on the row for debugging, behind the disclosure.
+ expect(rows[0].kind).toBe('message:user:content:some_future_part')
+ // ...but the visible text is a sentence, not the opcode.
+ expect(rows[0].text).not.toContain('message:user:content')
+ expect(rows[0].text.toLowerCase()).toContain('claude')
+ })
+
+ it('prefers a readable sentence the part carries over the placeholder', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(
+ userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' })
+ )
+
+ expect(providerRows(state.items)[0].text).toBe('the server refused the upload')
+ })
+})
diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts
new file mode 100644
index 00000000000..c471a8aca80
--- /dev/null
+++ b/src/main/claude/claude-structured-control-actions.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from 'vitest'
+import { cancelClaudeTurn, answerClaudePrompt } from './claude-structured-control-actions'
+import { ClaudeControlRequestError } from './claude-stream-json-connection'
+import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
+import type { ClaudeSession } from './claude-structured-session-state'
+
+type InterruptResult = Awaited>
+
+function sessionWith(input: {
+ capabilities?: string[]
+ interrupt: (options?: { cancelQueued?: boolean; timeoutMs?: number }) => Promise
+ cancelAsyncMessage?: (uuid: string) => Promise
+ prompts?: ClaudePromptRegistry
+}): {
+ session: ClaudeSession
+ interrupt: ReturnType
+ cancelAsyncMessage: ReturnType
+} {
+ const interrupt = vi.fn(input.interrupt)
+ const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {}))
+ const session = {
+ capabilities: input.capabilities ?? [],
+ prompts: input.prompts ?? new ClaudePromptRegistry(),
+ connection: { interrupt, cancelAsyncMessage }
+ } as unknown as ClaudeSession
+ return { session, interrupt, cancelAsyncMessage }
+}
+
+describe('cancelClaudeTurn', () => {
+ it('interrupts without a receipt on an older CLI and reports the turn cancelled', async () => {
+ const { session, interrupt, cancelAsyncMessage } = sessionWith({
+ interrupt: async () => undefined
+ })
+
+ await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true })
+ expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 })
+ expect(cancelAsyncMessage).not.toHaveBeenCalled()
+ })
+
+ it('withdraws every still-queued message a plain interrupt receipt reports', async () => {
+ const { session, interrupt, cancelAsyncMessage } = sessionWith({
+ capabilities: ['interrupt_receipt_v1'],
+ interrupt: async () => ({ still_queued: ['queued-1', 'queued-2'] })
+ })
+
+ await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true })
+ // No cancel_queued capability, so the queue is swept one uuid at a time.
+ expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 })
+ expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2'])
+ })
+
+ it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => {
+ const { session, interrupt, cancelAsyncMessage } = sessionWith({
+ capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'],
+ interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] })
+ })
+
+ await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true })
+ expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 })
+ expect(cancelAsyncMessage).not.toHaveBeenCalled()
+ })
+
+ it('reports a not-running interrupt as not cancelled without throwing', async () => {
+ const { session } = sessionWith({
+ interrupt: async () => {
+ throw new ClaudeControlRequestError('interrupt', 'not running')
+ }
+ })
+
+ await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: false })
+ })
+
+ it('propagates a transport failure such as an interrupt timeout', async () => {
+ const { session } = sessionWith({
+ interrupt: async () => {
+ throw new Error('claude interrupt request timed out')
+ }
+ })
+
+ await expect(cancelClaudeTurn(session, 5_000)).rejects.toThrow('timed out')
+ })
+})
+
+describe('answerClaudePrompt', () => {
+ it('settles the pending prompt callback and forgets it', async () => {
+ const prompts = new ClaudePromptRegistry()
+ const settle = vi.fn()
+ const prompt = prompts.register({
+ requestId: 'perm-1',
+ toolName: 'Bash',
+ toolUseId: 'tool-1',
+ input: { command: 'ls' },
+ suggestions: [],
+ settle
+ })!
+ prompts.bindJournalItemId('journal-1', prompt.promptKey)
+ const { session } = sessionWith({ interrupt: async () => undefined, prompts })
+
+ await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' })
+
+ expect(settle).toHaveBeenCalledWith(
+ expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' })
+ )
+ expect(prompts.find('journal-1')).toBeNull()
+ })
+
+ it('refuses an answer for a prompt Claude is no longer waiting on', async () => {
+ const { session } = sessionWith({ interrupt: async () => undefined })
+ await expect(
+ answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' })
+ ).rejects.toThrow(/no longer waiting/)
+ })
+})
diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts
new file mode 100644
index 00000000000..d4484963aae
--- /dev/null
+++ b/src/main/claude/claude-structured-control-actions.ts
@@ -0,0 +1,60 @@
+import { applyClaudePromptAnswer } from './claude-structured-prompt-replies'
+import { ClaudeControlRequestError } from './claude-stream-json-connection'
+import type { ClaudeSession } from './claude-structured-session-state'
+
+const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1'
+
+export type ClaudeTurnCancellationGuard = () => boolean
+
+/**
+ * Interrupt the running turn, then make sure no queued async user message survives to spawn a
+ * later unexpected turn. On a CLI advertising `interrupt_cancel_queued_v1` one round trip
+ * cancels the queue alongside the abort; otherwise the interrupt receipt lists `still_queued`
+ * uuids, and each is withdrawn best-effort with `cancel_async_message`. Older CLIs resolve no
+ * receipt, so there is nothing to sweep.
+ */
+export async function cancelClaudeTurn(
+ session: ClaudeSession,
+ timeoutMs: number | undefined,
+ isCurrent: ClaudeTurnCancellationGuard = () => true
+): Promise<{ cancelled: boolean }> {
+ // The SDK interrupt is session-scoped. Re-check the caller's turn/fence
+ // immediately before issuing it so a delayed request cannot stop a later turn.
+ if (!isCurrent()) {
+ return { cancelled: false }
+ }
+ const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY)
+ try {
+ const receipt = await session.connection.interrupt({
+ ...(cancelQueued ? { cancelQueued: true } : {}),
+ timeoutMs
+ })
+ if (!cancelQueued) {
+ for (const uuid of receipt?.still_queued ?? []) {
+ await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {})
+ }
+ }
+ return { cancelled: true }
+ } catch (error) {
+ if (error instanceof ClaudeControlRequestError) {
+ return { cancelled: false }
+ }
+ throw error
+ }
+}
+
+export async function answerClaudePrompt(
+ session: ClaudeSession,
+ input: { itemId: string; kind: 'approval' | 'question'; optionId: string }
+): Promise {
+ const found = session.prompts.find(input.itemId)
+ if (!found || found.prompt.kind !== input.kind) {
+ throw new Error(`claude is no longer waiting on ${input.itemId}`)
+ }
+ const response = applyClaudePromptAnswer(found, input.optionId)
+ if (response === null) {
+ return
+ }
+ session.prompts.forget(found.prompt)
+ found.prompt.settle(response)
+}
diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts
new file mode 100644
index 00000000000..71f180bc3ac
--- /dev/null
+++ b/src/main/claude/claude-structured-dispatch-content.ts
@@ -0,0 +1,165 @@
+import { createHash } from 'node:crypto'
+import { open } from 'node:fs/promises'
+import { extname } from 'node:path'
+import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
+import type { NativeChatBlock } from '../../shared/native-chat-types'
+
+const MAX_IMAGE_BYTES = 5 * 1024 * 1024
+const MAX_IMAGE_COUNT = 20
+const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024
+const MAX_REPLAY_CONTENT_KEY_BYTES = 256
+
+type ImageBudget = {
+ count: number
+ localBytes: number
+}
+
+export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise {
+ const file = await openImpl(path, 'r')
+ try {
+ const invalidImage = (): Error =>
+ new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`)
+ const info = await file.stat()
+ if (!info.isFile()) {
+ throw new Error('Claude image must be a file')
+ }
+ if (info.size > MAX_IMAGE_BYTES) {
+ throw invalidImage()
+ }
+ const buffer = Buffer.allocUnsafe(info.size + 1)
+ let bytesRead = 0
+ while (bytesRead < buffer.length) {
+ const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead)
+ if (result.bytesRead === 0) {
+ break
+ }
+ bytesRead += result.bytesRead
+ }
+ // A file can grow after the initial stat and after the final read returns
+ // zero. Prove the descriptor's size matches what was copied before sending.
+ const finalInfo = await file.stat()
+ if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) {
+ throw invalidImage()
+ }
+ return buffer.subarray(0, bytesRead)
+ } finally {
+ await file.close()
+ }
+}
+
+const IMAGE_MIME_BY_EXTENSION: Record = {
+ '.gif': 'image/gif',
+ '.jpeg': 'image/jpeg',
+ '.jpg': 'image/jpeg',
+ '.png': 'image/png',
+ '.webp': 'image/webp'
+}
+
+async function imageContent(
+ block: Extract,
+ budget: ImageBudget
+): Promise {
+ budget.count += 1
+ if (budget.count > MAX_IMAGE_COUNT) {
+ throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`)
+ }
+ if (block.url) {
+ return { type: 'image', source: { type: 'url', url: block.url } }
+ }
+ if (!block.path) {
+ throw new Error('image reference has neither a path nor a URL')
+ }
+ const data = await readClaudeImage(block.path)
+ budget.localBytes += data.byteLength
+ if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) {
+ throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`)
+ }
+ const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()]
+ if (!mediaType) {
+ throw new Error(`Claude does not support the image type ${extname(block.path)}`)
+ }
+ return {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: mediaType,
+ data: data.toString('base64')
+ }
+ }
+}
+
+export async function claudeDispatchMessageContent(
+ body: AgentJournalMessageItem
+): Promise {
+ if (body.role !== 'user') {
+ throw new Error('Claude dispatch accepts only user messages')
+ }
+ const content: unknown[] = []
+ const imageBudget: ImageBudget = { count: 0, localBytes: 0 }
+ for (const block of body.blocks as NativeChatBlock[]) {
+ if (block.type === 'text' && block.text.length > 0) {
+ content.push({ type: 'text', text: block.text })
+ } else if (block.type === 'image-ref') {
+ content.push(await imageContent(block, imageBudget))
+ }
+ }
+ if (content.length === 0) {
+ throw new Error('Claude dispatch requires text or an image')
+ }
+ return content
+}
+
+/**
+ * Keep waiter metadata bounded even when a dispatch contains large base64 images.
+ * The digest is only diagnostic: replay acknowledgement must use provider identity.
+ */
+export function claudeDispatchContentKey(content: readonly unknown[]): string {
+ const digest = createHash('sha256')
+ const summary = content
+ .map((part) => {
+ const record =
+ typeof part === 'object' && part !== null && !Array.isArray(part)
+ ? (part as Record)
+ : null
+ const type = typeof record?.type === 'string' ? record.type : 'unknown'
+ if (type === 'text') {
+ return `text:${typeof record?.text === 'string' ? record.text.length : 0}`
+ }
+ const source =
+ typeof record?.source === 'object' && record.source !== null
+ ? (record.source as Record)
+ : null
+ if (type === 'image' && source?.type === 'base64') {
+ return `image:${typeof source.media_type === 'string' ? source.media_type : ''}:${typeof source.data === 'string' ? source.data.length : 0}`
+ }
+ return type
+ })
+ .join(',')
+ for (const [index, part] of content.entries()) {
+ const record =
+ typeof part === 'object' && part !== null && !Array.isArray(part)
+ ? (part as Record)
+ : null
+ const type = typeof record?.type === 'string' ? record.type : 'unknown'
+ digest.update(`${index}:${type}:`)
+ if (type === 'text' && typeof record?.text === 'string') {
+ digest.update(record.text)
+ continue
+ }
+ const source =
+ typeof record?.source === 'object' && record.source !== null
+ ? (record.source as Record)
+ : null
+ if (type === 'image' && source?.type === 'base64') {
+ digest.update(typeof source.media_type === 'string' ? source.media_type : '')
+ digest.update(':')
+ if (typeof source.data === 'string') {
+ digest.update(source.data)
+ }
+ continue
+ }
+ digest.update(JSON.stringify(part))
+ }
+ const key = `v1:${summary.slice(0, 128)}:${digest.digest('hex')}`
+ return key.slice(0, MAX_REPLAY_CONTENT_KEY_BYTES)
+}
diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts
new file mode 100644
index 00000000000..4e8289a89e3
--- /dev/null
+++ b/src/main/claude/claude-structured-dispatch.test.ts
@@ -0,0 +1,598 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
+import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
+import { readClaudeImage } from './claude-structured-dispatch-content'
+import type { ClaudeSession } from './claude-structured-session-state'
+
+function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
+ return {
+ connection: { send } as unknown as ClaudeSession['connection'],
+ providerSessionId: 'provider-session',
+ claudeConfigDir: '/accounts/claude',
+ leafUuid: null,
+ fence: 1,
+ acquisitionGeneration: 'generation-1',
+ prompts: {} as ClaudeSession['prompts'],
+ dispatchWaiters: [],
+ retiredDispatchWaiters: [],
+ replayContentFallbackBlocked: false,
+ dispatchSequence: 0,
+ optionMutationSequence: 0,
+ options: new Map(),
+ reportedOptions: {},
+ reportedModelMutation: 0,
+ confirmedOptions: new Set(),
+ restoreSkippedOptions: new Set(),
+ capabilities: [],
+ events: undefined,
+ translator: null
+ }
+}
+
+function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
+ return { kind: 'message', role: 'user', blocks }
+}
+
+function userReplayFrame(uuid: string, text: string): Record {
+ return {
+ type: 'user',
+ parent_tool_use_id: null,
+ session_id: 'provider-session',
+ uuid,
+ message: { role: 'user', content: [{ type: 'text', text }] }
+ }
+}
+
+describe('Claude structured dispatch image limits', () => {
+ it('recovers the active identity when a timed-out replay arrives late', async () => {
+ const session = sessionFor()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+ await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
+
+ expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true)
+ expect(session.activeTurnId).toBe(sentUuid)
+ expect(session.activeTurnSequence).toBe(session.dispatchSequence)
+ })
+
+ it('never lets a late replay for dispatch A resolve dispatch B', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+
+ expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))).toBe(false)
+ expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
+ expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'))).toBe(true)
+ await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
+ })
+
+ it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const secondUuid = session.dispatchWaiters[0]!.sentUuid
+
+ expect(resolveClaudeReplayWaiter(session, userReplayFrame('provider-a', 'same prompt'))).toBe(
+ false
+ )
+ expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
+
+ resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'))
+ await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
+ })
+
+ it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+ const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid
+
+ const fillerDispatches = await Promise.all(
+ Array.from({ length: 64 }, (_, index) =>
+ dispatchClaudeTurn(
+ session,
+ {
+ clientMessageId: `filler-${index}`,
+ body: userMessage([{ type: 'text', text: 'same prompt' }])
+ },
+ 5
+ )
+ )
+ )
+ expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true)
+ expect(session.retiredDispatchWaiters).toHaveLength(64)
+ expect(session.replayContentFallbackBlocked).toBe(true)
+ expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe(
+ false
+ )
+
+ while (session.retiredDispatchWaiters.length > 0) {
+ const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid
+ resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid, 'same prompt'))
+ }
+ expect(session.retiredDispatchWaiters).toHaveLength(0)
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const secondUuid = session.dispatchWaiters[0]!.sentUuid
+
+ expect(
+ resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt'))
+ ).toBe(false)
+ expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
+
+ resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'))
+ await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
+ })
+
+ it('does not let a fresh-UUID result for an evicted slash dispatch resolve active dispatch B', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+ const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid
+
+ const fillerDispatches = await Promise.all(
+ Array.from({ length: 64 }, (_, index) =>
+ dispatchClaudeTurn(
+ session,
+ {
+ clientMessageId: `filler-${index}`,
+ body: userMessage([{ type: 'text', text: '/permissions' }])
+ },
+ 5
+ )
+ )
+ )
+ expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true)
+ expect(session.retiredDispatchWaiters).toHaveLength(64)
+ expect(session.replayContentFallbackBlocked).toBe(true)
+ expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe(
+ false
+ )
+
+ while (session.retiredDispatchWaiters.length > 0) {
+ const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'provider-session',
+ uuid: `result-${sentUuid}`,
+ user_message_uuid: sentUuid
+ })
+ ).toBe(false)
+ }
+ expect(session.retiredDispatchWaiters).toHaveLength(0)
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const secondUuid = session.dispatchWaiters[0]!.sentUuid
+
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'provider-session',
+ uuid: 'result-a-late'
+ })
+ ).toBe(false)
+ expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
+
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'provider-session',
+ uuid: 'result-b',
+ user_message_uuid: secondUuid
+ })
+ ).toBe(false)
+ await expect(second).resolves.toMatchObject({
+ providerIdentity: { uuid: 'result-b' }
+ })
+ })
+
+ it('does not let a legacy result for timed-out ordinary dispatch A resolve slash dispatch B', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'ordinary' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'provider-session',
+ uuid: 'legacy-result-a'
+ })
+ ).toBe(false)
+ await expect(second).resolves.toMatchObject({ state: 'unknown' })
+ })
+
+ it('removes only its own waiter when a later send fails', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const firstWaiter = session.dispatchWaiters[0]
+ session.connection.send = vi.fn().mockRejectedValue(new Error('broken pipe'))
+
+ await expect(
+ dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
+ 100
+ )
+ ).resolves.toMatchObject({ state: 'unknown', reason: 'broken pipe' })
+ expect(session.dispatchWaiters).toEqual([firstWaiter])
+
+ const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid
+ resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))
+ await expect(first).resolves.toMatchObject({ providerIdentity: { uuid: firstUuid } })
+ })
+
+ it('keeps a replay accepted before its send reports failure', async () => {
+ let session!: ClaudeSession
+ const send = vi.fn(async (message: Record) => {
+ resolveClaudeReplayWaiter(session, { ...message, uuid: 'turn-race' })
+ throw new Error('write raced provider acknowledgement')
+ })
+ session = sessionFor(send)
+
+ await expect(
+ dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
+ 100
+ )
+ ).resolves.toMatchObject({ state: 'accepted', providerIdentity: { uuid: 'turn-race' } })
+ expect(session.dispatchWaiters).toHaveLength(0)
+ })
+
+ it('accepts a slash command from its result receipt when Claude omits the user replay', async () => {
+ const session = sessionFor()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'provider-session',
+ uuid: 'command-result-uuid'
+ })
+ ).toBe(false)
+
+ await expect(dispatched).resolves.toEqual({
+ state: 'accepted',
+ providerIdentity: {
+ provider: 'claude',
+ sessionId: 'provider-session',
+ uuid: 'command-result-uuid'
+ }
+ })
+ })
+
+ it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => {
+ const session = sessionFor()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const secondUuid = session.dispatchWaiters[0]!.sentUuid
+
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'provider-session',
+ uuid: 'result-b',
+ user_message_uuid: secondUuid
+ })
+ ).toBe(false)
+ await expect(second).resolves.toMatchObject({
+ state: 'accepted',
+ providerIdentity: { uuid: 'result-b' }
+ })
+ })
+
+ it('does not mistake a normal turn result for its missing user replay', async () => {
+ const session = sessionFor()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'result',
+ session_id: 'provider-session',
+ uuid: 'unrelated-result-uuid'
+ })
+ ).toBe(false)
+ expect(session.dispatchWaiters).toHaveLength(1)
+ expect(
+ resolveClaudeReplayWaiter(session, {
+ type: 'user',
+ parent_tool_use_id: null,
+ session_id: 'provider-session',
+ uuid: 'user-replay-uuid',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: 'hello' }]
+ }
+ })
+ ).toBe(true)
+
+ await expect(dispatched).resolves.toMatchObject({
+ state: 'accepted',
+ providerIdentity: { uuid: 'user-replay-uuid' }
+ })
+ })
+
+ it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => {
+ const session = sessionFor()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+
+ resolveClaudeReplayWaiter(session, {
+ type: 'user',
+ parent_tool_use_id: null,
+ session_id: 'provider-session',
+ uuid: 'tool-result-uuid',
+ message: {
+ role: 'user',
+ content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }]
+ }
+ })
+ expect(session.dispatchWaiters).toHaveLength(1)
+
+ resolveClaudeReplayWaiter(session, {
+ type: 'user',
+ parent_tool_use_id: null,
+ session_id: 'provider-session',
+ uuid: 'user-replay-uuid',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: '/permissions' }]
+ }
+ })
+
+ await expect(dispatched).resolves.toEqual({
+ state: 'accepted',
+ providerIdentity: {
+ provider: 'claude',
+ sessionId: 'provider-session',
+ uuid: 'user-replay-uuid'
+ }
+ })
+ })
+
+ it('rejects more than twenty URL images before sending', async () => {
+ const session = sessionFor()
+ const body = userMessage(
+ Array.from({ length: 21 }, (_, index) => ({
+ type: 'image-ref' as const,
+ url: `https://example.test/${index}.png`
+ }))
+ )
+
+ await expect(
+ dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
+ ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' })
+ expect(session.connection.send).not.toHaveBeenCalled()
+ })
+
+ it('rejects local images whose aggregate size exceeds twenty MiB', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-'))
+ try {
+ const paths = await Promise.all(
+ Array.from({ length: 5 }, async (_, index) => {
+ const path = join(directory, `${index}.png`)
+ await writeFile(path, Buffer.alloc(5 * 1024 * 1024))
+ return path
+ })
+ )
+ const session = sessionFor()
+ const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path })))
+
+ await expect(
+ dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
+ ).resolves.toEqual({
+ state: 'rejected',
+ reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes`
+ })
+ expect(session.connection.send).not.toHaveBeenCalled()
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+ })
+
+ it('rejects a local image by actual bytes read beyond the per-image cap', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
+ try {
+ const path = join(directory, 'oversized.png')
+ await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1))
+ const session = sessionFor()
+ const body = userMessage([{ type: 'image-ref', path }])
+
+ await expect(
+ dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
+ ).resolves.toEqual({
+ state: 'rejected',
+ reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes`
+ })
+ expect(session.connection.send).not.toHaveBeenCalled()
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+ })
+
+ it('allocates local image reads from the file size, not the maximum cap', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
+ const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe')
+ try {
+ const path = join(directory, 'small.png')
+ await writeFile(path, Buffer.alloc(64))
+ const session = sessionFor()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'image-ref', path }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+ resolveClaudeReplayWaiter(session, {
+ ...userReplayFrame(sentUuid!, ''),
+ message: {
+ role: 'user',
+ content: [
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '' } }
+ ]
+ }
+ })
+ await expect(dispatched).resolves.toMatchObject({ state: 'accepted' })
+ expect(allocUnsafe).toHaveBeenCalled()
+ expect(allocUnsafe.mock.calls.some(([size]) => size === 64 + 1)).toBe(true)
+ expect(allocUnsafe.mock.calls.some(([size]) => size >= 5 * 1024 * 1024)).toBe(false)
+ } finally {
+ allocUnsafe.mockRestore()
+ await rm(directory, { recursive: true, force: true })
+ }
+ })
+
+ it('bounds retained waiter identity bytes when image dispatches time out', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
+ try {
+ const path = join(directory, 'large.png')
+ await writeFile(path, Buffer.alloc(64 * 1024))
+ const session = sessionFor()
+ const body = userMessage([{ type: 'image-ref', path }])
+ await Promise.all(
+ Array.from({ length: 64 }, (_, index) =>
+ dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }, 1)
+ )
+ )
+
+ expect(session.retiredDispatchWaiters).toHaveLength(64)
+ const retainedKeyBytes = session.retiredDispatchWaiters.reduce(
+ (total, waiter) => total + waiter.replayContentKey.length,
+ 0
+ )
+ expect(retainedKeyBytes).toBeLessThan(64 * 512)
+ expect(
+ session.retiredDispatchWaiters.every((waiter) => waiter.replayContentKey.length < 512)
+ ).toBe(true)
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+ })
+
+ it('rejects a local image when it grows after the initial stat', async () => {
+ const stat = vi
+ .fn()
+ .mockResolvedValueOnce({ isFile: () => true, size: 64 })
+ .mockResolvedValueOnce({ isFile: () => true, size: 128 })
+ const read = vi.fn(async (buffer: Buffer, offset: number) => {
+ if (read.mock.calls.length === 1) {
+ buffer.fill(1, offset, offset + 64)
+ return { bytesRead: 64, buffer }
+ }
+ return { bytesRead: 0, buffer }
+ })
+ const open = vi.fn().mockResolvedValue({
+ stat,
+ read,
+ close: vi.fn().mockResolvedValue(undefined)
+ } as never)
+ await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow(
+ `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes`
+ )
+ })
+})
diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts
new file mode 100644
index 00000000000..96271e41d71
--- /dev/null
+++ b/src/main/claude/claude-structured-dispatch.ts
@@ -0,0 +1,264 @@
+import { randomUUID } from 'node:crypto'
+import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
+import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
+import {
+ claudeHasReplayContent,
+ readClaudeMessageEnvelope
+} from './claude-structured-item-translation'
+import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state'
+import { readClaudeFrameString } from './claude-structured-init-proof'
+import {
+ claudeDispatchContentKey,
+ claudeDispatchMessageContent
+} from './claude-structured-dispatch-content'
+
+const MAX_RETIRED_DISPATCH_WAITERS = 64
+
+export function resolveClaudeReplayWaiter(
+ session: ClaudeSession,
+ message: Record
+): boolean {
+ const envelope = readClaudeMessageEnvelope(message)
+ const isUserReplay =
+ envelope?.role === 'user' &&
+ message.parent_tool_use_id === null &&
+ claudeHasReplayContent(envelope)
+ const isCompletedCommand = message.type === 'result'
+ if (
+ (!isUserReplay && !isCompletedCommand) ||
+ readClaudeFrameString(message, 'session_id') !== session.providerSessionId
+ ) {
+ return false
+ }
+ const uuid = readClaudeFrameString(message, 'uuid')
+ if (!uuid) {
+ return false
+ }
+
+ // Newer SDK frames carry the client uuid that caused a turn. A correlation
+ // value is authoritative: never fall back to queue order or content, since
+ // identical prompts may be in flight across a timeout boundary.
+ const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid')
+ if (userMessageUuid) {
+ const exact = session.dispatchWaiters.find(
+ (candidate) => candidate.sentUuid === userMessageUuid
+ )
+ if (exact) {
+ settleWaiter(session, exact, uuid)
+ return isUserReplay && exact.dispatchSequence === session.dispatchSequence
+ }
+ const retired = session.retiredDispatchWaiters.find(
+ (candidate) => candidate.sentUuid === userMessageUuid
+ )
+ if (retired) {
+ forgetRetiredWaiter(session, retired)
+ return recoverLateIdentity(session, retired, uuid, isUserReplay)
+ }
+ return false
+ }
+
+ const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
+ if (exact) {
+ settleWaiter(session, exact, uuid)
+ return isUserReplay && exact.dispatchSequence === session.dispatchSequence
+ }
+ const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
+ if (retired) {
+ forgetRetiredWaiter(session, retired)
+ return recoverLateIdentity(session, retired, uuid, isUserReplay)
+ }
+
+ if (isUserReplay) {
+ // Compatibility CLIs may mint a new replay uuid instead of echoing the
+ // client uuid. Content is an acceptable join only when it is the sole
+ // candidate on one side of the timeout boundary; with active and retired
+ // candidates present, identical prompts are intentionally left unknown.
+ const replayContentKey = claudeDispatchContentKey(envelope.content)
+ if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) {
+ const compatible = session.dispatchWaiters.filter(
+ (candidate) => candidate.replayContentKey === replayContentKey
+ )
+ if (compatible.length === 1) {
+ settleWaiter(session, compatible[0]!, uuid)
+ return compatible[0]!.dispatchSequence === session.dispatchSequence
+ }
+ } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) {
+ const lateCompatible = session.retiredDispatchWaiters.filter(
+ (candidate) => candidate.replayContentKey === replayContentKey
+ )
+ if (lateCompatible.length === 1) {
+ const [candidate] = lateCompatible
+ forgetRetiredWaiter(session, candidate!)
+ return recoverLateIdentity(session, candidate!, uuid, true)
+ }
+ }
+ return false
+ }
+ const current = session.dispatchWaiters[0]
+ if (isCompletedCommand && !current?.acceptsResult) {
+ return false
+ }
+ // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous,
+ // even when the retired dispatch was an ordinary turn rather than a slash command.
+ if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) {
+ return false
+ }
+ // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order.
+ if (isCompletedCommand && session.replayContentFallbackBlocked) {
+ return false
+ }
+ const waiter = uuid ? session.dispatchWaiters.shift() : undefined
+ if (waiter && uuid) {
+ clearTimeout(waiter.timer)
+ waiter.settledUuid = uuid
+ waiter.resolve(uuid)
+ return isUserReplay
+ }
+ return false
+}
+
+function settleWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string): void {
+ const index = session.dispatchWaiters.indexOf(waiter)
+ if (index !== -1) {
+ session.dispatchWaiters.splice(index, 1)
+ }
+ clearTimeout(waiter.timer)
+ waiter.settledUuid = uuid
+ waiter.resolve(uuid)
+}
+
+function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
+ const index = session.retiredDispatchWaiters.indexOf(waiter)
+ if (index !== -1) {
+ session.retiredDispatchWaiters.splice(index, 1)
+ }
+}
+
+function recoverLateIdentity(
+ session: ClaudeSession,
+ waiter: ClaudeDispatchWaiter,
+ uuid: string,
+ isUserReplay: boolean
+): boolean {
+ if (!isUserReplay && !waiter.acceptsResult) {
+ return false
+ }
+ if (waiter.dispatchSequence === session.dispatchSequence) {
+ session.activeTurnId = uuid
+ session.activeTurnSequence = waiter.dispatchSequence
+ }
+ return isUserReplay && waiter.dispatchSequence === session.dispatchSequence
+}
+
+function waitForReplay(
+ session: ClaudeSession,
+ timeoutMs: number,
+ acceptsResult: boolean,
+ sentUuid: string,
+ replayContentKey: string
+): { waiter: ClaudeDispatchWaiter; promise: Promise } {
+ let waiter!: ClaudeDispatchWaiter
+ const promise = new Promise((resolve) => {
+ waiter = {
+ acceptsResult,
+ sentUuid,
+ dispatchSequence: session.dispatchSequence,
+ replayContentKey,
+ resolve,
+ timer: setTimeout(() => {
+ const index = session.dispatchWaiters.indexOf(waiter)
+ if (index !== -1) {
+ session.dispatchWaiters.splice(index, 1)
+ }
+ retireWaiter(session, waiter)
+ resolve(null)
+ }, timeoutMs)
+ }
+ waiter.timer.unref?.()
+ session.dispatchWaiters.push(waiter)
+ })
+ return { waiter, promise }
+}
+
+function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
+ const index = session.dispatchWaiters.indexOf(waiter)
+ if (index !== -1) {
+ session.dispatchWaiters.splice(index, 1)
+ }
+ clearTimeout(waiter.timer)
+ if (!waiter.retired) {
+ waiter.retired = true
+ session.retiredDispatchWaiters.push(waiter)
+ if (session.retiredDispatchWaiters.length > MAX_RETIRED_DISPATCH_WAITERS) {
+ session.replayContentFallbackBlocked = true
+ session.retiredDispatchWaiters.splice(
+ 0,
+ session.retiredDispatchWaiters.length - MAX_RETIRED_DISPATCH_WAITERS
+ )
+ }
+ }
+}
+
+export async function dispatchClaudeTurn(
+ session: ClaudeSession,
+ input: { clientMessageId: string; body: AgentJournalMessageItem },
+ timeoutMs: number
+): Promise {
+ let content: unknown[]
+ try {
+ content = await claudeDispatchMessageContent(input.body)
+ } catch (error) {
+ return { state: 'rejected', reason: (error as Error).message }
+ }
+ const dispatchSequence = ++session.dispatchSequence
+ const acceptsResult = input.body.blocks.some(
+ (block) => block.type === 'text' && block.text.trimStart().startsWith('/')
+ )
+ const sentUuid = randomUUID()
+ const replay = waitForReplay(
+ session,
+ timeoutMs,
+ acceptsResult,
+ sentUuid,
+ claudeDispatchContentKey(content)
+ )
+ const replayed = replay.promise
+ try {
+ await session.connection.send({
+ type: 'user',
+ uuid: sentUuid,
+ message: { role: 'user', content },
+ parent_tool_use_id: null,
+ session_id: session.providerSessionId
+ })
+ } catch (error) {
+ const waiter = replay.waiter
+ if (waiter.settledUuid) {
+ const uuid = await replayed
+ if (uuid) {
+ session.activeTurnId = uuid
+ session.activeTurnSequence = dispatchSequence
+ return {
+ state: 'accepted',
+ providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
+ }
+ }
+ }
+ if (!waiter.retired) {
+ retireWaiter(session, waiter)
+ waiter.resolve(null)
+ }
+ return { state: 'unknown', reason: (error as Error).message }
+ }
+ const uuid = await replayed
+ if (uuid) {
+ session.activeTurnId = uuid
+ session.activeTurnSequence = dispatchSequence
+ }
+ return uuid
+ ? {
+ state: 'accepted',
+ providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
+ }
+ : { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' }
+}
diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts
new file mode 100644
index 00000000000..be022d86956
--- /dev/null
+++ b/src/main/claude/claude-structured-effort-reporting.test.ts
@@ -0,0 +1,257 @@
+import { describe, expect, it } from 'vitest'
+import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
+import {
+ restoreClaudeStructuredSessionOptions,
+ setClaudeStructuredOption
+} from './claude-structured-options'
+import { readClaudeSettingsEffort } from './claude-structured-session-options'
+import type { ClaudeSession } from './claude-structured-session-state'
+import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter'
+import { acquired, fakeClaude } from './claude-structured-session-test-support'
+
+/** Verbatim from Claude Code 2.1.258's get_settings response. */
+const REAL_SETTINGS = {
+ applied: { model: 'claude-opus-5[1m]', effort: 'high', advisor: null, ultracode: false },
+ effective: { model: 'claude-opus-5[1m]', effortLevel: 'high', env: {} },
+ sources: {}
+}
+
+function sessionWith(
+ reported: string | null,
+ calls: string[] = [],
+ listed?: { model: string; catalog: readonly Record[] }
+) {
+ return {
+ session: {
+ options: new Map(listed ? [['model', listed.model]] : []),
+ reportedOptions: {} as { model?: string; effort?: string },
+ optionMutationSequence: 0,
+ reportedModelMutation: 0,
+ confirmedOptions: new Set(),
+ restoreSkippedOptions: new Set(),
+ connection: {
+ supportedModels: async () => {
+ calls.push('list_models')
+ return [...(listed?.catalog ?? [])]
+ },
+ setModel: async (model: string) => {
+ calls.push(`set_model:${model}`)
+ },
+ applyFlagSettings: async (settings: { effortLevel?: string }) => {
+ // The measured behaviour: an unknown effort is accepted and ignored.
+ calls.push(`apply:${settings.effortLevel}`)
+ },
+ getSettings: async () => {
+ calls.push('get_settings')
+ return reported === null
+ ? { applied: {}, effective: {}, sources: {} }
+ : { applied: { effort: reported }, effective: { effortLevel: reported }, sources: {} }
+ }
+ }
+ } as unknown as ClaudeSession,
+ calls
+ }
+}
+
+describe('Claude effort reporting', () => {
+ it('reads the effort get_settings reports', () => {
+ expect(readClaudeSettingsEffort(REAL_SETTINGS)).toBe('high')
+ })
+
+ it.each([
+ [
+ 'the provider stops reporting it',
+ { applied: { effort: 'high' }, effective: {}, sources: {} }
+ ],
+ ['the payload carries no effective block', { applied: { effort: 'high' } }],
+ ['the request failed outright', null]
+ ])('reports no effort when %s', (_case, settings) => {
+ // Never defaulted: an effort nothing measured would be worse than a blank
+ // pill, and this is the assertion that goes red if the key is renamed.
+ expect(readClaudeSettingsEffort(settings)).toBeNull()
+ })
+
+ it('publishes the effort from get_settings, which system/init never carries', async () => {
+ const claude = fakeClaude({ settings: REAL_SETTINGS })
+ const adapter = await acquired(claude)
+
+ await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
+ current: { effort: 'high' }
+ })
+ })
+
+ it('leaves the effort unreported when the session never learns one', async () => {
+ const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} } })
+ const adapter = await acquired(claude)
+
+ const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(options.current.effort).toBeUndefined()
+ expect(options.current.model).toBeTruthy()
+ })
+
+ it('keeps the init fixture free of an effort the real frame never sends', async () => {
+ const events: ClaudeStructuredSessionEvent[] = []
+ await acquired(fakeClaude(), {}, events)
+ const init = events.flatMap((event) =>
+ event.type === 'message' && event.message.subtype === 'init' ? [event.message] : []
+ )
+
+ expect(init).toHaveLength(1)
+ expect(init[0]).toHaveProperty('model')
+ // The regression that hid this defect: a fixture inventing `effortLevel`
+ // kept every gate green over a value that is always empty in production.
+ expect(Object.keys(init[0])).not.toContain('effortLevel')
+ })
+})
+
+describe('Claude effort readback', () => {
+ it('records an effort the child did not adopt without vouching for it', async () => {
+ const { session, calls } = sessionWith('high')
+
+ // The disagreement stops the confirmation, not the write: no other client
+ // vetoes here, and the pre-flight catalog guard already refuses the levels
+ // the model cannot run.
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'bogus-effort-xyz' }, undefined)
+ ).resolves.toEqual({ effort: 'bogus-effort-xyz' })
+ expect(session.confirmedOptions.has('effort')).toBe(false)
+ // The child's own answer is kept rather than discarded with the refusal.
+ expect(session.reportedOptions.effort).toBe('high')
+ expect(calls).toEqual(['apply:bogus-effort-xyz', 'get_settings'])
+ })
+
+ it('records an effort the child confirms', async () => {
+ const { session } = sessionWith('low')
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
+ ).resolves.toEqual({ effort: 'low' })
+ })
+
+ it('records the request when the readback is unavailable', async () => {
+ // No evidence of a refusal is not evidence of one; the apply itself succeeded.
+ const { session } = sessionWith(null)
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
+ ).resolves.toEqual({ effort: 'low' })
+ })
+})
+
+describe('Claude effort against the model that must run it', () => {
+ const HAIKU = { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
+ const SONNET = {
+ value: 'sonnet',
+ resolvedModel: 'claude-sonnet-5',
+ displayName: 'Sonnet',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
+ }
+
+ it('refuses an effort the current model advertises no control for', async () => {
+ const { session, calls } = sessionWith('high', [], { model: 'haiku', catalog: [HAIKU, SONNET] })
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
+ ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
+ // Measured on Claude Code 2.1.260: apply_flag_settings stores `high` on a
+ // haiku session and get_settings reads it straight back, so a send here is
+ // never undone. The refusal has to land before the write.
+ expect(calls).toEqual(['list_models'])
+ expect(session.options.has('effort')).toBe(false)
+ })
+
+ it('refuses a level outside the ones the current model advertises', async () => {
+ const { session } = sessionWith('high', [], {
+ model: 'sonnet',
+ catalog: [{ ...SONNET, supportedEffortLevels: ['low', 'medium'] }]
+ })
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
+ ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
+ })
+
+ it('sends an effort the current model advertises', async () => {
+ const { session, calls } = sessionWith('high', [], {
+ model: 'sonnet',
+ catalog: [HAIKU, SONNET]
+ })
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
+ ).resolves.toEqual({ model: 'sonnet', effort: 'high' })
+ expect(calls).toEqual(['list_models', 'apply:high', 'get_settings'])
+ expect(session.confirmedOptions.has('effort')).toBe(true)
+ })
+
+ it('sends `max`, which the readback cannot report, when the model advertises it', async () => {
+ // UNREPORTED_EFFORTS still governs: no get_settings, so no false disagreement.
+ const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] })
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
+ ).resolves.toEqual({ model: 'sonnet', effort: 'max' })
+ expect(calls).toEqual(['list_models', 'apply:max'])
+ expect(session.confirmedOptions.has('effort')).toBe(false)
+ })
+
+ it('sends the effort when the model is not in the catalog the CLI listed', async () => {
+ // An unlisted model is an unknown one, not one that refuses effort.
+ const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU] })
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
+ ).resolves.toEqual({ model: 'sonnet', effort: 'high' })
+ expect(calls).toEqual(['list_models', 'apply:high', 'get_settings'])
+ })
+
+ it('sends the effort when list_models is unavailable', async () => {
+ const calls: string[] = []
+ const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [] })
+ session.connection.supportedModels = async () => {
+ calls.push('list_models')
+ throw new Error('this CLI predates list_models')
+ }
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
+ ).resolves.toEqual({ model: 'sonnet', effort: 'high' })
+ expect(calls).toEqual(['list_models', 'apply:high', 'get_settings'])
+ })
+
+ it('matches the model the init frame reported, not just the id the user picked', async () => {
+ const { session } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU, SONNET] })
+ session.options.delete('model')
+ session.reportedOptions.model = 'claude-haiku-4-5-20251001'
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
+ ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
+ })
+
+ it('keeps a disagreeing effort through restore instead of skipping it', async () => {
+ const calls: string[] = []
+ const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] })
+ session.options.set('effort', 'low')
+
+ await restoreClaudeStructuredSessionOptions(session, undefined)
+
+ expect(session.options.get('effort')).toBe('low')
+ expect(session.restoreSkippedOptions.has('effort')).toBe(false)
+ expect(session.confirmedOptions.has('effort')).toBe(false)
+ })
+
+ it('drops a stale effort on restore instead of replaying it onto the new model', async () => {
+ const calls: string[] = []
+ const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] })
+ session.options.set('model', 'haiku')
+ session.options.set('effort', 'high')
+
+ await restoreClaudeStructuredSessionOptions(session, undefined)
+
+ expect(session.options.has('effort')).toBe(false)
+ expect(session.restoreSkippedOptions.has('effort')).toBe(true)
+ expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([])
+ })
+})
diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts
new file mode 100644
index 00000000000..07be4bbb516
--- /dev/null
+++ b/src/main/claude/claude-structured-inbound-control.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk'
+import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
+import {
+ buildClaudePermissionCallbacks,
+ CLAUDE_BLOCKING_CONTROL_CALLBACKS,
+ CLAUDE_CAN_USE_TOOL_SUBTYPE,
+ CLAUDE_REQUEST_USER_DIALOG_SUBTYPE
+} from './claude-structured-inbound-control'
+
+type CanUseToolOptions = Parameters[2]
+
+function permissionOptions(
+ requestId: string,
+ toolUseID: string,
+ signal: AbortSignal,
+ suggestions?: unknown[]
+): CanUseToolOptions {
+ return {
+ requestId,
+ toolUseID,
+ signal,
+ ...(suggestions ? { suggestions } : {})
+ } as unknown as CanUseToolOptions
+}
+
+function callbacksFor() {
+ const prompts = new ClaudePromptRegistry()
+ const emit = vi.fn()
+ const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({
+ sessionId: 'session-1',
+ prompts,
+ emit
+ })
+ return { prompts, emit, canUseTool, onUserDialog }
+}
+
+describe('Claude permission callbacks', () => {
+ it('registers a decodable can_use_tool as a durable prompt and settles it from the registry', async () => {
+ const control = callbacksFor()
+ const answered = control.canUseTool(
+ 'Bash',
+ { command: 'git status' },
+ permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }])
+ )
+
+ expect(control.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'prompt',
+ sessionId: 'session-1',
+ prompt: expect.objectContaining({ promptKey: 'perm-1', toolName: 'Bash', kind: 'approval' })
+ })
+ )
+ const found = control.prompts.find('perm-1')
+ expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }])
+ // The prompt's settle is the SDK callback's own resolve — answering resolves this promise.
+ found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' })
+ await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' })
+ })
+
+ it('denies a malformed permission request without registering a prompt', async () => {
+ const control = callbacksFor()
+ const answered = control.canUseTool(
+ '',
+ {},
+ permissionOptions('perm-2', 'tool-2', new AbortController().signal)
+ )
+
+ await expect(answered).resolves.toEqual({
+ behavior: 'deny',
+ message: 'Orca could not decode this permission request.',
+ toolUseID: 'tool-2'
+ })
+ expect(control.prompts.find('perm-2')).toBeNull()
+ expect(control.emit).not.toHaveBeenCalled()
+ })
+
+ it('settles a pending prompt with null and forgets it when the abort signal fires', async () => {
+ const control = callbacksFor()
+ const controller = new AbortController()
+ const answered = control.canUseTool(
+ 'Bash',
+ { command: 'ls' },
+ permissionOptions('perm-3', 'tool-3', controller.signal)
+ )
+ expect(control.prompts.find('perm-3')).not.toBeNull()
+
+ controller.abort()
+
+ await expect(answered).resolves.toBeNull()
+ expect(control.emit).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-3' })
+ )
+ // Forgotten: a late answer can no longer find the prompt to authorize the wrong tool.
+ expect(control.prompts.find('perm-3')).toBeNull()
+ })
+
+ it('cancels a request whose abort raced ahead of delivery without emitting a prompt', async () => {
+ const control = callbacksFor()
+ const controller = new AbortController()
+ controller.abort()
+
+ const answered = control.canUseTool(
+ 'Bash',
+ { command: 'ls' },
+ permissionOptions('perm-4', 'tool-4', controller.signal)
+ )
+
+ await expect(answered).resolves.toBeNull()
+ expect(control.prompts.find('perm-4')).toBeNull()
+ expect(control.emit).toHaveBeenCalledTimes(1)
+ expect(control.emit).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-4' })
+ )
+ })
+
+ it('settles every in-flight prompt with null when the registry is cleared', async () => {
+ const control = callbacksFor()
+ const first = control.canUseTool(
+ 'Bash',
+ { command: 'a' },
+ permissionOptions('perm-5', 'tool-5', new AbortController().signal)
+ )
+ const second = control.canUseTool(
+ 'Bash',
+ { command: 'b' },
+ permissionOptions('perm-6', 'tool-6', new AbortController().signal)
+ )
+
+ // What session close does: settle each pending callback so no promise dangles.
+ for (const prompt of control.prompts.clear()) {
+ prompt.settle(null)
+ }
+
+ await expect(first).resolves.toBeNull()
+ await expect(second).resolves.toBeNull()
+ })
+
+ it('answers a user dialog deny-safe', async () => {
+ const control = callbacksFor()
+ await expect(
+ control.onUserDialog(
+ { dialogKind: 'refusal_fallback_prompt', payload: {} },
+ { signal: new AbortController().signal, requestId: 'dialog-1' }
+ )
+ ).resolves.toEqual({ behavior: 'cancelled' })
+ })
+
+ it('enumerates every blocking control request and wires a callback for each', () => {
+ // The stable surface of controls a turn can block on. Adding one here without wiring its
+ // callback below fails this test rather than silently leaving a control unhandled.
+ expect(new Set(Object.keys(CLAUDE_BLOCKING_CONTROL_CALLBACKS))).toEqual(
+ new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE])
+ )
+ const callbacks = buildClaudePermissionCallbacks({
+ sessionId: 'session-1',
+ prompts: new ClaudePromptRegistry(),
+ emit: vi.fn()
+ }) as unknown as Record
+ for (const callbackName of Object.values(CLAUDE_BLOCKING_CONTROL_CALLBACKS)) {
+ expect(typeof callbacks[callbackName], `${callbackName} must be wired`).toBe('function')
+ }
+ })
+})
diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts
new file mode 100644
index 00000000000..343e76d4ea5
--- /dev/null
+++ b/src/main/claude/claude-structured-inbound-control.ts
@@ -0,0 +1,91 @@
+import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk'
+import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
+import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
+
+export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool'
+export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog'
+
+/**
+ * The blocking control requests Orca answers, each mapped to the SDK consumer callback that
+ * answers it. This is the stable surface a real turn can block on: `can_use_tool` through
+ * `canUseTool` and `request_user_dialog` through `onUserDialog`. Every other control-request
+ * subtype the SDK routes (elicitation, oauth/host token refresh, mcp_message, hook_callback)
+ * is either not surfaced to this consumer or fails closed inside the SDK; adding a new
+ * blocking control Orca must answer means adding its callback here, and the catalog test
+ * fails if a named callback is missing.
+ */
+export const CLAUDE_BLOCKING_CONTROL_CALLBACKS = {
+ [CLAUDE_CAN_USE_TOOL_SUBTYPE]: 'canUseTool',
+ [CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]: 'onUserDialog'
+} as const
+
+export type ClaudeBlockingControlSubtype = keyof typeof CLAUDE_BLOCKING_CONTROL_CALLBACKS
+
+export type ClaudePermissionCallbackDeps = {
+ sessionId: string
+ prompts: ClaudePromptRegistry
+ emit: (event: ClaudeStructuredSessionEvent) => void
+}
+
+function denySafeResult(toolUseId: string | undefined): PermissionResult {
+ return {
+ behavior: 'deny',
+ message: 'Orca could not decode this permission request.',
+ ...(toolUseId ? { toolUseID: toolUseId } : {})
+ }
+}
+
+/**
+ * Build the SDK permission callbacks from the durable prompt registry.
+ *
+ * A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback;
+ * a malformed one is denied without registering. The SDK's abort signal fires on
+ * `control_cancel_request` (a cancelled turn), which forgets the prompt and settles it with
+ * `null` — never authorizing a tool. A late answer after abort finds no prompt and is refused
+ * by `answerClaudePrompt`. `onUserDialog` is deny-safe; the CLI only emits dialog kinds Orca
+ * declares in `supportedDialogKinds`, which is empty.
+ */
+export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDeps): {
+ canUseTool: CanUseTool
+ onUserDialog: OnUserDialog
+} {
+ const canUseTool: CanUseTool = (toolName, input, options) =>
+ new Promise((resolve) => {
+ const prompt = deps.prompts.register({
+ requestId: options.requestId,
+ toolName,
+ toolUseId: options.toolUseID,
+ input,
+ suggestions: options.suggestions ?? [],
+ settle: resolve as (response: Record | null) => void
+ })
+ if (!prompt) {
+ resolve(denySafeResult(options.toolUseID))
+ return
+ }
+ const cancel = (): void => {
+ if (deps.prompts.forgetIfPending(prompt)) {
+ deps.emit({
+ type: 'prompt-cancelled',
+ sessionId: deps.sessionId,
+ promptKey: prompt.promptKey
+ })
+ // Null is the SDK's "no response written" sentinel: a cancelled request must not
+ // be answered, only forgotten.
+ resolve(null)
+ }
+ }
+ if (options.signal.aborted) {
+ // No abort event can still fire, so registering a listener would park the callback
+ // forever behind a prompt nothing will answer.
+ cancel()
+ return
+ }
+ options.signal.addEventListener('abort', cancel, { once: true })
+ deps.emit({ type: 'prompt', sessionId: deps.sessionId, prompt })
+ })
+
+ const onUserDialog: OnUserDialog = () => Promise.resolve({ behavior: 'cancelled' })
+
+ return { canUseTool, onUserDialog }
+}
diff --git a/src/main/claude/claude-structured-init-deadline.ts b/src/main/claude/claude-structured-init-deadline.ts
new file mode 100644
index 00000000000..f3acd6c3af9
--- /dev/null
+++ b/src/main/claude/claude-structured-init-deadline.ts
@@ -0,0 +1,68 @@
+import type { ClaudeInitObservation } from './claude-structured-init-proof'
+import { claudeInitializationAuthError } from './claude-structured-init-proof'
+import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
+import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
+
+export type ClaudeInitDeadline = {
+ promise: Promise
+ resolve: (init: ClaudeInitObservation) => void
+ reject: (error: Error) => void
+ start: () => void
+ clear: () => void
+}
+
+export function claudeInitTimeoutError(
+ sessionId: string,
+ timeoutMs: number
+): AgentSessionAcquisitionRefusal {
+ return new AgentSessionAcquisitionRefusal(
+ `Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.`
+ )
+}
+
+export async function requestClaudeInitialization(
+ connection: ClaudeStreamJsonConnection,
+ sessionId: string,
+ timeoutMs: number
+): Promise {
+ try {
+ const result = await connection.initializationResult({ timeoutMs })
+ const authError = claudeInitializationAuthError(result)
+ if (authError) {
+ throw authError
+ }
+ return result
+ } catch (error) {
+ if (error instanceof Error && error.message === 'claude initialize request timed out') {
+ throw claudeInitTimeoutError(sessionId, timeoutMs)
+ }
+ throw error
+ }
+}
+
+export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline {
+ let resolve = (_init: ClaudeInitObservation): void => {}
+ let reject = (_error: Error): void => {}
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ void promise.catch(() => {})
+ let timer: ReturnType | null = null
+
+ return {
+ promise,
+ resolve,
+ reject,
+ start: () => {
+ timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs)
+ timer.unref?.()
+ },
+ clear: () => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+ }
+ }
+}
diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts
new file mode 100644
index 00000000000..c29cb2d4715
--- /dev/null
+++ b/src/main/claude/claude-structured-init-proof.ts
@@ -0,0 +1,88 @@
+import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
+import type { ClaudeAuthDiagnostic } from './claude-structured-session-state'
+import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
+
+export type ClaudeInitObservation = {
+ providerSessionId: string
+ uuid: string | null
+ /** The resolved model id the CLI reports it is running; only `system/init` carries it. */
+ model: string | null
+ message: Record
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+export function readClaudeFrameString(source: Record, key: string): string | null {
+ const value = source[key]
+ return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+export function readClaudeInit(message: Record): ClaudeInitObservation | null {
+ const hookName = readClaudeFrameString(message, 'hook_name')
+ const isInit = message.type === 'system' && message.subtype === 'init'
+ const isSessionStart =
+ message.type === 'system' &&
+ (message.subtype === 'hook_started' || message.subtype === 'hook_response') &&
+ hookName?.startsWith('SessionStart:') === true
+ if (!isInit && !isSessionStart) {
+ return null
+ }
+ const providerSessionId = readClaudeFrameString(message, 'session_id')
+ return providerSessionId
+ ? {
+ providerSessionId,
+ uuid: isInit ? readClaudeFrameString(message, 'uuid') : null,
+ model: isInit ? readClaudeFrameString(message, 'model') : null,
+ message
+ }
+ : null
+}
+
+export function readClaudeModels(initialization: unknown): unknown[] {
+ return isRecord(initialization) && Array.isArray(initialization.models)
+ ? initialization.models
+ : []
+}
+
+/** CLI capabilities advertised on the initialize result or the yielded system/init frame. */
+export function readClaudeCapabilities(
+ init: ClaudeInitObservation,
+ initialization: unknown
+): string[] {
+ const fromResult = isRecord(initialization) ? initialization.capabilities : undefined
+ const fromFrame = init.message.capabilities
+ const source = Array.isArray(fromResult) ? fromResult : Array.isArray(fromFrame) ? fromFrame : []
+ return source.filter((value): value is string => typeof value === 'string')
+}
+
+export function claudeInitializationAuthError(
+ initialization: unknown
+): AgentSessionAcquisitionRefusal | null {
+ const account =
+ isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null
+ return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none'
+ ? new AgentSessionAcquisitionRefusal(
+ 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.'
+ )
+ : null
+}
+
+export function claudeAuthDiagnostic(
+ init: ClaudeInitObservation,
+ settings: unknown
+): ClaudeAuthDiagnostic {
+ const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {}
+ const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource')
+ const configured = (key: string): boolean =>
+ (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) ||
+ Boolean(process.env[key]?.trim())
+ return {
+ apiKeySourceConfigured: apiKeySource !== null && apiKeySource !== 'none',
+ baseUrlConfigured: configured('ANTHROPIC_BASE_URL'),
+ authTokenConfigured: configured('ANTHROPIC_AUTH_TOKEN'),
+ apiKeyConfigured: configured('ANTHROPIC_API_KEY'),
+ settingSources: CLAUDE_DEFAULT_SETTING_SOURCES
+ }
+}
diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts
new file mode 100644
index 00000000000..d86093ee0a5
--- /dev/null
+++ b/src/main/claude/claude-structured-item-translation.ts
@@ -0,0 +1,179 @@
+import type {
+ AgentJournalItemBody,
+ AgentJournalItemIdentity,
+ AgentJournalMessageItem
+} from '../../shared/agent-session-journal-types'
+import type { NativeChatBlock } from '../../shared/native-chat-types'
+import {
+ boundInlineText,
+ DEFAULT_JOURNAL_PAYLOAD_LIMITS
+} from '../native-chat/agent-session-journal/journal-payload-bounds'
+
+export type ClaudeMessageEnvelope = {
+ sessionId: string
+ uuid: string
+ role: 'assistant' | 'user'
+ content: unknown[]
+ /** Messages API id shared by every frame of one streamed assistant message. */
+ messageId: string | null
+ parentToolUseId: string | null
+}
+
+export type ClaudeToolUse = { id: string; name: string; input: unknown }
+export type ClaudeToolResult = { toolUseId: string; output: string; failed: boolean }
+
+export function claudeRecord(value: unknown): Record | null {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+export function claudeText(value: unknown): string | null {
+ return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+export function readClaudeMessageEnvelope(
+ frame: Record
+): ClaudeMessageEnvelope | null {
+ if (frame.type !== 'assistant' && frame.type !== 'user') {
+ return null
+ }
+ const message = claudeRecord(frame.message)
+ const sessionId = claudeText(frame.session_id)
+ const uuid = claudeText(frame.uuid)
+ const role = message?.role
+ return sessionId && uuid && (role === 'assistant' || role === 'user')
+ ? {
+ sessionId,
+ uuid,
+ role,
+ content: messageContent(message?.content),
+ messageId: claudeText(message?.id),
+ parentToolUseId: claudeText(frame.parent_tool_use_id)
+ }
+ : null
+}
+
+// A user replay may carry its text as a bare string (MessageParam), not blocks.
+function messageContent(content: unknown): unknown[] {
+ if (Array.isArray(content)) {
+ return content
+ }
+ const text = claudeText(content)
+ return text ? [{ type: 'text', text }] : []
+}
+
+export function claudeMessageIdentity(
+ envelope: Pick
+): AgentJournalItemIdentity {
+ return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid }
+}
+
+function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] {
+ const blocks: NativeChatBlock[] = []
+ for (const value of envelope.content) {
+ const part = claudeRecord(value)
+ const text = claudeText(part?.text)
+ if (part?.type === 'text' && text) {
+ blocks.push({ type: 'text', text })
+ continue
+ }
+ const source = claudeRecord(part?.source)
+ const url = claudeText(source?.url)
+ if (part?.type === 'image' && source?.type === 'url' && url) {
+ blocks.push({ type: 'image-ref', url })
+ }
+ }
+ return blocks
+}
+
+export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournalMessageItem | null {
+ const blocks = messageBlocks(envelope)
+ return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null
+}
+
+export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean {
+ return envelope.content.some((value) => {
+ const part = claudeRecord(value)
+ return part !== null && part.type !== 'tool_result'
+ })
+}
+
+export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] {
+ return envelope.content.flatMap((value) => {
+ const part = claudeRecord(value)
+ const id = claudeText(part?.id)
+ const name = claudeText(part?.name)
+ return part?.type === 'tool_use' && id && name ? [{ id, name, input: part.input ?? null }] : []
+ })
+}
+
+function resultText(value: unknown): string {
+ if (typeof value === 'string') {
+ return value
+ }
+ if (!Array.isArray(value)) {
+ return value === undefined ? '' : JSON.stringify(value)
+ }
+ return value
+ .flatMap((entry) => {
+ if (typeof entry === 'string') {
+ return [entry]
+ }
+ const part = claudeRecord(entry)
+ return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : []
+ })
+ .join('\n')
+}
+
+export function claudeToolResults(envelope: ClaudeMessageEnvelope): ClaudeToolResult[] {
+ return envelope.content.flatMap((value) => {
+ const part = claudeRecord(value)
+ const toolUseId = claudeText(part?.tool_use_id)
+ return part?.type === 'tool_result' && toolUseId
+ ? [
+ {
+ toolUseId,
+ output: resultText(part.content),
+ failed: part.is_error === true
+ }
+ ]
+ : []
+ })
+}
+
+export function claudeThinkingText(envelope: ClaudeMessageEnvelope): string | null {
+ const parts = envelope.content.flatMap((value) => {
+ const part = claudeRecord(value)
+ const thinking = claudeText(part?.thinking)
+ return part?.type === 'thinking' && thinking ? [thinking] : []
+ })
+ return parts.length > 0 ? parts.join('\n') : null
+}
+
+export function claudeToolBody(input: {
+ tool: ClaudeToolUse
+ result?: ClaudeToolResult
+}): AgentJournalItemBody {
+ return {
+ kind: 'tool-call',
+ name: input.tool.name,
+ input: input.tool.input,
+ state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running',
+ ...(input.result
+ ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded }
+ : {})
+ }
+}
+
+export function claudeStreamingMessageBody(text: string): AgentJournalMessageItem {
+ return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
+}
+
+export function claudeToolIdentity(sessionId: string, toolUseId: string): AgentJournalItemIdentity {
+ return { provider: 'orca', clientMessageId: `claude-tool:${sessionId}:${toolUseId}` }
+}
+
+export function claudeThinkingIdentity(sessionId: string, uuid: string): AgentJournalItemIdentity {
+ return { provider: 'orca', clientMessageId: `claude-thinking:${sessionId}:${uuid}` }
+}
diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts
new file mode 100644
index 00000000000..f403313dae8
--- /dev/null
+++ b/src/main/claude/claude-structured-journal-translation.test.ts
@@ -0,0 +1,811 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ AgentJournalItemBody,
+ AgentJournalItemIdentity,
+ AgentJournalRenderItem,
+ AgentSessionJournalIdentity
+} from '../../shared/agent-session-journal-types'
+import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
+import { activeStructuredAgentSessionTurnId } from '../../shared/structured-agent-session-projection'
+import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
+import {
+ createDeferredStructuredAgentSessionEventSink,
+ type StructuredAgentSessionEventSink
+} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
+import {
+ boundInlineText,
+ DEFAULT_JOURNAL_PAYLOAD_LIMITS
+} from '../native-chat/agent-session-journal/journal-payload-bounds'
+import type { ClaudePendingPrompt } from './claude-structured-prompt-replies'
+import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
+
+function sinkState() {
+ const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
+ const tombstones: AgentJournalItemIdentity[] = []
+ const sink: StructuredAgentSessionEventSink = {
+ appendItem: (identity, body) => items.push({ identity, body }),
+ appendTombstone: (identity) => tombstones.push(identity),
+ publish: vi.fn()
+ }
+ return { sink, items, tombstones }
+}
+
+function message(
+ type: 'assistant' | 'user',
+ uuid: string,
+ content: unknown[],
+ parentToolUseId: string | null = null
+) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ ...(type === 'user' && parentToolUseId === null ? { startsTurn: true as const } : {}),
+ message: {
+ type,
+ uuid,
+ session_id: 'claude-session',
+ parent_tool_use_id: parentToolUseId,
+ message: { role: type, content }
+ }
+ }
+}
+
+// Frames below follow the Claude Code 2.1.258 / SDK 0.3.251 partial-message
+// cadence captured from the real CLI: every stream_event carries its own uuid,
+// the final assistant frame for a block carries yet another, and only
+// message.id ties them together.
+function streamEvent(uuid: string, event: Record) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ message: {
+ type: 'stream_event',
+ uuid,
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ event
+ }
+ }
+}
+
+function resultFrame(subtype: string, fields: Record) {
+ return {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ message: {
+ type: 'result',
+ subtype,
+ duration_ms: 1200,
+ duration_api_ms: 1100,
+ num_turns: 1,
+ session_id: 'claude-session',
+ uuid: `result-${subtype}`,
+ ...fields
+ }
+ }
+}
+
+/** One streamed text turn in wire order: message_start, the block's start frame,
+ * one delta per chunk, the block's final assistant frame, the stop frames and
+ * the success result. */
+function streamedTextTurn(input: {
+ messageId: string
+ startUuid: string
+ finalUuid: string
+ chunks: string[]
+}) {
+ const text = input.chunks.join('')
+ return {
+ start: [
+ streamEvent(`${input.messageId}-message-start`, {
+ type: 'message_start',
+ message: { id: input.messageId, role: 'assistant', content: [] }
+ }),
+ streamEvent(input.startUuid, {
+ type: 'content_block_start',
+ index: 0,
+ content_block: { type: 'text', text: '' }
+ })
+ ],
+ deltas: input.chunks.map((chunk, index) =>
+ streamEvent(`${input.messageId}-delta-${index}`, {
+ type: 'content_block_delta',
+ index: 0,
+ delta: { type: 'text_delta', text: chunk }
+ })
+ ),
+ final: {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ message: {
+ type: 'assistant',
+ uuid: input.finalUuid,
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ message: {
+ id: input.messageId,
+ role: 'assistant',
+ content: [{ type: 'text', text }],
+ stop_reason: null
+ }
+ }
+ },
+ stop: [
+ streamEvent(`${input.messageId}-block-stop`, { type: 'content_block_stop', index: 0 }),
+ streamEvent(`${input.messageId}-message-delta`, {
+ type: 'message_delta',
+ delta: { stop_reason: 'end_turn' }
+ }),
+ streamEvent(`${input.messageId}-message-stop`, { type: 'message_stop' }),
+ resultFrame('success', {
+ is_error: false,
+ result: text,
+ stop_reason: 'end_turn',
+ terminal_reason: 'completed'
+ })
+ ],
+ text
+ }
+}
+
+function assistantMessages(items: T[]): T[] {
+ return items.filter((item) => item.body.kind === 'message' && item.body.role === 'assistant')
+}
+
+function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
+ return items.flatMap((item) =>
+ item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
+ )
+}
+
+const JOURNAL_IDENTITY: AgentSessionJournalIdentity = {
+ sessionId: 'session-1',
+ workspaceId: 'workspace-1',
+ hostId: 'host-1',
+ agent: 'claude',
+ providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-1' }
+}
+
+let journalRoot = ''
+
+beforeEach(async () => {
+ journalRoot = await mkdtemp(join(tmpdir(), 'orca-claude-journal-translation-'))
+})
+
+afterEach(async () => {
+ await rm(journalRoot, { recursive: true, force: true })
+})
+
+describe('Claude structured journal translation', () => {
+ it('coalesces partial deltas onto the block identity and reconciles the final frame onto it', () => {
+ const state = sinkState()
+ let scheduled: (() => void) | null = null
+ const translator = createClaudeJournalTranslator({
+ sink: state.sink,
+ schedule: (run, delay) => {
+ expect(delay).toBe(60)
+ scheduled = run
+ return () => {
+ scheduled = null
+ }
+ }
+ })
+ const turn = streamedTextTurn({
+ messageId: 'msg_01',
+ startUuid: 'block-start-1',
+ finalUuid: 'assistant-final-1',
+ chunks: ['ST', 'REAMOK_ELEC_64E632']
+ })
+ const streamedIdentity = {
+ provider: 'claude',
+ sessionId: 'claude-session',
+ uuid: 'block-start-1'
+ }
+
+ for (const event of turn.start) {
+ translator.handle(event)
+ }
+ for (const delta of turn.deltas) {
+ translator.handle(delta)
+ }
+ expect(state.items).toEqual([])
+
+ const run = scheduled as (() => void) | null
+ run?.()
+ expect(state.items.at(-1)).toEqual({
+ identity: streamedIdentity,
+ body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] }
+ })
+
+ translator.handle(turn.final)
+ for (const event of turn.stop) {
+ translator.handle(event)
+ }
+ const assistant = assistantMessages(state.items)
+ expect(assistant.at(-1)).toEqual({
+ identity: streamedIdentity,
+ body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] }
+ })
+ expect(new Set(assistant.map((item) => agentJournalItemKey(item.identity))).size).toBe(1)
+ expect(providerFrameKinds(state.items)).toEqual([])
+ })
+
+ it('journals a count-to-200 stream as one assistant item carrying the complete reply', async () => {
+ const journal = await openAgentSessionJournal({
+ identity: JOURNAL_IDENTITY,
+ journalDir: journalRoot,
+ now: () => 1_700_000_000_000,
+ mintEpoch: () => 'epoch-1'
+ })
+ const deferred = createDeferredStructuredAgentSessionEventSink()
+ deferred.bind({ journal, fence: 1, publish: vi.fn() })
+ let scheduled: (() => void) | null = null
+ const translator = createClaudeJournalTranslator({
+ sink: deferred.sink,
+ schedule: (run) => {
+ scheduled = run
+ return () => {
+ scheduled = null
+ }
+ }
+ })
+ const numbers = Array.from({ length: 200 }, (_, index) => String(index + 1))
+ // The chunk boundaries the real CLI produced for this prompt.
+ const boundaries = [0, 1, 45, 93, 141, 189, 200]
+ const chunks = boundaries.slice(1).map((end, index) => {
+ const slice = numbers.slice(boundaries[index], end).join('\n')
+ return index === 0 ? slice : `\n${slice}`
+ })
+ const turn = streamedTextTurn({
+ messageId: 'msg_count',
+ startUuid: 'count-start',
+ finalUuid: 'count-final',
+ chunks
+ })
+
+ for (const event of turn.start) {
+ translator.handle(event)
+ }
+ for (const delta of turn.deltas) {
+ translator.handle(delta)
+ // Each chunk lands in its own coalescing window, as it did on the wire.
+ const run = scheduled as (() => void) | null
+ run?.()
+ }
+ translator.handle(turn.final)
+ for (const event of turn.stop) {
+ translator.handle(event)
+ }
+ await deferred.drained()
+
+ const items: AgentJournalRenderItem[] = journal.snapshot().items
+ const assistant = assistantMessages(items)
+ expect(assistant.map((item) => item.itemId)).toEqual(['claude:claude-session:count-start'])
+ expect(assistant[0]?.body).toEqual({
+ kind: 'message',
+ role: 'assistant',
+ blocks: [{ type: 'text', text: numbers.join('\n') }]
+ })
+ expect(providerFrameKinds(items)).toEqual([])
+ })
+
+ it('settles result frames, empty thinking and string user replays without painting a row', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ startsTurn: true,
+ message: {
+ type: 'user',
+ uuid: 'user-replay-1',
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ isReplay: true,
+ timestamp: '2026-09-01T00:00:00.000Z',
+ message: { role: 'user', content: 'Reply with exactly PROBE_OK_1 and nothing else.' }
+ }
+ })
+ translator.handle(
+ message('assistant', 'assistant-thinking-empty', [
+ { type: 'thinking', thinking: '', signature: 'CAQS6QcKEAgRGAI4AUIIdGhpbmtpbmc' }
+ ])
+ )
+ translator.handle(
+ resultFrame('success', {
+ is_error: false,
+ result: 'PROBE_OK_1',
+ stop_reason: 'end_turn',
+ terminal_reason: 'completed'
+ })
+ )
+ translator.handle(
+ message('user', 'user-interrupt', [{ type: 'text', text: '[Request interrupted by user]' }])
+ )
+ translator.handle(
+ resultFrame('error_during_execution', {
+ is_error: true,
+ errors: ['[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null'],
+ stop_reason: null,
+ terminal_reason: 'aborted_streaming',
+ permission_denials: []
+ })
+ )
+ translator.handle(message('user', 'control-only', []))
+
+ expect(providerFrameKinds(state.items)).toEqual([])
+ expect(
+ state.items.flatMap((item) =>
+ item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : []
+ )
+ ).toEqual([
+ [{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }],
+ [{ type: 'text', text: '[Request interrupted by user]' }]
+ ])
+ expect(
+ state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle)
+ ).toBe(false)
+ expect(
+ state.tombstones.flatMap((identity) =>
+ identity.provider === 'legacy' ? [identity.recordId] : []
+ )
+ ).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt'])
+ })
+
+ it('does not reopen a completed turn when the SDK replays its user row after restart', () => {
+ const live = sinkState()
+ const liveTranslator = createClaudeJournalTranslator({ sink: live.sink })
+ const replay = {
+ type: 'message' as const,
+ sessionId: 'orca-session',
+ message: {
+ type: 'user',
+ uuid: 'picker-command-1',
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ isReplay: true,
+ message: { role: 'user', content: '/model' }
+ }
+ }
+
+ liveTranslator.handle({ ...replay, startsTurn: true })
+ liveTranslator.handle(resultFrame('success', { is_error: false, result: '' }))
+ expect(live.tombstones).toContainEqual({
+ provider: 'legacy',
+ agent: 'claude',
+ sessionId: 'claude-session',
+ recordId: 'turn-lifecycle:picker-command-1'
+ })
+ liveTranslator.dispose()
+
+ const restarted = sinkState()
+ const restartedTranslator = createClaudeJournalTranslator({ sink: restarted.sink })
+ restartedTranslator.handle(replay)
+
+ expect(
+ activeStructuredAgentSessionTurnId(
+ restarted.items.map((item, sequence) => ({
+ itemId: agentJournalItemKey(item.identity),
+ revision: 1,
+ body: item.body,
+ sequence,
+ observedAt: sequence
+ }))
+ )
+ ).toBeNull()
+ })
+
+ it('surfaces an API error carried by a success-subtype result with no assistant frame', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(message('user', 'user-1', [{ type: 'text', text: 'summarize this' }]))
+ // The SDK models this as a SUCCESS-subtype result whose `result` string is the
+ // user-facing API error. Suppressing it as ordinary turn bookkeeping ends the
+ // turn with nothing shown at all.
+ translator.handle(
+ resultFrame('success', {
+ is_error: true,
+ result: 'API Error: 529 upstream overloaded',
+ stop_reason: null,
+ terminal_reason: 'api_error'
+ })
+ )
+
+ expect(providerFrameKinds(state.items)).toEqual(['message:result:success'])
+ expect(state.items.at(-1)?.body).toMatchObject({
+ kind: 'status',
+ text: 'API Error: 529 upstream overloaded'
+ })
+ // The turn still settles: the error is an extra row, not a stuck lifecycle.
+ expect(
+ state.tombstones.flatMap((identity) =>
+ identity.provider === 'legacy' ? [identity.recordId] : []
+ )
+ ).toEqual(['turn-lifecycle:user-1'])
+ })
+
+ it('drops the stream state of turns that ended without their final frame', () => {
+ const state = sinkState()
+ let scheduled: (() => void) | null = null
+ const translator = createClaudeJournalTranslator({
+ sink: state.sink,
+ schedule: (run) => {
+ scheduled = run
+ return () => {
+ scheduled = null
+ }
+ }
+ })
+ for (let turn = 0; turn < 3; turn += 1) {
+ const aborted = streamedTextTurn({
+ messageId: `msg_abort_${turn}`,
+ startUuid: `abort-start-${turn}`,
+ finalUuid: `abort-final-${turn}`,
+ chunks: ['x'.repeat(4_000)]
+ })
+ for (const event of [...aborted.start, ...aborted.deltas]) {
+ translator.handle(event)
+ }
+ const run = scheduled as (() => void) | null
+ run?.()
+ // The user interrupts: the result arrives with no final assistant frame,
+ // so nothing ever reconciles these blocks.
+ translator.handle(
+ resultFrame('error_during_execution', {
+ is_error: true,
+ terminal_reason: 'aborted_streaming'
+ })
+ )
+ // The partial text is already journaled; only the live state is dropped.
+ expect(translator.pendingStreamedBlocks).toBe(0)
+ }
+
+ expect(assistantMessages(state.items)).toHaveLength(3)
+ })
+
+ it('keeps an ordinary successful result off the timeline', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(resultFrame('success', { is_error: false, result: 'done', errors: [] }))
+
+ expect(providerFrameKinds(state.items)).toEqual([])
+ })
+
+ it('surfaces the reason an error-subtype result stopped the turn', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(
+ resultFrame('error_max_turns', { is_error: true, errors: ['turn limit reached'] })
+ )
+
+ expect(providerFrameKinds(state.items)).toEqual(['message:result:error_max_turns'])
+ })
+
+ it('keeps an unmodeled result subtype on the bounded provider fallback', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(
+ resultFrame('error_from_the_future', { is_error: true, errors: ['budget exhausted'] })
+ )
+
+ expect(providerFrameKinds(state.items)).toEqual(['message:result:error_from_the_future'])
+ })
+
+ it('journals turn lifecycle and updates one tool row through its result', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(message('user', 'user-1', [{ type: 'text', text: 'List files' }]))
+ translator.handle(
+ message('assistant', 'assistant-tool', [
+ { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } }
+ ])
+ )
+ translator.handle(
+ message(
+ 'user',
+ 'tool-result-1',
+ [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'a.ts\nb.ts' }],
+ 'tool-1'
+ )
+ )
+
+ const keyed = new Map(
+ state.items.map((item) => [agentJournalItemKey(item.identity), item.body])
+ )
+ expect(keyed.get('claude:claude-session:user-1')).toMatchObject({
+ kind: 'message',
+ role: 'user'
+ })
+ expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
+ kind: 'tool-call',
+ name: 'Bash',
+ state: 'completed',
+ output: { head: 'a.ts\nb.ts', truncated: false }
+ })
+ expect(
+ state.items.some(
+ (item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1'
+ )
+ ).toBe(true)
+
+ translator.handle(
+ message(
+ 'user',
+ 'tool-result-2',
+ [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done again' }],
+ 'tool-1'
+ )
+ )
+ expect(state.items.at(-1)?.body).toMatchObject({
+ kind: 'tool-call',
+ name: 'tool',
+ input: null,
+ output: { head: 'done again' }
+ })
+
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' }
+ })
+ expect(state.tombstones.at(-1)).toMatchObject({
+ provider: 'legacy',
+ agent: 'claude',
+ recordId: 'turn-lifecycle:user-1'
+ })
+ })
+
+ it('bounds persisted thinking text to the shared journal payload limit', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ const thinking = 'considering '.repeat(20_000)
+
+ translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }]))
+
+ expect(state.items.at(-1)?.body).toEqual({
+ kind: 'status',
+ text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
+ })
+ })
+
+ it('starts a cancellable lifecycle for image-only root user replays', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(
+ message('user', 'user-image', [
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }
+ ])
+ )
+
+ expect(state.items.at(-1)?.body).toEqual({
+ kind: 'status',
+ text: 'Claude is working…',
+ turnLifecycle: { turnId: 'user-image', state: 'running' }
+ })
+ })
+
+ it('does not start a lifecycle for a top-level user tool result', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(
+ message('user', 'tool-result-only', [
+ { type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }
+ ])
+ )
+
+ expect(state.items.map((item) => agentJournalItemKey(item.identity))).toEqual([
+ 'orca:claude-tool%3Aclaude-session%3Atool-1'
+ ])
+ expect(state.items[0]?.body).toMatchObject({
+ kind: 'tool-call',
+ state: 'completed',
+ output: { head: 'done' }
+ })
+ expect(
+ state.items.some(
+ (item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined
+ )
+ ).toBe(false)
+ })
+
+ it('paints nothing for a user frame that carries no content', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle(message('user', 'control-only', []))
+
+ expect(state.items).toEqual([])
+ expect(state.tombstones).toEqual([])
+ })
+
+ it('renders unmodeled substantive Claude frames as bounded provider rows', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'system', subtype: 'local_command_output', summary: 'x'.repeat(100_000) }
+ })
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' }
+ })
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'system', subtype: 'command_started', command: '/compact' }
+ })
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 }
+ })
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 }
+ })
+ translator.handle({
+ type: 'message',
+ sessionId: 'orca-session',
+ message: { type: 'prompt_suggestion', suggestion: '/compact' }
+ })
+ translator.handle(
+ message('user', 'attachment-1', [
+ { type: 'document', source: { type: 'base64', media_type: 'application/pdf' } }
+ ])
+ )
+ translator.handle({
+ type: 'provider-frame',
+ sessionId: 'orca-session',
+ kind: 'control_request:future_control',
+ payload: { subtype: 'future_control' }
+ })
+
+ const frames = state.items.flatMap((item) =>
+ item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : []
+ )
+ expect(frames.map((frame) => frame.kind)).toEqual(
+ expect.arrayContaining([
+ 'message:system:local_command_output',
+ 'message:system:command_started',
+ 'message:result',
+ 'message:user:content:document',
+ 'control_request:future_control'
+ ])
+ )
+ expect(frames.map((frame) => frame.kind)).not.toEqual(
+ expect.arrayContaining([
+ 'message:system:hook_response',
+ 'message:tool_progress',
+ 'message:prompt_suggestion'
+ ])
+ )
+ expect(
+ frames.find((frame) => frame.kind === 'message:system:local_command_output')?.payload
+ ).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) }))
+ })
+
+ it('preserves a question group as one addressable prompt and cancels it durably', () => {
+ const state = sinkState()
+ const bindings: unknown[][] = []
+ const translator = createClaudeJournalTranslator({
+ sink: state.sink,
+ bindPromptItemId: (...args) => bindings.push(args)
+ })
+ const approval = prompt({
+ requestId: 'permission-1',
+ promptKey: 'permission-1',
+ toolUseId: 'tool-1',
+ toolName: 'Bash',
+ kind: 'approval',
+ input: { command: 'git status' },
+ questionIds: []
+ })
+ translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval })
+ expect(state.items.at(-1)?.body).toMatchObject({
+ kind: 'approval',
+ title: 'Allow Bash?',
+ options: expect.arrayContaining([{ id: 'allow', label: 'Allow' }])
+ })
+ expect(bindings[0]).toEqual([
+ 'orca:claude-prompt%3Aorca-session%3Apermission-1',
+ 'permission-1'
+ ])
+
+ const questions = prompt({
+ requestId: 'questions-1',
+ promptKey: 'questions-1',
+ toolUseId: 'tool-q',
+ toolName: 'AskUserQuestion',
+ kind: 'question',
+ input: {
+ questions: [
+ { question: 'Library?', options: [{ label: 'Luxon' }] },
+ { question: 'Ship?', options: [{ label: 'Yes' }] }
+ ]
+ },
+ questionIds: ['Library?', 'Ship?']
+ })
+ translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions })
+ expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(1)
+ expect(state.items.at(-1)?.body).toMatchObject({
+ kind: 'question',
+ questions: [
+ { id: 'q1', question: 'Library?', multiSelect: false },
+ { id: 'q2', question: 'Ship?', multiSelect: false }
+ ]
+ })
+ expect(bindings.at(-1)).toEqual([
+ 'orca:claude-prompt%3Aorca-session%3Aquestions-1',
+ 'questions-1'
+ ])
+
+ const multiSelect = prompt({
+ requestId: 'questions-multi',
+ promptKey: 'questions-multi',
+ toolUseId: 'tool-multi',
+ toolName: 'AskUserQuestion',
+ kind: 'question',
+ input: {
+ questions: [
+ {
+ question: 'Libraries?',
+ multiSelect: true,
+ options: [{ label: 'Luxon' }, { label: 'Temporal' }]
+ }
+ ]
+ },
+ questionIds: ['Libraries?']
+ })
+ translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect })
+ expect(state.items.at(-1)?.body).toMatchObject({
+ kind: 'question',
+ question: '1 grouped question from Claude',
+ options: [],
+ questions: [
+ {
+ id: 'q1',
+ question: 'Libraries?',
+ multiSelect: true,
+ options: [{ label: 'Luxon' }, { label: 'Temporal' }],
+ freeTextQuestionId: 'q1'
+ }
+ ]
+ })
+
+ translator.handle({
+ type: 'prompt-cancelled',
+ sessionId: 'orca-session',
+ promptKey: 'questions-1'
+ })
+ expect(state.tombstones).toHaveLength(1)
+ })
+})
+
+function prompt(
+ input: Pick<
+ ClaudePendingPrompt,
+ 'requestId' | 'promptKey' | 'toolUseId' | 'toolName' | 'kind' | 'input' | 'questionIds'
+ >
+): ClaudePendingPrompt {
+ return {
+ ...input,
+ suggestions: [],
+ answers: new Map(),
+ settle: () => {}
+ }
+}
diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts
new file mode 100644
index 00000000000..ffaad4da570
--- /dev/null
+++ b/src/main/claude/claude-structured-journal-translation.ts
@@ -0,0 +1,287 @@
+import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
+import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
+import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
+import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
+import {
+ boundInlineText,
+ DEFAULT_JOURNAL_PAYLOAD_LIMITS
+} from '../native-chat/agent-session-journal/journal-payload-bounds'
+import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
+import {
+ claudeMessageBody,
+ claudeMessageIdentity,
+ claudeHasReplayContent,
+ claudeRecord,
+ claudeStreamingMessageBody,
+ claudeText,
+ claudeThinkingIdentity,
+ claudeThinkingText,
+ claudeToolBody,
+ claudeToolIdentity,
+ claudeToolResults,
+ claudeToolUses,
+ readClaudeMessageEnvelope,
+ type ClaudeToolUse
+} from './claude-structured-item-translation'
+import {
+ claudeApprovalItem,
+ claudePromptIdentity,
+ claudeQuestionItems
+} from './claude-structured-prompt-items'
+import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
+import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame'
+import {
+ CLAUDE_UNRENDERABLE_CONTENT_TEXT,
+ claudeProviderFrameKind,
+ claudeResultFailure,
+ createClaudeProviderFrameFallback,
+ isModeledClaudeContent,
+ isSettledClaudeResultKind
+} from './claude-structured-provider-fallback'
+import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
+import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
+
+export type ClaudeJournalTranslatorDeps = {
+ sink: StructuredAgentSessionEventSink
+ bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void
+ coalesceMs?: number
+ schedule?: AgentSessionDeltaCoalescerDeps['schedule']
+ fallbackIdPrefix?: string
+}
+
+export type ClaudeJournalTranslator = {
+ handle: (event: ClaudeStructuredSessionEvent) => void
+ flush: () => void
+ /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */
+ readonly pendingStreamedBlocks: number
+ dispose: () => void
+}
+
+export function createClaudeSessionJournalTranslator(
+ sink: StructuredAgentSessionEventSink | undefined,
+ prompts: ClaudePromptRegistry,
+ fallbackIdPrefix: string
+): ClaudeJournalTranslator | null {
+ return sink
+ ? createClaudeJournalTranslator({
+ sink,
+ fallbackIdPrefix,
+ bindPromptItemId: (itemId, promptKey, questionId) =>
+ prompts.bindJournalItemId(itemId, promptKey, questionId)
+ })
+ : null
+}
+
+function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity {
+ return {
+ provider: 'legacy',
+ agent: 'claude',
+ sessionId,
+ recordId: `turn-lifecycle:${turnId}`
+ }
+}
+
+export function createClaudeJournalTranslator(
+ deps: ClaudeJournalTranslatorDeps
+): ClaudeJournalTranslator {
+ const tools = new Map()
+ const promptItems = new Map()
+ const streamedBlocks = createClaudeStreamedBlockRegistry()
+ let currentTurn: { sessionId: string; turnId: string } | null = null
+ const providerFallback = createClaudeProviderFrameFallback(
+ deps.sink,
+ deps.fallbackIdPrefix ?? 'acquisition'
+ )
+ const streamedText = createClaudeStreamedTextCheckpoints({
+ ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }),
+ ...(deps.schedule ? { schedule: deps.schedule } : {}),
+ persist: (identity, text) => {
+ deps.sink.appendItem(identity, claudeStreamingMessageBody(text))
+ deps.sink.publish()
+ }
+ })
+
+ const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => {
+ const identity = lifecycleIdentity(sessionId, turnId)
+ if (running) {
+ deps.sink.appendItem(identity, {
+ kind: 'status',
+ text: 'Claude is working…',
+ turnLifecycle: { turnId, state: 'running' }
+ })
+ } else {
+ deps.sink.appendTombstone(identity)
+ }
+ deps.sink.publish()
+ }
+
+ const handleStream = (message: Record): boolean => {
+ const delta = streamedBlocks.observe(message)
+ if (!delta) {
+ return false
+ }
+ streamedText.append(delta.identity, delta.text)
+ return true
+ }
+
+ const handleMessage = (message: Record, startsTurn: boolean): boolean => {
+ const envelope = readClaudeMessageEnvelope(message)
+ if (!envelope) {
+ return false
+ }
+ let changed = false
+ const body = claudeMessageBody(envelope)
+ // The final frame of a streamed block lands on the block's identity, not its own uuid.
+ const identity =
+ (body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ??
+ claudeMessageIdentity(envelope)
+ streamedText.forget(agentJournalItemKey(identity))
+ if (body) {
+ deps.sink.appendItem(identity, body)
+ changed = true
+ }
+ for (const tool of claudeToolUses(envelope)) {
+ tools.set(tool.id, tool)
+ deps.sink.appendItem(
+ claudeToolIdentity(envelope.sessionId, tool.id),
+ claudeToolBody({ tool })
+ )
+ changed = true
+ }
+ for (const result of claudeToolResults(envelope)) {
+ const tool = tools.get(result.toolUseId) ?? {
+ id: result.toolUseId,
+ name: 'tool',
+ input: null
+ }
+ deps.sink.appendItem(
+ claudeToolIdentity(envelope.sessionId, result.toolUseId),
+ claudeToolBody({ tool, result })
+ )
+ // Tool inputs are only needed until their matching result arrives.
+ tools.delete(result.toolUseId)
+ changed = true
+ }
+ const thinking = claudeThinkingText(envelope)
+ if (thinking) {
+ deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
+ kind: 'status',
+ text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
+ })
+ changed = true
+ }
+ const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part))
+ for (const part of unhandledContent) {
+ const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
+ providerFallback.append(
+ `message:${envelope.role}:content:${partType}`,
+ part,
+ readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
+ )
+ changed = true
+ }
+ // An empty user frame is a replay with nothing to show, not an unknown kind.
+ if (envelope.content.length === 0 && envelope.role === 'assistant') {
+ providerFallback.append(`message:${envelope.role}:empty`, message)
+ changed = true
+ }
+ if (
+ envelope.role === 'user' &&
+ startsTurn &&
+ claudeHasReplayContent(envelope) &&
+ message.parent_tool_use_id === null
+ ) {
+ if (currentTurn) {
+ publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
+ }
+ currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
+ publishLifecycle(envelope.sessionId, envelope.uuid, true)
+ }
+ if (changed) {
+ deps.sink.publish()
+ }
+ return true
+ }
+
+ const handlePrompt = (event: Extract): void => {
+ const identities: AgentJournalItemIdentity[] = []
+ if (event.prompt.kind === 'question') {
+ for (const question of claudeQuestionItems({
+ sessionId: event.sessionId,
+ prompt: event.prompt
+ })) {
+ identities.push(question.identity)
+ deps.sink.appendItem(question.identity, question.body)
+ deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey)
+ }
+ } else {
+ const identity = claudePromptIdentity({
+ sessionId: event.sessionId,
+ promptKey: event.prompt.promptKey
+ })
+ identities.push(identity)
+ deps.sink.appendItem(identity, claudeApprovalItem(event.prompt))
+ deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey)
+ }
+ promptItems.set(event.prompt.promptKey, identities)
+ deps.sink.publish()
+ }
+
+ return {
+ handle: (event) => {
+ if (event.type === 'ended') {
+ streamedText.flush()
+ if (currentTurn) {
+ publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
+ currentTurn = null
+ }
+ return
+ }
+ if (event.type === 'message' && handleStream(event.message)) {
+ return
+ }
+ streamedText.flush()
+ if (event.type === 'prompt') {
+ handlePrompt(event)
+ } else if (event.type === 'prompt-cancelled') {
+ for (const identity of promptItems.get(event.promptKey) ?? []) {
+ deps.sink.appendTombstone(identity)
+ }
+ promptItems.delete(event.promptKey)
+ deps.sink.publish()
+ } else if (event.type === 'message' && event.message.type === 'result') {
+ if (currentTurn) {
+ publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
+ currentTurn = null
+ }
+ // The turn is over. A block still awaiting its final keeps the text the
+ // flush above journaled, but its live state goes: an interrupted turn
+ // would otherwise retain that text for the life of the session.
+ streamedBlocks.clear()
+ streamedText.settle()
+ const kind = claudeProviderFrameKind(event.message)
+ // Ordinary turn bookkeeping stays suppressed; a reported failure never does.
+ const failure = claudeResultFailure(event.message)
+ if (failure || !isSettledClaudeResultKind(kind)) {
+ providerFallback.append(kind, event.message, failure?.text)
+ }
+ } else if (event.type === 'message') {
+ if (!handleMessage(event.message, event.startsTurn === true)) {
+ providerFallback.append(claudeProviderFrameKind(event.message), event.message)
+ }
+ } else if (event.type === 'provider-frame') {
+ providerFallback.append(event.kind, event.payload)
+ }
+ },
+ flush: streamedText.flush,
+ get pendingStreamedBlocks() {
+ return streamedText.pending
+ },
+ dispose: () => {
+ streamedText.dispose()
+ tools.clear()
+ promptItems.clear()
+ streamedBlocks.clear()
+ }
+ }
+}
diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts
new file mode 100644
index 00000000000..650947cffa1
--- /dev/null
+++ b/src/main/claude/claude-structured-launch-resolution.test.ts
@@ -0,0 +1,392 @@
+import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { delimiter, join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { AgentSessionRecord } from '../../shared/agent-session-record'
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
+import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
+import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy'
+import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support'
+import {
+ CLAUDE_DEFAULT_SETTING_SOURCES,
+ CLAUDE_STRUCTURED_BASE_OPTIONS,
+ claudeSdkOptionsForLaunchArgs,
+ claudeSessionIdForOrcaSession,
+ createClaudeStructuredLaunchResolver
+} from './claude-structured-launch-resolution'
+
+const SESSION_ID = 'orca-session-1'
+const IDENTITY = { sessionId: SESSION_ID } as Parameters<
+ ReturnType
+>[0]['identity']
+
+function record(overrides: Partial = {}): AgentSessionRecord {
+ return {
+ sessionId: SESSION_ID,
+ provider: 'claude',
+ location: {
+ executionHostId: LOCAL_EXECUTION_HOST_ID,
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'folder'
+ },
+ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
+ providerHandleChain: [],
+ ...overrides
+ } as AgentSessionRecord
+}
+
+function identityAt(leafUuid: string | null): typeof IDENTITY {
+ return {
+ ...IDENTITY,
+ providerHandle: { kind: 'claude', sessionId: 'provider-current', leafUuid }
+ }
+}
+
+function makeExecutable(path: string): void {
+ mkdirSync(join(path, '..'), { recursive: true })
+ writeFileSync(path, '')
+ if (process.platform !== 'win32') {
+ chmodSync(path, 0o755)
+ }
+}
+
+function resolverFor(
+ value: AgentSessionRecord | null,
+ resolveEnv?: () => Record,
+ stripAuthEnv = false
+) {
+ return createClaudeStructuredLaunchResolver({
+ store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
+ resolveWorkspacePath: async (id) => `/repos/${id}`,
+ resolveCommand: () => '/usr/local/bin/claude',
+ resolveAuthPolicy: () => ({ stripAuthEnv }),
+ ...(resolveEnv ? { resolveEnv } : {})
+ })
+}
+
+function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') {
+ return {
+ id,
+ email: `${id}@example.com`,
+ managedAuthPath: `/managed/${id}`,
+ managedAuthRuntime,
+ authMethod: 'subscription-oauth' as const,
+ createdAt: 0,
+ updatedAt: 0,
+ lastAuthenticatedAt: 0
+ }
+}
+
+const HOST_SELECTED: ClaudeManagedAccountGateSettings = {
+ claudeManagedAccounts: [managedAccount('host-1', 'host')],
+ activeClaudeManagedAccountId: 'host-1',
+ activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} }
+}
+
+/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the
+ * prune drops the WSL account out of the host slot and persists that. */
+const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = {
+ claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')],
+ activeClaudeManagedAccountId: null,
+ activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } }
+}
+
+const RESUMABLE = record({
+ providerHandleChain: [
+ { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } }
+ ] as AgentSessionRecord['providerHandleChain']
+})
+
+describe('claude structured launch resolution', () => {
+ it('pre-mints a stable provider id and pins interactive setting sources', async () => {
+ const first = await resolverFor(record())({ identity: IDENTITY })
+ const second = await resolverFor(record())({ identity: IDENTITY })
+
+ expect(first.providerSessionId).toBe(claudeSessionIdForOrcaSession(SESSION_ID))
+ expect(second.providerSessionId).toBe(first.providerSessionId)
+ expect(first).toMatchObject({
+ pathToClaudeCodeExecutable: '/usr/local/bin/claude',
+ cwd: '/repos/workspace-1',
+ claudeConfigDir: '/home/work/.claude',
+ resumeLeafUuid: null,
+ resumed: false
+ })
+ expect(first.options).toEqual({
+ includePartialMessages: true,
+ settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES],
+ supportedDialogKinds: [],
+ extraArgs: { 'replay-user-messages': null },
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
+ sessionId: first.providerSessionId
+ })
+ expect(first.options.resume).toBeUndefined()
+ expect(CLAUDE_STRUCTURED_BASE_OPTIONS.includePartialMessages).toBe(true)
+ })
+
+ it('resumes the session and leaf at the durable chain head', async () => {
+ const launch = await resolverFor(
+ record({
+ providerHandleChain: [
+ { handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } },
+ {
+ handle: {
+ provider: 'claude',
+ sessionId: 'provider-current',
+ leafUuid: 'leaf-current'
+ }
+ }
+ ] as AgentSessionRecord['providerHandleChain']
+ })
+ )({ identity: identityAt('leaf-current') })
+
+ expect(launch).toMatchObject({
+ providerSessionId: 'provider-current',
+ resumeLeafUuid: 'leaf-current',
+ resumed: true
+ })
+ expect(launch.options.resume).toBe('provider-current')
+ expect(launch.options.resumeSessionAt).toBe('leaf-current')
+ expect(launch.options.sessionId).toBeUndefined()
+ })
+
+ it('refuses a durable journal leaf that diverged before resume resolution', async () => {
+ const resolve = resolverFor(
+ record({
+ providerHandleChain: [
+ {
+ handle: {
+ provider: 'claude',
+ sessionId: 'provider-current',
+ leafUuid: 'leaf-current'
+ }
+ }
+ ] as AgentSessionRecord['providerHandleChain']
+ })
+ )
+
+ await expect(resolve({ identity: identityAt('leaf-stale') })).rejects.toThrow(
+ 'durable resume identity changed before spawn'
+ )
+ })
+
+ it('keeps session-only resume when the durable handle has no leaf', async () => {
+ const launch = await resolverFor(
+ record({
+ providerHandleChain: [
+ {
+ handle: {
+ provider: 'claude',
+ sessionId: 'provider-current',
+ leafUuid: null
+ }
+ }
+ ] as AgentSessionRecord['providerHandleChain']
+ })
+ )({ identity: identityAt(null) })
+
+ expect(launch.options.resume).toBe('provider-current')
+ expect(launch.options.resumeSessionAt).toBeUndefined()
+ })
+
+ it('preserves durable Claude launch arguments as typed options and extraArgs', async () => {
+ const launch = await resolverFor(
+ record({
+ launchArgs: [
+ '--model',
+ 'claude-sonnet-4-5',
+ '--effort',
+ 'high',
+ '--dangerously-skip-permissions'
+ ]
+ })
+ )({ identity: IDENTITY })
+
+ expect(launch.options.model).toBe('claude-sonnet-4-5')
+ expect(launch.options.effort).toBe('high')
+ expect(launch.options.extraArgs).toEqual({
+ 'dangerously-skip-permissions': null,
+ 'replay-user-messages': null
+ })
+ })
+
+ it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => {
+ // The catalog's own output: each flag lands in exactly one place, so the SDK
+ // cannot emit it twice with two different values.
+ expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({
+ model: 'opus',
+ effort: 'xhigh'
+ })
+ // An effort the SDK's union does not name still reaches the CLI, unchanged.
+ expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({
+ extraArgs: { effort: 'ultra' }
+ })
+ expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({
+ extraArgs: { settings: '/tmp/s.json' }
+ })
+ expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/)
+ })
+
+ it('keeps the session launch environment pinned after account settings change', async () => {
+ const resolver = resolverFor(record(), () => ({
+ ANTHROPIC_AUTH_TOKEN: 'rotated-token',
+ ANTHROPIC_BASE_URL: 'https://gateway.example.test'
+ }))
+
+ expect((await resolver({ identity: IDENTITY })).env).toMatchObject({
+ ANTHROPIC_AUTH_TOKEN: 'rotated-token',
+ ANTHROPIC_BASE_URL: 'https://gateway.example.test'
+ })
+ expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token')
+ })
+
+ // Stripping is the managed-account rule the terminal preflight computes at
+ // runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers
+ // the system-auth half, where the user's own key has to survive.
+ it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => {
+ const restore = {
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
+ CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
+ ORCA_LAUNCH_RESOLUTION_MARKER: process.env.ORCA_LAUNCH_RESOLUTION_MARKER
+ }
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK'
+ process.env.ANTHROPIC_AUTH_TOKEN = 'tok-SHELL-LEAK'
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK'
+ process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited'
+ try {
+ const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY })
+
+ expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined()
+ expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
+ expect(launch.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
+ // The inherited env is still the base — only auth is removed from it.
+ expect(launch.env?.ORCA_LAUNCH_RESOLUTION_MARKER).toBe('inherited')
+ expect(launch.env?.PATH ?? launch.env?.Path).toBeTruthy()
+ } finally {
+ for (const [key, value] of Object.entries(restore)) {
+ if (value === undefined) {
+ delete process.env[key]
+ } else {
+ process.env[key] = value
+ }
+ }
+ }
+ })
+
+ it('lets an explicit Claude env overlay override ambient auth under system auth', async () => {
+ const restore = process.env.ANTHROPIC_API_KEY
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK'
+ try {
+ const launch = await resolverFor(record(), () => ({
+ ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED'
+ }))({ identity: IDENTITY })
+
+ expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED')
+ } finally {
+ if (restore === undefined) {
+ delete process.env.ANTHROPIC_API_KEY
+ } else {
+ process.env.ANTHROPIC_API_KEY = restore
+ }
+ }
+ })
+
+ it('pairs a resolved Claude CLI with its sibling Node runtime', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'orca-claude-launch-'))
+ const binDir = join(root, 'bin')
+ const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude')
+ const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')
+ makeExecutable(claudeCommand)
+ makeExecutable(nodeCommand)
+
+ const launch = await createClaudeStructuredLaunchResolver({
+ store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
+ resolveWorkspacePath: async (id) => `/repos/${id}`,
+ resolveCommand: () => claudeCommand,
+ resolveAuthPolicy: () => ({ stripAuthEnv: false }),
+ resolveEnv: () => ({
+ PATH: '/usr/bin',
+ CLAUDE_CONFIG_DIR: '/accounts/selected/home'
+ })
+ })({ identity: IDENTITY })
+
+ expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter)[0]).toBe(binDir)
+ })
+
+ it('refuses other hosts, WSL, providers, and account-home variables', async () => {
+ await expect(
+ resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({
+ identity: IDENTITY
+ })
+ ).rejects.toThrow(/local host/)
+ await expect(
+ resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({
+ identity: IDENTITY
+ })
+ ).rejects.toThrow(/local host/)
+ await expect(
+ resolverFor(record({ provider: 'codex' } as Partial))({
+ identity: IDENTITY
+ })
+ ).rejects.toThrow(/codex session/)
+ await expect(
+ resolverFor(record({ accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' } }))({
+ identity: IDENTITY
+ })
+ ).rejects.toThrow(/CLAUDE_CONFIG_DIR/)
+ })
+
+ /** The account state can change while a session lives, and a reacquire after an unexpected child
+ * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the
+ * account state has become. */
+ describe('managed-account gate on every acquisition', () => {
+ function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) {
+ return createClaudeStructuredLaunchResolver({
+ store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore,
+ resolveWorkspacePath: async (id) => `/repos/${id}`,
+ resolveCommand: () => '/usr/local/bin/claude',
+ // Derived, not a literal: the gate and the policy must read the SAME account state, so a
+ // hardcoded value could assert a pairing production cannot produce.
+ resolveAuthPolicy: () => {
+ const settings = read()
+ if (!settings) {
+ throw new Error('the gate refuses before the auth policy is computed')
+ }
+ return claudeStructuredAuthPolicyForSettings(settings)
+ },
+ readManagedAccountGate: read
+ })
+ }
+
+ it('refuses a reacquire once the account state becomes the refused shape', async () => {
+ let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED
+ const resolve = resolverWithGate(() => gate)
+
+ // Created while supported: the launch resolves and would spawn.
+ await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({
+ providerSessionId: 'provider-current'
+ })
+
+ gate = WSL_ONLY_NORMALIZED
+
+ // Reacquire after the account state changed: refused before anything spawns.
+ await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf(
+ AgentSessionPreSpawnError
+ )
+ })
+
+ it('fails closed when the account state cannot be read', async () => {
+ await expect(
+ resolverWithGate(() => null)({ identity: identityAt('leaf-current') })
+ ).rejects.toBeInstanceOf(AgentSessionPreSpawnError)
+ })
+
+ it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => {
+ await expect(
+ resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') })
+ ).resolves.toMatchObject({ providerSessionId: 'provider-current' })
+ })
+ })
+})
diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts
new file mode 100644
index 00000000000..4f28f14ad65
--- /dev/null
+++ b/src/main/claude/claude-structured-launch-resolution.ts
@@ -0,0 +1,273 @@
+import { createHash } from 'node:crypto'
+import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk'
+import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
+import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
+import {
+ CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
+ CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
+ applyClaudeEnvPatch,
+ hasClaudeAuthEnvConflict
+} from '../claude-accounts/environment'
+import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
+import {
+ CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS,
+ whenClaudeAuthSwitchSettles
+} from '../claude-accounts/live-pty-gate'
+import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
+import {
+ structuredClaudeMatchesActiveManagedAccount,
+ type ClaudeManagedAccountGateSettings
+} from '../native-chat/claude-structured-managed-account-support'
+import { resolveClaudeCommand } from '../codex-cli/command'
+import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
+
+export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const
+
+export type ClaudeStructuredSdkOptions = Pick<
+ ClaudeAgentSdkOptions,
+ | 'includePartialMessages'
+ | 'systemPrompt'
+ | 'settingSources'
+ | 'supportedDialogKinds'
+ | 'extraArgs'
+ | 'model'
+ | 'effort'
+ | 'sessionId'
+ | 'resume'
+ | 'resumeSessionAt'
+>
+
+/**
+ * The options translation of the flags this transport used to build by hand.
+ *
+ * `-p`, `--input-format`, `--output-format` and `--verbose` are implied by
+ * `query()`; `--permission-prompt-tool stdio` is emitted because a `canUseTool`
+ * callback is supplied. `--replay-user-messages` has no option — the SDK never
+ * emits it — and Orca's send acknowledgement depends on the replay.
+ */
+export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = {
+ includePartialMessages: true,
+ // Keep the SDK on Claude Code's own system-prompt contract.
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
+ settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES],
+ supportedDialogKinds: [],
+ extraArgs: { 'replay-user-messages': null }
+}
+
+const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
+
+function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record {
+ const next: Record = {}
+ for (const [key, value] of Object.entries(env)) {
+ if (value !== undefined) {
+ next[key] = value
+ }
+ }
+ return next
+}
+
+/**
+ * Translate the record's durable launch arguments into SDK options.
+ *
+ * Typed option first so a flag is never emitted twice; `extraArgs` carries
+ * anything without one. A token expressible neither way is refused rather than
+ * dropped — a silent drop is how this lane loses launch flags.
+ */
+export function claudeSdkOptionsForLaunchArgs(
+ args: readonly string[]
+): Pick {
+ let model: string | undefined
+ let effort: EffortLevel | undefined
+ const extraArgs: Record = {}
+ for (let index = 0; index < args.length; index += 1) {
+ const token = args[index] ?? ''
+ if (!token.startsWith('--') || token.length <= 2) {
+ throw new Error(
+ `claude launch argument ${token} has no SDK option; refusing rather than dropping it`
+ )
+ }
+ const equals = token.indexOf('=')
+ const flag = equals === -1 ? token : token.slice(0, equals)
+ let value = equals === -1 ? null : token.slice(equals + 1)
+ if (value === null) {
+ const next = args[index + 1]
+ if (next !== undefined && !next.startsWith('-')) {
+ value = next
+ index += 1
+ }
+ }
+ if (flag === '--model' && value !== null) {
+ model = value
+ } else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) {
+ effort = value as EffortLevel
+ } else {
+ extraArgs[flag.slice(2)] = value
+ }
+ }
+ return {
+ ...(model === undefined ? {} : { model }),
+ ...(effort === undefined ? {} : { effort }),
+ ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {})
+ }
+}
+
+export type ClaudeStructuredLaunch = {
+ /** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */
+ pathToClaudeCodeExecutable: string
+ options: ClaudeStructuredSdkOptions
+ cwd: string
+ env?: Record
+ claudeConfigDir: string
+ providerSessionId: string
+ resumeLeafUuid: string | null
+ resumed: boolean
+}
+
+export type ClaudeStructuredLaunchResolverDeps = {
+ store: AgentSessionRecordStore
+ resolveWorkspacePath: (workspaceId: string) => Promise
+ resolveCommand?: () => string
+ resolveEnv?: () =>
+ | Promise | undefined>
+ | Record
+ | undefined
+ /**
+ * Required, and deliberately not defaulted. `stripAuthEnv` used to be a literal
+ * `true` here, so a missing dependency could not under-strip. Now it can, and the
+ * failure is silent — so every caller states the account's policy rather than
+ * inherit a guess. Build it with claudeStructuredAuthPolicyForSettings.
+ */
+ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy
+ /** How long an in-flight account switch may hold a launch before it is refused. */
+ authSwitchSettleTimeoutMs?: number
+ /** Account state for the managed-account gate; null when it cannot be read, which refuses. */
+ readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null
+}
+
+/**
+ * Wait a running account switch out, and refuse only if it never settles.
+ *
+ * Launch resolution is reached from `acquireClaudeSession` *after* the old child has
+ * been closed and proved, so a plain refusal here would leave the user with a dead
+ * chat and no replacement — the very harm the acquire-entry guard exists to prevent.
+ * The entry guard still refuses outright, because nothing has been torn down yet.
+ */
+export async function assertClaudeAuthSwitchSettled(
+ timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS
+): Promise {
+ if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) {
+ throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
+ }
+}
+
+export function claudeSessionIdForOrcaSession(sessionId: string): string {
+ const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16)
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80
+ const hex = bytes.toString('hex')
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
+}
+
+export function createClaudeStructuredLaunchResolver(
+ deps: ClaudeStructuredLaunchResolverDeps
+): (input: { identity: AgentSessionJournalIdentity }) => Promise {
+ return async ({ identity }) => {
+ await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs)
+ const record = deps.store.getRecord(identity.sessionId)
+ if (!record) {
+ throw new Error(`no durable agent-session record for ${identity.sessionId}`)
+ }
+ if (record.provider !== 'claude') {
+ throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
+ }
+ if (
+ record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
+ record.location.wslDistro !== null
+ ) {
+ throw new Error(
+ `claude structured sessions run on the local host, not ${record.location.executionHostId}`
+ )
+ }
+ if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') {
+ throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`)
+ }
+ // Every acquisition, not just the first: the account state can change under a live session, and
+ // a reacquire after an unexpected exit would otherwise spawn under whatever it has become.
+ // Codex has no gate here — it resolves its account on a different path.
+ if (
+ deps.readManagedAccountGate &&
+ !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate())
+ ) {
+ throw new AgentSessionPreSpawnError(
+ 'structured Claude is not offered under the active managed Claude account'
+ )
+ }
+ const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
+ if (
+ head?.handle.provider === 'claude' &&
+ (identity.providerHandle.kind !== 'claude' ||
+ identity.providerHandle.sessionId !== head.handle.sessionId ||
+ identity.providerHandle.leafUuid !== head.handle.leafUuid)
+ ) {
+ throw new Error('claude durable resume identity changed before spawn')
+ }
+ const providerSessionId =
+ head?.handle.provider === 'claude'
+ ? head.handle.sessionId
+ : claudeSessionIdForOrcaSession(identity.sessionId)
+ const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? [])
+ const command = (deps.resolveCommand ?? resolveClaudeCommand)()
+ const auth = await deps.resolveAuthPolicy()
+ const overlay = await deps.resolveEnv?.()
+ // A switch can begin while the policy and overlay resolve, exactly as it can
+ // during the terminal preflight's prepareClaudeAuth — recheck after the awaits.
+ await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs)
+ // Under a managed account the pinned credential is the only auth this launch may
+ // use, so an explicit override is refused rather than silently beating the pin.
+ if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) {
+ throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
+ }
+ // Why the overlay merges onto the inherited env rather than replacing it: the child
+ // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath
+ // derives PATH from what it is handed. Ambient Anthropic auth is stripped from the
+ // inherited half only when a managed account owns the credential; a system-auth
+ // user's own key is their sign-in and must reach the child.
+ const env = withCliRuntimeOnPath(
+ command,
+ {
+ ...applyClaudeEnvPatch(
+ cloneDefinedEnv(process.env),
+ {},
+ {
+ stripAuthEnv: auth.stripAuthEnv,
+ platform: process.platform
+ }
+ ),
+ ...(overlay ? cloneDefinedEnv(overlay) : {})
+ },
+ { platform: process.platform }
+ )
+ return {
+ pathToClaudeCodeExecutable: command,
+ options: {
+ ...durable,
+ ...CLAUDE_STRUCTURED_BASE_OPTIONS,
+ extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs },
+ ...(head?.handle.provider === 'claude'
+ ? {
+ resume: providerSessionId,
+ ...(head.handle.leafUuid === null ? {} : { resumeSessionAt: head.handle.leafUuid })
+ }
+ : { sessionId: providerSessionId })
+ },
+ cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
+ env,
+ claudeConfigDir: record.accountHome.path,
+ providerSessionId,
+ resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null,
+ resumed: head?.handle.provider === 'claude'
+ }
+ }
+}
diff --git a/src/main/claude/claude-structured-location-support.test.ts b/src/main/claude/claude-structured-location-support.test.ts
new file mode 100644
index 00000000000..1106667d544
--- /dev/null
+++ b/src/main/claude/claude-structured-location-support.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ __setWindowsProcessTreeLoaderForTests,
+ resetWindowsProcessTableForTests
+} from '../windows/windows-process-table'
+import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
+
+function setPlatform(platform: NodeJS.Platform): PropertyDescriptor | undefined {
+ const previous = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: platform })
+ return previous
+}
+
+describe('supportsClaudeStructuredLocation', () => {
+ let previousPlatform: PropertyDescriptor | undefined
+
+ beforeEach(() => {
+ previousPlatform = setPlatform('darwin')
+ __setWindowsProcessTreeLoaderForTests()
+ })
+
+ afterEach(() => {
+ __setWindowsProcessTreeLoaderForTests()
+ resetWindowsProcessTableForTests()
+ if (previousPlatform) {
+ Object.defineProperty(process, 'platform', previousPlatform)
+ }
+ })
+
+ it('allows local non-WSL locations on macOS and Linux', () => {
+ expect(
+ supportsClaudeStructuredLocation({
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ })
+ ).toBe(true)
+ })
+
+ it('rejects Windows local locations until creation-time proof is available', () => {
+ previousPlatform = setPlatform('win32')
+ __setWindowsProcessTreeLoaderForTests(() => ({
+ ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
+ getAllProcesses: () => undefined
+ }))
+ expect(
+ supportsClaudeStructuredLocation({
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ })
+ ).toBe(false)
+ })
+
+ it('accepts Windows local locations once creation-time proof is available', () => {
+ previousPlatform = setPlatform('win32')
+ __setWindowsProcessTreeLoaderForTests(() => ({
+ ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
+ getAllProcesses: () => undefined
+ }))
+ expect(
+ supportsClaudeStructuredLocation({
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ })
+ ).toBe(true)
+ })
+
+ it('rejects WSL and remote locations', () => {
+ expect(
+ supportsClaudeStructuredLocation({
+ executionHostId: 'local',
+ wslDistro: 'Ubuntu',
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ })
+ ).toBe(false)
+ expect(
+ supportsClaudeStructuredLocation({
+ executionHostId: 'runtime:env-1',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/main/claude/claude-structured-location-support.ts b/src/main/claude/claude-structured-location-support.ts
new file mode 100644
index 00000000000..9c784a91325
--- /dev/null
+++ b/src/main/claude/claude-structured-location-support.ts
@@ -0,0 +1,11 @@
+import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
+
+export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean {
+ return (
+ location.executionHostId === LOCAL_EXECUTION_HOST_ID &&
+ location.wslDistro === null &&
+ (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable())
+ )
+}
diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts
new file mode 100644
index 00000000000..7bd8f629119
--- /dev/null
+++ b/src/main/claude/claude-structured-model-confirmation.test.ts
@@ -0,0 +1,209 @@
+import { describe, expect, it } from 'vitest'
+import { setClaudeStructuredOption } from './claude-structured-options'
+import type { ClaudeSession } from './claude-structured-session-state'
+import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support'
+
+/** Verbatim rows from Claude Code 2.1.258's list_models response. */
+const CATALOG = [
+ {
+ value: 'default',
+ resolvedModel: 'claude-opus-5[1m]',
+ displayName: 'Default (recommended)',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
+ },
+ {
+ value: 'sonnet',
+ resolvedModel: 'claude-sonnet-5',
+ displayName: 'Sonnet',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
+ },
+ { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
+]
+
+function initFrame(model: string): Record {
+ // Keys mirror the real per-turn system/init frame: it carries `model` as the
+ // resolved id, and no effort of any kind.
+ return {
+ type: 'system',
+ subtype: 'init',
+ session_id: PROVIDER_SESSION_ID,
+ uuid: 'turn-init-uuid',
+ model,
+ apiKeySource: 'none'
+ }
+}
+
+describe('Claude model confirmation', () => {
+ it('adopts the model a later turn reports when nothing was set since', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
+ current: { model: 'sonnet' }
+ })
+
+ // The CLI's own report of what it is running — the only channel that carries
+ // it, since set_model answers success for a model it never resolves.
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001'))
+
+ await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
+ current: { model: 'haiku' }
+ })
+ })
+
+ it('keeps a just-set model until the next turn reports one', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+
+ // No turn has run, so the acquisition-time report is older than the write and
+ // must not flip the pill back to the model the session started on.
+ await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
+ current: { model: 'haiku' }
+ })
+ })
+
+ it('corrects the record when the turn runs a different model than was set', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
+
+ await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
+ current: { model: 'sonnet' }
+ })
+ })
+
+ it('guards an effort against the model the turn reported, not the one that was set', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+ // set_model answered success for a model it never resolved; the turn runs sonnet.
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
+
+ // The picker offers sonnet's levels, so refusing one under haiku — a model the
+ // pill does not show and the child is not running — is the false positive.
+ await expect(
+ adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
+ ).resolves.toMatchObject({ effort: 'high' })
+ })
+
+ it('keeps guarding against the reported model across a second effort write', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
+ await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
+
+ // The effort write bumps the option fence but does not change what the child
+ // runs, so the sonnet report is still current and still governs the guard.
+ // `max` skips the settings readback by contract, so only the catalog gates it:
+ // sonnet advertises it, haiku advertises no effort control at all.
+ await expect(
+ adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 })
+ ).resolves.toMatchObject({ effort: 'max' })
+ })
+
+ it('guards an effort against a just-set model no turn has reported yet', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+
+ // The acquisition-time report predates the write, so haiku — which advertises
+ // no effort control — is still the model the guard must answer for.
+ await expect(
+ adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
+ ).rejects.toThrow('claude model haiku does not accept effort high')
+ })
+
+ it('stops vouching for a confirmed effort once the model changes', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
+ await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
+ current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] }
+ })
+
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+
+ // The readback was taken under sonnet; nothing has reported haiku holding it.
+ const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(options.current.effort).toBe('high')
+ expect(options.current.confirmed).toBeUndefined()
+ })
+})
+
+describe('Claude effort the settings readback cannot report', () => {
+ function sessionWith(
+ reported: string,
+ calls: string[] = []
+ ): { session: ClaudeSession; calls: string[] } {
+ return {
+ session: {
+ options: new Map([['model', 'sonnet']]),
+ reportedOptions: {},
+ optionMutationSequence: 0,
+ confirmedOptions: new Set(),
+ connection: {
+ supportedModels: async () => {
+ calls.push('list_models')
+ return CATALOG
+ },
+ applyFlagSettings: async (settings: { effortLevel?: string }) => {
+ calls.push(`apply:${settings.effortLevel}`)
+ },
+ getSettings: async () => {
+ calls.push('get_settings')
+ return {
+ applied: { effort: reported },
+ effective: { effortLevel: reported },
+ sources: {}
+ }
+ }
+ }
+ } as unknown as ClaudeSession,
+ calls
+ }
+ }
+
+ it('records a session-scoped effort the persisted settings never carry', async () => {
+ // `max` applies for the session and is deliberately excluded from the
+ // persisted effortLevel, so the readback reporting `high` is an absence of
+ // evidence, not a refusal — and the CLI offers `max` in its own catalog.
+ const { session, calls } = sessionWith('high')
+
+ await expect(
+ setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
+ ).resolves.toEqual({ model: 'sonnet', effort: 'max' })
+ expect(calls).toEqual(['list_models', 'apply:max'])
+ })
+})
diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts
new file mode 100644
index 00000000000..ca7b8b70f1c
--- /dev/null
+++ b/src/main/claude/claude-structured-option-confirmation.test.ts
@@ -0,0 +1,193 @@
+import { describe, expect, it } from 'vitest'
+import {
+ applyStructuredAgentSessionOptions,
+ createStructuredAgentSessionOptionState,
+ structuredAgentSessionOptionSnapshot
+} from '../../shared/structured-agent-session-options'
+import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex'
+import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire'
+import type { SessionOptionDescriptor } from '../../shared/native-chat-session-options'
+import { setClaudeStructuredOption } from './claude-structured-options'
+import type { ClaudeSession } from './claude-structured-session-state'
+import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support'
+
+/** Verbatim rows from Claude Code 2.1.260's list_models response: `haiku` really
+ * does omit both effort keys, which is what makes an effort under it refusable. */
+const CATALOG = [
+ {
+ value: 'sonnet',
+ resolvedModel: 'claude-sonnet-5',
+ displayName: 'Sonnet',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
+ },
+ { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
+]
+
+function initFrame(model: string): Record {
+ return {
+ type: 'system',
+ subtype: 'init',
+ session_id: PROVIDER_SESSION_ID,
+ uuid: 'turn-init-uuid',
+ model,
+ apiKeySource: 'none'
+ }
+}
+
+function modelPill(result: AgentSessionOptionsResult): SessionOptionDescriptor | undefined {
+ const state = applyStructuredAgentSessionOptions(
+ createStructuredAgentSessionOptionState('claude'),
+ CLAUDE_SESSION_OPTION_CATALOG,
+ result
+ )
+ return structuredAgentSessionOptionSnapshot(state).find((d) => d.category === 'model')
+}
+
+/** Provenance the record keeps. Nothing renders it — the pill shows the value
+ * either way, and a report that disagrees is what corrects it. */
+function modelSource(result: AgentSessionOptionsResult): string | undefined {
+ return modelPill(result)?.valueSource
+}
+
+function modelValue(result: AgentSessionOptionsResult): string | undefined {
+ const kind = modelPill(result)?.kind
+ return kind?.type === 'select' ? kind.currentValue : undefined
+}
+
+describe('structured option confirmation reaches the pill', () => {
+ it('shows a just-set model before any turn reports it', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+
+ const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(result.current.confirmed ?? []).not.toContain('model')
+ expect(modelSource(result)).toBe('dispatched')
+ })
+
+ it('marks the model reported once the provider names it back', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001'))
+
+ const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(result.current.confirmed).toContain('model')
+ expect(modelSource(result)).toBe('reported')
+ })
+
+ it('records an effort the readback could not take without confirming it', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ settings: { applied: {}, effective: {}, sources: {} },
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+ // `max` is session-scoped and absent from the persisted settings, so it records
+ // without a readback — recorded, never vouched for.
+ await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 })
+
+ const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(result.current.effort).toBe('max')
+ expect(result.current.confirmed ?? []).not.toContain('effort')
+ })
+
+ it('confirms an effort the readback agreed with', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ settings: { applied: { effort: 'low' }, effective: { effortLevel: 'low' }, sources: {} },
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+ await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'low', fence: 7 })
+
+ const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(result.current.confirmed).toContain('effort')
+ })
+
+ it('treats a host that reports no confirmation as unconfirmed', () => {
+ // Wire compatibility: an older host omits `confirmed` entirely. Absence must
+ // read as unconfirmed provenance, and the pill still shows the host's value.
+ const result = {
+ models: [{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }],
+ current: { model: 'haiku' }
+ }
+ expect(modelSource(result)).toBe('dispatched')
+ expect(modelValue(result)).toBe('haiku')
+ })
+})
+
+describe('the provider report corrects the pill', () => {
+ it('moves the pill to the model the turn actually ran', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+ expect(modelValue(await adapter.readOptions({ sessionId: 'session-1', fence: 7 }))).toBe(
+ 'haiku'
+ )
+
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
+
+ const corrected = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(modelValue(corrected)).toBe('sonnet')
+ expect(corrected.current.confirmed).toContain('model')
+ })
+
+ it('lets a newer write outrank the report it precedes', async () => {
+ const claude = fakeClaude({
+ initModel: 'claude-sonnet-5',
+ routes: { list_models: () => CATALOG }
+ })
+ const adapter = await acquired(claude)
+ claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
+ await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
+
+ const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
+ expect(modelValue(result)).toBe('haiku')
+ expect(result.current.confirmed ?? []).not.toContain('model')
+ })
+})
+
+describe('confirmation never outlives the write it belongs to', () => {
+ it('drops an earlier effort confirmation when the value changes', async () => {
+ const calls: string[] = []
+ let reported = 'low'
+ const session = {
+ options: new Map([['model', 'sonnet']]),
+ reportedOptions: {},
+ optionMutationSequence: 0,
+ confirmedOptions: new Set(),
+ connection: {
+ supportedModels: async () => CATALOG,
+ applyFlagSettings: async (s: { effortLevel?: string }) => {
+ calls.push(`apply:${s.effortLevel}`)
+ },
+ getSettings: async () => ({
+ applied: { effort: reported },
+ effective: { effortLevel: reported },
+ sources: {}
+ })
+ }
+ } as unknown as ClaudeSession
+
+ await setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
+ expect(session.confirmedOptions.has('effort')).toBe(true)
+
+ // The provider now reports a level it cannot represent; the stale confirmation
+ // must not survive into the new value.
+ await setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
+ expect(session.options.get('effort')).toBe('max')
+ expect(session.confirmedOptions.has('effort')).toBe(false)
+ expect(calls).toEqual(['apply:low', 'apply:max'])
+ })
+})
diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts
new file mode 100644
index 00000000000..bc18a589e10
--- /dev/null
+++ b/src/main/claude/claude-structured-options.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from 'vitest'
+import { setClaudeStructuredOption } from './claude-structured-options'
+import type { ClaudeSession } from './claude-structured-session-state'
+
+function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
+ return {
+ connection: { setModel } as ClaudeSession['connection'],
+ providerSessionId: 'provider-session',
+ claudeConfigDir: '/accounts/claude',
+ leafUuid: null,
+ fence: 1,
+ acquisitionGeneration: 'generation-1',
+ prompts: {} as ClaudeSession['prompts'],
+ dispatchWaiters: [],
+ retiredDispatchWaiters: [],
+ replayContentFallbackBlocked: false,
+ dispatchSequence: 0,
+ optionMutationSequence: 0,
+ options: new Map(),
+ reportedOptions: {},
+ reportedModelMutation: 0,
+ confirmedOptions: new Set(),
+ restoreSkippedOptions: new Set(),
+ capabilities: [],
+ events: undefined,
+ translator: null
+ }
+}
+
+describe('Claude structured option mutation fencing', () => {
+ it('does not let a delayed earlier apply overwrite a later option', async () => {
+ let releaseFirst!: () => void
+ const firstApply = new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ const setModel = vi
+ .fn()
+ .mockReturnValueOnce(firstApply)
+ .mockResolvedValue(undefined)
+ const session = sessionFor(setModel)
+
+ const first = setClaudeStructuredOption(session, { key: 'model', value: 'old' }, undefined)
+ await vi.waitFor(() => expect(setModel).toHaveBeenCalledTimes(1))
+ const second = setClaudeStructuredOption(session, { key: 'model', value: 'new' }, undefined)
+ await expect(second).resolves.toEqual({ model: 'new' })
+
+ releaseFirst()
+ await expect(first).resolves.toEqual({ model: 'new' })
+ expect(session.options).toEqual(new Map([['model', 'new']]))
+ })
+})
diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts
new file mode 100644
index 00000000000..3d1377b12c6
--- /dev/null
+++ b/src/main/claude/claude-structured-options.ts
@@ -0,0 +1,147 @@
+import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'
+import { ClaudeControlRequestError } from './claude-stream-json-connection'
+import {
+ AgentSessionOptionRejectedError,
+ isAgentSessionOptionRejectedError
+} from '../native-chat/agent-session-wire/structured-agent-session-option-error'
+import {
+ readClaudeCurrentModel,
+ readClaudeModelEffortLevels,
+ readClaudeSettingsEffort
+} from './claude-structured-session-options'
+import type { ClaudeSession } from './claude-structured-session-state'
+
+const OPTION_ORDER = ['model', 'effort', 'permissionMode'] as const
+
+/**
+ * Efforts the settings readback cannot report. `max` applies for the rest of the
+ * session and is excluded from the persisted `effortLevel` by contract, so
+ * `get_settings` answers with the level underneath it — an absence of evidence
+ * that must not be read as the child refusing a level its own catalog offers.
+ */
+const UNREPORTED_EFFORTS: ReadonlySet = new Set(['max'])
+
+export function restoredClaudeStructuredSessionOptions(
+ options: Readonly> | undefined
+): Map {
+ return new Map(
+ OPTION_ORDER.flatMap((key) => {
+ const value = options?.[key]
+ return value ? [[key, value] as const] : []
+ })
+ )
+}
+
+export async function setClaudeStructuredOption(
+ session: ClaudeSession,
+ input: { key: string; value: string },
+ timeoutMs: number | undefined
+): Promise>> {
+ const apply =
+ input.key === 'model'
+ ? () => session.connection.setModel(input.value, { timeoutMs })
+ : input.key === 'permissionMode'
+ ? () => session.connection.setPermissionMode(input.value as PermissionMode, { timeoutMs })
+ : input.key === 'effort'
+ ? () =>
+ session.connection.applyFlagSettings(
+ { effortLevel: input.value as EffortLevel },
+ { timeoutMs }
+ )
+ : null
+ if (!apply) {
+ throw new AgentSessionOptionRejectedError(
+ `claude stream-json has no session option named ${input.key}`
+ )
+ }
+ // The child stores an effort its model has no control for and keeps it across
+ // every later model switch and restore, so refuse before the write rather than
+ // read the acceptance back as adoption. Refused here, restore drops the stale
+ // value instead of replaying it onto a model that cannot use it.
+ if (input.key === 'effort') {
+ const { modelId, levels } = await readClaudeModelEffortLevels(session, timeoutMs)
+ if (levels && !levels.has(input.value)) {
+ throw new AgentSessionOptionRejectedError(
+ `claude model ${modelId} does not accept effort ${input.value}`
+ )
+ }
+ }
+ const modelWasConfirmed = readClaudeCurrentModel(session).confirmed
+ const mutationSequence = ++session.optionMutationSequence
+ // Only a model write can stale the model report — an effort or permission-mode
+ // write does not change what the child is running. Leaving the stamp behind
+ // would drop the session back to the written model and refuse, on the next
+ // effort write, a level the model actually running advertises.
+ if (modelWasConfirmed && input.key !== 'model') {
+ session.reportedModelMutation = mutationSequence
+ }
+ try {
+ await apply()
+ } catch (error) {
+ if (error instanceof ClaudeControlRequestError) {
+ throw new AgentSessionOptionRejectedError(error)
+ }
+ throw error
+ }
+ // apply_flag_settings answers `success` for an effort it then ignores, so the
+ // absence of a throw proves nothing. Ask what the child actually holds.
+ const adopted =
+ input.key === 'effort' && !UNREPORTED_EFFORTS.has(input.value)
+ ? await session.connection
+ .getSettings({ timeoutMs })
+ .then(readClaudeSettingsEffort)
+ .catch(() => null)
+ : null
+ if (mutationSequence !== session.optionMutationSequence) {
+ return Object.fromEntries(session.options)
+ }
+ // A disagreement stops main vouching for the value, it does not veto the write:
+ // the pre-flight guard already refuses levels the model advertises no control
+ // for, and no other client refuses on a readback. Keep the child's own answer so
+ // the disagreement survives as the level a later read falls back to.
+ if (adopted !== null && adopted !== input.value) {
+ session.reportedOptions.effort = adopted
+ }
+ session.options.set(input.key, input.value)
+ // Only a readback that agreed is adoption evidence; one that disagreed or could
+ // not be taken records the value but must not also claim the provider vouched for it.
+ if (adopted !== null && adopted === input.value) {
+ session.confirmedOptions.add(input.key)
+ } else {
+ session.confirmedOptions.delete(input.key)
+ }
+ // The effort readback was taken under the old model, so a model switch retires
+ // it: the child keeps the value but nothing has reported the new model holding
+ // it, and vouching for it would show a confirmed effort no readback covers.
+ if (input.key === 'model') {
+ session.confirmedOptions.delete('effort')
+ }
+ return Object.fromEntries(session.options)
+}
+
+export async function restoreClaudeStructuredSessionOptions(
+ session: ClaudeSession,
+ timeoutMs: number | undefined
+): Promise {
+ // Any write that was already in flight belongs to the previous acquisition
+ // state and must not repopulate this map after restore starts.
+ session.optionMutationSequence += 1
+ // The fence bump is not a write, so the report the session already holds is still
+ // current as of this instant; leaving the stamp behind would make every restored
+ // session read as unconfirmed until its next turn.
+ session.reportedModelMutation = session.optionMutationSequence
+ const options = [...session.options.entries()]
+ session.options.clear()
+ for (const [key, value] of options) {
+ try {
+ await setClaudeStructuredOption(session, { key, value }, timeoutMs)
+ } catch (error) {
+ if (!isAgentSessionOptionRejectedError(error)) {
+ throw error
+ }
+ // A stale or unavailable preference must not poison every future acquire;
+ // the provider's current value remains authoritative and is re-persisted.
+ session.restoreSkippedOptions.add(key)
+ }
+ }
+}
diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts
new file mode 100644
index 00000000000..592b61df338
--- /dev/null
+++ b/src/main/claude/claude-structured-owner-identity.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from 'vitest'
+import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
+
+const IDENTITY = {
+ sessionId: 'session-identity',
+ workspaceId: 'workspace-1',
+ hostId: 'local',
+ agent: 'claude' as const,
+ providerHandle: { kind: 'claude' as const, sessionId: 'session-1', leafUuid: 'leaf-1' }
+}
+
+describe('claude structured owner identity', () => {
+ it('exports the spawn token env and records the observed process identity', async () => {
+ expect(CLAUDE_SPAWN_TOKEN_ENV).toBe('ORCA_AGENT_SESSION_SPAWN_TOKEN')
+ await expect(
+ claudeProcessIdentity(
+ { identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 },
+ async () => 123
+ )
+ ).resolves.toEqual({
+ hostId: 'local',
+ pid: 4242,
+ processStartTimeMs: 123,
+ spawnToken: 'spawn-a'
+ })
+ })
+
+ it('retries a failed start-time read before giving up', async () => {
+ const readStartTime = vi
+ .fn<(pid: number) => Promise>()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(456)
+ await expect(
+ claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime)
+ ).resolves.toMatchObject({ processStartTimeMs: 456 })
+ expect(readStartTime).toHaveBeenCalledTimes(3)
+ })
+})
diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts
index 1d13e6ec7c2..e8f251d41f9 100644
--- a/src/main/claude/claude-structured-owner-identity.ts
+++ b/src/main/claude/claude-structured-owner-identity.ts
@@ -1,4 +1,7 @@
+import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
+import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
+import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe'
export function claudeProviderHandleLink(input: {
sessionId: string
@@ -19,3 +22,41 @@ export function claudeProviderHandleLink(input: {
observedAt: input.observedAt
}
}
+
+/** The child echoes its spawn token here so the owner probe can tell a live
+ * child of this reservation from a same-pid stranger. */
+export const CLAUDE_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
+
+const START_TIME_READ_ATTEMPTS = 3
+
+export async function claudeProcessIdentity(
+ input: {
+ identity: AgentSessionJournalIdentity
+ spawnToken: string
+ pid: number | undefined
+ },
+ readStartTime: (pid: number) => Promise = readProcessStartTimeMs
+): Promise {
+ if (input.pid === undefined) {
+ throw new Error('claude app-server started without a pid')
+ }
+ let processStartTimeMs: number | null = null
+ for (
+ let attempt = 0;
+ attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null;
+ attempt += 1
+ ) {
+ processStartTimeMs = await readStartTime(input.pid)
+ }
+ if (processStartTimeMs === null) {
+ // Why: recording null makes every later owner probe indeterminate — a durable latch.
+ // Failing here reaps the child and leaves a retryable refusal instead.
+ throw new Error(`claude app-server start time for pid ${input.pid} could not be read`)
+ }
+ return {
+ hostId: input.identity.hostId,
+ pid: input.pid,
+ processStartTimeMs,
+ spawnToken: input.spawnToken
+ }
+}
diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts
new file mode 100644
index 00000000000..79916d6a507
--- /dev/null
+++ b/src/main/claude/claude-structured-prompt-items.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest'
+import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
+import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer'
+import { claudeQuestionItems } from './claude-structured-prompt-items'
+import {
+ applyClaudePromptAnswer,
+ encodeClaudeQuestionOptionId,
+ type ClaudePendingPrompt
+} from './claude-structured-prompt-replies'
+
+describe('Claude structured question addressing', () => {
+ it('keeps wire IDs bounded while returning the original question and choice', () => {
+ const questionId = 'Which option? '.repeat(100)
+ const label = 'A detailed choice '.repeat(100)
+ const prompt: ClaudePendingPrompt = {
+ requestId: 'question-1',
+ promptKey: 'question-1',
+ toolUseId: 'tool-1',
+ toolName: 'AskUserQuestion',
+ kind: 'question',
+ input: { questions: [{ question: questionId, options: [{ label }] }] },
+ suggestions: [],
+ questionIds: [questionId],
+ answers: new Map(),
+ settle: () => {}
+ }
+
+ const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]!
+ expect(agentJournalItemKey(item.identity).length).toBeLessThan(512)
+ expect(item.body.options[0]!.id.length).toBeLessThan(512)
+ expect(item.body.freeTextQuestionId).toBe('q1')
+ expect(applyClaudePromptAnswer({ prompt }, item.body.options[0]!.id)).toMatchObject({
+ updatedInput: { answers: { [questionId]: label } }
+ })
+ })
+
+ it('preserves colon-containing free-text answers', () => {
+ const questionId = 'Where should this run?'
+ const prompt: ClaudePendingPrompt = {
+ requestId: 'question-1',
+ promptKey: 'question-1',
+ toolUseId: 'tool-1',
+ toolName: 'AskUserQuestion',
+ kind: 'question',
+ input: { questions: [{ question: questionId }] },
+ suggestions: [],
+ questionIds: [questionId],
+ answers: new Map(),
+ settle: () => {}
+ }
+ const answer = 'https://example.test:8443/path'
+
+ expect(
+ applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer))
+ ).toMatchObject({
+ updatedInput: { answers: { [questionId]: answer } }
+ })
+ })
+
+ it('returns arrays for multi-select and preserves mixed single and Other answers', () => {
+ const multiQuestion = 'Which targets?'
+ const singleQuestion = 'Which mode?'
+ const otherQuestion = 'Where should it run?'
+ const prompt: ClaudePendingPrompt = {
+ requestId: 'question-1',
+ promptKey: 'question-1',
+ toolUseId: 'tool-1',
+ toolName: 'AskUserQuestion',
+ kind: 'question',
+ input: {
+ questions: [
+ {
+ question: multiQuestion,
+ multiSelect: true,
+ options: [{ label: 'frontend' }, { label: 'backend' }]
+ },
+ {
+ question: singleQuestion,
+ options: [{ label: 'fast' }, { label: 'safe' }]
+ },
+ { question: otherQuestion, options: [] }
+ ]
+ },
+ suggestions: [],
+ questionIds: [multiQuestion, singleQuestion, otherQuestion],
+ answers: new Map(),
+ settle: () => {}
+ }
+ const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]!
+ const questions = item.body.questions!
+ const encoded = encodeAgentSessionQuestionAnswers([
+ {
+ questionId: 'q1',
+ optionIds: [questions[0]!.options[0]!.id, questions[0]!.options[1]!.id]
+ },
+ { questionId: 'q2', optionIds: [questions[1]!.options[1]!.id] },
+ { questionId: 'q3', optionIds: [], other: 'remote host' }
+ ])
+
+ expect(applyClaudePromptAnswer({ prompt }, encoded)).toMatchObject({
+ updatedInput: {
+ answers: {
+ [multiQuestion]: ['frontend', 'backend'],
+ [singleQuestion]: 'safe',
+ [otherQuestion]: 'remote host'
+ }
+ }
+ })
+ })
+})
diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts
new file mode 100644
index 00000000000..3bdf8ab6091
--- /dev/null
+++ b/src/main/claude/claude-structured-prompt-items.ts
@@ -0,0 +1,134 @@
+import type {
+ AgentJournalApprovalItem,
+ AgentJournalItemIdentity,
+ AgentJournalPromptOption,
+ AgentJournalQuestion,
+ AgentJournalQuestionItem
+} from '../../shared/agent-session-journal-types'
+import {
+ boundInlineText,
+ DEFAULT_JOURNAL_PAYLOAD_LIMITS
+} from '../native-chat/agent-session-journal/journal-payload-bounds'
+import { claudeRecord, claudeText } from './claude-structured-item-translation'
+import {
+ CLAUDE_APPROVAL_DECISIONS,
+ encodeClaudeQuestionOptionId,
+ type ClaudeApprovalDecision,
+ type ClaudePendingPrompt
+} from './claude-structured-prompt-replies'
+
+const APPROVAL_LABELS: Record