@meistrari/agent-core 0.2.0-experimental.6 → 0.2.0-experimental.8

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
@@ -149,3 +149,10 @@ bypass-permissions mode as root, so run them as a regular user.
149
149
  Regenerate the experimental Codex wire types with `bun run generate:codex-protocol`.
150
150
  The generator pins Codex 0.159.0 and verifies any `CODEX_EXECUTABLE` override before
151
151
  using it. Commit generated files with generator changes; CI checks reproducibility.
152
+
153
+
154
+ ## Standalone settings changes
155
+
156
+ `agent.change-settings` carries a full `settings: { model, reasoningEffort }` pair through the supervisor wire and the agent protocol. Claude and Codex apply it only at an idle turn boundary, without submitting input, and emit `session.settings.changed` with the command ID and `appliesFrom: next_turn`. Product catalog, provider compatibility and admission ordering remain the caller's responsibility.
157
+
158
+ The supervisor advertises `capabilities["model-change"] = "v1"` in health. It commits the settings command outcome and inbox status in one SQLite transaction. After a process restart, an interrupted settings command can be reapplied idempotently; prompt commands retain their uncertain-delivery policy and are never retried by this path. Confirmed settings override the immutable bootstrap defaults when resuming the provider. Prompt and user-input-answer commands may carry resolved `settings` so their execution does not rely solely on mutable defaults.
@@ -0,0 +1,60 @@
1
+ # Model and reasoning effort changes
2
+
3
+ agent-core can update a session's model and reasoning effort without sending a
4
+ conversational prompt. Healthy Claude and Codex sessions keep the same CLI process
5
+ and provider session, preserving their warmed runtime state.
6
+
7
+ ![Claude and Codex live model and effort updates](diagrams/model-effort-change.png)
8
+
9
+ ## Standalone command
10
+
11
+ Send `agent.change-settings` with `settings: { model, reasoningEffort }` at an idle
12
+ turn boundary. The caller schedules that boundary; a busy run defers the operation.
13
+
14
+ The supervisor persists the command in its inbox and acknowledges receipt with
15
+ `command.ack`. Receipt does not mean the provider has applied the settings.
16
+
17
+ | Provider | How settings are applied | Acceptance |
18
+ | --- | --- | --- |
19
+ | Claude | `query.setModel(model)` and `query.applyFlagSettings({ effortLevel })`, skipping unchanged known values | The required control responses succeed |
20
+ | Codex | `thread/settings/update` with `{ threadId, model, effort }` | The JSON-RPC request succeeds |
21
+
22
+ After acceptance, the run updates its defaults and emits `session.settings.changed`
23
+ with `model`, `reasoningEffort`, the initiating `commandId`, and
24
+ `appliesFrom: 'next_turn'`. The supervisor commits the accepted settings together
25
+ with command completion, then sends `supervisor.command.applied`.
26
+
27
+ A provider failure does not produce a successful settings-change event or a
28
+ successful command completion. Receipt alone must never update a consumer's
29
+ display of confirmed settings.
30
+
31
+ ## Settings carried by inputs
32
+
33
+ `agent.send-prompt.settings` and `agent.respond-user-input.settings` can carry
34
+ partial settings. Omitted fields retain the currently accepted values. Prompt
35
+ settings retain the provider's `next` and `steer` behavior; the confirmation event
36
+ identifies whether the change applies to the current or next turn. Already-delivered
37
+ answers do not change defaults or emit another settings-change event.
38
+
39
+ For prompts, a supplied `settings` object takes precedence over the entire legacy
40
+ top-level `model` / `reasoningEffort` pair, even when the object is empty. Without
41
+ that object, accepted legacy fields are also persisted as default changes. Missing
42
+ legacy fields do not clear previously accepted defaults.
43
+
44
+ Claude's legacy top-level effort field must match the current accepted effort.
45
+ Use `settings.reasoningEffort` or the standalone command to change it. For example,
46
+ after starting at `medium` and successfully applying standalone `high`, a legacy
47
+ prompt specifying `high` is accepted; one specifying `medium` is rejected.
48
+
49
+ ## Recovery
50
+
51
+ The supervisor restores the latest accepted defaults across standalone commands,
52
+ prompts, and answers. For example, standalone A/high, followed by a legacy prompt
53
+ B/low, followed by `settings: { reasoningEffort: 'medium' }`, recovers as B/medium.
54
+ Failed commands and already-delivered answers do not replace those defaults.
55
+
56
+ The Bedrock wrapper retains its legacy prompt model-rebinding path. Standalone
57
+ changes bypass that path on a healthy connection. If a previous replacement failed
58
+ and left the old run stopped, the wrapper first recovers a connection with its
59
+ previously accepted settings, then applies the requested update. Recovery preserves
60
+ the provider session identity and does not replay the failed prompt.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.2.0-experimental.6",
4
+ "version": "0.2.0-experimental.8",
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",
@@ -3,6 +3,7 @@ import type {
3
3
  AgentEvent,
4
4
  AgentNotifyCommand,
5
5
  AgentRunMetadata,
6
+ ChangeSettingsAgentCommand,
6
7
  CompactContextAgentCommand,
7
8
  InterruptAgentCommand,
8
9
  RespondUserInputAgentCommand,
@@ -34,6 +35,7 @@ export interface AgentRun {
34
35
  prepareWorkspace: (options?: AgentOperationOptions) => Promise<void>
35
36
  // Providers without root registration accept the root without provider I/O.
36
37
  addWorkspaceRepositoryRoot: (root: string, options?: AgentOperationOptions) => Promise<void>
38
+ changeSettings?: (command: ChangeSettingsAgentCommand, options?: AgentOperationOptions) => Promise<void>
37
39
  sendPrompt: (command: SendPromptCommand, options?: AgentPromptOperationOptions) => Promise<AgentPromptAcceptance>
38
40
  // Optional capabilities: the Claude and Codex runs implement both; other runs may omit them.
39
41
  notify?: (command: AgentNotifyCommand, options?: AgentOperationOptions) => Promise<AgentNotificationAcceptance>
@@ -25,7 +25,7 @@ export class ClaudeCodeProcess {
25
25
  private termination: Promise<void> | undefined
26
26
  private stderr = ''
27
27
 
28
- constructor(private readonly exitTimeoutMs = CLAUDE_PROCESS_EXIT_TIMEOUT_MS) {}
28
+ constructor(private readonly exitTimeoutMs = CLAUDE_PROCESS_EXIT_TIMEOUT_MS, private readonly processLauncher?: string) {}
29
29
 
30
30
  /** The SDK `spawnClaudeCodeProcess` hook. A run spawns at most one process. */
31
31
  readonly spawn = (options: SpawnOptions): SpawnedProcess => {
@@ -33,10 +33,10 @@ export class ClaudeCodeProcess {
33
33
  throw new Error('Claude Code process already spawned for this run.')
34
34
  // `detached` makes the CLI a process-group leader. The forwarded signal aborts only after the
35
35
  // SDK's graceful stdin-EOF window, as for the SDK's own spawn.
36
- const child = spawn(options.command, options.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true })
36
+ const child = spawn(this.processLauncher ?? options.command, this.processLauncher ? [options.command, ...options.args] : options.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true })
37
37
  this.child = child
38
38
  this.exit = new Promise((resolve) => {
39
- child.once('exit', () => resolve())
39
+ child.once(this.processLauncher ? 'close' : 'exit', () => resolve())
40
40
  // A failed spawn emits `error` without `exit`; there is no process to wait for.
41
41
  child.once('error', () => {
42
42
  if (child.pid === undefined)
@@ -70,15 +70,21 @@ export class ClaudeCodeProcess {
70
70
  const exit = this.exit
71
71
  if (!child || !exit || child.pid === undefined)
72
72
  return
73
- if (await settlesWithin(exit, this.exitTimeoutMs))
73
+ // A fork/wait launcher can exit while its CLI still holds the stdio pipes.
74
+ // For launchers, await close and clean the group even if the wrapper already exited.
75
+ if (await settlesWithin(exit, this.exitTimeoutMs) && !this.processLauncher)
74
76
  return
75
77
  try {
76
78
  process.kill(-child.pid, 'SIGKILL')
77
79
  }
78
- catch {
80
+ catch (error) {
81
+ if (this.processLauncher && process.platform !== 'win32' && (error as NodeJS.ErrnoException).code !== 'ESRCH')
82
+ throw error
79
83
  child.kill('SIGKILL')
80
84
  }
81
- await settlesWithin(exit, killedExitWaitMs)
85
+ const closed = await settlesWithin(exit, killedExitWaitMs)
86
+ if (this.processLauncher && !closed)
87
+ throw new Error('Claude launcher process group did not close after SIGKILL.')
82
88
  }
83
89
  }
84
90
 
@@ -71,6 +71,14 @@ export interface ClaudeProviderConfig {
71
71
  desktop?: ClaudeDesktopConfig | null
72
72
  tools?: readonly AgentTool[]
73
73
  executable?: string
74
+ /**
75
+ * Trusted launcher invoked as <CLI executable> <CLI args>; owns any privilege drop.
76
+ * Preserve stdio and the process group: exec the CLI or keep children in that group.
77
+ * On Windows, the launcher must exec the CLI in place (group signals are unavailable).
78
+ */
79
+ processLauncher?: string
80
+ /** Private provider stores; defaults to the CLI transcript directory when omitted. */
81
+ runtimeStateRoot?: string
74
82
  environment?: Record<string, string | undefined>
75
83
  nativeToolActorBridge?: ClaudeNativeToolActorBridge
76
84
  startupDeadlineMs?: number
@@ -139,6 +147,8 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
139
147
  private readonly desktop: ClaudeDesktopConfig | null
140
148
  private readonly tools: readonly AgentTool[]
141
149
  private readonly executable: string | undefined
150
+ private readonly processLauncher: string | undefined
151
+ private readonly runtimeStateRoot: string | undefined
142
152
  private readonly environment: Record<string, string | undefined>
143
153
  private readonly nativeToolActorBridge: ClaudeNativeToolActorBridge | undefined
144
154
  private readonly startupDeadlineMs: number
@@ -156,6 +166,8 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
156
166
  this.desktop = config.desktop ?? null
157
167
  this.tools = config.tools ?? []
158
168
  this.executable = config.executable
169
+ this.processLauncher = config.processLauncher
170
+ this.runtimeStateRoot = config.runtimeStateRoot
159
171
  this.environment = config.environment ?? {}
160
172
  this.nativeToolActorBridge = config.nativeToolActorBridge
161
173
  this.startupDeadlineMs = config.startupDeadlineMs ?? CLAUDE_STARTUP_DEADLINE_MS
@@ -202,10 +214,10 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
202
214
  const runtime = createClaudeToolRuntimeRef({ turnId: () => state.currentTurnId })
203
215
  // The transcript location is a pre-input fact; session-local stores live beside it.
204
216
  const transcriptPath = join(configDir, 'projects', encodeClaudeProjectPath(input.cwd), `${expectedSessionId}.jsonl`)
205
- const steeredNotifications = ClaudeSteeredNotificationStore.open({ sessionId: input.sessionId, transcriptPath })
217
+ const steeredNotifications = ClaudeSteeredNotificationStore.open({ sessionId: input.sessionId, transcriptPath, rootDir: this.runtimeStateRoot })
206
218
  const resources = await this.openRunResources({ input, expectedSessionId, signal: abortController.signal })
207
219
  const { nativeToolActorRun, workspaceSkills, selectedRepositoryInstructions, browserAuthBroker, browserTurnMetadata } = resources
208
- const claudeProcess = new ClaudeCodeProcess()
220
+ const claudeProcess = new ClaudeCodeProcess(undefined, this.processLauncher)
209
221
  const declaredSubscriptionType = this.auth ? fetchFreeSubscriptionType(this.auth.knownSubscriptionType()) : undefined
210
222
  const runtimeMcpServers = toClaudeMcpServers({
211
223
  tools,
@@ -302,7 +314,7 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
302
314
  const [userInputRequests, initialization, , sessionContext] = await Promise.all([
303
315
  UserInputRequestStore.open({
304
316
  sessionId: metadata.sessionId,
305
- rootDir: userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
317
+ rootDir: this.runtimeStateRoot ?? userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
306
318
  }),
307
319
  awaitClaudeInitialization({ query, signal: abortController.signal, deadlineMs: this.startupDeadlineMs }),
308
320
  this.auth?.acquire({ signal: abortController.signal }),
@@ -314,8 +326,12 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
314
326
  if (this.auth && declaredSubscriptionType !== undefined && declaredSubscriptionType !== fetchFreeSubscriptionType(this.auth.knownSubscriptionType()) && !params.respawnedForSubscriptionType) {
315
327
  query.close()
316
328
  inputQueue.close()
317
- await claudeProcess.terminate()
318
- await closeRunResources(resources)
329
+ try {
330
+ await claudeProcess.terminate()
331
+ }
332
+ finally {
333
+ await closeRunResources(resources)
334
+ }
319
335
  return await this.openRun({ ...params, respawnedForSubscriptionType: true })
320
336
  }
321
337
 
@@ -372,8 +388,12 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
372
388
  query.close()
373
389
  inputQueue.close()
374
390
  // A failed open (for example a missed initialize deadline) must not leave its CLI behind.
375
- await claudeProcess.terminate()
376
- await closeRunResources(resources)
391
+ try {
392
+ await claudeProcess.terminate()
393
+ }
394
+ finally {
395
+ await closeRunResources(resources)
396
+ }
377
397
  throw error
378
398
  }
379
399
  }
@@ -1,18 +1,6 @@
1
1
  import type { Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
2
2
  import type { UUID } from 'node:crypto'
3
- import type {
4
- AgentContextCompactionRequestResult,
5
- AgentError,
6
- AgentNotifyCommand,
7
- AgentReasoningEffort,
8
- AgentRunMetadata,
9
- AgentSettingsChange,
10
- CompactContextAgentCommand,
11
- InterruptAgentCommand,
12
- RespondUserInputAgentCommand,
13
- SendPromptCommand,
14
- StopAgentCommand,
15
- } from '../../protocol'
3
+ import type { AgentContextCompactionRequestResult, AgentError, AgentNotifyCommand, AgentReasoningEffort, AgentRunMetadata, AgentSettingsChange, ChangeSettingsAgentCommand, CompactContextAgentCommand, InterruptAgentCommand, RespondUserInputAgentCommand, SendPromptCommand, StopAgentCommand } from '../../protocol'
16
4
  import type { AgentEventDraft } from '../agent-event-stream'
17
5
  import type { AgentOperationOptions, AgentPromptOperationOptions } from '../agent-operation'
18
6
  import type { AgentNotificationAcceptance, AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
@@ -297,6 +285,10 @@ export class ClaudeRun implements AgentRun {
297
285
  this.events.push(decorated)
298
286
  if (draft.type === 'turn.started' && draft.payload.commandId)
299
287
  this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
288
+ // Running may precede the input's started receipt. Consumption identifies the turn
289
+ // after that receipt, before the mapper emits the assistant's message.
290
+ if (draft.type === 'prompt.turn-correlated')
291
+ this.emitAcceptedMessage(draft.payload.commandId, draft.payload.turnId)
300
292
  }
301
293
 
302
294
  // The accepted input becomes a user message of the turn that consumed it, correlated with the
@@ -356,6 +348,28 @@ export class ClaudeRun implements AgentRun {
356
348
  this.skillReloadOwed = false
357
349
  }
358
350
 
351
+ async changeSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
352
+ this.assertCommandTargetsRun(command)
353
+ this.assertRunning()
354
+ options.signal?.throwIfAborted()
355
+ if (this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0 || this.modelChangeInFlight)
356
+ throw new AgentPromptDeferredError({ message: 'Settings changes require an idle turn boundary.' })
357
+ this.modelChangeInFlight = true
358
+ try {
359
+ const settings = this.settingsWith(command.settings)
360
+ await this.applyProviderSettings(settings)
361
+ this.current = settings
362
+ if (settings.model) {
363
+ this.metadata.model = settings.model
364
+ this.init.requestedModel = settings.model
365
+ }
366
+ this.pushEvent(sessionSettingsChangedEvent({ model: settings.model!, reasoningEffort: settings.effort, commandId: command.commandId, appliesFrom: 'next_turn' }))
367
+ }
368
+ finally {
369
+ this.modelChangeInFlight = false
370
+ }
371
+ }
372
+
359
373
  async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
360
374
  this.assertCommandTargetsRun(command)
361
375
  this.assertRunning()
@@ -365,7 +379,7 @@ export class ClaudeRun implements AgentRun {
365
379
  message: 'Bypass submission requires a steer prompt.',
366
380
  })
367
381
  }
368
- if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
382
+ if (!command.settings && command.reasoningEffort !== undefined && command.reasoningEffort !== this.current.effort)
369
383
  throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
370
384
  // Claude Code steers by cancelling the running ask, which would abort a manual compaction
371
385
  // (CLI 2.1.280). Like Codex's non-steerable compaction turn, the steer waits for it to end.
@@ -441,6 +455,8 @@ export class ClaudeRun implements AgentRun {
441
455
  private async applyCommandModel(command: SendPromptCommand, options: AgentOperationOptions): Promise<void> {
442
456
  if (this.modelChangeInFlight)
443
457
  throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
458
+ if (command.settings)
459
+ return
444
460
  const currentModel = this.init.requestedModel ?? this.metadata.model
445
461
  if (!command.model || command.model === currentModel)
446
462
  return
@@ -742,7 +758,8 @@ export class ClaudeRun implements AgentRun {
742
758
  return 'already_delivered'
743
759
  // Deliver first: on a prebound resume this is the run's first input, and the resolved
744
760
  // event must not precede the lifecycle events the init barrier emits.
745
- await this.applyProviderSettings(this.current)
761
+ const settings = this.settingsWith(command.settings)
762
+ await this.applyProviderSettings(settings)
746
763
  // The answer opens its turn as the user's input, correlated like a prompt: the turn it
747
764
  // starts carries this command, and the answer is accepted as that turn's user message.
748
765
  const clientMessageId = deriveMessageId(command.commandId)
@@ -762,6 +779,16 @@ export class ClaudeRun implements AgentRun {
762
779
  finally {
763
780
  this.acceptanceWaiters.delete(clientMessageId)
764
781
  }
782
+ if (command.settings) {
783
+ this.current = settings
784
+ this.pushEvent(sessionSettingsChangedEvent({
785
+ model: settings.model ?? await this.initialization,
786
+ reasoningEffort: settings.effort,
787
+ commandId: command.commandId,
788
+ turnId: this.eventMapperState.currentTurnId,
789
+ appliesFrom: 'current_turn',
790
+ }))
791
+ }
765
792
  if (resolved.event)
766
793
  this.pushEvent(resolved.event)
767
794
  this.userInputRequests.markDelivered(command.requestId)
@@ -879,18 +906,31 @@ export class ClaudeRun implements AgentRun {
879
906
  cause: error,
880
907
  }))
881
908
  this.ended = this.endAfterProcessExit(reason, error)
909
+ // Autonomous stream failure may finish without a stop caller; keep rejection observed.
910
+ void this.ended.catch(() => undefined)
882
911
  }
883
912
 
884
- // session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
913
+ // A successful session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
885
914
  // CLAUDE_PROCESS_EXIT_TIMEOUT_MS, then a process-group kill). close() is idempotent and sends
886
915
  // the stdin EOF the CLI exits on.
887
916
  private async endAfterProcessExit(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error: AgentError | undefined): Promise<void> {
888
917
  this.closeQuery()
889
- await this.process.terminate()
890
- this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
891
- this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
892
- this.events.end()
893
- void this.onFinished().catch(() => undefined)
918
+ try {
919
+ await this.process.terminate()
920
+ this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
921
+ this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
922
+ }
923
+ catch (cause) {
924
+ const shutdownError = { ...serializeAgentError(cause), code: 'agents.provider-shutdown-failed' }
925
+ this.events.push({ type: 'error', payload: { message: 'Claude CLI shutdown could not be confirmed.', fatal: true, source: 'runtime', error: shutdownError } })
926
+ this.events.push({ type: 'session.ended', payload: { reason: 'failed', error: shutdownError } })
927
+ this.events.push({ type: 'session.state.changed', payload: { state: 'failed' } })
928
+ throw cause
929
+ }
930
+ finally {
931
+ this.events.end()
932
+ await this.onFinished().catch(() => undefined)
933
+ }
894
934
  }
895
935
 
896
936
  // Reports the request's one result; later calls return the reported one. The caller decides
@@ -48,9 +48,9 @@ export interface SteeredNotificationDelivery {
48
48
  export class ClaudeSteeredNotificationStore {
49
49
  private constructor(private readonly filePath: string, private file: StoreFile) {}
50
50
 
51
- static open(input: { sessionId: string, transcriptPath: string }): ClaudeSteeredNotificationStore {
51
+ static open(input: { sessionId: string, transcriptPath: string, rootDir?: string }): ClaudeSteeredNotificationStore {
52
52
  const filePath = join(
53
- dirname(input.transcriptPath),
53
+ input.rootDir ?? dirname(input.transcriptPath),
54
54
  storeDirectoryName,
55
55
  Buffer.from(input.sessionId, 'utf8').toString('base64url'),
56
56
  storeFileName,
@@ -59,7 +59,9 @@ export class CodexJsonRpcClient {
59
59
  readonly child: ChildProcessWithoutNullStreams
60
60
  private nextId = 0
61
61
  private stopped = false
62
+ private shutdown: Promise<void> | undefined
62
63
  private readonly closed: Promise<void>
64
+ private readonly processGroup: boolean
63
65
  private readonly pending = new Map<string, {
64
66
  method: ClientRequestMethod
65
67
  resolve: (value: unknown) => void
@@ -70,6 +72,7 @@ export class CodexJsonRpcClient {
70
72
  executable: string,
71
73
  options: {
72
74
  cwd: string
75
+ processLauncher?: string
73
76
  environment?: Record<string, string | undefined>
74
77
  configOverrides?: readonly string[]
75
78
  onMessage: (message: CodexJsonRpcMessage) => void
@@ -78,10 +81,13 @@ export class CodexJsonRpcClient {
78
81
  maxLineBytes?: number
79
82
  },
80
83
  ) {
81
- this.child = spawn(executable, ['app-server', '--listen', 'stdio://', ...(options.configOverrides ?? []).flatMap(value => ['-c', value])], {
84
+ this.processGroup = options.processLauncher !== undefined && process.platform !== 'win32'
85
+ const args = ['app-server', '--listen', 'stdio://', ...(options.configOverrides ?? []).flatMap(value => ['-c', value])]
86
+ this.child = spawn(options.processLauncher ?? executable, options.processLauncher ? [executable, ...args] : args, {
82
87
  cwd: options.cwd,
83
88
  env: options.environment,
84
89
  stdio: 'pipe',
90
+ detached: this.processGroup,
85
91
  })
86
92
  this.closed = new Promise(resolve => this.child.once('close', () => resolve()))
87
93
 
@@ -229,9 +235,16 @@ export class CodexJsonRpcClient {
229
235
  }
230
236
 
231
237
  async closeInputAndStop(): Promise<void> {
238
+ this.shutdown ??= this.drainAndStop()
239
+ await this.shutdown
240
+ }
241
+
242
+ private async drainAndStop(): Promise<void> {
232
243
  if (this.stopped) {
233
244
  if (!await waitForClosed(this.closed, PROCESS_SHUTDOWN_GRACE_MS))
234
- await terminateChild(this.child, this.closed)
245
+ await terminateChild(this.child, this.closed, this.processGroup)
246
+ if (this.processGroup)
247
+ signalProcessGroup(this.child, 'SIGKILL')
235
248
  return
236
249
  }
237
250
  this.stopped = true
@@ -242,9 +255,10 @@ export class CodexJsonRpcClient {
242
255
  // EOF/drain and process exit share one grace window. Never wait separately for a
243
256
  // buffered stdin write: a child that stops reading must still reach signal escalation.
244
257
  this.child.stdin.end()
245
- if (await waitForClosed(this.closed, PROCESS_SHUTDOWN_GRACE_MS))
246
- return
247
- await terminateChild(this.child, this.closed)
258
+ if (!await waitForClosed(this.closed, PROCESS_SHUTDOWN_GRACE_MS))
259
+ await terminateChild(this.child, this.closed, this.processGroup)
260
+ if (this.processGroup)
261
+ signalProcessGroup(this.child, 'SIGKILL')
248
262
  }
249
263
 
250
264
  async stop(): Promise<void> {
@@ -288,7 +302,17 @@ export class CodexJsonRpcClient {
288
302
  }
289
303
  }
290
304
 
291
- async function terminateChild(child: ChildProcessWithoutNullStreams, closed: Promise<void>): Promise<void> {
305
+ async function terminateChild(child: ChildProcessWithoutNullStreams, closed: Promise<void>, processGroup: boolean): Promise<void> {
306
+ if (processGroup) {
307
+ // The launcher may already be gone while the CLI retains its pipes. Signal the
308
+ // retained group regardless of the wrapper's exitCode, and bound the close wait.
309
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL'] as const) {
310
+ signalProcessGroup(child, signal)
311
+ if (await waitForClosed(closed, signal === 'SIGKILL' ? 1_000 : PROCESS_SHUTDOWN_GRACE_MS))
312
+ return
313
+ }
314
+ throw new Error('Codex launcher process group did not close after SIGKILL.')
315
+ }
292
316
  if (child.exitCode !== null || child.signalCode !== null) {
293
317
  await closed
294
318
  return
@@ -308,6 +332,18 @@ async function terminateChild(child: ChildProcessWithoutNullStreams, closed: Pro
308
332
  await closed
309
333
  }
310
334
 
335
+ function signalProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
336
+ if (child.pid === undefined)
337
+ return
338
+ try {
339
+ process.kill(-child.pid, signal)
340
+ }
341
+ catch (error) {
342
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH')
343
+ throw error
344
+ }
345
+ }
346
+
311
347
  async function waitForClosed(closed: Promise<void>, timeoutMs: number): Promise<boolean> {
312
348
  return await new Promise<boolean>((resolve) => {
313
349
  const timer = setTimeout(() => resolve(false), timeoutMs)
@@ -54,6 +54,14 @@ export interface CodexProviderConfig {
54
54
  auth: CodexAuthProvider | CodexAuthentication
55
55
  tools?: readonly AgentTool[]
56
56
  executable?: string
57
+ /**
58
+ * Trusted launcher invoked as <CLI executable> <CLI args>; owns any privilege drop.
59
+ * Preserve stdio and the process group: exec the CLI or keep children in that group.
60
+ * On Windows, the launcher must exec the CLI in place (group signals are unavailable).
61
+ */
62
+ processLauncher?: string
63
+ /** Private provider stores; defaults to the CLI transcript directory when omitted. */
64
+ runtimeStateRoot?: string
57
65
  environment?: Record<string, string | undefined>
58
66
  onStartupStage?: (fact: CodexStartupStageFact) => void
59
67
  instructions?: InstructionComposer
@@ -111,6 +119,8 @@ export class CodexProvider implements AgentProvider<'codex'> {
111
119
  private readonly tools: readonly AgentTool[]
112
120
  private readonly executable: string
113
121
  private readonly environment: Record<string, string | undefined>
122
+ private readonly processLauncher: string | undefined
123
+ private readonly runtimeStateRoot: string | undefined
114
124
  private readonly auth: CodexAuthentication
115
125
  /** The validated endpoint of an API key login that names one. */
116
126
  private readonly apiKeyEndpoint: string | undefined
@@ -130,6 +140,8 @@ export class CodexProvider implements AgentProvider<'codex'> {
130
140
  this.tools = config.tools ?? []
131
141
  this.executable = config.executable ?? 'codex'
132
142
  this.environment = config.environment ?? {}
143
+ this.processLauncher = config.processLauncher
144
+ this.runtimeStateRoot = config.runtimeStateRoot
133
145
  this.onStartupStage = config.onStartupStage
134
146
  this.instructions = config.instructions ?? emptyInstructionComposer
135
147
  this.sessionContext = config.sessionContext
@@ -169,18 +181,19 @@ export class CodexProvider implements AgentProvider<'codex'> {
169
181
  return this.wrapBedrockRun(await this.resumeRawSession(input, options), input)
170
182
  }
171
183
 
172
- // Bedrock model bindings are fixed per app-server session, so a model change resumes the same
173
- // thread under the new binding at an idle turn boundary.
184
+ // Legacy prompt-only model changes rebind at an idle boundary. Explicit settings
185
+ // commands use the native update on the existing thread and keep the CLI warm.
174
186
  private wrapBedrockRun(run: CodexRun, input: AgentSessionOpenInput<'codex'>): AgentRun {
175
187
  if (this.auth.kind !== 'amazon-bedrock')
176
188
  return run
177
189
  const roots = new Set(input.workspaceRepositoryRoots)
178
- return new ModelRebindingRun(run, async (model, signal) => await this.resumeRawSession({
190
+ return new ModelRebindingRun(run, async (model, signal, reasoningEffort) => await this.resumeRawSession({
179
191
  ...input,
180
192
  model: codexModelIdSchema.parse(model),
193
+ reasoningEffort,
181
194
  providerSessionId: run.metadata.providerSessionId,
182
195
  workspaceRepositoryRoots: [...roots],
183
- }, { signal }), root => roots.add(root))
196
+ }, { signal }), root => roots.add(root), input.reasoningEffort)
184
197
  }
185
198
 
186
199
  private async resumeRawSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<CodexRun> {
@@ -458,6 +471,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
458
471
 
459
472
  try {
460
473
  client = new CodexJsonRpcClient(this.executable, {
474
+ processLauncher: this.processLauncher,
461
475
  cwd,
462
476
  environment: holder.browserAuthBroker
463
477
  ? { ...this.environment, CODING_AGENT_BROWSER_AUTH_SOCKET: holder.browserAuthBroker.socketPath }
@@ -637,7 +651,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
637
651
  }),
638
652
  UserInputRequestStore.open({
639
653
  sessionId: metadata.sessionId,
640
- rootDir: userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
654
+ rootDir: this.runtimeStateRoot ?? userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
641
655
  }),
642
656
  pendingSessionContext({
643
657
  compose: this.sessionContext,
@@ -1,17 +1,4 @@
1
- import type {
2
- AgentAuthor,
3
- AgentContextCompactionRequestResult,
4
- AgentNotifyCommand,
5
- AgentPrompt,
6
- AgentReasoningEffort,
7
- AgentRunMetadata,
8
- AgentSettingsChange,
9
- CompactContextAgentCommand,
10
- InterruptAgentCommand,
11
- RespondUserInputAgentCommand,
12
- SendPromptCommand,
13
- StopAgentCommand,
14
- } from '../../protocol'
1
+ import type { AgentAuthor, AgentContextCompactionRequestResult, AgentNotifyCommand, AgentPrompt, AgentReasoningEffort, AgentRunMetadata, AgentSettingsChange, ChangeSettingsAgentCommand, CompactContextAgentCommand, InterruptAgentCommand, RespondUserInputAgentCommand, SendPromptCommand, StopAgentCommand } from '../../protocol'
15
2
  import type { AgentEventDraft } from '../agent-event-stream'
16
3
  import type { AgentOperationOptions, AgentPromptOperationOptions } from '../agent-operation'
17
4
  import type { AgentNotificationAcceptance, AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
@@ -21,6 +8,7 @@ import type { CodexEventMapperState } from './codex-event-mapper'
21
8
  import type { CodexJsonRpcClient } from './codex-json-rpc-client'
22
9
  import type { ClientRequestResponsesByMethod, CodexUserInput } from './codex-protocol'
23
10
  import { serializeAgentNotificationToolOutput } from '../../protocol'
11
+ import { serializeAgentError } from '../agent-error-serializer'
24
12
  import { AgentEventStream } from '../agent-event-stream'
25
13
  import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentRunStateError } from '../agent-runtime-error'
26
14
  import { sessionSettingsChangedEvent } from '../agent-session-events'
@@ -146,7 +134,7 @@ export class CodexRun implements AgentRun {
146
134
  sessionId: this.metadata.sessionId,
147
135
  providerSessionId: this.metadata.providerSessionId,
148
136
  reason: 'aborted',
149
- }))
137
+ }).catch(() => undefined))
150
138
  }
151
139
 
152
140
  pushEvent(draft: AgentEventDraft): void {
@@ -273,6 +261,22 @@ export class CodexRun implements AgentRun {
273
261
  await stopping.promise
274
262
  }
275
263
 
264
+ async changeSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
265
+ this.assertCommandTargetsRun(command)
266
+ this.assertRunning()
267
+ options.signal?.throwIfAborted()
268
+ this.deferNextWhileTurnActive('next')
269
+ const settings = withSettingsChange(this.current, command.settings)
270
+ try {
271
+ await this.client.request('thread/settings/update', { threadId: this.metadata.providerSessionId, model: this.resolveInferenceModel(settings.model), effort: codexEffort(settings.effort) })
272
+ }
273
+ catch (error) {
274
+ throw mapSettingsTransportLoss(error)
275
+ }
276
+ this.metadata.model = settings.model
277
+ this.settingsAccepted({ settings, commandId: command.commandId, turnId: undefined, placement: { appliesFrom: 'next_turn' } })
278
+ }
279
+
276
280
  async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
277
281
  this.assertCommandTargetsRun(command)
278
282
  this.assertRunning()
@@ -582,11 +586,14 @@ export class CodexRun implements AgentRun {
582
586
  if (resolved.event)
583
587
  this.pushEvent(resolved.event)
584
588
  const prepared = await this.prepareTurn({ prompt: resolved.prompt, commandId: command.commandId, options })
585
- await this.startTurn({
589
+ const settings = withSettingsChange(this.current, command.settings)
590
+ const { receipt } = await this.startTurn({
586
591
  commandId: prepared.commandId,
587
- settings: this.current,
592
+ settings,
588
593
  content: { kind: 'prompt', input: prepared.input, origin: prepared.origin },
589
594
  }, options)
595
+ if (command.settings)
596
+ this.settingsAccepted({ settings, commandId: command.commandId, turnId: receipt.turnId, placement: { appliesFrom: 'current_turn' } })
590
597
  this.sessionContextDelivered(prepared.sessionContext)
591
598
  this.userInputRequests.markDelivered(command.requestId)
592
599
  return 'submitted'
@@ -639,8 +646,20 @@ export class CodexRun implements AgentRun {
639
646
  const revocation = new Promise<void>((resolve) => {
640
647
  resolve(this.onStop())
641
648
  })
642
- void Promise.all([this.client.stop(), revocation])
643
- .then(finalize)
649
+ void Promise.allSettled([this.client.stop(), revocation])
650
+ .then((outcomes) => {
651
+ const failure = outcomes.find(outcome => outcome.status === 'rejected')
652
+ if (!failure) {
653
+ finalize()
654
+ return
655
+ }
656
+ const error = { ...serializeAgentError(failure.reason), code: 'agents.provider-shutdown-failed' }
657
+ this.events.push({ type: 'error', payload: { message: 'Codex run shutdown could not be confirmed.', fatal: true, source: 'runtime', error } })
658
+ this.events.push({ type: 'session.ended', payload: { reason: 'failed', error } })
659
+ this.events.push({ type: 'session.state.changed', payload: { state: 'failed' } })
660
+ this.events.end()
661
+ throw failure.reason
662
+ })
644
663
  .then(stopping.resolve, stopping.reject)
645
664
  }
646
665
 
@@ -1,5 +1,5 @@
1
- import type { Buffer } from 'node:buffer'
2
1
  import type { MaterializedAgentInputAttachment } from './materialized-input-attachment'
2
+ import { Buffer } from 'node:buffer'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { AgentInputAttachmentPreparationFailure } from './input-attachment.errors'
5
5
 
@@ -8,7 +8,9 @@ type NativeImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/web
8
8
  export async function readAttachmentBytes(attachment: MaterializedAgentInputAttachment, signal: AbortSignal): Promise<Buffer> {
9
9
  signal.throwIfAborted()
10
10
  try {
11
- const bytes = await readFile(attachment.localPath, { signal })
11
+ const bytes = attachment.readBytes
12
+ ? Buffer.from(await attachment.readBytes(signal))
13
+ : await readFile(attachment.localPath, { signal })
12
14
  signal.throwIfAborted()
13
15
  return bytes
14
16
  }
@@ -6,6 +6,8 @@ export type MaterializedAgentInputAttachment = Readonly<{
6
6
  byteSize: number
7
7
  sha256: string
8
8
  localPath: string
9
+ /** Read as the file owner when the provider uses a different process identity. */
10
+ readBytes?: (signal: AbortSignal) => Promise<Uint8Array>
9
11
  }>
10
12
 
11
13
  export type AgentInputAttachmentProjection = Readonly<{
@@ -1,4 +1,4 @@
1
- import type { AgentEvent, AgentRunMetadata, SendPromptCommand } from '../protocol'
1
+ import type { AgentEvent, AgentReasoningEffort, AgentRunMetadata, ChangeSettingsAgentCommand, SendPromptCommand } from '../protocol'
2
2
  import type { AgentOperationOptions, AgentPromptOperationOptions } from './agent-operation'
3
3
  import type { AgentPromptAcceptance, AgentRun } from './agent-run'
4
4
  import { AgentEventStream } from './agent-event-stream'
@@ -12,7 +12,8 @@ export class ModelRebindingRun implements AgentRun {
12
12
  private pump: Promise<void>
13
13
  private activeTurn: string | undefined
14
14
  private lastEndedTurn: string | undefined
15
- private dispatching: Promise<AgentPromptAcceptance> | undefined
15
+ private dispatching: Promise<unknown> | undefined
16
+ private settingsChanging = false
16
17
  private changing = false
17
18
  private needsRebind = false
18
19
  private stopped = false
@@ -21,7 +22,7 @@ export class ModelRebindingRun implements AgentRun {
21
22
  private readonly lifetime = new AbortController()
22
23
  private readonly controls = new Set<Promise<unknown>>()
23
24
 
24
- constructor(run: AgentRun, private readonly reopen: (model: string, signal: AbortSignal) => Promise<AgentRun>, private readonly rootAdded?: (root: string) => void) {
25
+ constructor(run: AgentRun, private readonly reopen: (model: string, signal: AbortSignal, reasoningEffort?: AgentReasoningEffort) => Promise<AgentRun>, private readonly rootAdded?: (root: string) => void, private reasoningEffort?: AgentReasoningEffort) {
25
26
  this.run = run
26
27
  this.metadata = { ...run.metadata }
27
28
  this.events = new AgentEventStream(this.metadata)
@@ -42,7 +43,13 @@ export class ModelRebindingRun implements AgentRun {
42
43
  })
43
44
  }
44
45
 
45
- respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.withControl(signal => this.run.respondUserInput(command, { ...options, signal }), options)
46
+ respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.withControl(async (signal) => {
47
+ const result = await this.run.respondUserInput(command, { ...options, signal })
48
+ if (result === 'submitted')
49
+ this.reasoningEffort = command.settings?.reasoningEffort ?? this.reasoningEffort
50
+ return result
51
+ }, options)
52
+
46
53
  interrupt: AgentRun['interrupt'] = async (command, options) => await this.withControl(signal => this.run.interrupt(command, { ...options, signal }), options)
47
54
  notify: NonNullable<AgentRun['notify']> = async (command, options) => await this.withControl(async (signal) => {
48
55
  if (!this.run.notify)
@@ -74,7 +81,56 @@ export class ModelRebindingRun implements AgentRun {
74
81
  throw new AgentRunStateError({ message: 'Prompt does not target the bound run.' })
75
82
  if (this.changing)
76
83
  throw new AgentPromptDeferredError({ message: 'The provider model is being rebound.' })
77
- const model = command.model ?? this.metadata.model
84
+ const settings = command.settings ?? { model: command.model, reasoningEffort: command.reasoningEffort }
85
+ await this.rebind(settings.model ?? this.metadata.model, options)
86
+ this.lifetime.signal.throwIfAborted()
87
+ options.signal?.throwIfAborted()
88
+ const acceptance = await this.run.sendPrompt(command, { ...options, signal: AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]) })
89
+ this.reasoningEffort = settings.reasoningEffort ?? this.reasoningEffort
90
+ if (acceptance.kind === 'turn' && acceptance.turnId !== this.lastEndedTurn)
91
+ this.activeTurn = acceptance.turnId
92
+ return acceptance
93
+ }
94
+
95
+ async changeSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
96
+ if (this.stopped)
97
+ throw new AgentRunStateError({ message: 'The run stopped.' })
98
+ if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
99
+ throw new AgentRunStateError({ message: 'Settings command does not target the bound run.' })
100
+ if (this.dispatching || this.workspaceChanging || this.controls.size || this.changing)
101
+ throw new AgentPromptDeferredError({ message: 'A provider operation is already in progress.' })
102
+ if (this.activeTurn)
103
+ throw new AgentPromptDeferredError({ message: 'Settings changes require an idle turn boundary.' })
104
+ this.settingsChanging = true
105
+ const task = this.dispatchSettings(command, options)
106
+ this.dispatching = task
107
+ try {
108
+ await task
109
+ }
110
+ finally {
111
+ this.dispatching = undefined
112
+ this.settingsChanging = false
113
+ }
114
+ }
115
+
116
+ private async dispatchSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions): Promise<void> {
117
+ const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])])
118
+ signal.throwIfAborted()
119
+ // A failed legacy prompt rebind leaves the old run stopped. Recover its
120
+ // accepted settings before applying the new command on the replacement.
121
+ if (this.needsRebind)
122
+ await this.rebind(this.metadata.model ?? command.settings.model, { signal })
123
+ signal.throwIfAborted()
124
+ // Native settings updates configure the existing app-server thread, including
125
+ // empty threads. Keep the CLI and its warmed state instead of reopening it.
126
+ if (!this.run.changeSettings)
127
+ throw new AgentRunStateError({ message: 'The provider run does not support settings changes.' })
128
+ await this.run.changeSettings(command, { signal })
129
+ this.metadata.model = this.run.metadata.model
130
+ this.reasoningEffort = command.settings.reasoningEffort
131
+ }
132
+
133
+ private async rebind(model: string | undefined, options: AgentOperationOptions): Promise<void> {
78
134
  if (model && (this.needsRebind || model !== this.metadata.model)) {
79
135
  if (this.activeTurn)
80
136
  throw new AgentPromptDeferredError({ message: 'Model changes require an idle turn boundary.' })
@@ -88,7 +144,7 @@ export class ModelRebindingRun implements AgentRun {
88
144
  await this.run.stop({ type: 'agent.stop', ...this.target(), reason: 'model_rebind' }, { signal })
89
145
  await this.pump
90
146
  signal.throwIfAborted()
91
- replacement = await this.reopen(model, signal)
147
+ replacement = await this.reopen(model, signal, this.reasoningEffort)
92
148
  signal.throwIfAborted()
93
149
  if (replacement.metadata.providerSessionId !== this.metadata.providerSessionId || replacement.metadata.sessionId !== this.metadata.sessionId || replacement.metadata.provider !== this.metadata.provider || replacement.metadata.model !== model) {
94
150
  identityMismatch = true
@@ -111,12 +167,6 @@ export class ModelRebindingRun implements AgentRun {
111
167
  this.changing = false
112
168
  }
113
169
  }
114
- this.lifetime.signal.throwIfAborted()
115
- options.signal?.throwIfAborted()
116
- const acceptance = await this.run.sendPrompt(command, { ...options, signal: AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]) })
117
- if (acceptance.kind === 'turn' && acceptance.turnId !== this.lastEndedTurn)
118
- this.activeTurn = acceptance.turnId
119
- return acceptance
120
170
  }
121
171
 
122
172
  stop: AgentRun['stop'] = async (command, options) => {
@@ -138,7 +188,7 @@ export class ModelRebindingRun implements AgentRun {
138
188
  private async withControl<T>(operation: (signal: AbortSignal) => Promise<T>, options: AgentOperationOptions = {}): Promise<T> {
139
189
  if (this.stopped)
140
190
  throw new AgentRunStateError({ message: 'The run stopped.' })
141
- if (this.changing || this.needsRebind)
191
+ if (this.settingsChanging || this.changing || this.needsRebind)
142
192
  throw new AgentPromptDeferredError({ message: 'The provider model is being rebound.' })
143
193
  const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])])
144
194
  const task = Promise.resolve().then(async () => {
@@ -90,10 +90,18 @@ export const respondUserInputAgentCommandSchema = runningCommandBaseSchema.exten
90
90
  commandId: commandIdSchema,
91
91
  requestId: z.string().min(1),
92
92
  answers: agentUserInputAnswersSchema,
93
+ settings: agentSettingsChangeSchema.optional(),
94
+ })
95
+
96
+ export const changeSettingsAgentCommandSchema = runningCommandBaseSchema.extend({
97
+ type: z.literal('agent.change-settings'),
98
+ commandId: commandIdSchema,
99
+ settings: z.strictObject({ model: agentModelIdSchema, reasoningEffort: agentReasoningEffortSchema }),
93
100
  })
94
101
 
95
102
  export const agentCommandSchema = z.union([
96
103
  sendPromptCommandSchema,
104
+ changeSettingsAgentCommandSchema,
97
105
  agentNotifyCommandSchema,
98
106
  interruptAgentCommandSchema,
99
107
  compactContextAgentCommandSchema,
@@ -112,3 +120,5 @@ export type CompactContextAgentCommand = z.infer<typeof compactContextAgentComma
112
120
  export type StopAgentCommand = z.infer<typeof stopAgentCommandSchema>
113
121
  export type RespondUserInputAgentCommand = z.infer<typeof respondUserInputAgentCommandSchema>
114
122
  export type AgentCommand = z.infer<typeof agentCommandSchema>
123
+
124
+ export type ChangeSettingsAgentCommand = z.infer<typeof changeSettingsAgentCommandSchema>
@@ -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.2.0-experimental.6",
4
- sha: "1b8f8c0c8cc698990c1b0ed9e03c417216bc0316",
5
- buildTime: "2026-10-02T20:59:57.760Z",
3
+ version: "0.2.0-experimental.8",
4
+ sha: "4b63c95e2cc33fde3fbae56b5135006a2afac1e8",
5
+ buildTime: "2026-10-20T14:09:24.027Z",
6
6
  } as const
@@ -176,12 +176,18 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
176
176
 
177
177
  async ready(): Promise<void> { await this.recovered.promise }
178
178
 
179
- async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): Promise<void> {
179
+ async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): ReturnType<SupervisorAgentRuntime['handle']> {
180
180
  this.attachEmitter(input.emit)
181
181
  await this.ready()
182
182
  if (this.eventFailure)
183
183
  throw this.eventFailure
184
184
  const metadata = this.input.run.metadata
185
+ if (input.body.type === 'agent.change-settings') {
186
+ if (!this.input.run.changeSettings)
187
+ throw new Error('Runtime does not support standalone settings changes.')
188
+ await this.input.run.changeSettings({ ...metadata, type: 'agent.change-settings', commandId: input.commandId, settings: input.body.settings }, { signal: input.signal })
189
+ return { appliedSettings: input.body.settings }
190
+ }
185
191
  if (input.body.type === 'agent.send-prompt') {
186
192
  const inputAttachmentPreparation = await this.input.prepareInputAttachments?.({
187
193
  commandId: input.commandId,
@@ -196,6 +202,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
196
202
  commandId: input.commandId,
197
203
  mode: input.body.mode,
198
204
  turnId: input.body.turnId,
205
+ settings: input.body.settings,
199
206
  model: input.body.model,
200
207
  reasoningEffort: input.body.reasoningEffort,
201
208
  origin: 'user',
@@ -212,7 +219,13 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
212
219
  inputAttachmentPreparation,
213
220
  senderContext: input.body.user?.senderContext,
214
221
  })
215
- return
222
+ // Both prompt formats can change later defaults. Omitted legacy fields must
223
+ // not overwrite accepted values, and settings takes whole-object precedence.
224
+ const settings = input.body.settings ?? {
225
+ ...(input.body.model === undefined ? {} : { model: input.body.model }),
226
+ ...(input.body.reasoningEffort === undefined ? {} : { reasoningEffort: input.body.reasoningEffort }),
227
+ }
228
+ return input.body.settings || Object.keys(settings).length ? { appliedSettings: settings } : undefined
216
229
  }
217
230
  if (input.body.type === 'agent.interrupt') {
218
231
  await this.input.run.interrupt({
@@ -225,7 +238,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
225
238
  }, { signal: input.signal })
226
239
  return
227
240
  }
228
- await this.input.run.respondUserInput({
241
+ const submission = await this.input.run.respondUserInput({
229
242
  type: 'agent.respond-user-input',
230
243
  provider: metadata.provider,
231
244
  sessionId: metadata.sessionId,
@@ -233,7 +246,10 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
233
246
  commandId: input.commandId,
234
247
  requestId: input.body.requestId,
235
248
  answers: input.body.answers,
249
+ settings: input.body.settings,
236
250
  }, { signal: input.signal })
251
+ if (submission === 'submitted' && input.body.settings)
252
+ return { appliedSettings: input.body.settings }
237
253
  }
238
254
 
239
255
  async refreshSecrets(input: {
@@ -1,3 +1,4 @@
1
+ import type { AgentSettingsChange } from '../../protocol'
1
2
  import type { SessionBootstrapBody } from '../../supervisor-protocol/bootstrap'
2
3
  import type { CommandEnvelope } from '../../supervisor-protocol/envelopes/control-plane-to-supervisor'
3
4
  import type { DurableEventEnvelope } from '../../supervisor-protocol/envelopes/supervisor-to-control-plane'
@@ -6,6 +7,7 @@ import type { SupervisorAgentRunSnapshot } from '../../supervisor-protocol/super
6
7
  import { mkdirSync } from 'node:fs'
7
8
  import { dirname } from 'node:path'
8
9
  import { Database } from 'bun:sqlite'
10
+ import { agentSettingsChangeSchema } from '../../protocol'
9
11
  import { sessionBootstrapBodySchema } from '../../supervisor-protocol/bootstrap'
10
12
  import { wireCommandBodySchema } from '../../supervisor-protocol/command-body'
11
13
  import { wireEventBodySchema } from '../../supervisor-protocol/event-body'
@@ -215,6 +217,27 @@ export class SupervisorStore {
215
217
  })()
216
218
  }
217
219
 
220
+ /** Restore accepted defaults in application order; the bootstrap remains immutable. */
221
+ appliedSettings(): AgentSettingsChange | undefined {
222
+ const row = this.database.query<{ value: string }, []>('SELECT value FROM metadata WHERE key = \'applied_settings\'').get()
223
+ return row ? agentSettingsChangeSchema.parse(JSON.parse(row.value)) : undefined
224
+ }
225
+
226
+ /** Commit settings, the outcome and inbox state together, including partial prompt/answer updates. */
227
+ finishCommand(input: { commandSeq: number, body: WireEventBody, appliedSettings?: AgentSettingsChange }): DurableEventEnvelope {
228
+ return this.database.transaction(() => {
229
+ const event = this.appendDurableEvent({ occurredAt: new Date().toISOString(), body: input.body })
230
+ const applied = input.body.type === 'supervisor.command.applied'
231
+ if (applied && input.appliedSettings) {
232
+ const settings = agentSettingsChangeSchema.parse({ ...this.appliedSettings(), ...input.appliedSettings })
233
+ this.database.query(`INSERT INTO metadata (key, value) VALUES ('applied_settings', ?)
234
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(canonicalJson(settings))
235
+ }
236
+ this.markCommand({ commandSeq: input.commandSeq, status: applied ? 'applied' : 'failed' })
237
+ return event
238
+ })()
239
+ }
240
+
218
241
  /** Commit before calling a provider: a process crash makes the outcome uncertain. */
219
242
  beginCommandDispatch(commandSeq: number): void {
220
243
  this.database.query('INSERT INTO command_dispatches(seq) VALUES (?)').run(commandSeq)
@@ -228,6 +251,12 @@ export class SupervisorStore {
228
251
  JOIN command_dispatches ON command_dispatches.seq=commands.seq ORDER BY commands.seq
229
252
  `).all()
230
253
  for (const row of rows) {
254
+ // Reapplying a full settings pair is idempotent and submits no input. Prompt
255
+ // commands retain the existing uncertain-delivery policy below.
256
+ if (commandBodyFromRow(row).type === 'agent.change-settings') {
257
+ this.database.query('DELETE FROM command_dispatches WHERE seq = ?').run(row.seq)
258
+ continue
259
+ }
231
260
  this.appendDurableEvent({
232
261
  occurredAt: new Date().toISOString(),
233
262
  body: {
@@ -1,3 +1,4 @@
1
+ import type { AgentSettingsChange } from '../protocol'
1
2
  import type { EphemeralCredentials, SessionBootstrapBody, SessionBootstrapGitToken } from '../supervisor-protocol/bootstrap'
2
3
  import type { WireCommandBody } from '../supervisor-protocol/command-body'
3
4
  import type { WireEventBody } from '../supervisor-protocol/event-body'
@@ -13,7 +14,7 @@ export interface SupervisorAgentRuntime {
13
14
  body: WireCommandBody
14
15
  signal: AbortSignal
15
16
  emit: (body: WireEventBody) => void
16
- }) => Promise<void>
17
+ }) => Promise<void | { appliedSettings: AgentSettingsChange }>
17
18
  refreshSecrets?: (input: {
18
19
  credentials?: EphemeralCredentials
19
20
  gitToken?: SessionBootstrapGitToken
@@ -105,6 +105,7 @@ export async function createResidentSupervisor(input: {
105
105
  binding: binding.status,
106
106
  profile: input.profile,
107
107
  agentCore: agentCoreProvenance(),
108
+ capabilities: { 'model-change': 'v1' },
108
109
  }),
109
110
  getBoundSessionId: () => binding.status === 'bound' ? binding.sessionId : undefined,
110
111
  getLocalEventMax: () => binding.status === 'bound' ? binding.store.localEventMax() : 0,
@@ -207,7 +207,7 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
207
207
  gitToken: this.dependencies.gitToken,
208
208
  }
209
209
  this.createAgentTask = this.dependencies.providerFactory.create({
210
- bootstrap: binding.body,
210
+ bootstrap: { ...binding.body, agent: { ...binding.body.agent, ...this.dependencies.store.appliedSettings() } },
211
211
  ...initialSecrets,
212
212
  rpc: this.rpc,
213
213
  signal: this.lifecycleController.signal,
@@ -240,42 +240,42 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
240
240
  agent: SupervisorAgentRuntime
241
241
  command: CommandEnvelope & { body: WireCommandBody }
242
242
  }): Promise<void> {
243
+ this.dependencies.store.beginCommandDispatch(input.command.commandSeq)
244
+ let result: Awaited<ReturnType<SupervisorAgentRuntime['handle']>>
243
245
  try {
244
- this.dependencies.store.beginCommandDispatch(input.command.commandSeq)
245
- await input.agent.handle({
246
+ result = await input.agent.handle({
246
247
  commandId: input.command.commandId,
247
248
  commandSeq: input.command.commandSeq,
248
249
  body: input.command.body,
249
250
  signal: this.lifecycleController.signal,
250
251
  emit: body => this.emit(body),
251
252
  })
252
- this.dependencies.store.markCommand({ commandSeq: input.command.commandSeq, status: 'applied' })
253
- this.emit({
254
- type: 'supervisor.command.applied',
255
- payload: {
256
- commandId: input.command.commandId,
257
- commandSeq: input.command.commandSeq,
258
- bodyType: input.command.body.type,
259
- },
260
- })
261
253
  }
262
- catch {
254
+ catch (error) {
263
255
  if (this.lifecycleController.signal.aborted)
264
256
  return
265
- this.dependencies.store.markCommand({ commandSeq: input.command.commandSeq, status: 'failed' })
266
- this.emit({
267
- type: 'supervisor.command.failed',
268
- payload: {
257
+ this.dependencies.logger.error({ err: error, commandId: input.command.commandId, bodyType: input.command.body.type }, 'Agent command execution failed')
258
+ this.sendEvent(this.dependencies.store.finishCommand({
259
+ commandSeq: input.command.commandSeq,
260
+ body: { type: 'supervisor.command.failed', payload: {
269
261
  commandId: input.command.commandId,
270
262
  commandSeq: input.command.commandSeq,
271
263
  bodyType: input.command.body.type,
272
- failure: {
273
- type: 'command',
274
- detail: 'Agent command execution failed.',
275
- },
276
- },
277
- })
264
+ failure: { type: 'command', detail: 'Agent command execution failed.' },
265
+ } },
266
+ }))
267
+ return
278
268
  }
269
+ // Persistence and socket errors must not turn an accepted command into a failed one.
270
+ this.sendEvent(this.dependencies.store.finishCommand({
271
+ commandSeq: input.command.commandSeq,
272
+ appliedSettings: result?.appliedSettings,
273
+ body: { type: 'supervisor.command.applied', payload: {
274
+ commandId: input.command.commandId,
275
+ commandSeq: input.command.commandSeq,
276
+ bodyType: input.command.body.type,
277
+ } },
278
+ }))
279
279
  }
280
280
 
281
281
  private emit(body: WireEventBody): void {
@@ -1,5 +1,5 @@
1
1
  import z from 'zod'
2
- import { agentPromptModeSchema, agentPromptSchema, agentReasoningEffortSchema, agentUserInputAnswersSchema, jsonObjectSchema, jsonUtf8ByteLength } from '../protocol'
2
+ import { agentPromptModeSchema, agentPromptSchema, agentReasoningEffortSchema, agentSettingsChangeSchema, agentUserInputAnswersSchema, jsonObjectSchema, jsonUtf8ByteLength } from '../protocol'
3
3
 
4
4
  const wireSenderContextSchema = z.discriminatedUnion('kind', [
5
5
  z.object({ kind: z.literal('application') }).strict(),
@@ -43,6 +43,7 @@ const wireSendPromptCommandSchema = z.object({
43
43
  turnId: z.string().min(1).optional(),
44
44
  model: z.string().trim().min(1).max(256).optional(),
45
45
  reasoningEffort: agentReasoningEffortSchema.optional(),
46
+ settings: agentSettingsChangeSchema.optional(),
46
47
  prompt: agentPromptSchema,
47
48
  attachments: z.array(wireInputAttachmentRefSchema).superRefine((attachments, context) => {
48
49
  const attachmentIds = new Set<string>()
@@ -69,10 +70,12 @@ const wireRespondUserInputCommandSchema = z.object({
69
70
  type: z.literal('agent.respond-user-input'),
70
71
  requestId: z.string().min(1),
71
72
  answers: agentUserInputAnswersSchema,
73
+ settings: agentSettingsChangeSchema.optional(),
72
74
  user: wireCommandUserSchema.optional(),
73
75
  }).strict()
74
76
 
75
77
  export const wireCommandBodySchema = z.discriminatedUnion('type', [
78
+ z.strictObject({ type: z.literal('agent.change-settings'), settings: z.strictObject({ model: z.string().trim().min(1).max(256), reasoningEffort: agentReasoningEffortSchema }) }),
76
79
  wireSendPromptCommandSchema,
77
80
  wireInterruptCommandSchema,
78
81
  wireRespondUserInputCommandSchema,