@meistrari/agent-core 0.1.3 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,8 @@ The package ships TypeScript source and targets Bun `>=1.3.10`. There is no wire
21
21
 
22
22
  agent-core does not own a universal E2B template. Each consuming service builds and onboards its own template against the shared contracts and records that template's agent-core provenance. agent-api and Remy may therefore use different base images, harness versions, system packages, and startup hooks without forking the control protocol.
23
23
 
24
+ Prompt commands may carry an application-resolved `model`. Applications validate harness compatibility and persist the selection with command admission; they should send the resolved model on every prompt so recovery does not depend on mutable harness memory. Claude applies a changed model through its control channel before input; Codex supplies it to `turn/start`. Neither adapter changes models on an active turn, and unchanged Claude selections add no control request. This is not an effort override contract. Existing immutable sandboxes must not be advertised as supporting this field unless their baked runtime includes it.
25
+
24
26
  ## Development
25
27
 
26
28
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.1.3",
4
+ "version": "0.1.9",
5
5
  "packageManager": "bun@1.3.12",
6
6
  "description": "Shared contracts and runtime modules for Tela coding-agent sandboxes: agent protocol, supervisor wire protocol, resident supervisor, worker runtime client, and Claude/Codex harness adapters.",
7
7
  "license": "UNLICENSED",
@@ -0,0 +1,110 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { createInterface } from 'node:readline'
6
+ import { Database } from 'bun:sqlite'
7
+
8
+ // Offline, synthetic-only probe. Never inherit model or AWS credentials.
9
+ const executable = process.argv[2]
10
+ if (!executable)
11
+ throw new Error('Pass the absolute path to the pinned Codex executable.')
12
+
13
+ for (const mode of ['default', 'rust-log-off', 'feedback-off'] as const) {
14
+ const directory = await mkdtemp(join(tmpdir(), `codex-diagnostic-${mode}-`))
15
+ const canary = 'SYNTHETIC_CODEX_DIAGNOSTIC_CANARY_NO_AUTHORITY'
16
+ const child = spawn(executable, [
17
+ 'app-server',
18
+ '--listen',
19
+ 'stdio://',
20
+ '-c',
21
+ 'cli_auth_credentials_store="ephemeral"',
22
+ ...(mode === 'feedback-off' ? ['-c', 'feedback.enabled=false'] : []),
23
+ ], {
24
+ env: {
25
+ PATH: process.env.PATH,
26
+ HOME: directory,
27
+ CODEX_HOME: directory,
28
+ HTTP_PROXY: 'http://127.0.0.1:1',
29
+ HTTPS_PROXY: 'http://127.0.0.1:1',
30
+ ALL_PROXY: 'http://127.0.0.1:1',
31
+ NO_PROXY: '',
32
+ ...(mode === 'rust-log-off' ? { RUST_LOG: 'off' } : {}),
33
+ },
34
+ stdio: 'pipe',
35
+ })
36
+ const exited = new Promise<number | null>((resolve, reject) => {
37
+ child.once('close', resolve)
38
+ child.once('error', reject)
39
+ })
40
+ let stderrHasCanary = false
41
+ child.stderr.on('data', (data) => {
42
+ stderrHasCanary ||= String(data).includes(canary)
43
+ })
44
+ const send = (value: unknown) => child.stdin.write(`${JSON.stringify(value)}\n`)
45
+ let loginResponded = false
46
+ let turnAccepted = false
47
+ const protocolErrors: { id: unknown, code: unknown }[] = []
48
+ const lines = createInterface({ input: child.stdout })
49
+ lines.on('line', (line) => {
50
+ const value = JSON.parse(line)
51
+ if (value.error)
52
+ protocolErrors.push({ id: value.id, code: value.error.code })
53
+ if (value.id === 1) {
54
+ send({ method: 'initialized' })
55
+ send({ id: 2, method: 'account/login/start', params: { type: 'amazonBedrock', apiKey: canary, region: 'us-east-1' } })
56
+ }
57
+ if (value.id === 2) {
58
+ loginResponded = true
59
+ send({ id: 3, method: 'thread/start', params: { modelProvider: 'amazon-bedrock', model: 'openai.gpt-5.6-terra', cwd: directory, approvalPolicy: 'never', sandbox: 'danger-full-access' } })
60
+ }
61
+ if (value.id === 3 && value.result?.thread?.id) {
62
+ send({ id: 4, method: 'turn/start', params: { threadId: value.result.thread.id, input: [{ type: 'text', text: 'Synthetic offline diagnostic probe. Do not use tools.' }] } })
63
+ }
64
+ if (value.id === 4 || value.error) {
65
+ turnAccepted = value.id === 4 && Boolean(value.result?.turn?.id)
66
+ // Allow the runtime's asynchronous diagnostic writer to flush.
67
+ setTimeout(() => child.stdin.end(), 2_000)
68
+ }
69
+ })
70
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'diagnostic-canary-probe', version: '1' }, capabilities: { experimentalApi: true } } })
71
+ const timeout = setTimeout(() => child.kill('SIGTERM'), 10_000)
72
+ const exitCode = await exited
73
+ clearTimeout(timeout)
74
+ lines.close()
75
+ const files = await readdir(directory)
76
+ const logs = files.filter(name => /^logs_\d+\.sqlite$/.test(name))
77
+ const databases = logs.map((name) => {
78
+ // These synthetic-only files belong to this probe. SQLite may need to
79
+ // recreate its WAL shared-memory file after the child exits.
80
+ const db = new Database(join(directory, name), { readwrite: true, create: false })
81
+ try {
82
+ return { name, ...db.query('SELECT count(*) AS records, coalesce(sum(instr(feedback_log_body, ?) > 0), 0) AS canaryRecords FROM logs').get(canary) as { records: number, canaryRecords: number } }
83
+ }
84
+ finally { db.close() }
85
+ })
86
+ const canaryFiles: string[] = []
87
+ const skippedFiles: string[] = []
88
+ let scannedFiles = 0
89
+ async function scan(relative = ''): Promise<void> {
90
+ for (const entry of await readdir(join(directory, relative), { withFileTypes: true })) {
91
+ const path = join(relative, entry.name)
92
+ if (entry.isDirectory()) {
93
+ await scan(path)
94
+ }
95
+ else if (entry.isFile()) {
96
+ if ((await stat(join(directory, path))).size > 16 * 1024 * 1024) {
97
+ skippedFiles.push(path)
98
+ continue
99
+ }
100
+ scannedFiles++
101
+ if ((await readFile(join(directory, path))).includes(canary))
102
+ canaryFiles.push(path)
103
+ }
104
+ }
105
+ }
106
+ await scan()
107
+ console.log(JSON.stringify({ mode, directory, loginResponded, turnAccepted, protocolErrors, exitCode, stderrHasCanary, databases, scannedFiles, canaryFiles, skippedFiles }))
108
+ if (!turnAccepted || protocolErrors.length || exitCode !== 0 || stderrHasCanary || canaryFiles.length || skippedFiles.length)
109
+ process.exitCode = 1
110
+ }
@@ -0,0 +1,65 @@
1
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
2
+ import { Buffer } from 'node:buffer'
3
+ import { constants } from 'node:fs'
4
+ import { open, realpath } from 'node:fs/promises'
5
+ import { basename, dirname, join, resolve } from 'node:path'
6
+
7
+ const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
8
+
9
+ export async function hydrateClaudeToolOutput(message: SDKMessage, input: {
10
+ transcriptPath: string
11
+ toolNameById: ReadonlyMap<string, string>
12
+ }): Promise<SDKMessage> {
13
+ if (message.type !== 'user' || !message.tool_use_result || typeof message.tool_use_result !== 'object')
14
+ return message
15
+ const result = message.tool_use_result as Record<string, unknown>
16
+ if (result.persistedOutputPath === undefined)
17
+ return message
18
+ const content = message.message.content
19
+ if (!Array.isArray(content))
20
+ throw new Error('Claude persisted output has no correlated tool result.')
21
+ const blocks = content.filter(block => block.type === 'tool_result')
22
+ if (blocks.length !== 1 || input.toolNameById.get(blocks[0]!.tool_use_id) !== 'bash')
23
+ throw new Error('Claude persisted output has ambiguous tool correlation.')
24
+ if (typeof result.persistedOutputPath !== 'string' || typeof result.persistedOutputSize !== 'number')
25
+ throw new Error('Claude persisted output metadata is malformed.')
26
+ const text = await readClaudePersistedOutput({ transcriptPath: input.transcriptPath, path: result.persistedOutputPath, byteSize: result.persistedOutputSize })
27
+ return { ...message, message: { ...message.message, content: content.map(block => block === blocks[0] ? { ...block, content: text } : block) } }
28
+ }
29
+
30
+ /** Read only the SDK-owned tool-results file, never a path parsed from model text. */
31
+ export async function readClaudePersistedOutput(input: {
32
+ transcriptPath: string
33
+ path: string
34
+ byteSize: number
35
+ }): Promise<string> {
36
+ const transcript = resolve(input.transcriptPath)
37
+ const root = join(dirname(transcript), basename(transcript, '.jsonl'), 'tool-results')
38
+ if (!Number.isSafeInteger(input.byteSize) || input.byteSize < 0 || input.byteSize > MAX_OUTPUT_BYTES)
39
+ throw new Error('Claude persisted tool output exceeds the bounded capture budget.')
40
+ if (input.path !== resolve(input.path) || dirname(input.path) !== root || await realpath(root) !== root)
41
+ throw new Error('Claude persisted tool output is outside its provider session.')
42
+ const file = await open(input.path, constants.O_RDONLY | constants.O_NOFOLLOW)
43
+ try {
44
+ const before = await file.stat()
45
+ if (!before.isFile() || before.nlink !== 1 || before.size !== input.byteSize)
46
+ throw new Error('Claude persisted tool output metadata does not match its file.')
47
+ // Bounded reads also detect growth between stat and read; readFile could
48
+ // allocate arbitrarily if the file is concurrently extended.
49
+ const bytes = Buffer.alloc(input.byteSize + 1)
50
+ let offset = 0
51
+ while (offset < bytes.length) {
52
+ const result = await file.read(bytes, offset, bytes.length - offset, offset)
53
+ if (!result.bytesRead)
54
+ break
55
+ offset += result.bytesRead
56
+ }
57
+ const after = await file.stat()
58
+ if (offset !== input.byteSize || after.size !== before.size || after.mtimeMs !== before.mtimeMs)
59
+ throw new Error('Claude persisted tool output changed during capture.')
60
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, offset))
61
+ }
62
+ finally {
63
+ await file.close()
64
+ }
65
+ }
@@ -4,6 +4,7 @@ import type { AgentOperationOptions } from '../agent-operation'
4
4
  import type { AgentProvider, AgentSessionOpenInput, AgentSessionResumeInput } from '../agent-provider'
