Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8100b5e
fix(claude): end a Claude start that never answers initialize after 1…
brennanb2025 Oct 4, 2026
a9144f4
Read the Claude startup deadline inside startup; fix a stale test com…
brennanb2025 Oct 4, 2026
37254a1
fix(claude): start a Claude chat on its initialize answer, not on a f…
brennanb2025 Oct 4, 2026
9a48934
fix(claude): time the Claude start by silence, and fail it at once on…
brennanb2025 Oct 4, 2026
94de572
test(claude): a real Claude chat starts and answers with every hook d…
brennanb2025 Oct 4, 2026
91f41ad
Say what the start-frame re-arm covers, and check only start frames i…
brennanb2025 Oct 4, 2026
073f1b0
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 5, 2026
2d51fd4
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 6, 2026
6b5f55c
fix(native-chat): a Claude chat starts with its saved options and tak…
brennanb2025 Oct 6, 2026
222792b
fix(native-chat): a host stop of a Claude start fails the message it …
brennanb2025 Oct 6, 2026
f96208b
test(native-chat): a Claude chat's first message is written before in…
brennanb2025 Oct 6, 2026
49962ab
revert(native-chat): keep a failed Claude turn's error row
brennanb2025 Oct 6, 2026
81de612
fix(native-chat): pass saved Claude options unchecked, heal a retired…
brennanb2025 Oct 6, 2026
5b280f6
fix(native-chat): type the close's ended event and the start-exit tes…
brennanb2025 Oct 6, 2026
70aed7c
fix(native-chat): a Claude model heal keeps a later pick, a refused F…
brennanb2025 Oct 6, 2026
d2ae832
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 6, 2026
5ee4b84
fix(native-chat): a new Claude chat reports started before its saved …
brennanb2025 Oct 6, 2026
460ad16
fix(native-chat): a healed Claude chat goes back to the default model…
brennanb2025 Oct 6, 2026
b4e60fd
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 6, 2026
6d8be68
fix(native-chat): a message handed to a Claude start that never answe…
brennanb2025 Oct 6, 2026
ba2a171
fix(native-chat): a quit during a Claude start that never answered of…
brennanb2025 Oct 6, 2026
9a981dd
refactor(native-chat): the saved permission-mode launch helpers live …
brennanb2025 Oct 6, 2026
1a7f4dc
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 6, 2026
4ae3666
fix(native-chat): a Claude chat's saved options take precedence over …
brennanb2025 Oct 6, 2026
6462ce7
test(native-chat): the hand-built Claude session in the options test …
brennanb2025 Oct 6, 2026
f12847d
test(native-chat): the queued rig's start spy carries a named Mock type
brennanb2025 Oct 6, 2026
7f40c3e
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 6, 2026
dbcc7b5
fix(ci): run the PR's SQLite-backed tests in the Node runtime project
brennanb2025 Oct 7, 2026
72be620
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-s…
brennanb2025 Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/claude-structured-exit-mid-response.test.ts',
'src/main/runtime/claude-structured-failed-start-resume.test.ts',
'src/main/runtime/claude-structured-resumed-start-failure.test.ts',
'src/main/runtime/claude-structured-send-held-for-startup.test.ts',
'src/main/runtime/claude-structured-send-during-startup.test.ts',
'src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts',
'src/main/runtime/claude-structured-session-integration.test.ts',
'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts',
Expand Down
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts',
Expand Down
5 changes: 3 additions & 2 deletions src/main/claude/claude-adapter-capture-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import type {
import {
fakeClaude,
identityFor,
PROVIDER_SESSION_ID
PROVIDER_SESSION_ID,
claudeStartupSettled
} from './claude-structured-session-test-support'

type CapturedEvent =
Expand Down Expand Up @@ -153,7 +154,7 @@ async function replayCapture(name: string): Promise<Replay> {
if (event.kind === 'dispatch') {
if (!startedAwaited) {
// The proof frames have been delivered by now; startup can settle.
await adapter.awaitStarted('session-1')
await claudeStartupSettled(adapter, 'session-1')
startedAwaited = true
}
const body: AgentJournalMessageItem = {
Expand Down
5 changes: 3 additions & 2 deletions src/main/claude/claude-command-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
fakeClaude,
identityFor,
PROVIDER_SESSION_ID,
type FakeConnection
type FakeConnection,
claudeStartupSettled
} from './claude-structured-session-test-support'

type CapturedEvent =
Expand Down Expand Up @@ -195,7 +196,7 @@ async function replayCapture(
proofDelivered = true
} else if (event.kind === 'dispatch') {
if (proofDelivered) {
await adapter.awaitStarted('session-1')
await claudeStartupSettled(adapter, 'session-1')
}
await expect(
adapter.dispatch({
Expand Down
5 changes: 3 additions & 2 deletions src/main/claude/claude-fold-steer-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import type { ClaudeStructuredLaunch } from './claude-structured-launch-resoluti
import {
fakeClaude,
identityFor,
PROVIDER_SESSION_ID
PROVIDER_SESSION_ID,
claudeStartupSettled
} from './claude-structured-session-test-support'
import {
assistantText,
Expand Down Expand Up @@ -100,7 +101,7 @@ async function riggedAdapter(
}
const adapter = new ClaudeStructuredSessionAdapter(deps)
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink })
await adapter.awaitStarted('session-1')
await claudeStartupSettled(adapter, 'session-1')
return {
adapter,
claude,
Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-stream-json-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,11 +575,11 @@ describe('Claude stream-json connection', () => {
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({
expect(claudeAuthDiagnostic(null, init, null)).toMatchObject({
baseUrlConfigured: false,
authTokenConfigured: false
})
const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings())
const diagnostic = claudeAuthDiagnostic(null, init, await connection.getSettings())
expect(diagnostic).toMatchObject({
baseUrlConfigured: true,
authTokenConfigured: true,
Expand Down
22 changes: 20 additions & 2 deletions src/main/claude/claude-structured-acquisition-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof'
import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation'
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
import {
claudeStructuredSpawnOptions,
type ClaudeStructuredSpawnOptions
} from './claude-structured-spawn-options'
import {
cancelClaudeAcquisitionAttempt,
type ClaudeAcquisitionAttempt,
Expand All @@ -21,6 +25,11 @@ import {
closeClaudePublishedSessionForDeps
} from './claude-structured-session-close'

/** The launch with the chat's saved options in its spawn options. */
export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & {
savedOptions: Omit<ClaudeStructuredSpawnOptions, 'sdkOptions'>
}

export async function resolveClaudeAcquisitionLaunch(args: {
input: StructuredAgentSessionAcquireInput
deps: ClaudeStructuredSessionAdapterDeps
Expand All @@ -30,7 +39,7 @@ export async function resolveClaudeAcquisitionLaunch(args: {
callbacks: ClaudeAcquireCallbacks
previous: ClaudeAcquisitionAttempt | undefined
attempt: ClaudeAcquisitionAttempt
}): Promise<ClaudeStructuredLaunch> {
}): Promise<ClaudeAcquisitionLaunch> {
const { input, deps, sessions, acquisitions, exits, callbacks, previous, attempt } = args
const sessionId = input.identity.sessionId
return withAgentSessionCreatePhase('auth_settle', input.recordPhase, async () => {
Expand Down Expand Up @@ -87,6 +96,15 @@ export async function resolveClaudeAcquisitionLaunch(args: {
: new AgentSessionPreSpawnError(error)
})
acquisitions.assertCurrent(sessionId, attempt)
return launch
const spawn = claudeStructuredSpawnOptions({ launch, saved: input.options })
return {
...launch,
options: spawn.sdkOptions,
savedOptions: {
options: spawn.options,
skipped: spawn.skipped,
fastModeAtStart: spawn.fastModeAtStart
}
}
})
}
79 changes: 79 additions & 0 deletions src/main/claude/claude-structured-child-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// What a structured Claude child's environment is built from, shared by its launch and the
// `--version` probe that runs the same binary.

import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { resolveClaudeCommand } from '../codex-cli/command'
import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin'
import type { ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution'

function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
next[key] = value
}
}
return next
}

export type ClaudeEnvDeps = Pick<
ClaudeStructuredLaunchResolverDeps,
'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv'
>

/** What a child's env is built from, before any auth policy applies to it. */
export type ClaudeChildEnvSources = {
command: string
overlay: Record<string, string> | undefined
inheritedEnv: Record<string, string>
}

export async function resolveClaudeChildEnvSources(
deps: ClaudeEnvDeps
): Promise<ClaudeChildEnvSources> {
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
const overlay = await deps.resolveEnv?.()
const inheritedEnv = deps.resolveInheritedEnv
? await deps.resolveInheritedEnv()
: cloneDefinedEnv(process.env)
return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv }
}

// 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.
export function claudeChildEnv(
sources: ClaudeChildEnvSources,
stripAuthEnv: boolean,
decorateEnv: (env: Record<string, string>) => Record<string, string> = (env) => env
): Record<string, string> {
return withCliRuntimeOnPath(
sources.command,
decorateEnv({
...applyClaudeEnvPatch(
withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform),
{},
{ stripAuthEnv, platform: process.platform }
),
...sources.overlay
}),
{ platform: process.platform }
)
}

/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the
* Claude auth variables, auth-like custom headers and an inherited CLAUDE_CONFIG_DIR, which asking
* a version needs none of. Everything else is what this same binary receives at launch anyway. */
export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record<string, string> {
return applyClaudeEnvPatch(
claudeChildEnv(sources, true),
{},
{
stripAuthEnv: true,
platform: process.platform
}
)
}
2 changes: 2 additions & 0 deletions src/main/claude/claude-structured-dispatch-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C
reportedModelMutation: 0,
confirmedOptions: new Set(),
restoreSkippedOptions: new Set(),
launchedModel: null,
fastModeAtStart: false,
capabilities: [],
events: undefined,
translator: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
PROVIDER_SESSION_ID,
fakeClaude,
identityFor,
recordingJournalSink
recordingJournalSink,
claudeStartupSettled
} from './claude-structured-session-test-support'

const SESSION = 'session-1'
Expand Down Expand Up @@ -120,7 +121,7 @@ async function startChild(
events: recordingJournalSink(),
...(options ? { options } : {})
})
await adapter.awaitStarted(SESSION)
await claudeStartupSettled(adapter, SESSION)
return adapter
}

Expand Down
30 changes: 1 addition & 29 deletions src/main/claude/claude-structured-effort-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
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 { 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'
Expand Down Expand Up @@ -238,29 +235,4 @@ describe('Claude effort against the model that must run it', () => {
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([])
})
})
18 changes: 17 additions & 1 deletion src/main/claude/claude-structured-event-delivery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { claudePromptCardWritten } from './claude-child-work-evidence'
import {
resetClaudeRetiredModel,
retireClaudeLaunchedModel
} from './claude-structured-retired-model'
import type {
ClaudeSession,
ClaudeStructuredSessionAdapterDeps,
Expand All @@ -8,7 +12,7 @@ import type {
type ClaudeEventDelivery = {
session: ClaudeSession | null
event: ClaudeStructuredSessionEvent
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'onEvent'>
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'onEvent' | 'requestTimeoutMs' | 'logger'>
publishChildWork: (
sessionId: string,
session?: ClaudeSession | null,
Expand Down Expand Up @@ -38,6 +42,18 @@ export function emitClaudeStructuredSessionEvent({
}
session?.translator?.handle(event)
deps.onEvent?.(event)
const retired =
event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null
if (session && retired !== null) {
deps.onEvent?.({
type: 'options-skipped',
sessionId: event.sessionId,
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration,
options: { model: retired }
})
resetClaudeRetiredModel(session, deps, event.sessionId, retired)
}
publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null)
// A prompt blocks its child only after the journal has written its card.
void claudePromptCardWritten(session, event)?.then(() => publishChildWork(event.sessionId))
Expand Down
9 changes: 7 additions & 2 deletions src/main/claude/claude-structured-init-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,16 @@ export function claudeInitializationAuthError(
}

export function claudeAuthDiagnostic(
init: ClaudeInitObservation,
initialization: unknown,
init: ClaudeInitObservation | null,
settings: unknown
): ClaudeAuthDiagnostic {
const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {}
const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource')
const account =
isRecord(initialization) && isRecord(initialization.account) ? initialization.account : {}
const apiKeySource =
readClaudeFrameString(account, 'apiKeySource') ??
(init ? readClaudeFrameString(init.message, 'apiKeySource') : null)
const configured = (key: string): boolean =>
(typeof env[key] === 'string' && (env[key] as string).trim().length > 0) ||
Boolean(process.env[key]?.trim())
Expand Down
Loading
Loading