@meistrari/agent-core 0.2.0-experimental.0 → 0.2.0-experimental.1

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
@@ -48,6 +48,38 @@ Use `--loopback-tcp` for local and image-build probes. Production images default
48
48
 
49
49
  Publishing runs from `main` through `.github/workflows/publish.yml` (conventional commits decide the bump).
50
50
 
51
+ ## Command transports
52
+
53
+ `session.bootstrap` carries an optional `commandTransport`. Both modes share the same wire frames, ES256
54
+ control authority, durable event ACK floor, reverse RPC and SQLite journal.
55
+
56
+ - **Omitted (legacy, background-coding-agent).** The bootstrap embeds command 1, the journal requires a
57
+ contiguous `commandSeq`, and the worker pump advances a cumulative cursor through `CommandSource.next`.
58
+ - **`'wss-v1'` (agent-api v5).** The bootstrap carries no command and rejects one. The supervisor attaches
59
+ the provider immediately, the journal accepts gaps and a delivery order different from admission while
60
+ keeping identity/hash deduplication, and `runtime.state.sourceId` announces a journal identity persisted
61
+ in SQLite and preserved across reopen. The worker passes that identity to
62
+ `ConnectionAuthorityStore.markConnectionReady`; the store pins it on first readiness and answers a
63
+ `ConnectionReadinessRejection` when a later supervisor announces a different journal, which fails the
64
+ session deterministically instead of resuming from a stale cursor. Durable events replayed after
65
+ `runtime.state` are persisted only after the store accepted the readiness. The worker pump selects work
66
+ through `CommandSource.pending` and persists each receipt through `CommandSource.received` before
67
+ selecting again; `next` is never called. A command the supervisor will never accept (invalid body,
68
+ sequence already journaled under another identity) is answered with a `rejected` ACK
69
+ (`supervisor.command.invalid-body`, `supervisor.command.conflicting-duplicate`) instead of a socket
70
+ close; the pump persists it through `CommandSource.rejected` and keeps delivering the session's other
71
+ work. The pump also accepts `ackTimeoutMs`, `onFailure` and an awaitable `close()`.
72
+
73
+ The product-owned `pending` selection decides priority: withdrawn commands leave gaps, and cancel or steer
74
+ may be delivered ahead of a queued message. The resident supervisor executes commands in arrival order;
75
+ a `next` prompt dispatched while a turn is active may be refused by the adapter (Codex defers it), so the
76
+ selection must hold such messages back until the turn ends.
77
+
78
+ `e2e/loopback.test.ts` covers the legacy mode. `e2e/wss-v1-loopback.test.ts` covers `wss-v1` through
79
+ `createSandboxConnectionRuntime` and through the exported helpers agent-api's worker composes directly:
80
+ `openSupervisorConnectionBeforeDeadline`, `sendSupervisorBootstrap`, `createOrderedSupervisorInbox`,
81
+ `routeSupervisorFrame`, `SessionCommandPump` and `drainSupervisorConnection`.
82
+
51
83
  ## Durable command outcomes
52
84
 
53
85
  A supervisor `received` ACK advances the command delivery cursor only. Implementations of
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.0",
4
+ "version": "0.2.0-experimental.1",
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",
@@ -5,6 +5,7 @@ import type { BetaMessage, BetaRawMessageStreamEvent, BetaUsage } from '@anthrop
5
5
  import type { AgentError, AgentEventActor, AgentUsage, JsonValue } from '../../protocol'
6
6
  import type { AgentWorkObservation } from '../../protocol/agent-work'
7
7
  import type { AgentEventDraft } from '../agent-event-stream'
8
+ import { randomUUID } from 'node:crypto'
8
9
  import z from 'zod'
9
10
  import { jsonValueSchema } from '../../protocol'
10
11
  import { canonicalToolName } from '../../protocol/agent-tool-name'
@@ -95,6 +96,7 @@ const taskListWorkOutputSchema = z.object({
95
96
  }).passthrough() satisfies z.ZodType<TaskListWorkOutput>
96
97
 
