Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
63425b6
fix(native-chat): a Claude chat saved as Approve for me keeps it when…
brennanb2025 Oct 19, 2026
38442ee
fix(native-chat): a model list from a CLI older than auto mode still …
brennanb2025 Oct 19, 2026
75e4057
docs(chat): correct auto support reporting version
brennanb2025 Oct 19, 2026
89f332d
test(chat): type saved launch option cases
brennanb2025 Oct 20, 2026
832f7a0
Merge origin/main into brennanb2025/claude-chat-launch-intent
brennanb2025 Oct 20, 2026
461cdd6
Merge origin/main into brennanb2025/claude-chat-launch-intent
brennanb2025 Oct 20, 2026
0bc086a
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-c…
brennanb2025 Oct 21, 2026
84554ad
Honor requested chat permissions across model changes
brennanb2025 Oct 22, 2026
dda3a2b
Merge main into chat permission transition
brennanb2025 Oct 22, 2026
7293bed
Match outgrown child fixture to journal null contract
brennanb2025 Oct 22, 2026
e0cbb58
fix(native-chat): preserve admitted input and current mail choices
brennanb2025 Oct 22, 2026
7ddb0b0
Merge origin/main before final QA
brennanb2025 Oct 23, 2026
1dab215
perf(native-chat): derive wake debt and held sends once per retiremen…
brennanb2025 Oct 23, 2026
7abdd6c
Keep active Claude input on its turn choices
brennanb2025 Oct 23, 2026
109907a
Fix chat admission type imports and cover turn-scan costs
brennanb2025 Oct 23, 2026
a3dde71
Preserve root lifecycle delivery under journal pressure
brennanb2025 Oct 23, 2026
9f6ed57
Merge origin/main before final QA
brennanb2025 Oct 23, 2026
f47d78a
Merge origin/main to include CI file-length repair
brennanb2025 Oct 23, 2026
8722b25
Merge origin/main to integrate CI repair
brennanb2025 Oct 24, 2026
25c58ee
test: construct Codex mail fixture with its source
brennanb2025 Oct 24, 2026
f78425f
test: expect root retention metadata in Claude turn timing
brennanb2025 Oct 24, 2026
1c3c287
Merge main into native chat launch intent
brennanb2025 Oct 24, 2026
efd720b
test: cover captured Claude full-ID Auto launch
brennanb2025 Oct 24, 2026
aa0547e
Merge main into native chat launch intent
brennanb2025 Oct 24, 2026
a8dae4a
Merge main into native chat launch intent
brennanb2025 Oct 24, 2026
45371f8
Merge main into native chat launch intent
brennanb2025 Oct 25, 2026
16235bf
test(native-chat): explain queued-send test timeout
brennanb2025 Oct 25, 2026
e68340f
Merge main into native chat launch intent
brennanb2025 Oct 25, 2026
de1ae42
Merge main permission settings into native chat launch intent
brennanb2025 Oct 25, 2026
0d3f071
Merge main CI repairs into native chat launch intent
brennanb2025 Oct 25, 2026
6a631fe
test: supply explicit mailbox working policy
brennanb2025 Oct 26, 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
Original file line number Diff line number Diff line change
Expand Up @@ -282,18 +282,18 @@ function listingLandsLaterClient() {
}

describe('Approve for me on a phone chat opened before its account was listed', () => {
it('is offered once the listing lands', async () => {
it('is offered before and after the listing lands', async () => {
const host = listingLandsLaterClient()
const options = await mountOptions({
client: host.client,
fence: 1,
optionStamp: '',
agent: 'claude'
})
expect(options.offered()).toEqual(['ask', 'accept-edits', 'full-access'])
expect(options.offered()).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
host.land()
await options.rerender({})
expect(options.offered()).toContain('auto')
expect(options.offered()).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
await options.unmount()
})
})
2 changes: 1 addition & 1 deletion src/main/claude/claude-open-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,6 @@ export class ClaudeOpenTurn {
options: { ...item.options, lifecycle: true }
})
// Separate keys preserve a queued start publication when the end arrives.
this.deps.sink.publish({ coalescingKey: item.publishCoalescingKey })
this.deps.sink.publish({ criticalRoot: true, coalescingKey: item.publishCoalescingKey })
}
}
13 changes: 9 additions & 4 deletions src/main/claude/claude-structured-acquisition-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,21 @@ export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & {
savedOptions: Omit<ClaudeStructuredSpawnOptions, 'sdkOptions'>
}