5
5
  import type { AgentTool } from '../agent-tool'
6
6
  import type { InstructionComposer } from '../instructions'
7
+ import type { ClaudeStartupTiming } from './claude-startup-timing'
7
8
  import { homedir } from 'node:os'
8
9
  import { join } from 'node:path'
9
10
  import { query as queryClaude } from '@anthropic-ai/claude-agent-sdk'
@@ -15,6 +16,7 @@ import { removeUndefined } from '../normalize'
15
16
  import { UserInputRequestStore, userInputRequestStoreRootFromTranscriptPath } from '../user-input-request'
16
17
  import { createClaudeEventMapperState } from './claude-event-mapper'
17
18
  import { ClaudeRun } from './claude-run'
19
+ import { createClaudeStartupTiming, observeClaudeControlInitialization } from './claude-startup-timing'
18
20
  import { claudeAllowedToolNames, claudeToolActorHooks, createClaudeToolRuntimeRef, toClaudeMcpServers } from './claude-tool-mapper'
19
21
 
20
22
  export interface ClaudeProviderConfig {
@@ -22,6 +24,7 @@ export interface ClaudeProviderConfig {
22
24
  executable?: string
23
25
  environment?: Record<string, string | undefined>
24
26
  instructions?: InstructionComposer
27
+ onStartupTiming?: (timing: ClaudeStartupTiming) => void
25
28
  }
26
29
 
27
30
  export class ClaudeProvider implements AgentProvider<'claude'> {
@@ -30,12 +33,14 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
30
33
  private readonly executable: string | undefined
31
34
  private readonly environment: Record<string, string | undefined>
32
35
  private readonly instructions: InstructionComposer
36
+ private readonly onStartupTiming: ClaudeProviderConfig['onStartupTiming']
33
37
 
34
38
  private constructor(config: ClaudeProviderConfig) {
35
39
  this.tools = config.tools ?? []
36
40
  this.executable = config.executable
37
41
  this.environment = config.environment ?? {}
38
42
  this.instructions = config.instructions ?? emptyInstructionComposer
43
+ this.onStartupTiming = config.onStartupTiming
39
44
  }
40
45
 
41
46
  static create(config: ClaudeProviderConfig = {}): ClaudeProvider {
@@ -96,7 +101,11 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
96
101
  pathToClaudeCodeExecutable: this.executable,
97
102
  })
98
103
 
104
+ const recordStartup = createClaudeStartupTiming(this.onStartupTiming)
99
105
  const query = queryClaude({ prompt: inputQueue, options: sdkOptions })
106
+ recordStartup('query_created')
107
+ if (this.onStartupTiming)
108
+ observeClaudeControlInitialization(query.initializationResult(), recordStartup)
100
109
  try {
101
110
  // Binding metadata comes from pre-input facts only: the live runtime emits system/init
102
111
  // only after the first user message, so the run validates identity/config on the first
@@ -122,6 +131,7 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
122
131
  userInputRequests,
123
132
  input: inputQueue,
124
133
  eventMapperState: state,
134
+ recordStartup,
125
135
  init: {
126
136
  expectedSessionId,
127
137
  requestedModel: input.model,
@@ -13,14 +13,16 @@ import type { AgentOperationOptions } from '../agent-operation'
13
13
  import type { AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
14
14
  import type { UserInputRequestInput, UserInputRequestStore } from '../user-input-request'
15
15
  import type { ClaudeEventMapperState } from './claude-event-mapper'
16
+ import type { ClaudeStartupPhase } from './claude-startup-timing'
16
17
  import z from 'zod'
17
18
  import { serializeAgentError } from '../agent-error-serializer'
18
19
  import { AgentEventStream } from '../agent-event-stream'
19
- import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
20
+ import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
20
21
  import { sessionConfiguredEvent } from '../agent-session-events'
21
22
  import { deriveMessageId } from '../message-id'
22
23
  import { mapClaudeUserMessage, toClaudeUserMessage } from './claude-command-mapper'
23
24
  import { mapClaudeMessage } from './claude-event-mapper'
25
+ import { hydrateClaudeToolOutput } from './claude-persisted-output'
24
26
 
25
27
  // The ONE long-lived SDK input stream created with the query. SDK 0.3.207 Query.streamInput ends
26
28
  // stdin after any finite iterable and the transport silently drops later writes, so every user
@@ -77,6 +79,7 @@ interface ClaudeRunInput {
77
79
  input: ClaudeInputStream
78
80
  eventMapperState: ClaudeEventMapperState
79
81
  init: ClaudeInitExpectation
82
+ recordStartup?: (phase: ClaudeStartupPhase) => void
80
83
  }
81
84
 
82
85
  export class ClaudeRun implements AgentRun {
@@ -87,23 +90,27 @@ export class ClaudeRun implements AgentRun {
87
90
  private readonly input: ClaudeInputStream
88
91
  private readonly eventMapperState: ClaudeEventMapperState
89
92
  private readonly init: ClaudeInitExpectation
93
+ private readonly recordStartup: ClaudeRunInput['recordStartup']
90
94
  private readonly initialization: Promise<void>
91
95
  private initializationResolve: () => void = () => undefined
92
96
  private initializationReject: (error: unknown) => void = () => undefined
93
97
  private initialized = false
94
98
  private firstInputPushed = false
99
+ private modelChangeReady = true
100
+ private modelChangeInFlight = false
95
101
  private finished = false
96
102
  private interruptRequest?: { turnId: string, promise: Promise<void> }
97
103
  private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
98
104
  private readonly commandByClientMessageId = new Map<string, string>()
99
105
 
100
- constructor({ metadata, query, userInputRequests, input, eventMapperState, init }: ClaudeRunInput) {
106
+ constructor({ metadata, query, userInputRequests, input, eventMapperState, init, recordStartup }: ClaudeRunInput) {
101
107
  this.metadata = metadata
102
108
  this.query = query
103
109
  this.userInputRequests = userInputRequests
104
110
  this.input = input
105
111
  this.eventMapperState = eventMapperState
106
112
  this.init = init
113
+ this.recordStartup = recordStartup
107
114
  this.initialization = new Promise<void>((resolve, reject) => {
108
115
  this.initializationResolve = resolve
109
116
  this.initializationReject = reject
@@ -130,6 +137,10 @@ export class ClaudeRun implements AgentRun {
130
137
  pushEvent(draft: AgentEventDraft): void {
131
138
  if (this.finished)
132
139
  return
140
+ if (draft.type === 'turn.started')
141
+ this.modelChangeReady = false
142
+ if (draft.type === 'session.state.changed' && draft.payload.state === 'idle')
143
+ this.modelChangeReady = true
133
144
  const decorated = this.userInputRequests.decorateTurnEnded(draft)
134
145
  if (decorated.type === 'session.state.changed' && decorated.payload.state === 'idle' && this.userInputRequests.shouldHoldIdle()) {
135
146
  this.events.push({ type: 'session.state.changed', payload: { state: 'waiting_for_user_input' } })
@@ -161,6 +172,26 @@ export class ClaudeRun implements AgentRun {
161
172
  const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext }
162
173
  const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
163
174
  options.signal?.throwIfAborted()
175
+ if (this.modelChangeInFlight)
176
+ throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
177
+ const currentModel = this.init.requestedModel ?? this.metadata.model
178
+ if (command.model && command.model !== currentModel) {
179
+ if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
180
+ throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
181
+ // The control receipt must precede the input. Reapplying the same resolved model
182
+ // after a crash is safe; never retry an uncertain prompt to retry configuration.
183
+ this.modelChangeInFlight = true
184
+ try {
185
+ await this.query.setModel(command.model)
186
+ }
187
+ finally {
188
+ this.modelChangeInFlight = false
189
+ }
190
+ this.metadata.model = command.model
191
+ this.init.requestedModel = command.model
192
+ options.signal?.throwIfAborted()
193
+ }
194
+ this.modelChangeReady = false
164
195
  // Register before the push: the CLI's queued receipt can outrun the local push resolution,
165
196
  // and a receipt with no registered waiter would be lost.
166
197
  const clientMessageId = deriveMessageId(command.commandId)
@@ -374,6 +405,7 @@ export class ClaudeRun implements AgentRun {
374
405
  private async pushOwnedStream(message: SDKUserMessage): Promise<void> {
375
406
  try {
376
407
  await this.input.push(message)
408
+ this.recordStartup?.('first_input_queued')
377
409
  }
378
410
  catch (error) {
379
411
  if (error instanceof AgentRunStateError) {
@@ -401,7 +433,13 @@ export class ClaudeRun implements AgentRun {
401
433
  this.completeInitialization(message)
402
434
  continue
403
435
  }
404
- for (const draft of mapClaudeMessage(message, this.eventMapperState)) this.pushEvent(draft)
436
+ const completeMessage = message.type === 'user'
437
+ ? await hydrateClaudeToolOutput(message, {
438
+ transcriptPath: this.metadata.transcriptPath,
439
+ toolNameById: this.eventMapperState.toolNameById,
440
+ })
441
+ : message
442
+ for (const draft of mapClaudeMessage(completeMessage, this.eventMapperState)) this.pushEvent(draft)
405
443
  }
406
444
  // An intentional stop already finalized the run; the ended stream is expected then.
407
445
  if (this.finished)
@@ -437,6 +475,7 @@ export class ClaudeRun implements AgentRun {
437
475
  if (!effectiveModel) {
438
476
  throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
439
477
  }
478
+ this.metadata.model = effectiveModel
440
479
 
441
480
  this.pushEvent({ type: 'session.started', payload: this.metadata })
442
481
  this.pushEvent(sessionConfiguredEvent({
@@ -449,6 +488,7 @@ export class ClaudeRun implements AgentRun {
449
488
  this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
450
489
  for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
451
490
  this.initialized = true
491
+ this.recordStartup?.('session_ready')
452
492
  this.initializationResolve()
453
493
  }
454
494
 
@@ -0,0 +1,34 @@
1
+ export type ClaudeStartupPhase = 'query_created' | 'control_ready' | 'control_failed' | 'first_input_queued' | 'session_ready'
2
+
3
+ export interface ClaudeStartupTiming {
4
+ phase: ClaudeStartupPhase
5
+ elapsedMs: number
6
+ }
7
+
8
+ /** Payload-free diagnostics only; never becomes an execution readiness barrier. */
9
+ export function createClaudeStartupTiming(
10
+ observer?: (timing: ClaudeStartupTiming) => void,
11
+ now: () => number = () => performance.now(),
12
+ ): (phase: ClaudeStartupPhase) => void {
13
+ const started = now()
14
+ const seen = new Set<ClaudeStartupPhase>()
15
+ return (phase) => {
16
+ if (!observer || seen.has(phase))
17
+ return
18
+ seen.add(phase)
19
+ try {
20
+ observer({ phase, elapsedMs: Math.max(0, now() - started) })
21
+ }
22
+ catch {
23
+ // Observability must not reject a command or stop the provider stream.
24
+ }
25
+ }
26
+ }
27
+
28
+ export function observeClaudeControlInitialization(
29
+ initialization: Promise<unknown>,
30
+ record: (phase: ClaudeStartupPhase) => void,
31
+ ): void {
32
+ // Discard account/configuration response and error bodies, which may be sensitive.
33
+ void initialization.then(() => record('control_ready'), () => record('control_failed'))
34
+ }
@@ -29,6 +29,17 @@ export interface CodexApiKeyAuthProvider {
29
29
  getApiKey: (input?: CodexAuthTokenRequest) => Promise<CodexApiKeyCredentials>
30
30
  }
31
31
 
32
+ export interface CodexBedrockCredentials {
33
+ bearerToken: string
34
+ expiresAt: number
35
+ generation: number
36
+ }
37
+
38
+ export interface CodexBedrockAuthProvider {
39
+ getCredentials: (input?: CodexAuthTokenRequest) => Promise<CodexBedrockCredentials>
40
+ }
41
+
32
42
  export type CodexAuthentication
33
43
  = | { kind: 'chatgpt', provider: CodexAuthProvider }
34
44
  | { kind: 'api-key', provider: CodexApiKeyAuthProvider, modelProvider?: string }
45
+ | { kind: 'amazon-bedrock', region: string, provider: CodexBedrockAuthProvider }
@@ -0,0 +1,6 @@
1
+ /** Raw runtime diagnostics can contain bearer credentials and request bodies. */
2
+ export function bedrockDiagnosticMessage(source: 'stderr' | 'invalid-json-rpc'): string {
3
+ return source === 'stderr'
4
+ ? 'Codex emitted a provider diagnostic; raw content withheld.'
5
+ : 'Codex emitted an invalid JSON-RPC line; raw content withheld.'
6
+ }
@@ -0,0 +1,79 @@
1
+ import type { CodexBedrockAuthProvider, CodexBedrockCredentials } from './codex-auth'
2
+ import { AgentPromptDeferredError, AgentProviderRuntimeError } from '../agent-runtime-error'
3
+
4
+ export function validateBedrockCredentials(credentials: CodexBedrockCredentials, now = Date.now()): void {
5
+ if (!credentials.bearerToken || !Number.isFinite(credentials.expiresAt) || credentials.expiresAt <= now
6
+ || !Number.isSafeInteger(credentials.generation) || credentials.generation < 1) {
7
+ throw new AgentProviderRuntimeError({ message: 'Codex Bedrock credentials are invalid or expired.' })
8
+ }
9
+ }
10
+
11
+ /** Updates authentication only; never dispatches or retries a provider prompt. */
12
+ export class CodexBedrockSessionAuth {
13
+ private current: CodexBedrockCredentials
14
+ private pending: Promise<void> | undefined
15
+ private terminalFailure: Error | undefined
16
+
17
+ constructor(private readonly input: {
18
+ initial: CodexBedrockCredentials
19
+ provider: CodexBedrockAuthProvider
20
+ login: (credentials: CodexBedrockCredentials) => Promise<void>
21
+ isActive: () => boolean
22
+ now?: () => number
23
+ refreshTimeoutMs?: number
24
+ }) {
25
+ this.current = { ...input.initial }
26
+ }
27
+
28
+ async beforePrompt(signal?: AbortSignal): Promise<void> {
29
+ signal?.throwIfAborted()
30
+ if (this.terminalFailure)
31
+ throw this.terminalFailure
32
+ const now = this.input.now ?? Date.now
33
+ if (this.current.expiresAt - now() > 5 * 60_000)
34
+ return
35
+ if (this.input.isActive())
36
+ throw new AgentPromptDeferredError({ message: 'Bedrock credential refresh requires an idle turn boundary.' })
37
+ this.pending ??= this.boundedRefresh(now).finally(() => {
38
+ this.pending = undefined
39
+ })
40
+ await this.pending
41
+ signal?.throwIfAborted()
42
+ }
43
+
44
+ private async boundedRefresh(now: () => number): Promise<void> {
45
+ const abort = new AbortController()
46
+ let timer: ReturnType<typeof setTimeout> | undefined
47
+ const deadline = new Promise<never>((_resolve, reject) => {
48
+ timer = setTimeout(() => {
49
+ this.terminalFailure = new AgentProviderRuntimeError({ message: 'Bedrock authentication refresh timed out; the provider connection must be reopened before dispatch.' })
50
+ abort.abort(this.terminalFailure)
51
+ reject(this.terminalFailure)
52
+ }, this.input.refreshTimeoutMs ?? 30_000)
53
+ })
54
+ try {
55
+ await Promise.race([this.refresh(now, abort.signal), deadline])
56
+ }
57
+ finally {
58
+ clearTimeout(timer)
59
+ }
60
+ }
61
+
62
+ private async refresh(now: () => number, signal: AbortSignal): Promise<void> {
63
+ // A caller abort must not cancel refresh shared by other pending callers.
64
+ const credentials = await this.input.provider.getCredentials({ signal })
65
+ signal.throwIfAborted()
66
+ validateBedrockCredentials(credentials, now())
67
+ if (credentials.expiresAt - now() <= 5 * 60_000)
68
+ throw new AgentProviderRuntimeError({ message: 'Bedrock credentials expire inside the refresh window.' })
69
+ if (credentials.generation < this.current.generation
70
+ || (credentials.generation === this.current.generation && (credentials.bearerToken !== this.current.bearerToken || credentials.expiresAt !== this.current.expiresAt))) {
71
+ throw new AgentProviderRuntimeError({ message: 'Bedrock credential generation is stale or conflicting.' })
72
+ }
73
+ if (this.input.isActive())
74
+ throw new AgentPromptDeferredError({ message: 'Bedrock credential refresh raced an active turn.' })
75
+ await this.input.login(credentials)
76
+ signal.throwIfAborted()
77
+ this.current = { ...credentials }
78
+ }
79
+ }
@@ -323,7 +323,10 @@ function toolCallCompletion(item: CodexCompletedItem): { status: AgentToolCallSt
323
323
  return { status: item.status === 'failed' ? 'failed' : 'completed', output: imageGenerationResult(item) }
324
324
  if (item.type === 'commandExecution') {
325
325
  const status = item.exitCode === undefined || item.exitCode === null || item.exitCode === 0 ? 'completed' : 'failed'
326
- return { status, output: commandExecutionResult(item, status), error: 'error' in item && typeof item.error === 'string' ? { message: item.error } : undefined }
326
+ const error = 'error' in item && typeof item.error === 'string'
327
+ ? { message: item.error }
328
+ : status === 'failed' ? { message: `Command exited with code ${item.exitCode}.` } : undefined
329
+ return { status, output: commandExecutionResult(item, status), error }
327
330
  }
328
331
  if (item.type === 'dynamicToolCall')
329
332
  return { status: item.success === false ? 'failed' : 'completed', output: dynamicToolResult(item) }
@@ -401,7 +404,10 @@ function hookPromptText(item: CodexHookPromptItem): string | undefined {
401
404
  function commandExecutionResult(item: Extract<CodexCompletedItem, { type: 'commandExecution' }>, status: 'completed' | 'failed'): AgentToolResult | undefined {
402
405
  // Stdout is content, not a label: whitespace and an explicitly empty
403
406
  // successful output must survive normalization into the complete step.
404
- const text = item.aggregatedOutput
407
+ // Native build_command_execution_end_item encodes empty captured output as
408
+ // null, alongside a concrete exit code. That authoritative terminal receipt
409
+ // differs from an incomplete item whose output and exit code are both absent.
410
+ const text = item.aggregatedOutput ?? (Number.isInteger(item.exitCode) ? '' : undefined)
405
411
  if (typeof text !== 'string')
406
412
  return undefined
407
413
 
@@ -70,12 +70,13 @@ export class CodexJsonRpcClient {
70
70
  options: {
71
71
  cwd: string
72
72
  environment?: Record<string, string | undefined>
73
+ configOverrides?: readonly string[]
73
74
  onMessage: (message: CodexJsonRpcMessage) => void
74
75
  onInvalidLine: (line: string, error: unknown) => void
75
76
  onStderr: (line: string) => void
76
77
  },
77
78
  ) {
78
- this.child = spawn(executable, ['app-server', '--listen', 'stdio://'], {
79
+ this.child = spawn(executable, ['app-server', '--listen', 'stdio://', ...(options.configOverrides ?? []).flatMap(value => ['-c', value])], {
79
80
  cwd: options.cwd,
80
81
  env: options.environment,
81
82
  stdio: 'pipe',
@@ -1,9 +1,10 @@
1
1
  import type { AgentReasoningEffort, AgentToolDefinition, CodexModelId } from '../../protocol'
2
2
  import type { AgentOperationOptions } from '../agent-operation'
3
3
  import type { AgentProvider, AgentSessionOpenInput, AgentSessionResumeInput } from '../agent-provider'
4
+ import type { AgentRun } from '../agent-run'
4
5
  import type { AgentTool } from '../agent-tool'
5
6
  import type { InstructionComposer } from '../instructions'
6
- import type { CodexAuthentication, CodexAuthProvider, CodexAuthRefreshInput, CodexAuthTokens } from './codex-auth'
7
+ import type { CodexAuthentication, CodexAuthProvider, CodexAuthRefreshInput, CodexAuthTokens, CodexBedrockCredentials } from './codex-auth'
7
8
  import type { CodexEventMapperState, CodexNotification } from './codex-event-mapper'
8
9
  import type { CodexJsonRpcMessage, CodexServerRequest } from './codex-json-rpc-client'
9
10
  import type { ClientRequestResponsesByMethod, CodexChatGPTAuthTokensRefreshParams } from './codex-protocol'
@@ -16,7 +17,10 @@ import { AgentNotAcceptedError, AgentProviderMetadataError, AgentProviderProtoco
16
17
  import { sessionConfiguredEvent } from '../agent-session-events'
17
18
  import { selectAgentTools, toolByName } from '../agent-tool'
18
19
  import { emptyInstructionComposer } from '../instructions'
20
+ import { ModelRebindingRun } from '../model-rebinding-run'
19
21
  import { UserInputRequestStore, userInputRequestStoreRootFromTranscriptPath } from '../user-input-request'
22
+ import { bedrockDiagnosticMessage } from './codex-bedrock-diagnostics'
23
+ import { CodexBedrockSessionAuth, validateBedrockCredentials } from './codex-bedrock-session-auth'
20
24
  import { cacheCodexThreadMetadata, codexNotificationFromMessage, createCodexEventMapperState, mapCodexNotification } from './codex-event-mapper'
21
25
  import { CodexJsonRpcClient, isCodexServerRequest } from './codex-json-rpc-client'
22
26
  import { CodexRun } from './codex-run'
@@ -38,17 +42,21 @@ export interface CodexProviderConfig {
38
42
  environment?: Record<string, string | undefined>
39
43
  onStartupStage?: (fact: CodexStartupStageFact) => void
40
44
  instructions?: InstructionComposer
45
+ /** Service-owned public model to inference model mapping. */
46
+ resolveInferenceModel?: (model: string) => string
41
47
  }
42
48
 
43
49
  interface CodexRunHolder {
44
50
  toolsByName: Map<string, AgentTool>
45
51
  run?: CodexRun
46
52
  authTokens?: CodexAuthTokens
53
+ bedrockAuth?: CodexBedrockSessionAuth
47
54
  startupFailure?: Error
48
55
  }
49
56
  type CodexAuthMaterial
50
57
  = | { kind: 'chatgpt', tokens: CodexAuthTokens }
51
58
  | { kind: 'api-key', apiKey: string }
59
+ | { kind: 'amazon-bedrock', region: string, credentials: CodexBedrockCredentials }
52
60
  interface CodexConnection {
53
61
  client: CodexJsonRpcClient
54
62
  abortController: AbortController
@@ -78,6 +86,8 @@ export class CodexProvider implements AgentProvider<'codex'> {
78
86
  private readonly auth: CodexAuthentication
79
87
  private readonly onStartupStage: ((fact: CodexStartupStageFact) => void) | undefined
80
88
  private readonly instructions: InstructionComposer
89
+ private readonly resolveInferenceModel: (model: string) => string
90
+ private readonly hasInferenceModelMapping: boolean
81
91
 
82
92
  private constructor(config: CodexProviderConfig) {
83
93
  this.auth = normalizeCodexAuthentication(config.auth)
@@ -86,21 +96,23 @@ export class CodexProvider implements AgentProvider<'codex'> {
86
96
  this.environment = config.environment ?? {}
87
97
  this.onStartupStage = config.onStartupStage
88
98
  this.instructions = config.instructions ?? emptyInstructionComposer
99
+ this.resolveInferenceModel = config.resolveInferenceModel ?? (model => model)
100
+ this.hasInferenceModelMapping = config.resolveInferenceModel !== undefined
89
101
  }
90
102
 
91
103
  static create(config: CodexProviderConfig): CodexProvider {
92
104
  return new CodexProvider(config)
93
105
  }
94
106
 
95
- async openSession(input: AgentSessionOpenInput<'codex'>, options: AgentOperationOptions = {}): Promise<CodexRun> {
96
- return await this.startSession({
107
+ async openSession(input: AgentSessionOpenInput<'codex'>, options: AgentOperationOptions = {}): Promise<AgentRun> {
108
+ const run = await this.startSession({
97
109
  config: input,
98
110
  options,
99
111
  stage: 'thread-start',
100
112
  requestThread: async ({ client, tools }): Promise<CodexThreadBootstrapResult> => await client.request('thread/start', {
101
113
  cwd: input.cwd,
102
- model: input.model,
103
- modelProvider: this.auth.kind === 'api-key' ? this.auth.modelProvider : undefined,
114
+ model: input.model ? this.resolveInferenceModel(input.model) : undefined,
115
+ modelProvider: this.modelProvider(),
104
116
  ephemeral: false,
105
117
  approvalPolicy: 'never',
106
118
  sandbox: 'danger-full-access',
@@ -108,9 +120,26 @@ export class CodexProvider implements AgentProvider<'codex'> {
108
120
  dynamicTools: toCodexDynamicTools(tools),
109
121
  }),
110
122
  })
123
+ return this.wrapBedrockRun(run, input)
124
+ }
125
+
126
+ async resumeSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<AgentRun> {
127
+ return this.wrapBedrockRun(await this.resumeRawSession(input, options), input)
128
+ }
129
+
130
+ private wrapBedrockRun(run: CodexRun, input: AgentSessionOpenInput<'codex'>): AgentRun {
131
+ if (this.auth.kind !== 'amazon-bedrock')
132
+ return run
133
+ const roots = new Set(input.workspaceRepositoryRoots)
134
+ return new ModelRebindingRun(run, async (model, signal) => await this.resumeRawSession({
135
+ ...input,
136
+ model: codexModelIdSchema.parse(model),
137
+ providerSessionId: run.metadata.providerSessionId,
138
+ workspaceRepositoryRoots: [...roots],
139
+ }, { signal }), root => roots.add(root))
111
140
  }
112
141
 
113
- async resumeSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<CodexRun> {
142
+ private async resumeRawSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<CodexRun> {
114
143
  return await this.startSession({
115
144
  config: input,
116
145
  options,
@@ -120,8 +149,8 @@ export class CodexProvider implements AgentProvider<'codex'> {
120
149
  cwd: input.cwd,
121
150
  approvalPolicy: 'never',
122
151
  sandbox: 'danger-full-access',
123
- model: input.model,
124
- modelProvider: this.auth.kind === 'api-key' ? this.auth.modelProvider : undefined,
152
+ model: input.model ? this.resolveInferenceModel(input.model) : undefined,
153
+ modelProvider: this.modelProvider(),
125
154
  developerInstructions: this.instructions({ provider: 'codex', tools }),
126
155
  }),
127
156
  validateThread: (result) => {
@@ -320,6 +349,12 @@ export class CodexProvider implements AgentProvider<'codex'> {
320
349
  }
321
350
 
322
351
  private async getAuthMaterial(signal: AbortSignal): Promise<CodexAuthMaterial> {
352
+ if (this.auth.kind === 'amazon-bedrock') {
353
+ const credentials = await this.auth.provider.getCredentials({ signal })
354
+ signal.throwIfAborted()
355
+ validateBedrockCredentials(credentials)
356
+ return { kind: 'amazon-bedrock', region: this.auth.region, credentials }
357
+ }
323
358
  if (this.auth.kind === 'api-key') {
324
359
  const credentials = await this.auth.provider.getApiKey({ signal })
325
360
  return { kind: 'api-key', apiKey: credentials.apiKey }
@@ -328,6 +363,10 @@ export class CodexProvider implements AgentProvider<'codex'> {
328
363
  return { kind: 'chatgpt', tokens }
329
364
  }
330
365
 
366
+ private modelProvider(): string | undefined {
367
+ return this.auth.kind === 'amazon-bedrock' ? 'amazon-bedrock' : this.auth.kind === 'api-key' ? this.auth.modelProvider : undefined
368
+ }
369
+
331
370
  private async openConnection(input: {
332
371
  sessionId: string
333
372
  cwd: string
@@ -347,11 +386,19 @@ export class CodexProvider implements AgentProvider<'codex'> {
347
386
  client = new CodexJsonRpcClient(this.executable, {
348
387
  cwd,
349
388
  environment: this.environment,
389
+ configOverrides: this.auth.kind === 'amazon-bedrock'
390
+ ? ['cli_auth_credentials_store="ephemeral"', 'model_provider="amazon-bedrock"', `model_providers.amazon-bedrock.aws.region=${JSON.stringify(this.auth.region)}`, 'model_providers.amazon-bedrock.aws.wire_api="responses"']
391
+ : undefined,
350
392
  onMessage: (message) => {
351
393
  messageQueue = this.enqueueMessageHandling(messageQueue, { message, holder, state, client, signal: abortController.signal })
352
394
  },
353
- onInvalidLine: (line, error) => holder.run?.pushEvent({ type: 'error', payload: { message: `Invalid Codex JSON-RPC line: ${line.slice(0, 200)}`, fatal: false, source: 'transport', error: serializeAgentError(error) } }),
354
- onStderr: line => holder.run?.pushEvent({ type: 'error', payload: { message: line, fatal: false, source: 'provider' } }),
395
+ onInvalidLine: (line, error) => holder.run?.pushEvent({
396
+ type: 'error',
397
+ payload: this.auth.kind === 'amazon-bedrock'
398
+ ? { message: bedrockDiagnosticMessage('invalid-json-rpc'), fatal: false, source: 'transport' }
399
+ : { message: `Invalid Codex JSON-RPC line: ${line.slice(0, 200)}`, fatal: false, source: 'transport', error: serializeAgentError(error) },
400
+ }),
401
+ onStderr: line => holder.run?.pushEvent({ type: 'error', payload: { message: this.auth.kind === 'amazon-bedrock' ? bedrockDiagnosticMessage('stderr') : line, fatal: false, source: 'provider' } }),
355
402
  })
356
403
  }
357
404
  catch (error) {
@@ -483,12 +530,15 @@ export class CodexProvider implements AgentProvider<'codex'> {
483
530
  })
484
531
  }
485
532
 
533
+ if (this.hasInferenceModelMapping && config.model && result.model && result.model !== this.resolveInferenceModel(config.model))
534
+ throw new AgentProviderProtocolError({ message: 'Codex returned a model that does not match the configured inference binding.' })
535
+
486
536
  const metadata = {
487
537
  provider: 'codex' as const,
488
538
  sessionId: config.sessionId,
489
539
  providerSessionId: thread.id,
490
540
  cwd: thread.cwd ?? config.cwd,
491
- model: firstCodexModelId(effectiveModel),
541
+ model: firstCodexModelId(this.hasInferenceModelMapping ? config.model ?? effectiveModel : effectiveModel),
492
542
  transcriptPath: thread.path,
493
543
  }
494
544
  throwIfStartupFailed(holder)
@@ -507,8 +557,10 @@ export class CodexProvider implements AgentProvider<'codex'> {
507
557
  ])
508
558
  startupSignal?.throwIfAborted()
509
559
  const run = new CodexRun({
560
+ beforePrompt: holder.bedrockAuth ? signal => holder.bedrockAuth!.beforePrompt(signal) : undefined,
510
561
  metadata,
511
562
  reasoningEffort: config.reasoningEffort,
563
+ resolveInferenceModel: this.resolveInferenceModel,
512
564
  client,
513
565
  requestedCwd: config.cwd,
514
566
  workspaceRepositoryRoots: config.workspaceRepositoryRoots,
@@ -529,7 +581,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
529
581
  holder.run = run
530
582
 
531
583
  run.pushEvent({ type: 'session.started', payload: run.metadata })
532
- run.pushEvent(sessionConfiguredEvent({ model: effectiveModel, cwd: run.metadata.cwd, reasoningEffort: config.reasoningEffort, tools: config.tools, environment: this.environment }))
584
+ run.pushEvent(sessionConfiguredEvent({ model: run.metadata.model ?? effectiveModel, cwd: run.metadata.cwd, reasoningEffort: config.reasoningEffort, tools: config.tools, environment: this.environment }))
533
585
  run.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
534
586
  return run
535
587
  }
@@ -615,6 +667,23 @@ export class CodexProvider implements AgentProvider<'codex'> {
615
667
  }): Promise<void> {
616
668
  const { client, holder, authMaterial } = input
617
669
  throwIfStartupFailed(holder)
670
+ if (authMaterial.kind === 'amazon-bedrock') {
671
+ if (authMaterial.credentials.expiresAt <= Date.now())
672
+ throw new AgentProviderRuntimeError({ message: 'Codex Bedrock credentials expired before login.' })
673
+ await client.request('account/login/start', { type: 'amazonBedrock', apiKey: authMaterial.credentials.bearerToken, region: authMaterial.region })
674
+ throwIfStartupFailed(holder)
675
+ if (this.auth.kind !== 'amazon-bedrock')
676
+ throw new AgentProviderProtocolError({ message: 'Bedrock authentication mode changed during login.' })
677
+ holder.bedrockAuth = new CodexBedrockSessionAuth({
678
+ initial: authMaterial.credentials,
679
+ provider: this.auth.provider,
680
+ isActive: () => holder.run?.hasActiveTurn() ?? false,
681
+ login: async (credentials) => {
682
+ await client.request('account/login/start', { type: 'amazonBedrock', apiKey: credentials.bearerToken, region: authMaterial.region })
683
+ },
684
+ })
685
+ return
686
+ }
618
687
  if (authMaterial.kind === 'api-key') {
619
688
  await client.request('account/login/start', { type: 'apiKey', apiKey: authMaterial.apiKey })
620
689
  throwIfStartupFailed(holder)
@@ -35,9 +35,12 @@ interface PreparedCodexTurn {
35
35
  input: CodexUserInput[]
36
36
  commandId: string
37
37
  origin: 'user' | 'supervisor'
38
+ model?: string
38
39
  }
39
40
 
40
41
  interface CodexRunInput {
42
+ beforePrompt?: (signal?: AbortSignal) => Promise<void>
43
+ resolveInferenceModel?: (model: string) => string
41
44
  metadata: AgentRunMetadata
42
45
  reasoningEffort?: AgentReasoningEffort
43
46
  client: CodexJsonRpcClient
@@ -56,6 +59,8 @@ export class CodexRun implements AgentRun {
56
59
  readonly events: AgentEventStream
57
60
  readonly metadata: AgentRunMetadata
58
61
  private readonly client: CodexJsonRpcClient
62
+ private readonly beforePrompt: ((signal?: AbortSignal) => Promise<void>) | undefined
63
+ private readonly resolveInferenceModel: (model: string) => string
59
64
  private readonly reasoningEffort: AgentReasoningEffort | undefined
60
65
  private readonly onStop: () => void
61
66
  private readonly userInputRequests: UserInputRequestStore
@@ -74,6 +79,8 @@ export class CodexRun implements AgentRun {
74
79
  constructor(input: CodexRunInput) {
75
80
  this.metadata = input.metadata
76
81
  this.client = input.client
82
+ this.beforePrompt = input.beforePrompt
83
+ this.resolveInferenceModel = input.resolveInferenceModel ?? (model => model)
77
84
  this.reasoningEffort = input.reasoningEffort
78
85
  this.onStop = input.onStop
79
86
  this.userInputRequests = input.userInputRequests
@@ -225,6 +232,10 @@ export class CodexRun implements AgentRun {
225
232
  // and answering a phantom turn id, so the next-vs-active race must be refused from adapter
226
233
  // state before any request — nothing is prepared or retained.
227
234
  this.deferNextWhileTurnActive(command.mode)
235
+ if (this.beforePrompt)
236
+ await this.beforePrompt(options.signal)
237
+ this.assertRunning()
238
+ this.deferNextWhileTurnActive(command.mode)
228
239
  const prepared = await this.prepareTurn({
229
240
  prompt: command.prompt,
230
241
  commandId: command.commandId,
@@ -232,10 +243,13 @@ export class CodexRun implements AgentRun {
232
243
  author: command.author,
233
244
  options,
234
245
  })
246
+ prepared.model = command.model
235
247
  // A turn/started fact may have reduced during attachment preparation.
236
248
  this.deferNextWhileTurnActive(command.mode)
237
249
 
238
250
  const activeTurnId = this.activeTurnId
251
+ if (activeTurnId && command.model && command.model !== this.metadata.model)
252
+ throw new AgentPromptDeferredError({ message: 'Codex model changes require an idle turn boundary.' })
239
253
  if (command.mode === 'next' || !activeTurnId)
240
254
  return await this.startPreparedTurn(prepared)
241
255
 
@@ -261,6 +275,10 @@ export class CodexRun implements AgentRun {
261
275
  return { kind: 'turn', turnId: result.turnId, placement: 'steered' }
262
276
  }
263
277
 
278
+ hasActiveTurn(): boolean {
279
+ return this.activeTurnId !== undefined
280
+ }
281
+
264
282
  private deferNextWhileTurnActive(mode: SendPromptCommand['mode']): void {
265
283
  if (mode !== 'next' || !this.activeTurnId)
266
284
  return
@@ -336,6 +354,8 @@ export class CodexRun implements AgentRun {
336
354
  }
337
355
 
338
356
  private async startPreparedTurn(prepared: PreparedCodexTurn): Promise<AgentPromptAcceptance> {
357
+ const publicModel = prepared.model ?? this.metadata.model
358
+ const inferenceModel = publicModel ? this.resolveInferenceModel(publicModel) : undefined
339
359
  const clientUserMessageId = this.rememberPromptCommand(prepared.commandId, prepared.origin)
340
360
  let result: ClientRequestResponsesByMethod['turn/start']
341
361
  try {
@@ -343,7 +363,7 @@ export class CodexRun implements AgentRun {
343
363
  threadId: this.metadata.providerSessionId,
344
364
  input: prepared.input,
345
365
  cwd: this.metadata.cwd,
346
- model: this.metadata.model,
366
+ model: inferenceModel,
347
367
  effort: codexEffort(this.reasoningEffort),
348
368
  summary: codexReasoningSummary,
349
369
  approvalPolicy: 'never',
@@ -355,6 +375,8 @@ export class CodexRun implements AgentRun {
355
375
  throw mapTurnTransportLoss(error)
356
376
  }
357
377
  this.activeTurnId = result.turn.id
378
+ if (prepared.model)
379
+ this.metadata.model = prepared.model
358
380
  return { kind: 'turn', turnId: result.turn.id, placement: 'started' }
359
381
  }
360
382
 
@@ -0,0 +1,166 @@
1
+ import type { AgentEvent, AgentRunMetadata, SendPromptCommand } from '../protocol'
2
+ import type { AgentOperationOptions } from './agent-operation'
3
+ import type { AgentPromptAcceptance, AgentRun } from './agent-run'
4
+ import { AgentEventStream } from './agent-event-stream'
5
+ import { AgentNotAcceptedError, AgentPromptDeferredError, AgentRunStateError } from './agent-runtime-error'
6
+
7
+ /** Keeps one public event stream while replacing an idle provider connection. */
8
+ export class ModelRebindingRun implements AgentRun {
9
+ readonly events: AgentEventStream
10
+ readonly metadata: AgentRunMetadata
11
+ private run: AgentRun
12
+ private pump: Promise<void>
13
+ private activeTurn: string | undefined
14
+ private lastEndedTurn: string | undefined
15
+ private dispatching: Promise<AgentPromptAcceptance> | undefined
16
+ private changing = false
17
+ private needsRebind = false
18
+ private stopped = false
19
+ private workspacePrepared = false
20
+ private workspaceChanging = false
21
+ private readonly lifetime = new AbortController()
22
+
23
+ constructor(run: AgentRun, private readonly reopen: (model: string, signal: AbortSignal) => Promise<AgentRun>, private readonly rootAdded?: (root: string) => void) {
24
+ this.run = run
25
+ this.metadata = { ...run.metadata }
26
+ this.events = new AgentEventStream(this.metadata)
27
+ this.pump = this.consume(run)
28
+ }
29
+
30
+ prepareWorkspace: AgentRun['prepareWorkspace'] = async (options) => {
31
+ await this.withWorkspaceChange(async () => {
32
+ await this.run.prepareWorkspace(options)
33
+ this.workspacePrepared = true
34
+ })
35
+ }
36
+
37
+ addWorkspaceRepositoryRoot: AgentRun['addWorkspaceRepositoryRoot'] = async (root, options) => {
38
+ await this.withWorkspaceChange(async () => {
39
+ await this.run.addWorkspaceRepositoryRoot(root, options)
40
+ this.rootAdded?.(root)
41
+ })
42
+ }
43
+
44
+ respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.run.respondUserInput(command, options)
45
+ interrupt: AgentRun['interrupt'] = async (command, options) => await this.run.interrupt(command, options)
46
+
47
+ async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
48
+ if (this.dispatching || this.workspaceChanging)
49
+ throw new AgentPromptDeferredError({ message: 'A prompt admission is already in progress.' })
50
+ const task = this.dispatchPrompt(command, options)
51
+ this.dispatching = task
52
+ try {
53
+ return await task
54
+ }
55
+ finally { this.dispatching = undefined }
56
+ }
57
+
58
+ private async dispatchPrompt(command: SendPromptCommand, options: AgentOperationOptions): Promise<AgentPromptAcceptance> {
59
+ if (this.stopped)
60
+ throw new AgentRunStateError({ message: 'Cannot send a prompt after the run stopped.' })
61
+ if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
62
+ throw new AgentRunStateError({ message: 'Prompt does not target the bound run.' })
63
+ if (this.changing)
64
+ throw new AgentPromptDeferredError({ message: 'The provider model is being rebound.' })
65
+ const model = command.model ?? this.metadata.model
66
+ if (model && (this.needsRebind || model !== this.metadata.model)) {
67
+ if (this.activeTurn)
68
+ throw new AgentPromptDeferredError({ message: 'Model changes require an idle turn boundary.' })
69
+ this.changing = true
70
+ const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : []), AbortSignal.timeout(10_000)])
71
+ let replacement: AgentRun | undefined
72
+ let identityMismatch = false
73
+ try {
74
+ signal.throwIfAborted()
75
+ this.needsRebind = true
76
+ await this.run.stop({ type: 'agent.stop', ...this.target(), reason: 'model_rebind' }, { signal })
77
+ await this.pump
78
+ signal.throwIfAborted()
79
+ replacement = await this.reopen(model, signal)
80
+ signal.throwIfAborted()
81
+ if (replacement.metadata.providerSessionId !== this.metadata.providerSessionId || replacement.metadata.sessionId !== this.metadata.sessionId || replacement.metadata.provider !== this.metadata.provider || replacement.metadata.model !== model) {
82
+ identityMismatch = true
83
+ throw new Error('Provider rebind changed the persisted identity or model.')
84
+ }
85
+ if (this.workspacePrepared)
86
+ await replacement.prepareWorkspace({ signal })
87
+ signal.throwIfAborted()
88
+ this.run = replacement
89
+ this.metadata.model = replacement.metadata.model
90
+ this.needsRebind = false
91
+ this.pump = this.consume(replacement, true)
92
+ }
93
+ catch {
94
+ if (replacement)
95
+ await replacement.stop({ type: 'agent.stop', provider: replacement.metadata.provider, sessionId: replacement.metadata.sessionId, providerSessionId: replacement.metadata.providerSessionId, reason: 'model_rebind_failed' }).catch(() => undefined)
96
+ throw new AgentNotAcceptedError({ message: 'Provider model rebind failed before prompt dispatch.', recovery: identityMismatch ? { kind: 'terminal', reason: 'model_rebind_identity_mismatch' } : { kind: 'retry', reason: 'model_rebind_failed', retryAfterMs: 1_000 } })
97
+ }
98
+ finally {
99
+ this.changing = false
100
+ }
101
+ }
102
+ this.lifetime.signal.throwIfAborted()
103
+ options.signal?.throwIfAborted()
104
+ const acceptance = await this.run.sendPrompt(command, { ...options, signal: AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]) })
105
+ if (acceptance.kind === 'turn' && acceptance.turnId !== this.lastEndedTurn)
106
+ this.activeTurn = acceptance.turnId
107
+ return acceptance
108
+ }
109
+
110
+ stop: AgentRun['stop'] = async (command, options) => {
111
+ if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
112
+ throw new AgentRunStateError({ message: 'Stop does not target the bound run.' })
113
+ this.stopped = true
114
+ this.lifetime.abort()
115
+ await this.run.stop(command, options)
116
+ await this.dispatching?.catch(() => undefined)
117
+ await this.pump
118
+ this.events.end()
119
+ }
120
+
121
+ private target() {
122
+ return { provider: this.metadata.provider, sessionId: this.metadata.sessionId, providerSessionId: this.metadata.providerSessionId }
123
+ }
124
+
125
+ private async withWorkspaceChange(operation: () => Promise<void>): Promise<void> {
126
+ if (this.stopped)
127
+ throw new AgentRunStateError({ message: 'The run stopped.' })
128
+ if (this.dispatching || this.workspaceChanging)
129
+ throw new AgentPromptDeferredError({ message: 'A provider operation is already in progress.' })
130
+ this.workspaceChanging = true
131
+ try {
132
+ await operation()
133
+ }
134
+ finally { this.workspaceChanging = false }
135
+ }
136
+
137
+ private async consume(run: AgentRun, rebound = false): Promise<void> {
138
+ try {
139
+ for await (const event of run.events) {
140
+ if (event.type === 'turn.started' && event.actor.type === 'main')
141
+ this.activeTurn = event.turnId
142
+ if (event.type === 'turn.ended' && event.actor.type === 'main') {
143
+ this.lastEndedTurn = event.turnId
144
+ if (event.turnId === this.activeTurn)
145
+ this.activeTurn = undefined
146
+ }
147
+ // Internal connection teardown is not product-session completion.
148
+ if (this.changing && (event.type === 'session.ended' || event.type === 'session.state.changed'))
149
+ continue
150
+ if (rebound && event.type === 'session.started')
151
+ continue
152
+ this.emit(event)
153
+ }
154
+ }
155
+ catch {
156
+ this.stopped = true
157
+ this.lifetime.abort()
158
+ this.events.push({ type: 'error', payload: { message: 'Provider event stream failed.', fatal: true, source: 'provider' } })
159
+ this.events.end()
160
+ }
161
+ }
162
+
163
+ private emit(event: AgentEvent): void {
164
+ this.events.push(event)
165
+ }
166
+ }
@@ -26,6 +26,8 @@ export const sendPromptCommandSchema = runningCommandBaseSchema.extend({
26
26
  origin: z.enum(['user', 'supervisor']).default('user'),
27
27
  prompt: agentPromptSchema,
28
28
  commandId: commandIdSchema,
29
+ /** Resolved by the application for this prompt, including after runtime recovery. */
30
+ model: z.string().trim().min(1).max(256).optional(),
29
31
  author: agentAuthorSchema.optional(),
30
32
  }).strict()
31
33
 
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/write-provenance.ts. Do not edit by hand.
2
2
  export const generatedProvenance = {
3
- version: "0.1.3",
4
- sha: "8b997b992439bb6300854fed35d461ca0fdbd3e2",
5
- buildTime: "2026-09-09T19:31:26.657Z",
3
+ version: "0.1.9",
4
+ sha: "2b4aae8268cb99a570e9a2d40d6086189c1aa19a",
5
+ buildTime: "2026-09-11T20:10:58.896Z",
6
6
  } as const
@@ -51,7 +51,13 @@ export class DurableStepProjector {
51
51
  }
52
52
 
53
53
  async process(event: AgentEvent, emit: (body: WireEventBody) => void): Promise<void> {
54
- if (['message.delta', 'tool.output.delta', 'reasoning.summary.delta'].includes(event.type)) {
54
+ // Tool stdout is not assistant text. Its canonical completion carries
55
+ // full content to detail storage and emits only a bounded preview.
56
+ // Splitting stdout into small frames would still stream heavy bytes
57
+ // through the API and defeat the individual-details boundary.
58
+ if (event.type === 'tool.output.delta')
59
+ return
60
+ if (['message.delta', 'reasoning.summary.delta'].includes(event.type)) {
55
61
  // Live deltas never become historical tool payloads. Large transport
56
62
  // deltas are split rather than dropped by the supervisor frame limit.
57
63
  const body = wrapAgentEvent(event)
@@ -128,6 +128,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
128
128
  providerSessionId: metadata.providerSessionId,
129
129
  commandId: input.commandId,
130
130
  mode: input.body.mode,
131
+ model: input.body.model,
131
132
  origin: 'user',
132
133
  prompt: input.body.prompt,
133
134
  author: input.body.user
@@ -40,6 +40,7 @@ export const wireCommandExtensionsSchema = jsonObjectSchema.refine(
40
40
  const wireSendPromptCommandSchema = z.object({
41
41
  type: z.literal('agent.send-prompt'),
42
42
  mode: agentPromptModeSchema,
43
+ model: z.string().trim().min(1).max(256).optional(),
43
44
  prompt: agentPromptSchema,
44
45
  attachments: z.array(wireInputAttachmentRefSchema).superRefine((attachments, context) => {
45
46
  const attachmentIds = new Set<string>()