97
98
  export interface ClaudeEventMapperState {
99
+ readonly usageScopeId: string
98
100
  readonly toolNameById: Map<string, string>
99
101
  readonly pendingWorkCallById: Map<string, PendingClaudeWorkCall>
100
102
  readonly startedMessageIds: Set<string>
@@ -118,6 +120,7 @@ export interface ClaudeEventMapperState {
118
120
 
119
121
  export function createClaudeEventMapperState(): ClaudeEventMapperState {
120
122
  return {
123
+ usageScopeId: `claude-query:${randomUUID()}`,
121
124
  toolNameById: new Map(),
122
125
  pendingWorkCallById: new Map(),
123
126
  startedMessageIds: new Set(),
@@ -135,7 +138,7 @@ export function createClaudeEventMapperState(): ClaudeEventMapperState {
135
138
  }
136
139
  }
137
140
 
138
- export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
141
+ export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperState, options: { continueTurn?: boolean } = {}): AgentEventDraft[] {
139
142
  const drafts: AgentEventDraft[] = []
140
143
 
141
144
  if (message.type === 'system') {
@@ -189,23 +192,33 @@ export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperSt
189
192
  if (message.type === 'result') {
190
193
  const turnId = ensureTurnStarted(drafts, state)
191
194
  const usage = usageFromObject(message.usage)
192
- // Claude Agent SDK result usage is per completed ask/turn for the stream-json path we use,
193
- // not a monotonic counter across the long-lived query. Emit it directly so summing
194
- // normalized usage events gives the run total; do not diff against prior results.
195
+ // SDK usage is per result, but modelUsage is cumulative within this query process.
196
+ // A resumed CLI resets modelUsage even when it retains the native session ID.
197
+ // Keep display counters separate; the control plane deduplicates/differences accounting.
195
198
  if (usage) {
196
199
  drafts.push({ type: 'usage', turnId, actor: mainActor, payload: { usage, accounting: {
197
200
  observationId: message.uuid,
198
- mode: 'increment',
199
- scopeId: turnId,
201
+ mode: Object.keys(message.modelUsage ?? {}).length ? 'cumulative' : 'increment',
202
+ scopeId: Object.keys(message.modelUsage ?? {}).length ? state.usageScopeId : turnId,
200
203
  includesChildren: true,
201
204
  models: Object.fromEntries(Object.entries(message.modelUsage ?? {}).map(([model, counts]) => [model, {
202
205
  inputTokens: counts.inputTokens,
203
206
  outputTokens: counts.outputTokens,
204
207
  cacheReadTokens: counts.cacheReadInputTokens,
205
208
  cacheWriteTokens: counts.cacheCreationInputTokens,
209
+ webSearchRequests: counts.webSearchRequests,
206
210
  }])),
207
211
  } } })
208
212
  }
213
+ // A native steer may finish one query segment while retaining the logical turn.
214
+ // Usage belongs to this segment and must still be emitted exactly once. The segment
215
+ // may have been aborted mid-generation (error_during_execution): close whatever it
216
+ // streamed so the steer's user message follows a closed assistant step.
217
+ if (options.continueTurn) {
218
+ endOpenReasoning(drafts, state, turnId)
219
+ endOpenAssistantMessage(drafts, state, turnId)
220
+ return drafts
221
+ }
209
222
  endOpenReasoning(drafts, state, turnId)
210
223
  const interruptAccepted = state.interrupt.status === 'accepted' && state.interrupt.turnId === turnId
211
224
  drafts.push({ type: 'turn.ended', turnId, actor: mainActor, payload: turnEndedPayload(message, interruptAccepted) })
@@ -599,6 +612,16 @@ function endOpenReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperSta
599
612
  }
600
613
  }
601
614
 
615
+ function endOpenAssistantMessage(drafts: AgentEventDraft[], state: ClaudeEventMapperState, turnId: string): void {
616
+ const messageId = state.currentMessageId
617
+ if (!messageId)
618
+ return
619
+ endAssistantMessageById(drafts, state, turnId, messageId, state.streamedTextByMessageId.get(messageId), state.actorByMessageId.get(messageId))
620
+ state.actorByMessageId.delete(messageId)
621
+ state.currentMessageId = undefined
622
+ state.contentBlockTypeByIndex.clear()
623
+ }
624
+
602
625
  function reasoningIdForMessage(messageId: string): string {
603
626
  return `reasoning-${messageId}`
604
627
  }
@@ -96,12 +96,14 @@ export class ClaudeRun implements AgentRun {
96
96
  private initializationReject: (error: unknown) => void = () => undefined
97
97
  private initialized = false
98
98
  private firstInputPushed = false
99
+ private pendingSteerResult?: Extract<SDKMessage, { type: 'result' }>
99
100
  private modelChangeReady = true
100
101
  private modelChangeInFlight = false
101
102
  private finished = false
102
103
  private interruptRequest?: { turnId: string, promise: Promise<void> }
103
104
  private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
104
105
  private readonly commandByClientMessageId = new Map<string, string>()
106
+ private readonly pendingMessages = new Map<string, { messageId: string, mode: 'next' | 'steer', turnId?: string, started: boolean }>()
105
107
 
106
108
  constructor({ metadata, query, userInputRequests, input, eventMapperState, init, recordStartup }: ClaudeRunInput) {
107
109
  this.metadata = metadata
@@ -147,6 +149,20 @@ export class ClaudeRun implements AgentRun {
147
149
  return
148
150
  }
149
151
  this.events.push(decorated)
152
+ if (draft.type === 'turn.started' && draft.payload.commandId)
153
+ this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
154
+ }
155
+
156
+ private emitAcceptedMessage(commandId: string, turnId: string): void {
157
+ const pending = this.pendingMessages.get(commandId)
158
+ if (!pending?.started || !this.initialized || this.finished)
159
+ return
160
+ if (pending.mode === 'steer' && pending.turnId && pending.turnId !== turnId)
161
+ return
162
+ this.pendingMessages.delete(commandId)
163
+ const actor = { type: 'main', actorId: 'main' } as const
164
+ this.events.push({ type: 'message.started', turnId, actor, payload: { messageId: pending.messageId, commandId, role: 'user' } })
165
+ this.events.push({ type: 'message.ended', turnId, actor, payload: { messageId: pending.messageId, commandId, role: 'user' } })
150
166
  }
151
167
 
152
168
  async requestUserInput(input: UserInputRequestInput): Promise<{ requestId: string }> {
@@ -169,6 +185,8 @@ export class ClaudeRun implements AgentRun {
169
185
  this.assertCommandTargetsRun(command)
170
186
  this.assertRunning()
171
187
  options.signal?.throwIfAborted()
188
+ if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
189
+ throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
172
190
  const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext }
173
191
  const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
174
192
  options.signal?.throwIfAborted()
@@ -191,12 +209,15 @@ export class ClaudeRun implements AgentRun {
191
209
  this.init.requestedModel = command.model
192
210
  options.signal?.throwIfAborted()
193
211
  }
212
+ if (command.turnId && (command.mode !== 'steer' || command.turnId !== this.eventMapperState.currentTurnId))
213
+ throw new AgentNotAcceptedError({ message: 'The targeted claude turn is no longer active.', recovery: { kind: 'terminal', reason: 'target_turn_not_active' } })
194
214
  this.modelChangeReady = false
195
215
  // Register before the push: the CLI's queued receipt can outrun the local push resolution,
196
216
  // and a receipt with no registered waiter would be lost.
197
217
  const clientMessageId = deriveMessageId(command.commandId)
198
218
  const receipt = this.registerAcceptanceWaiter(clientMessageId)
199
219
  this.commandByClientMessageId.set(clientMessageId, command.commandId)
220
+ this.pendingMessages.set(command.commandId, { messageId: clientMessageId, mode: command.mode, turnId: command.turnId, started: false })
200
221
  try {
201
222
  await this.deliverInput(message)
202
223
  // `submitted` requires the provider's positive acceptance receipt — the pinned
@@ -233,6 +254,8 @@ export class ClaudeRun implements AgentRun {
233
254
  this.assertRunning()
234
255
  options.signal?.throwIfAborted()
235
256
  const turnId = this.eventMapperState.currentTurnId
257
+ if (command.turnId && command.turnId !== turnId)
258
+ return
236
259
  if (!turnId)
237
260
  return
238
261
  if (this.eventMapperState.interrupt.status === 'accepted' && this.eventMapperState.interrupt.turnId === turnId)
@@ -291,6 +314,7 @@ export class ClaudeRun implements AgentRun {
291
314
  }
292
315
  this.acceptanceWaiters.clear()
293
316
  this.commandByClientMessageId.clear()
317
+ this.pendingMessages.clear()
294
318
  this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
295
319
  this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
296
320
  this.events.end()
@@ -322,6 +346,20 @@ export class ClaudeRun implements AgentRun {
322
346
  // they reject any pending waiter as delivery-unknown so command processing can advance.
323
347
  // Valid positive frames without a pending
324
348
  // waiter are internal lifecycle for already-settled or non-prompt inputs and are ignored.
349
+ private hasPendingSteer(): boolean {
350
+ return [...this.pendingMessages.values()].some(pending => pending.mode === 'steer'
351
+ && (!pending.turnId || pending.turnId === this.eventMapperState.currentTurnId))
352
+ }
353
+
354
+ private flushSteerResult(continueTurn = false): void {
355
+ const result = this.pendingSteerResult
356
+ this.pendingSteerResult = undefined
357
+ if (result) {
358
+ for (const draft of mapClaudeMessage(result, this.eventMapperState, { continueTurn }))
359
+ this.pushEvent(draft)
360
+ }
361
+ }
362
+
325
363
  private settleAcceptance(frame: ClaudeCommandLifecycleMessage): void {
326
364
  const parsed = commandLifecycleFrameSchema.safeParse(frame)
327
365
  if (!parsed.success) {
@@ -336,21 +374,39 @@ export class ClaudeRun implements AgentRun {
336
374
  details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
337
375
  })
338
376
  }
377
+ const commandId = this.commandByClientMessageId.get(parsed.data.command_uuid)
339
378
  if (parsed.data.state === 'cancelled' || parsed.data.state === 'discarded') {
379
+ if (commandId)
380
+ this.pendingMessages.delete(commandId)
340
381
  this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
341
382
  message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
342
383
  }))
343
384
  this.commandByClientMessageId.delete(parsed.data.command_uuid)
385
+ if (!this.hasPendingSteer())
386
+ this.flushSteerResult()
344
387
  return
345
388
  }
346
389
  // A queued receipt acknowledges admission, not turn ownership. Keep the
347
390
  // mapping after acceptance settles: the provider's started receipt is
348
391
  // the authority for the next root turn, even with several queued inputs.
349
392
  if (parsed.data.state === 'started') {
350
- this.eventMapperState.currentCommandId = this.commandByClientMessageId.get(parsed.data.command_uuid)
393
+ const pending = commandId ? this.pendingMessages.get(commandId) : undefined
394
+ // Only a native started receipt can join the interrupted segment to a steer.
395
+ // A queued receipt alone must never suppress the logical turn's completion.
396
+ this.flushSteerResult(pending?.mode === 'steer' && (!pending.turnId || pending.turnId === this.eventMapperState.currentTurnId))
397
+ this.eventMapperState.currentCommandId = commandId
398
+ if (pending && commandId) {
399
+ pending.started = true
400
+ const turnId = this.eventMapperState.currentTurnId
401
+ if (pending.mode === 'steer' && turnId && (!pending.turnId || pending.turnId === turnId))
402
+ this.emitAcceptedMessage(commandId, turnId)
403
+ }
351
404
  }
352
- if (parsed.data.state === 'completed')
405
+ if (parsed.data.state === 'completed') {
353
406
  this.commandByClientMessageId.delete(parsed.data.command_uuid)
407
+ if (commandId)
408
+ this.pendingMessages.delete(commandId)
409
+ }
354
410
  this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
355
411
  }
356
412
 
@@ -433,6 +489,17 @@ export class ClaudeRun implements AgentRun {
433
489
  this.completeInitialization(message)
434
490
  continue
435
491
  }
492
+ this.flushSteerResult()
493
+ if (message.type === 'result' && this.hasPendingSteer()) {
494
+ // priority: now ends the current segment and yields its result before the
495
+ // cancelled/started receipts: a success after a tool call or mid-response
496
+ // (null stop reason), or error_during_execution when the CLI aborted the
497
+ // in-flight model call (the steer landed while the model was still thinking).
498
+ // Hold it until that native boundary proves continuation of the logical turn
499
+ // or its true completion; a steer must never open a new turn.
500
+ this.pendingSteerResult = message
501
+ continue
502
+ }
436
503
  const completeMessage = message.type === 'user'
437
504
  ? await hydrateClaudeToolOutput(message, {
438
505
  transcriptPath: this.metadata.transcriptPath,
@@ -447,6 +514,7 @@ export class ClaudeRun implements AgentRun {
447
514
  if (!this.initialized) {
448
515
  throw new AgentProviderProtocolError({ message: 'Claude SDK ended before emitting system init.' })
449
516
  }
517
+ this.flushSteerResult()
450
518
  this.finish('completed')
451
519
  }
452
520
  catch (error) {
@@ -36,6 +36,7 @@ interface PreparedCodexTurn {
36
36
  commandId: string
37
37
  origin: 'user' | 'supervisor'
38
38
  model?: string
39
+ reasoningEffort?: AgentReasoningEffort
39
40
  }
40
41
 
41
42
  interface CodexRunInput {
@@ -61,7 +62,7 @@ export class CodexRun implements AgentRun {
61
62
  private readonly client: CodexJsonRpcClient
62
63
  private readonly beforePrompt: ((signal?: AbortSignal) => Promise<void>) | undefined
63
64
  private readonly resolveInferenceModel: (model: string) => string
64
- private readonly reasoningEffort: AgentReasoningEffort | undefined
65
+ private reasoningEffort: AgentReasoningEffort | undefined
65
66
  private readonly onStop: () => void
66
67
  private readonly userInputRequests: UserInputRequestStore
67
68
  private readonly eventMapperState: CodexEventMapperState
@@ -244,12 +245,17 @@ export class CodexRun implements AgentRun {
244
245
  options,
245
246
  })
246
247
  prepared.model = command.model
248
+ prepared.reasoningEffort = command.reasoningEffort
247
249
  // A turn/started fact may have reduced during attachment preparation.
248
250
  this.deferNextWhileTurnActive(command.mode)
249
251
 
252
+ if (command.turnId && (command.mode !== 'steer' || command.turnId !== this.activeTurnId))
253
+ throw new AgentNotAcceptedError({ message: 'The targeted codex turn is no longer active.', recovery: { kind: 'terminal', reason: 'target_turn_not_active' } })
250
254
  const activeTurnId = this.activeTurnId
251
255
  if (activeTurnId && command.model && command.model !== this.metadata.model)
252
256
  throw new AgentPromptDeferredError({ message: 'Codex model changes require an idle turn boundary.' })
257
+ if (activeTurnId && command.reasoningEffort && command.reasoningEffort !== this.reasoningEffort)
258
+ throw new AgentPromptDeferredError({ message: 'Codex reasoning effort changes require an idle turn boundary.' })
253
259
  if (command.mode === 'next' || !activeTurnId)
254
260
  return await this.startPreparedTurn(prepared)
255
261
 
@@ -306,6 +312,8 @@ export class CodexRun implements AgentRun {
306
312
  this.assertCommandTargetsRun(command)
307
313
  this.assertRunning()
308
314
  options.signal?.throwIfAborted()
315
+ if (command.turnId && command.turnId !== this.activeTurnId)
316
+ return
309
317
  const turnId = command.turnId ?? this.activeTurnId
310
318
  if (!turnId)
311
319
  return
@@ -364,7 +372,7 @@ export class CodexRun implements AgentRun {
364
372
  input: prepared.input,
365
373
  cwd: this.metadata.cwd,
366
374
  model: inferenceModel,
367
- effort: codexEffort(this.reasoningEffort),
375
+ effort: codexEffort(prepared.reasoningEffort ?? this.reasoningEffort),
368
376
  summary: codexReasoningSummary,
369
377
  approvalPolicy: 'never',
370
378
  sandboxPolicy: { type: 'dangerFullAccess' },
@@ -375,6 +383,8 @@ export class CodexRun implements AgentRun {
375
383
  throw mapTurnTransportLoss(error)
376
384
  }
377
385
  this.activeTurnId = result.turn.id
386
+ if (prepared.reasoningEffort)
387
+ this.reasoningEffort = prepared.reasoningEffort
378
388
  if (prepared.model)
379
389
  this.metadata.model = prepared.model
380
390
  return { kind: 'turn', turnId: result.turn.id, placement: 'started' }
@@ -23,11 +23,13 @@ const runningCommandBaseSchema = z.object({
23
23
  export const sendPromptCommandSchema = runningCommandBaseSchema.extend({
24
24
  type: z.literal('agent.send-prompt'),
25
25
  mode: agentPromptModeSchema,
26
+ turnId: z.string().min(1).optional(),
26
27
  origin: z.enum(['user', 'supervisor']).default('user'),
27
28
  prompt: agentPromptSchema,
28
29
  commandId: commandIdSchema,
29
30
  /** Resolved by the application for this prompt, including after runtime recovery. */
30
31
  model: z.string().trim().min(1).max(256).optional(),
32
+ reasoningEffort: agentReasoningEffortSchema.optional(),
31
33
  author: agentAuthorSchema.optional(),
32
34
  }).strict()
33
35
 
@@ -9,6 +9,7 @@ export const agentUsageSchema = z.object({
9
9
  cacheWriteTokens: tokenCountSchema.optional(),
10
10
  reasoningOutputTokens: tokenCountSchema.optional(),
11
11
  totalTokens: tokenCountSchema.optional(),
12
+ webSearchRequests: tokenCountSchema.optional(),
12
13
  }).strict()
13
14
 
14
15
  export type AgentUsage = z.infer<typeof agentUsageSchema>
@@ -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.0",
4
- sha: "da33c4f2db2875602c75e8ad3fde2d01d8ea7a94",
5
- buildTime: "2026-09-22T18:15:52.470Z",
3
+ version: "0.2.0-experimental.1",
4
+ sha: "e5c42a085c63ca53443040d94e1ef40b159073e2",
5
+ buildTime: "2026-09-22T18:27:33.931Z",
6
6
  } as const
@@ -81,8 +81,11 @@ export function splitStepText(text: string): string[] {
81
81
 
82
82
  export function stepPreview(value: unknown): string {
83
83
  const text = (typeof value === 'string' ? value : JSON.stringify(value) ?? '').replaceAll('\0', '\\0')
84
+ if (Buffer.byteLength(JSON.stringify(text)) <= 2048)
85
+ return text
84
86
  let preview = ''
85
- let bytes = 0
87
+ // Include the JSON quotes and truncation marker in the same preview budget.
88
+ let bytes = Buffer.byteLength(JSON.stringify('…'))
86
89
  for (const character of text) {
87
90
  const size = Buffer.byteLength(JSON.stringify(character)) - 2
88
91
  if (bytes + size > 2048)
@@ -272,8 +272,24 @@ export class DurableStepProjector {
272
272
  ).get(...key)?.command_id
273
273
  const payload = 'payload' in event ? event.payload as Record<string, unknown> : event
274
274
  const explicit = typeof payload.commandId === 'string' ? payload.commandId : undefined
275
- if (previous && explicit && previous !== explicit)
275
+ if (previous && explicit && previous !== explicit) {
276
+ if ('type' in event && event.actor.type === 'main' && payload.role === 'user'
277
+ && (event.type === 'message.started' || event.type === 'message.ended') && 'messageId' in payload && typeof payload.messageId === 'string') {
278
+ const messageStepId = stepIdFor({ sessionId: event.sessionId, providerSessionId: event.providerSessionId, actor: event.actor, turnId: event.turnId, kind: 'message', sourceId: payload.messageId })
279
+ const known = this.db.query<{ command_id: string | null }, [string]>(
280
+ 'select json_extract(body,\'$.commandId\') as command_id from steps where id=?',
281
+ ).get(messageStepId)
282
+ // Delayed/repeated facts for an older message must not rewind the turn's current command.
283
+ if (known?.command_id === explicit)
284
+ return explicit
285
+ if (!known && event.type === 'message.started') {
286
+ this.db.query('update turn_commands set command_id=? where session_id=? and provider_session_id=? and actor_id=? and turn_id=?')
287
+ .run(explicit, ...key)
288
+ return explicit
289
+ }
290
+ }
276
291
  throw new Error('Conflicting command identity for a provider turn.')
292
+ }
277
293
  let inherited: string | undefined
278
294
  if (!previous && !explicit && event.actor.type === 'subagent') {
279
295
  const toolCallId = event.actor.parentToolCallId
@@ -7,6 +7,7 @@ import type { WireEventBody } from '../supervisor-protocol/event-body'
7
7
  import type { SupervisorAgentRunSnapshot } from '../supervisor-protocol/supervisor-agent-run-snapshot'
8
8
  import type { SupervisorAgentRuntime, SupervisorProviderFactory } from './provider-factory'
9
9
  import type { SupervisorRpcClient } from './rpc-client'
10
+ import { Database } from 'bun:sqlite'
10
11
  import { wrapAgentEvent } from '../supervisor-protocol/agent-event-wrapper'
11
12
  import { createSupervisorProviderFactory } from './provider-factory'
12
13
 
@@ -15,6 +16,40 @@ export interface AgentProviderBindingStore {
15
16
  save: (input: { sessionId: string, provider: AgentProviderId, providerSessionId: string }) => Promise<void>
16
17
  }
17
18
 
19
+ /** Local provider identity only; credentials and transcripts do not belong in this store. */
20
+ export class SqliteAgentProviderBindingStore implements AgentProviderBindingStore {
21
+ private readonly db: Database
22
+
23
+ constructor(path: string) {
24
+ this.db = new Database(path, { create: true })
25
+ this.db.exec(`pragma journal_mode=WAL; pragma synchronous=FULL;
26
+ create table if not exists provider_bindings(
27
+ session_id text not null,
28
+ provider text not null,
29
+ provider_session_id text not null,
30
+ primary key(session_id, provider)
31
+ );`)
32
+ }
33
+
34
+ async load(input: { sessionId: string, provider: AgentProviderId }): Promise<string | undefined> {
35
+ return this.db.query<{ provider_session_id: string }, [string, string]>(
36
+ 'select provider_session_id from provider_bindings where session_id=? and provider=?',
37
+ ).get(input.sessionId, input.provider)?.provider_session_id
38
+ }
39
+
40
+ async save(input: { sessionId: string, provider: AgentProviderId, providerSessionId: string }): Promise<void> {
41
+ if (!input.sessionId || !input.providerSessionId)
42
+ throw new Error('Provider binding requires session and provider session identities.')
43
+ this.db.query(`insert into provider_bindings(session_id,provider,provider_session_id) values (?,?,?)
44
+ on conflict(session_id,provider) do update set provider_session_id=excluded.provider_session_id`)
45
+ .run(input.sessionId, input.provider, input.providerSessionId)
46
+ }
47
+
48
+ close(): void {
49
+ this.db.close()
50
+ }
51
+ }
52
+
18
53
  export interface AgentSupervisorProviderFactoryOptions {
19
54
  eventProcessor?: {
20
55
  recover: (emit: (body: WireEventBody) => void, options?: { interruptUnfinishedTurns: boolean }) => Promise<void>
@@ -65,11 +100,28 @@ export function createAgentSupervisorProviderFactory(
65
100
  ? await provider.resumeSession({ ...sessionInput, provider: 'codex', providerSessionId: previousProviderSessionId }, { signal: input.signal })
66
101
  : await provider.openSession({ ...sessionInput, provider: 'codex' }, { signal: input.signal })
67
102
 
68
- await options.bindingStore.save({
69
- sessionId: input.bootstrap.sessionId,
70
- provider: provider.id,
71
- providerSessionId: run.metadata.providerSessionId,
72
- })
103
+ try {
104
+ await options.bindingStore.save({
105
+ sessionId: input.bootstrap.sessionId,
106
+ provider: provider.id,
107
+ providerSessionId: run.metadata.providerSessionId,
108
+ })
109
+ }
110
+ catch (error) {
111
+ try {
112
+ await run.stop({
113
+ type: 'agent.stop',
114
+ provider: run.metadata.provider,
115
+ sessionId: run.metadata.sessionId,
116
+ providerSessionId: run.metadata.providerSessionId,
117
+ reason: 'provider_binding_persistence_failed',
118
+ }, { signal: new AbortController().signal })
119
+ }
120
+ catch (cleanupError) {
121
+ throw new AggregateError([error, cleanupError], 'Provider binding persistence and cleanup failed.')
122
+ }
123
+ throw error
124
+ }
73
125
 
74
126
  return new AgentSupervisorRuntime({
75
127
  run,
@@ -138,7 +190,9 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
138
190
  providerSessionId: metadata.providerSessionId,
139
191
  commandId: input.commandId,
140
192
  mode: input.body.mode,
193
+ turnId: input.body.turnId,
141
194
  model: input.body.model,
195
+ reasoningEffort: input.body.reasoningEffort,
142
196
  origin: 'user',
143
197
  prompt: input.body.prompt,
144
198
  author: input.body.user
@@ -158,6 +212,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
158
212
  if (input.body.type === 'agent.interrupt') {
159
213
  await this.input.run.interrupt({
160
214
  type: 'agent.interrupt',
215
+ turnId: input.body.turnId,
161
216
  provider: metadata.provider,
162
217
  sessionId: metadata.sessionId,
163
218
  providerSessionId: metadata.providerSessionId,
@@ -51,8 +51,8 @@ export function createBootstrapBinder(input: {
51
51
  outcome: 'rejected',
52
52
  ack: {
53
53
  kind: 'command.ack',
54
- commandId: request.envelope.body.initialCommand.commandId,
55
- commandSeq: request.envelope.body.initialCommand.commandSeq,
54
+ commandId: request.envelope.body.initialCommand?.commandId ?? `bootstrap:${request.envelope.body.sessionId}`,
55
+ commandSeq: request.envelope.body.initialCommand?.commandSeq ?? 1,
56
56
  status: 'rejected',
57
57
  errorCode: error.code,
58
58
  detail: error.publicMessage.slice(0, 240),
@@ -123,6 +123,7 @@ export function createBootstrapBinder(input: {
123
123
  outcome: 'accepted',
124
124
  runtimeState: {
125
125
  kind: 'runtime.state',
126
+ ...(request.envelope.body.commandTransport === 'wss-v1' ? { sourceId: acceptedBinding.store.journalSourceId() } : {}),
126
127
  lastReceivedCommandSeq: acceptedBinding.store.lastReceivedCommandSeq(),
127
128
  nextEventSeq: acceptedBinding.store.localEventMax() + 1,
128
129
  agentRun: acceptedBinding.store.agentRunSnapshot(),
@@ -1,4 +1,4 @@
1
- export { createAgentSupervisorProviderFactory } from './agent-provider-factory'
1
+ export { createAgentSupervisorProviderFactory, SqliteAgentProviderBindingStore } from './agent-provider-factory'
2
2
  export type { AgentProviderBindingStore, AgentSupervisorProviderFactoryOptions } from './agent-provider-factory'
3
3
  export {
4
4
  CLEAN_EXIT_CODE,
@@ -46,6 +46,9 @@ export interface PersistedSupervisorBinding {
46
46
  runtimeConnectionAttemptId: string
47
47
  }
48
48
 
49
+ /** A command whose sequence is already journaled under a different identity or body. */
50
+ export const commandSequenceConflictCode = 'agent-core.command-sequence-conflict'
51
+
49
52
  export class SupervisorStore {
50
53
  private closed = false
51
54
 
@@ -113,12 +116,14 @@ export class SupervisorStore {
113
116
  now,
114
117
  )
115
118
  this.insertAttempt(input)
116
- this.insertCommand({
117
- kind: 'command',
118
- commandId: input.body.initialCommand.commandId,
119
- commandSeq: input.body.initialCommand.commandSeq,
120
- body: input.body.initialCommand.body,
121
- })
119
+ if (input.body.initialCommand) {
120
+ this.insertCommand({
121
+ kind: 'command',
122
+ commandId: input.body.initialCommand.commandId,
123
+ commandSeq: input.body.initialCommand.commandSeq,
124
+ body: input.body.initialCommand.body,
125
+ })
126
+ }
122
127
  return {
123
128
  body: input.body,
124
129
  bodyHash,
@@ -175,12 +180,12 @@ export class SupervisorStore {
175
180
  if (existing.command_id !== envelope.commandId
176
181
  || existing.body_hash !== sha256CanonicalJson(body)
177
182
  || existing.body_json !== canonicalJson(body)) {
178
- throw new SupervisorPersistenceError({ message: 'Command sequence conflicts with durable inbox state.' })
183
+ throw new SupervisorPersistenceError({ code: commandSequenceConflictCode, message: 'Command sequence conflicts with durable inbox state.' })
179
184
  }
180
185
  return { status: 'received', duplicate: true }
181
186
  }
182
187
  const maximum = this.lastReceivedCommandSeq()
183
- if (envelope.commandSeq !== maximum + 1)
188
+ if (this.binding()?.body.commandTransport !== 'wss-v1' && envelope.commandSeq !== maximum + 1)
184
189
  throw new SupervisorPersistenceError({ message: 'Command sequence contains a gap.' })
185
190
  try {
186
191
  this.insertCommand({ ...envelope, body })
@@ -240,6 +245,15 @@ export class SupervisorStore {
240
245
  })()
241
246
  }
242
247
 
248
+ /** Stable identity of this SQLite journal, independent of socket and worker. */
249
+ journalSourceId(): string {
250
+ this.database.query('INSERT OR IGNORE INTO metadata (key, value) VALUES (\'journal_source_id\', ?)').run(crypto.randomUUID())
251
+ const row = this.database.query<{ value: string }, []>('SELECT value FROM metadata WHERE key = \'journal_source_id\'').get()
252
+ if (!row)
253
+ throw new SupervisorPersistenceError({ message: 'Missing journal identity.' })
254
+ return row.value
255
+ }
256
+
243
257
  lastReceivedCommandSeq(): number {
244
258
  return this.database.query<{ maximum: number }, []>('SELECT COALESCE(MAX(seq), 0) AS maximum FROM commands').get()?.maximum ?? 0
245
259
  }