export async function resolveClaudeAcquisitionLaunch(args: {
export type ClaudeAcquisitionContext = {
input: StructuredAgentSessionAcquireInput
deps: ClaudeStructuredSessionAdapterDeps
sessions: Map<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
exits: Map<string, ClaudeSessionExit>
callbacks: ClaudeAcquireCallbacks
previous: ClaudeAcquisitionAttempt | undefined
attempt: ClaudeAcquisitionAttempt
}): Promise<ClaudeAcquisitionLaunch> {
}

export async function resolveClaudeAcquisitionLaunch(
args: ClaudeAcquisitionContext & {
previous: ClaudeAcquisitionAttempt | undefined
attempt: ClaudeAcquisitionAttempt
}
): 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
21 changes: 19 additions & 2 deletions src/main/claude/claude-structured-dispatch-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ describe('Claude structured dispatch admission', () => {
await dispatchClaudeTurn(session, {
clientMessageId: 'client-b',
body: userMessage([{ type: 'text', text: 'b' }]),
requestedAt: 200
requestedAt: 150,
admitPreparedInput: async (offer) => offer(200)
})
await dispatchClaudeTurn(session, {
clientMessageId: 'client-c',
Expand Down Expand Up @@ -122,7 +123,7 @@ describe('Claude structured dispatch admission', () => {

it('bounds pending replay identities instead of retaining an unbounded queue', async () => {
const session = sessionFor()
for (let index = 0; index < 64; index += 1) {
for (let index = 0; index < 63; index += 1) {
await expect(
dispatchClaudeTurn(session, {
clientMessageId: `client-${index}`,
Expand All @@ -131,6 +132,22 @@ describe('Claude structured dispatch admission', () => {
).resolves.toEqual({ state: 'admitted' })
}

await expect(
dispatchClaudeTurn(session, {
clientMessageId: 'prepared-at-capacity',
body: userMessage([{ type: 'text', text: 'prepared before the last slot filled' }]),
admitPreparedInput: async (offer) => {
await dispatchClaudeTurn(session, {
clientMessageId: 'last-slot',
body: userMessage([{ type: 'text', text: 'fills the last slot' }])
})
await offer(200)
}
})
).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'writeFailed' } })
expect(session.dispatchWaiters).toHaveLength(64)
expect(session.connection.send).toHaveBeenCalledTimes(64)

await expect(
dispatchClaudeTurn(session, {
clientMessageId: 'client-over-capacity',
Expand Down
35 changes: 28 additions & 7 deletions src/main/claude/claude-structured-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
waitForReplay
} from './claude-structured-dispatch-waiters'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state'
import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution'
import {
Expand Down Expand Up @@ -118,6 +121,9 @@ export async function dispatchClaudeTurn(
requestedAt?: number
sentUuid?: string
signal?: AbortSignal
admitPreparedInput?: Parameters<
StructuredAgentSessionAdapter['dispatch']
>[0]['admitPreparedInput']
},
beforeDispatch?: () => Promise<void>
): Promise<AgentSessionDispatchOutcome> {
Expand All @@ -133,7 +139,7 @@ export async function dispatchClaudeTurn(
// Classify the mapped trailing prompt so command detection matches what Claude receives.
const acceptsResult = claudeDispatchInvokesSlashCommand(content)
const sentUuid = input.sentUuid ?? randomUUID()
const arm = () => {
const arm = (requestedAt = input.requestedAt) => {
++session.dispatchSequence
// Invalidate context reports requested before this send.
session.translator?.markContextActivity()
Expand All @@ -143,7 +149,7 @@ export async function dispatchClaudeTurn(
sentUuid,
claudeDispatchContentKey(content),
input.clientMessageId ?? null,
input.requestedAt ?? null
requestedAt ?? null
)
}
const message = {
Expand All @@ -153,7 +159,7 @@ export async function dispatchClaudeTurn(
parent_tool_use_id: null,
session_id: session.providerSessionId
}
const pending = { replay: beforeDispatch ? undefined : arm() }
const pending = { replay: beforeDispatch || input.admitPreparedInput ? undefined : arm() }
const authorize = beforeDispatch
? async () => {
await beforeDispatch()
Expand All @@ -164,9 +170,24 @@ export async function dispatchClaudeTurn(
}
: undefined
try {
await (authorize
? session.connection.send(message, authorize)
: session.connection.send(message))
if (input.admitPreparedInput) {
await beforeDispatch?.()
await input.admitPreparedInput(
(requestedAt) =>
new Promise<void>((resolve) => {
if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) {
throw claudeUnwrittenUserMessageError(new Error(DISPATCH_REJECTED_QUEUE_FULL))
}
pending.replay = arm(requestedAt)
resolve(session.connection.send(message))
}),
() => session.translator?.currentTurnId ?? null
)
} else {
await (authorize
? session.connection.send(message, authorize)
: session.connection.send(message))
}
} catch (error) {
const replay = pending.replay
if (!replay) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ describe('Claude structured turn timing', () => {
state: 'running',
startedAt: 1_000,
userItemId: USER_1_KEY,
options: { observedAt: 1_000, lifecycle: true, turnScope: { kind: 'thread' } }
options: {
criticalRoot: true,
observedAt: 1_000,
lifecycle: true,
turnScope: { kind: 'thread' }
}
}
])
})
Expand Down Expand Up @@ -191,7 +196,7 @@ describe('Claude structured turn timing', () => {
startedAt: 1_000,
completedAt: 4_500,
userItemId: USER_1_KEY,
options: { lifecycle: true, turnScope: { kind: 'thread' } }
options: { criticalRoot: true, lifecycle: true, turnScope: { kind: 'thread' } }
})
expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity)
})
Expand Down
21 changes: 15 additions & 6 deletions src/main/claude/claude-structured-launch-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,21 @@ describe('claude structured launch resolution', () => {
['ask', 'default'],
['accept-edits', 'acceptEdits'],
['auto', 'auto']
] as const)('starts a %s chat in permission mode %s', async (permissionMode, expected) => {
const launch = await resolverFor(record())({ identity: IDENTITY, permissionMode })

expect(launch.options.permissionMode).toBe(expected)
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
})
] as const)(
'starts and resumes a %s chat in permission mode %s',
async (permissionMode, expected) => {
const launches = [
await resolverFor(record())({ identity: IDENTITY, permissionMode }),
await resolverFor(RESUMABLE)({ identity: identityAt('leaf-current'), permissionMode })
]

expect(launches[1]?.options.resume).toBe('provider-current')
for (const launch of launches) {
expect(launch.options.permissionMode).toBe(expected)
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
}
}
)

it('ignores the bypass flag the agent Arguments carry', async () => {
const launch = await resolverFor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('the context ring after a session option write', () => {
it('sizes a new session from the model it was launched with', () => {
const s = ringSession()
adoptClaudeStructuredSpawnOptions(s.session, {
sdkOptions: { model: 'opus[1m]' },
options: new Map([['model', 'opus[1m]']]),
fastModeAtStart: false
})
Expand Down
20 changes: 20 additions & 0 deletions src/main/claude/claude-structured-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,32 @@ import {
readClaudeSettingsFastMode
} from './claude-structured-session-options'
import type { ClaudeSession } from './claude-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'

import { beginClaudeOptionMutation } from './claude-structured-execution-observations'

const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const

export function claudeModelTransitionRestart(
sessions: ReadonlyMap<string, ClaudeSession>
): NonNullable<StructuredAgentSessionAdapter['requiresModelTransitionRestart']> {
return (input) => {
const session = sessions.get(input.sessionId)
const model = input.executionOptions?.model
const opened = session?.firstRootInit?.permissionMode
return Boolean(
session?.fence === input.fence &&
model !== undefined &&
session.launchPermissionMode !== undefined &&
opened !== undefined &&
opened !== null &&
opened !== session.launchPermissionMode &&
session.appliedInputOptions.get('model') !== model
)
}
}

export function restoredClaudeStructuredSessionOptions(
options: Readonly<Record<string, string>> | undefined
): Map<string, string> {
Expand Down
36 changes: 21 additions & 15 deletions src/main/claude/claude-structured-session-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@ import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-opt
import { observeClaudeExecutionModel } from './claude-structured-execution-observations'
import { observeClaudeFastModeFacts } from './claude-structured-session-options'
import { createClaudeSessionPublication } from './claude-structured-session-publication'
import {
mintClaudeAcquisitionGeneration,
type ClaudeSession,
type ClaudeSessionAcquisitionInput,
type ClaudeLateDispatchOutcome
} from './claude-structured-session-state'
import * as sessionState from './claude-structured-session-state'
import { resolveClaudeAcquisitionError } from './claude-structured-session-close'
import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid'
Expand All @@ -35,6 +30,7 @@ import {
bindClaudeConnectionJournalControls,
createClaudeSessionJournalTranslator
} from './claude-structured-session-journal-control'
import { isRootClaudeFrame } from './claude-turn-opening'

export async function acquireClaudeSession({
input,
Expand All @@ -43,12 +39,13 @@ export async function acquireClaudeSession({
acquisitions,
exits,
callbacks
}: ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
}: sessionState.ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const prompts = new ClaudePromptRegistry()
const { previous, attempt } = acquisitions.start(sessionId, prompts)
let unbindReadingControl: (() => void) | undefined
let liveSession: ClaudeSession | null = null
let liveSession: sessionState.ClaudeSession | null = null
let firstRootInit: sessionState.ClaudeSession['firstRootInit']
let observedLeafUuid: string | null = null,
expectedProviderSessionId: string | null = null
// Preserve the CLI diagnostic when SDK cleanup reports a generic error.
Expand All @@ -70,7 +67,7 @@ export async function acquireClaudeSession({
)
const contextActivity = translator?.contextActivity ?? 0
const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps)
const observedOptions: ClaudeSession['reportedOptions'] = {}
const observedOptions: sessionState.ClaudeSession['reportedOptions'] = {}
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
Expand All @@ -80,6 +77,12 @@ export async function acquireClaudeSession({
}
return
}
if (message.type === 'system' && message.subtype === 'init' && isRootClaudeFrame(message)) {
firstRootInit ??= { permissionMode: readClaudeFrameString(message, 'permissionMode') }
if (liveSession) {
liveSession.firstRootInit = firstRootInit
}
}
const permissionChanged = observeClaudeReportedPermissionMode(observedOptions, message)
const modelChanged =
liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false
Expand All @@ -99,7 +102,7 @@ export async function acquireClaudeSession({
}
}
// Publish the echoed turn before settling its send to avoid transient idle status.
const settlements: ClaudeLateDispatchOutcome[] = []
const settlements: sessionState.ClaudeLateDispatchOutcome[] = []
const turnOrigin = liveSession
? resolveClaudeReplayTurn(liveSession, message, (settlement) => settlements.push(settlement))
: null
Expand Down Expand Up @@ -239,7 +242,7 @@ export async function acquireClaudeSession({
new Error(`claude stream-json for session ${sessionId} exited while being acquired`)
)
}
const publication = createClaudeSessionPublication({
const { session, acquisition } = createClaudeSessionPublication({
connection,
providerSessionId: launch.providerSessionId,
leafUuid: observedLeafUuid,
Expand All @@ -252,7 +255,7 @@ export async function acquireClaudeSession({
events: input.events,
...(unbindReadingControl ? { unbindReadingControl } : {}),
process,
acquisitionGeneration: mintClaudeAcquisitionGeneration(deps),
acquisitionGeneration: sessionState.mintClaudeAcquisitionGeneration(deps),
options: launch.savedOptions.options,
initialization: {
proof: initProof,
Expand All @@ -270,10 +273,13 @@ export async function acquireClaudeSession({
...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}),
observedAt: deps.now?.() ?? Date.now()
})
const session = publication.session
session.reportedOptions = observedOptions
liveSession = session
adoptClaudeStructuredSpawnOptions(session, launch.savedOptions)
session.firstRootInit = firstRootInit
adoptClaudeStructuredSpawnOptions(session, {
...launch.savedOptions,
sdkOptions: launch.options
})
acquisitions.deleteIfCurrent(sessionId, attempt)
await withAgentSessionCreatePhase('publish', input.recordPhase, async () => {
sessions.set(sessionId, session)
Expand All @@ -288,7 +294,7 @@ export async function acquireClaudeSession({
exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned')
)
}
return publication.acquisition
return acquisition
} catch (error) {
unbindReadingControl?.()
throw await resolveClaudeAcquisitionError({
Expand Down
Loading
Loading