@cabane/companion 0.6.92 → 0.6.94

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.
Files changed (3) hide show
  1. package/dist/cli.js +238 -340
  2. package/dist/runtime.js +238 -340
  3. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -2125,8 +2125,12 @@ var CabaneApi = class {
2125
2125
  // it), so it is passed down rather than inferred here. It gates the COST of
2126
2126
  // the aggregate, never the tenancy scoping, which the route applies
2127
2127
  // unconditionally.
2128
- getTurnContext(conversationId, messageId2, turnId, resumed) {
2129
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "");
2128
+ // `fresh` asks for the context a session that holds NOTHING needs: the read
2129
+ // from the start of the conversation, the agent's own words included, and
2130
+ // the first-turn material — what the adapter runs on when it refused to
2131
+ // resume the stored session and is starting a fresh one.
2132
+ getTurnContext(conversationId, messageId2, turnId, resumed, fresh) {
2133
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "") + (fresh ? "&fresh=1" : "");
2130
2134
  return this.request("GET", `/api/agent/turn-context?${q}`);
2131
2135
  }
2132
2136
  // CT1380: the local half of a resumed turn's seq floor (see
@@ -2135,6 +2139,18 @@ var CabaneApi = class {
2135
2139
  outboxMaxSeqForTurn(turnId) {
2136
2140
  return this.opts.outbox?.maxSeqForTurn(turnId) ?? 0;
2137
2141
  }
2142
+ // Whether this turn's speech is still waiting in the outbox — the half of
2143
+ // "did the span speak" the server cannot see: a first message absorbed by a
2144
+ // transient failure before the crash is on disk here, spent and undelivered,
2145
+ // and the resumed run must not commit its next note as first words again.
2146
+ // A runtime notice queued under the turn is not speech.
2147
+ outboxHasSpeechForTurn(turnId) {
2148
+ const outbox = this.opts.outbox;
2149
+ if (!outbox) return false;
2150
+ return outbox.list().some(
2151
+ (e) => e.turnId === turnId && e.kind === "message" && e.body?.kind !== "runtime_notice"
2152
+ );
2153
+ }
2138
2154
  // CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
2139
2155
  // (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
2140
2156
  // server-side (the URL MCP surface) rather than the dispatcher's in-memory
@@ -2260,6 +2276,23 @@ var CabaneApi = class {
2260
2276
  async durableActiveRunWrite(path, conversationId, agentId, body) {
2261
2277
  const outbox = this.opts.outbox;
2262
2278
  const key = activeRunOutboxKey(conversationId, agentId);
2279
+ const turnId = body?.turnId;
2280
+ if (outbox && typeof turnId === "string" && outbox.maxSeqForTurn(turnId) > 0) {
2281
+ outbox.persist({
2282
+ enqueuedAt: Date.now(),
2283
+ turnId: key,
2284
+ seq: ACTIVE_RUN_OUTBOX_SEQ,
2285
+ method: "PATCH",
2286
+ path,
2287
+ body,
2288
+ kind: "active-run"
2289
+ });
2290
+ this.opts.log?.warn(
2291
+ { conversationId, agentId, turnId },
2292
+ "companion: settle queued behind this turn's undelivered commits (drains in order)"
2293
+ );
2294
+ return;
2295
+ }
2263
2296
  try {
2264
2297
  await this.request("PATCH", path, body, { retry: true });
2265
2298
  outbox?.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
@@ -2301,16 +2334,11 @@ var CabaneApi = class {
2301
2334
  // (transport errors + 5xx), never a 4xx, so no `(turnId, kind)` row is
2302
2335
  // double-posted. The caller threads its turn abort signal so a cancel
2303
2336
  // mid-commit aborts the in-flight POST rather than letting it land.
2304
- // CT11: `kind` includes `'stopped'` for the terminal cancel markersame
2305
- // wire shape as `progress`/`final`, distinguished only by `kind` so the chat
2306
- // drawer's turn-group renderer treats it as a closing row. CT1295: this
2307
- // companion no longer WRITES that kind — the server does, in the transaction
2308
- // that terminalizes the stopped turn — so the value survives here only as
2309
- // wire vocabulary the server also speaks. `seq` is the companion's per-turn
2337
+ // Workzone: a commit is a message — the agent's first or last words or the
2338
+ // runtime's own line (`runtime_notice`). The words between tool calls go
2339
+ // through `reportText`, the trail's door. `seq` is the companion's per-turn
2310
2340
  // monotonic counter, stamped on the row so the merged timeline orders the
2311
- // commit deterministically against the persisted tool/thinking rows. Both
2312
- // fields are optional on the wire — an older companion that didn't mint seq
2313
- // still validates (the server defaults to 0).
2341
+ // commit deterministically against the persisted tool/thinking rows.
2314
2342
  postTurnMessage(workspaceId, conversationId, body, signal) {
2315
2343
  return this.durableCommit(
2316
2344
  "message",
@@ -2321,35 +2349,6 @@ var CabaneApi = class {
2321
2349
  signal
2322
2350
  );
2323
2351
  }
2324
- // CT1357: post ONE ordinary addressed message — not turn speech, no `turnId`
2325
- // — so `dispatch` is honoured and the target agent is woken. The turn-commit
2326
- // fork ignores `dispatch` (an act rides its own call now), which leaves this
2327
- // as the only way a companion can address a peer OUTSIDE a running turn.
2328
- //
2329
- // The prepare-failure handback is exactly that case and the reason this
2330
- // exists. It fires before the turn is ever admitted — the active-run PATCH
2331
- // that admits it comes after the prepare hook — so `turnAct` is not available:
2332
- // its route resolves the turn first and 404s when there is none. The failure
2333
- // notice's own commit is what admits the turn, and by then it has also settled
2334
- // it. An ordinary addressed post depends on no lease at all, which is what
2335
- // makes it the right shape here.
2336
- //
2337
- // Retried like any durable write, and therefore REQUIRING `idempotencyKey`:
2338
- // this is not outbox-queued (there is no `(turnId, seq)` for a non-turn row),
2339
- // so without a key a committed request whose response was lost — or a
2340
- // redelivered dispatch event — writes a second row and wakes the peer twice.
2341
- // The server dedupes on `(conversation_id, idempotency_key)`: the repeat
2342
- // returns the existing row and raises no second dispatch (CT239). Required,
2343
- // not optional, so a future caller cannot omit it and silently lose the
2344
- // guarantee.
2345
- postAddressedMessage(workspaceId, conversationId, body, signal) {
2346
- return this.request(
2347
- "POST",
2348
- `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
2349
- body,
2350
- signal ? { retry: true, signal } : { retry: true }
2351
- );
2352
- }
2353
2352
  // SJ477: report one tool-activity transition (start / done / error) for the
2354
2353
  // live activity cards. Transient — the server publishes an `agent_activity`
2355
2354
  // SSE and writes no row. Agent-PAT authed; the URL `:agentId` must match the
@@ -2379,6 +2378,20 @@ var CabaneApi = class {
2379
2378
  signal
2380
2379
  );
2381
2380
  }
2381
+ // Workzone: report one text the agent wrote between tool calls — the trail's
2382
+ // door. Durable like a thinking row (same outbox, same `(turnId, seq)`
2383
+ // replay), and never a message: the server writes it to `message_activity`
2384
+ // under the span and nothing lands, stamps or wakes on it.
2385
+ reportText(workspaceId, conversationId, agentId, body, signal) {
2386
+ return this.durableCommit(
2387
+ "text",
2388
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/text`,
2389
+ body,
2390
+ body.turnId,
2391
+ body.seq,
2392
+ signal
2393
+ );
2394
+ }
2382
2395
  // SJ493: fetch the agent's self-view — identity + operating context. The
2383
2396
  // companion calls this on each dispatch to get its `systemPrompt` (composed
2384
2397
  // server-side from the bundled default + the agent's charter) rather than
@@ -3511,20 +3524,24 @@ var THINKING_TEXT_MAX_CHARS = 3900;
3511
3524
  var TurnPump = class {
3512
3525
  constructor(opts) {
3513
3526
  this.opts = opts;
3527
+ this.hasSpoken = opts.spoke ?? false;
3514
3528
  this.sink = {
3515
3529
  onAssistantText: async (evt) => {
3516
3530
  if (opts.signal.aborted) return;
3517
3531
  const body = normalizeCommitText(evt.text);
3518
3532
  if (!body) return;
3519
- const kind = evt.final ? "final" : "progress";
3520
3533
  const seq = opts.nextSeq();
3521
- await opts.commit.commitMessage({ body, kind, seq });
3534
+ if (!evt.final && this.hasSpoken) {
3535
+ await opts.commit.reportText({ text: body, seq });
3536
+ return;
3537
+ }
3538
+ await opts.commit.commitMessage({ body, kind: "message", seq });
3539
+ this.hasSpoken = true;
3522
3540
  if (evt.final) {
3523
3541
  this.emittedFinal = true;
3542
+ this.lastSeq = seq;
3524
3543
  this.finalReplyBody = body;
3525
3544
  this.emittedFinalSource = "runtime_text";
3526
- } else {
3527
- this.lastProgressBody = body;
3528
3545
  }
3529
3546
  },
3530
3547
  onToolActivity: async (evt) => {
@@ -3561,11 +3578,12 @@ var TurnPump = class {
3561
3578
  };
3562
3579
  }
3563
3580
  opts;
3564
- // `finalEmitted` gates the end-of-turn progress promotion;
3565
- // `lastProgressBody` is what we promote to `final` when a clean turn ended on
3566
- // a tool call with no closing text.
3581
+ // A message has landed under this span — its first words. Every later
3582
+ // non-terminal text is the trail. Seeded from the host on a resume.
3583
+ hasSpoken;
3584
+ // The last words landed, and their `seq` — what the settle declares.
3567
3585
  emittedFinal = false;
3568
- lastProgressBody = null;
3586
+ lastSeq = null;
3569
3587
  // The turn's final reply text, captured for the dashboard feed / logging.
3570
3588
  finalReplyBody = null;
3571
3589
  emittedFinalSource = "none";
@@ -3576,31 +3594,20 @@ var TurnPump = class {
3576
3594
  // frozen committer test — drive. Each callback applies the choreography and
3577
3595
  // commits through the injected sink.
3578
3596
  sink;
3579
- // End-of-turn progress promotion. A clean turn that emitted interim text but
3580
- // no closing text promotes the agent's own last progress body (the drawer
3581
- // collapses the duplicate). If the agent emitted no words, emit no message:
3582
- // Cabane never attributes host-authored text to an agent, in any turn shape,
3583
- // for any reason. The host closes that wordless turn with a marker instead.
3584
- // Skipped when cancelled or already final. The held-text flush that precedes
3585
- // this is a classification concern, driven by the caller before this runs.
3586
- async finalize(ok) {
3587
- if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
3588
- const body = this.lastProgressBody;
3589
- const seq = this.opts.nextSeq();
3590
- try {
3591
- await this.opts.commit.commitMessage({ body, kind: "final", seq });
3592
- this.emittedFinal = true;
3593
- this.finalReplyBody = body;
3594
- this.emittedFinalSource = "progress_promotion";
3595
- } catch (err) {
3596
- this.opts.onError?.(err, "progress-promotion");
3597
- }
3597
+ // Whether a message has landed under this span first words, at least. A
3598
+ // span that spoke settles; one that never did is a pass.
3599
+ get spoke() {
3600
+ return this.hasSpoken;
3598
3601
  }
3599
- // Whether the turn has committed its `final` row — read by the host to decide
3600
- // whether a marker is owed.
3602
+ // Whether the turn has committed its last words.
3601
3603
  get finalEmitted() {
3602
3604
  return this.emittedFinal;
3603
3605
  }
3606
+ // The `seq` of the last words, for the settle to declare — or null when the
3607
+ // span ended without any. Nothing is put in their place.
3608
+ get lastMessageSeq() {
3609
+ return this.lastSeq;
3610
+ }
3604
3611
  // The turn's final reply text, or null if none landed (failure / cancel /
3605
3612
  // empty). Used for the dashboard feed's reply preview.
3606
3613
  get replyBody() {
@@ -4069,7 +4076,11 @@ function sdkEvents(queryFn, req, signal, built, degraded) {
4069
4076
  degraded
4070
4077
  });
4071
4078
  }
4072
- async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded) {
4079
+ async function freshRequest(req, reason, hooks) {
4080
+ const recomposed = hooks?.fresh ? await hooks.fresh(reason) : null;
4081
+ return recomposed ? { req: { ...req, ...recomposed, session: null }, degraded: false } : { req: { ...req, session: null }, degraded: true };
4082
+ }
4083
+ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded, hooks) {
4073
4084
  let emitted = false;
4074
4085
  for await (const event of sdkEvents(queryFn, req, signal, initial, degraded)) {
4075
4086
  const recoverResume = initial.resume !== null && !emitted && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
@@ -4083,9 +4094,10 @@ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOpti
4083
4094
  discardedSessionId: initial.resume
4084
4095
  }
4085
4096
  );
4086
- const freshReq = { ...req, session: null };
4087
- const fresh = buildClaudeCodeOptions(freshReq, augmentOptions);
4088
- yield* sdkEvents(queryFn, freshReq, signal, fresh, true);
4097
+ const fresh = await freshRequest(req, "session_start_failed", hooks);
4098
+ if (signal.aborted) return;
4099
+ const options = buildClaudeCodeOptions(fresh.req, augmentOptions);
4100
+ yield* sdkEvents(queryFn, fresh.req, signal, options, fresh.degraded);
4089
4101
  return;
4090
4102
  }
4091
4103
  emitted = true;
@@ -4098,23 +4110,32 @@ function createClaudeCodeAdapter(deps = {}) {
4098
4110
  name: "claude-code",
4099
4111
  promptAddendum: () => CLAUDE_CODE_ADDENDUM,
4100
4112
  dialectSchema: claudeCodeDialectSchema,
4101
- runTurn(req, signal) {
4102
- const { options, resume, freshReason } = buildClaudeCodeOptions(req, deps.augmentOptions);
4103
- if (freshReason === "cwd_mismatch") {
4113
+ async *runTurn(request, signal, hooks) {
4114
+ let req = request;
4115
+ let built = buildClaudeCodeOptions(req, deps.augmentOptions);
4116
+ if (built.freshReason === "cwd_mismatch") {
4104
4117
  deps.onWarn?.(
4105
4118
  "claude-code adapter: stored session cwd no longer matches the current environment \u2014 starting a fresh session (SJ527 guard)",
4106
4119
  { cwd: req.local.cwd }
4107
4120
  );
4108
4121
  }
4109
- const degraded = freshReason !== void 0 && freshReason !== "no_session";
4110
- return runWithResumeRecovery(
4122
+ let degraded = false;
4123
+ if (built.freshReason !== void 0 && built.freshReason !== "no_session") {
4124
+ const fresh = await freshRequest(req, built.freshReason, hooks);
4125
+ if (signal.aborted) return;
4126
+ req = fresh.req;
4127
+ degraded = fresh.degraded;
4128
+ built = buildClaudeCodeOptions(req, deps.augmentOptions);
4129
+ }
4130
+ yield* runWithResumeRecovery(
4111
4131
  queryFn,
4112
4132
  req,
4113
4133
  signal,
4114
- { options, resume, freshReason },
4134
+ built,
4115
4135
  deps.augmentOptions,
4116
4136
  deps.onWarn,
4117
- degraded
4137
+ degraded,
4138
+ hooks
4118
4139
  );
4119
4140
  }
4120
4141
  };
@@ -5276,7 +5297,8 @@ function createOpencodeAdapter(deps = {}) {
5276
5297
  // argument went with the named tool surface it selected against.
5277
5298
  promptAddendum: () => OPENCODE_ADDENDUM,
5278
5299
  dialectSchema: opencodeDialectSchema,
5279
- async *runTurn(req, signal) {
5300
+ async *runTurn(request, signal, hooks) {
5301
+ let req = request;
5280
5302
  if (!transport) {
5281
5303
  yield {
5282
5304
  type: "result",
@@ -5294,7 +5316,15 @@ function createOpencodeAdapter(deps = {}) {
5294
5316
  { directory }
5295
5317
  );
5296
5318
  }
5297
- const degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
5319
+ let degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
5320
+ if ("fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session" && hooks?.fresh) {
5321
+ const recomposed = await hooks.fresh(decision.reason);
5322
+ if (signal.aborted) return;
5323
+ if (recomposed) {
5324
+ req = { ...req, ...recomposed, session: null };
5325
+ degraded = false;
5326
+ }
5327
+ }
5298
5328
  const spec = buildRunSpec(req, resume);
5299
5329
  const result = await transport.run(spec, signal);
5300
5330
  if (signal.aborted) return;
@@ -6301,7 +6331,8 @@ function createCodexAdapter(deps = {}) {
6301
6331
  // argument went with the named tool surface it selected against.
6302
6332
  promptAddendum: () => CODEX_ADDENDUM,
6303
6333
  dialectSchema: codexDialectSchema,
6304
- async *runTurn(req, signal) {
6334
+ async *runTurn(request, signal, hooks) {
6335
+ let req = request;
6305
6336
  if (!transport) {
6306
6337
  yield {
6307
6338
  type: "result",
@@ -6320,7 +6351,15 @@ function createCodexAdapter(deps = {}) {
6320
6351
  { directory }
6321
6352
  );
6322
6353
  }
6323
- const degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
6354
+ let degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
6355
+ if ("fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session" && hooks?.fresh) {
6356
+ const recomposed = await hooks.fresh(decision.reason);
6357
+ if (signal.aborted) return;
6358
+ if (recomposed) {
6359
+ req = { ...req, ...recomposed, session: null };
6360
+ degraded = false;
6361
+ }
6362
+ }
6324
6363
  let instructions = null;
6325
6364
  if (!req.policy.hostFs) {
6326
6365
  if (!deps.writeInstructionsFile) {
@@ -7362,14 +7401,11 @@ var MIN_ASK_OPTIONS = 2;
7362
7401
  var MAX_ASK_OPTIONS = 4;
7363
7402
  var MAX_WAKE_NOTE_BYTES = 2e3;
7364
7403
  var MAX_WITHDRAW_REASON_BYTES = 400;
7365
- var MAX_SEND_MESSAGE_BYTES = 65536;
7366
7404
  var MAX_SKIP_REASON_BYTES = 500;
7367
7405
  var TURN_CONTROL_TOOL_DESCRIPTIONS = {
7368
- ask: `Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. Provide EITHER \`question\` (single) or \`questions\` (array), never both. **Several decisions landing together are ONE call with \`questions\`** \u2014 never cram the extras into prose, drop all but one, or fire an ask each: a second \`ask\` is a second inbox item competing with the first, never an edit to it. Each question keeps the same form: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links and detail go in your REPLY; inline markdown only, no lists or headings), and ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. The ask is a first-class attention item aimed at that person; your final reply carries the CONTEXT, the ask carries the QUESTION(S). It is written and delivered THE MOMENT you call this \u2014 the person sees it in their inbox while your turn is still running \u2014 so call it once you know the question. The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered. Ask only when blocked \u2014 never ceremonially. \`replaces\` withdraws a stale ask of yours (or anyone's) in this conversation in the same write, with the reason shown to the person. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
7406
+ ask: `Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. Provide EITHER \`question\` (single) or \`questions\` (array), never both. **Several decisions landing together are ONE call with \`questions\`** \u2014 never cram the extras into prose, drop all but one, or fire an ask each: a second \`ask\` is a second inbox item competing with the first, never an edit to it. Each question keeps the same form: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links and detail go in your REPLY; inline markdown only, no lists or headings), and ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. The ask is a first-class attention item aimed at that person; your final reply carries the CONTEXT, the ask carries the QUESTION(S). It is written and delivered THE MOMENT you call this \u2014 the person sees it in their inbox while your turn is still running \u2014 so call it once you know the question. The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered. Ask only when blocked \u2014 never ceremonially. \`replaces\` withdraws a stale ask of yours (or anyone's) in this conversation in the same write, with the reason shown to the person. Targets a human only; to bring a peer AGENT in, name them by \`@handle\` in your reply.`,
7369
7407
  cancel_wake: `Stand down your own wake \u2014 the inverse of \`wake_me\`. No arguments: it cancels the wake you have armed in this conversation, NOW, when you call it. Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. Each call acts as it arrives: \`cancel_wake\` then \`wake_me\` leaves the NEW wake armed; \`wake_me\` then \`cancel_wake\` leaves nothing armed. It reaches only your own wake in this conversation \u2014 never a peer's, and never a recurring schedule (those are \`schedules.*\`).`,
7370
- reply_to: `Declare which addressed ask your final response answers. Pass the \`messageId\` shown as the owed reply id in your turn context, then finish your response normally. This records lineage only: it does not send another message. The server verifies that this agent owes that ask in this conversation; one reply per turn \u2014 a later call replaces the earlier declaration.`,
7371
- send: `Send one addressed message to another agent in THIS conversation. Pass the peer's \`agentId\` and the complete \`message\` they should act on. The message is WRITTEN the moment you call this \u2014 durable, in the conversation's queue, in the order you called \u2014 but it is DELIVERED only after your own turn ends: the peer is not running yet and cannot answer you inside this turn. Each call is one message; two calls are two messages. Writing \`@handle\` in prose dispatches nobody. An unknown, deactivated, or self target is refused here, in the result. A handoff to a DIFFERENT conversation is \`conversations.create\` / \`conversations.post\` with their \`dispatch\` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.`,
7372
- skip_turn: `End your current turn WITHOUT posting a reply. Call this when you've been dispatched but the message genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or a pile-on where someone else has it. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
7408
+ skip_turn: `End your current turn WITHOUT posting a reply. Call this when you've been woken to read a message that genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or something another participant clearly owns. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
7373
7409
  wake_me: `Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for "wait until X": when the thing you need hasn't happened yet (a PR isn't merged, a human hasn't answered), arm a wake, end your turn, and you're woken later to CHECK \u2014 read the workspace, and either act or re-arm. Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a known reset time. A speculative far-future check-in you invented yourself is the one thing not to arm: if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it \u2014 finish now, or raise an \`ask\`. Pass EXACTLY ONE of \`afterSeconds\` (a relative delay \u2014 \`300\` for five minutes) or \`at\` (an absolute ISO-8601 timestamp WITH a zone, e.g. \`2026-07-16T09:00:00-07:00\` \u2014 YOU compute it; the system never parses natural-language time). \`note\` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check ("check whether CT441 merged yet"). The wake arms NOW, when you call this, and the delay counts from this call; a later \`wake_me\` in the same turn replaces it, and \`cancel_wake\` stands it down. A wake that comes due while your turn is still running waits and is delivered after your reply. This is the ONLY sanctioned way to schedule your own continuation; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days \u2014 a wake that can't be armed returns an error here, in this call, so you can recompute; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you'll be steered to raise an \`ask\` to the human instead.`,
7374
7410
  withdraw_ask: `Withdraw an open ask \u2014 yours or anyone's in this conversation \u2014 because the question is moot, was wrong, or has been overtaken. \`reason\` is required and is shown to the person and, if it wasn't your ask, to the agent who asked it. If there is a current question to put in its place, don't call this \u2014 call \`ask\` with \`replaces\` so the two land as one and the person never sees neither or both. Never withdraw to hurry an answer; to act on a deadline, arm a \`wake_me\` and decide then.`
7375
7411
  };
@@ -7388,13 +7424,6 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
7388
7424
  "replaces.reason": `Why the old question is no longer the question \u2014 shown to the person on their Done row. Up to ${MAX_WITHDRAW_REASON_BYTES} characters.`
7389
7425
  },
7390
7426
  cancel_wake: {},
7391
- reply_to: {
7392
- messageId: "The owed ask message id from this turn context."
7393
- },
7394
- send: {
7395
- agentId: "The peer agent to address \u2014 a workspace agent id, from your turn context's roster.",
7396
- message: `The complete new request the peer should receive and act on. Up to ${MAX_SEND_MESSAGE_BYTES} characters.`
7397
- },
7398
7427
  skip_turn: {
7399
7428
  reason: `Short reason you are declining \u2014 used for telemetry/debugging, never posted. Up to ${MAX_SKIP_REASON_BYTES} characters.`
7400
7429
  },
@@ -7412,34 +7441,20 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
7412
7441
  // src/turn-control-tools.ts
7413
7442
  import { z as z13 } from "zod";
7414
7443
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
7415
- var SEND_TOOL = "send";
7416
- var SEND_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SEND_TOOL}`;
7417
7444
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
7418
7445
  var SKIP_TURN_TOOL = "skip_turn";
7419
7446
  var ASK_TOOL = "ask";
7420
7447
  var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
7421
7448
  var WITHDRAW_ASK_TOOL = "withdraw_ask";
7422
7449
  var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
7423
- var REPLY_TO_TOOL = "reply_to";
7424
- var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
7425
7450
  var WAKE_ME_TOOL = "wake_me";
7426
7451
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
7427
7452
  var CANCEL_WAKE_TOOL = "cancel_wake";
7428
7453
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7429
7454
  var TURN_ACT_TIMEOUT_MS = 45e3;
7430
- function createReplyState() {
7431
- return { answersMessageId: null };
7432
- }
7433
7455
  function createSkipState() {
7434
7456
  return { skipped: false, reason: null };
7435
7457
  }
7436
- function resolveDeclaredReplyField(input) {
7437
- const explicit = input.replyState.answersMessageId;
7438
- if (explicit) return { answersMessageId: explicit };
7439
- if (input.kind !== "final") return {};
7440
- if (!input.owedReplyMessageId) return {};
7441
- return { answersMessageId: input.owedReplyMessageId, answersAutoDeclared: true };
7442
- }
7443
7458
  function toolError(text) {
7444
7459
  return { isError: true, content: [{ type: "text", text }] };
7445
7460
  }
@@ -7469,7 +7484,7 @@ async function performAct(acts, act) {
7469
7484
  if (!result.ok) return toolError(JSON.stringify(result));
7470
7485
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7471
7486
  }
7472
- function createTurnControlMcpServer(acts, skipState, replyState) {
7487
+ function createTurnControlMcpServer(acts, skipState) {
7473
7488
  const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7474
7489
  const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
7475
7490
  const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
@@ -7477,42 +7492,6 @@ function createTurnControlMcpServer(acts, skipState, replyState) {
7477
7492
  name: COMPANION_LOCAL_MCP_SERVER,
7478
7493
  version: "0.0.0",
7479
7494
  tools: [
7480
- tool(
7481
- SEND_TOOL,
7482
- TURN_CONTROL_TOOL_DESCRIPTIONS.send,
7483
- {
7484
- agentId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.send.agentId),
7485
- message: z13.string().min(1).max(MAX_SEND_MESSAGE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.send.message)
7486
- },
7487
- async (args) => performAct(acts, {
7488
- kind: "send",
7489
- callId: randomUUID(),
7490
- agentId: args.agentId,
7491
- message: args.message
7492
- }),
7493
- { annotations: ACT_TOOL, alwaysLoad: true }
7494
- ),
7495
- ...replyState ? [
7496
- tool(
7497
- REPLY_TO_TOOL,
7498
- TURN_CONTROL_TOOL_DESCRIPTIONS.reply_to,
7499
- {
7500
- messageId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.reply_to.messageId)
7501
- },
7502
- async (args) => {
7503
- replyState.answersMessageId = args.messageId;
7504
- return {
7505
- content: [
7506
- {
7507
- type: "text",
7508
- text: JSON.stringify({ answersMessageId: args.messageId })
7509
- }
7510
- ]
7511
- };
7512
- },
7513
- { annotations: RECORDER_TOOL, alwaysLoad: true }
7514
- )
7515
- ] : [],
7516
7495
  ...skipState ? [
7517
7496
  tool(
7518
7497
  SKIP_TURN_TOOL,
@@ -8039,8 +8018,7 @@ var TurnCommitter = class {
8039
8018
  kind,
8040
8019
  turnId: deps.turnId,
8041
8020
  seq,
8042
- parentMessageId: deps.parentMessageId,
8043
- ...kind === "final" ? this.turnControlFields("final") : {}
8021
+ parentMessageId: deps.parentMessageId
8044
8022
  },
8045
8023
  deps.signal
8046
8024
  );
@@ -8081,19 +8059,22 @@ var TurnCommitter = class {
8081
8059
  { turnId: deps.turnId, text, seq },
8082
8060
  deps.signal
8083
8061
  );
8062
+ },
8063
+ reportText: async ({ text, seq }) => {
8064
+ await deps.api.reportText(
8065
+ deps.workspaceId,
8066
+ deps.conversationId,
8067
+ deps.agentId,
8068
+ { turnId: deps.turnId, text, seq },
8069
+ deps.signal
8070
+ );
8084
8071
  }
8085
8072
  };
8086
8073
  this.pump = new TurnPump({
8087
8074
  commit,
8088
8075
  signal: deps.signal,
8089
8076
  nextSeq: deps.nextSeq,
8090
- onError: (err) => {
8091
- deps.log.warn(
8092
- { err: err instanceof Error ? err.message : String(err) },
8093
- "dispatcher: progress-promotion commit failed"
8094
- );
8095
- deps.onCommitFailed?.(err);
8096
- }
8077
+ ...deps.spoke !== void 0 ? { spoke: deps.spoke } : {}
8097
8078
  });
8098
8079
  this.emit = sinkEmitter(this.pump.sink, this.onError);
8099
8080
  }
@@ -8117,15 +8098,17 @@ var TurnCommitter = class {
8117
8098
  this.onError(err, event.type);
8118
8099
  }
8119
8100
  }
8120
- // End-of-turn progress promotion. The held-text flush is now the adapter's
8121
- // job (it emits the closing reply as a `text` event before `result`), so this
8122
- // only asks the pump to promote agent-authored interim text. A wordless turn
8123
- // stays wordless and the dispatcher closes it with a `silent`/`skipped`
8124
- // marker (CT1295: a CANCELLED turn is closed by the SERVER's `stopped`
8125
- // marker, not from this side). Guards on the abort signal, so on cancel no
8126
- // `final` is forced.
8127
- async finalize(okResult) {
8128
- await this.pump.finalize(okResult);
8101
+ // A message has landed under this span its first words at least. A span
8102
+ // that spoke settles; one that never did is a pass. Nothing is promoted at
8103
+ // the end of a turn: the held-text flush is the adapter's (it emits the
8104
+ // closing reply as a terminal `text` event before `result`), and a span
8105
+ // that ended on working text ends without last words.
8106
+ get spoke() {
8107
+ return this.pump.spoke;
8108
+ }
8109
+ // The `seq` of the last words, for the settle to declare; null when none.
8110
+ get lastMessageSeq() {
8111
+ return this.pump.lastMessageSeq;
8129
8112
  }
8130
8113
  // The turn's final reply text, or null if none landed.
8131
8114
  get replyBody() {
@@ -8137,26 +8120,6 @@ var TurnCommitter = class {
8137
8120
  get finalEmitted() {
8138
8121
  return this.pump.finalEmitted;
8139
8122
  }
8140
- // A closing textual reply and a wordless terminal marker carry the same
8141
- // declared-reply projection. The KIND matters: the auto-declared reply binds
8142
- // only a textual `final` — a wordless turn has no answer to bind, and the
8143
- // server's mute-settle notice speaks for it.
8144
- turnControlFields(kind) {
8145
- return this.answersField(kind);
8146
- }
8147
- // The declared reply: explicit `reply_to` first, else the runtime's own
8148
- // declaration for the turn that just answers — see
8149
- // `resolveDeclaredReplyField` for the whole rule and its reasons. CT1224: an
8150
- // auto-declaration is flagged as such on the wire, because the server drops
8151
- // it when the turn performed an outward act (CT1354: any of them, read off
8152
- // the ledger). An explicit `reply_to` carries no flag and always stands.
8153
- answersField(kind) {
8154
- return resolveDeclaredReplyField({
8155
- kind,
8156
- owedReplyMessageId: this.deps.owedReplyMessageId,
8157
- replyState: this.deps.replyState
8158
- });
8159
- }
8160
8123
  };
8161
8124
 
8162
8125
  // src/turn-runtime-integrity.ts
@@ -8170,7 +8133,7 @@ async function postIncompleteNotice(ctx, harness, resolution) {
8170
8133
  try {
8171
8134
  await ctx.postTurnMessage(ctx.workspaceId, ctx.conversationId, {
8172
8135
  body: turnIncompleteCopy(harness),
8173
- kind: "final",
8136
+ kind: "runtime_notice",
8174
8137
  turnId: ctx.turnId,
8175
8138
  parentMessageId: ctx.parentMessageId
8176
8139
  });
@@ -8239,12 +8202,9 @@ async function absorbBundledLoss(ctx, aborted, outcome) {
8239
8202
 
8240
8203
  // src/turn-execution.ts
8241
8204
  var PREPARING_TOOL_NAME = "preparing";
8242
- var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
8243
8205
  var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
8244
8206
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
8245
8207
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
8246
- var SKIPPED_MARKER_BODY = "(skipped)";
8247
- var SILENT_MARKER_BODY = "(no reply)";
8248
8208
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8249
8209
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8250
8210
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -8265,7 +8225,6 @@ function initialOutcome() {
8265
8225
  contentBearingEvents: 0,
8266
8226
  latestSessionState: null,
8267
8227
  settledDiagnostics: null,
8268
- silentMarkerEmitted: false,
8269
8228
  timeoutReason: null,
8270
8229
  leaseLost: false,
8271
8230
  runtimeIncomplete: false
@@ -8314,13 +8273,15 @@ var TurnExecution = class {
8314
8273
  // `turnId` is set on every dispatch. `resumedFromSeq` is what it seeded from.
8315
8274
  resumed;
8316
8275
  resumedFromSeq;
8276
+ // Whether the span this resumed run continues already has first words —
8277
+ // from the server (`turnSpoke`) or from speech still queued in the outbox.
8278
+ resumedSpoke = false;
8317
8279
  turnContext;
8318
8280
  resolvedMcpServers;
8319
8281
  effectiveCwd;
8320
8282
  hookEnv;
8321
8283
  turnEnv;
8322
8284
  skipState;
8323
- replyState;
8324
8285
  turnControlServer;
8325
8286
  request;
8326
8287
  adapter;
@@ -8338,9 +8299,9 @@ var TurnExecution = class {
8338
8299
  disarmWatchdogs = () => {
8339
8300
  };
8340
8301
  // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
8341
- // PATCH returns — the mailroom now holds this turn as the conversation's
8342
- // running one, so every exit after this point must settle THE TURN, not just
8343
- // clear the participant flag.
8302
+ // PATCH returns — the lease is granted and this is the pair's running span,
8303
+ // so every exit after this point must settle THE SPAN, not just clear the
8304
+ // participant flag.
8344
8305
  admitted = false;
8345
8306
  concluded(reason, errorReason) {
8346
8307
  return new TurnConcluded(reason, errorReason);
@@ -8378,14 +8339,16 @@ var TurnExecution = class {
8378
8339
  return this.report();
8379
8340
  }
8380
8341
  async concludeBeforeRun(reason, errorReason) {
8381
- const { payload, turnLog, startedAt } = this;
8382
- const body = {
8383
- activeRunStartedAt: null,
8384
- // CT1046: this dispatch is over before it ran — retire its evidence so it
8385
- // can't authorize a turn-token mint afterwards.
8386
- settledMessageId: payload.messageId
8387
- };
8342
+ const { payload, turnLog, startedAt, turnId } = this;
8343
+ const body = { activeRunStartedAt: null, turnId, outcome: "failed" };
8388
8344
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
8345
+ const context = this.turnContext;
8346
+ if (context?.readThrough !== void 0) {
8347
+ body.readThrough = context.readThrough;
8348
+ if (context.readRevision !== void 0) body.readRevision = context.readRevision;
8349
+ } else {
8350
+ body.readThrough = payload.messageId;
8351
+ }
8389
8352
  try {
8390
8353
  await this.opts.api.setActiveRun(
8391
8354
  this.opts.workspaceId,
@@ -8404,21 +8367,18 @@ var TurnExecution = class {
8404
8367
  return { ok: false, durationMs, reason };
8405
8368
  }
8406
8369
  // The post-admission analogue of `concludeBeforeRun` (Codo's stack review,
8407
- // blocking finding #2): this turn WAS admitted — the mailroom holds it as
8408
- // the conversation's running turn — so the clear must NAME the turn and
8409
- // DECLARE its terminal outcome. A bare pre-run clear here would reset the
8410
- // participant flag while the turn metadata stayed running, the dispatch
8411
- // stayed live, and the queue stayed held until the reaper. `outcome`
8412
- // follows the settle wire's rule: a failure names its reason; without one
8413
- // the turn settled the runtime-unavailable exit posts its user-facing
8414
- // `final` before concluding, so the turn genuinely produced its terminal
8415
- // output and `settled` releases the queue honestly.
8370
+ // blocking finding #2): this turn WAS admitted — it holds the conversation's
8371
+ // lease — so the clear must NAME the turn and DECLARE its terminal outcome.
8372
+ // A bare pre-run clear here would reset the participant flag while the span
8373
+ // stayed running until the lost sweep. `outcome` follows the settle wire's
8374
+ // rule: a failure names its reason; without one the turn settled — the
8375
+ // runtime-unavailable exit posts its user-facing `final` before concluding,
8376
+ // so the turn genuinely produced its terminal output.
8416
8377
  async concludeAdmittedRun(reason, errorReason) {
8417
8378
  const { payload, turnLog, startedAt, turnId } = this;
8418
8379
  const body = {
8419
8380
  activeRunStartedAt: null,
8420
8381
  turnId,
8421
- settledMessageId: payload.messageId,
8422
8382
  // CT1379: `runtimeIncomplete` is a real failure that deliberately carries
8423
8383
  // no `errorReason`, so the inference alone would read it as settled.
8424
8384
  outcome: errorReason || this.outcome.runtimeIncomplete ? "failed" : "settled"
@@ -8444,6 +8404,7 @@ var TurnExecution = class {
8444
8404
  async fetchContext() {
8445
8405
  const { payload, turnId, turnLog } = this;
8446
8406
  const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8407
+ const outboxSpoke = this.resumed && this.opts.api.outboxHasSpeechForTurn(turnId);
8447
8408
  let turnContext;
8448
8409
  try {
8449
8410
  turnContext = await this.opts.api.getTurnContext(
@@ -8471,12 +8432,14 @@ var TurnExecution = class {
8471
8432
  const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
8472
8433
  this.resumedFromSeq = resolveSeqFloor(sources, { turnId, log: turnLog });
8473
8434
  this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
8435
+ this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
8436
+ if (this.resumedSpoke) turnLog.info({ turnId }, "dispatcher: resumed span already spoke");
8474
8437
  }
8475
8438
  }
8476
8439
  gateTrigger() {
8477
8440
  const { payload, turnLog } = this;
8478
8441
  const message = this.turnContext.message;
8479
- const isDispatchableTrigger = message.role === "user" || message.role === "agent" || message.role === "system" && message.hasPrimaryDispatch === true;
8442
+ const isDispatchableTrigger = message.role === "user" || message.role === "agent" || message.role === "system";
8480
8443
  if (!isDispatchableTrigger) {
8481
8444
  turnLog.warn({ role: message.role }, "dispatcher: trigger role not dispatchable \u2014 skipping");
8482
8445
  throw this.concluded("unexpected_role", UNEXPECTED_ROLE_REASON);
@@ -8502,7 +8465,7 @@ var TurnExecution = class {
8502
8465
  try {
8503
8466
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8504
8467
  body: `${MISSING_SECRET_PREFIX} ${list}`,
8505
- kind: "final",
8468
+ kind: "runtime_notice",
8506
8469
  turnId: randomUUID2(),
8507
8470
  // CT113: even a turn that fails before it runs answers a message.
8508
8471
  parentMessageId: payload.messageId
@@ -8532,32 +8495,6 @@ var TurnExecution = class {
8532
8495
  effectiveCwd = void 0;
8533
8496
  }
8534
8497
  let hookEnv;
8535
- const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
8536
- const sendPrepareFailureHandback = async () => {
8537
- if (!this.turnContext.dispatchedByAgentId || triggerIsPrepareFailure) return;
8538
- try {
8539
- await this.opts.api.postAddressedMessage(workspaceId, payload.conversationId, {
8540
- body: `${PREPARE_FAILED_PREFIX}
8541
-
8542
- The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`,
8543
- dispatch: this.turnContext.dispatchedByAgentId,
8544
- // Keyed on the TRIGGER, not the turn: a redelivered dispatch event is
8545
- // the replay this has to survive, and it mints a fresh `turnId` while
8546
- // carrying the same `messageId`. One handback per failed pickup of a
8547
- // given trigger — which is also CT992's rule, that the first failure
8548
- // is the one carrying information. A genuine re-dispatch after a
8549
- // repair authors a NEW trigger message, so it keys differently and
8550
- // hands back again.
8551
- idempotencyKey: `prepare-failure-handback:${payload.messageId}`,
8552
- parentMessageId: payload.messageId
8553
- });
8554
- } catch (err) {
8555
- turnLog.warn(
8556
- { err: err instanceof Error ? err.message : String(err) },
8557
- "dispatcher: prepare-failure handback failed"
8558
- );
8559
- }
8560
- };
8561
8498
  if (prepareHook) {
8562
8499
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8563
8500
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8589,23 +8526,8 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
8589
8526
  } catch (err) {
8590
8527
  const reason = err instanceof Error ? err.message : String(err);
8591
8528
  turnLog.error({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
8592
- try {
8593
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8594
- body: `${PREPARE_FAILED_PREFIX}
8595
-
8596
- ${reason}`,
8597
- kind: "final",
8598
- turnId,
8599
- parentMessageId: payload.messageId
8600
- });
8601
- } catch (postErr) {
8602
- turnLog.warn(
8603
- { err: postErr instanceof Error ? postErr.message : String(postErr) },
8604
- "dispatcher: prepare-rejection post failed"
8605
- );
8606
- }
8607
- await sendPrepareFailureHandback();
8608
- throw this.concluded(`prepare_failed: ${reason}`);
8529
+ const failReason = `prepare_failed: ${reason}`;
8530
+ throw this.concluded(failReason, failReason);
8609
8531
  }
8610
8532
  }
8611
8533
  } else {
@@ -8664,25 +8586,8 @@ ${reason}`,
8664
8586
  if (preparingStarted) reportPreparing("error");
8665
8587
  const reason = err instanceof Error ? err.message : String(err);
8666
8588
  turnLog.error({ err: reason }, "dispatcher: prepare hook failed");
8667
- try {
8668
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8669
- body: `${PREPARE_FAILED_PREFIX}
8670
-
8671
- ${reason}`,
8672
- kind: "final",
8673
- turnId,
8674
- // CT113: stamp the parent even on the prepare-failed close.
8675
- parentMessageId: payload.messageId
8676
- });
8677
- } catch (postErr) {
8678
- turnLog.warn(
8679
- { err: postErr instanceof Error ? postErr.message : String(postErr) },
8680
- "dispatcher: prepare-failure post failed"
8681
- );
8682
- }
8683
- await sendPrepareFailureHandback();
8684
8589
  const failReason = `prepare_failed: ${reason}`;
8685
- throw this.concluded(failReason);
8590
+ throw this.concluded(failReason, failReason);
8686
8591
  }
8687
8592
  }
8688
8593
  }
@@ -8709,7 +8614,12 @@ ${reason}`,
8709
8614
  // already emitted a "preparing" activity row for this turn above (it
8710
8615
  // runs before this flip), which would otherwise make the sweep mistake
8711
8616
  // this live turn for an abandoned one and close it with a `stopped`.
8712
- turnId
8617
+ turnId,
8618
+ // The newest message this turn's read covered: the lease records it,
8619
+ // and settle moves the cursor to it. Omitted when the server sent
8620
+ // none (an older API), so the cursor stays where it was.
8621
+ ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
8622
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {}
8713
8623
  });
8714
8624
  } catch (err) {
8715
8625
  const refusal = leaseRefusal(err);
@@ -8732,11 +8642,9 @@ ${reason}`,
8732
8642
  buildRequest() {
8733
8643
  const { payload, workspaceId } = this;
8734
8644
  const skipState = this.skipState = createSkipState();
8735
- const replyState = this.replyState = createReplyState();
8736
8645
  const turnControlServer = createTurnControlMcpServer(
8737
8646
  { api: this.opts.api, workspaceId, turnId: this.turnId },
8738
- skipState,
8739
- replyState
8647
+ skipState
8740
8648
  );
8741
8649
  this.request = buildCompanionTurnRequest({
8742
8650
  turnContext: this.turnContext,
@@ -8805,7 +8713,7 @@ ${reason}`,
8805
8713
  try {
8806
8714
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8807
8715
  body: `${RUNTIME_UNAVAILABLE_PREFIX} ${err.message}`,
8808
- kind: "final",
8716
+ kind: "runtime_notice",
8809
8717
  turnId,
8810
8718
  parentMessageId: payload.messageId
8811
8719
  });
@@ -8820,6 +8728,43 @@ ${reason}`,
8820
8728
  throw this.concluded(`runtime_unavailable:${err.runtime}`);
8821
8729
  }
8822
8730
  }
8731
+ // The adapter refused to resume this pair's stored session and is starting a
8732
+ // fresh one. The request it holds was composed for a session that had the
8733
+ // conversation so far — the read is what landed since the cursor, the
8734
+ // agent's own words left out because the session held them. A fresh session
8735
+ // holds none of it, so fetch the context again as a first turn on an empty
8736
+ // session (the whole conversation, own words included, the first-turn
8737
+ // material) and hand the adapter that to run on — before it speaks or acts.
8738
+ // The lease already granted stands; the read reaches at least its mark. A
8739
+ // fetch that fails returns null and the adapter falls back to the request
8740
+ // it has, flagged degraded, so the server rewinds the cursor at settle.
8741
+ async freshRequest(reason) {
8742
+ const { payload, turnId, turnLog } = this;
8743
+ try {
8744
+ const context = await this.opts.api.getTurnContext(
8745
+ payload.conversationId,
8746
+ payload.messageId,
8747
+ turnId,
8748
+ false,
8749
+ true
8750
+ );
8751
+ turnLog.warn(
8752
+ { reason },
8753
+ "dispatcher: stored session not resumed \u2014 the turn context was recomposed for a fresh session"
8754
+ );
8755
+ return {
8756
+ systemPrompt: context.systemPrompt,
8757
+ prompt: context.prompt,
8758
+ content: context.content
8759
+ };
8760
+ } catch (err) {
8761
+ turnLog.error(
8762
+ { reason, err: err instanceof Error ? err.message : String(err) },
8763
+ "dispatcher: could not recompose the turn context for a fresh session \u2014 running on the incremental one, degraded"
8764
+ );
8765
+ return null;
8766
+ }
8767
+ }
8823
8768
  async execute() {
8824
8769
  const { payload, workspaceId, turnId, turnLog, startedAt } = this;
8825
8770
  const o = this.outcome;
@@ -8851,10 +8796,7 @@ ${reason}`,
8851
8796
  signal: abortController.signal,
8852
8797
  log: turnLog,
8853
8798
  nextSeq: this.nextSeq,
8854
- replyState: this.replyState,
8855
- // The ledger-derived owed reply, for the runtime's own declaration when
8856
- // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8857
- owedReplyMessageId: this.turnContext.owedReplyMessageId ?? null,
8799
+ spoke: this.resumedSpoke,
8858
8800
  // CT1292: a commit that didn't land may mean this turn's lease is gone.
8859
8801
  // `noteCommitFailed` is a field slot the lease watchdog fills when it
8860
8802
  // arms (it starts as a no-op) — the committer only fires it from inside
@@ -8873,9 +8815,6 @@ ${reason}`,
8873
8815
  payload.agentId,
8874
8816
  turnId
8875
8817
  );
8876
- if (intent.answersMessageId) {
8877
- this.replyState.answersMessageId = intent.answersMessageId;
8878
- }
8879
8818
  if (intent.skipped) {
8880
8819
  this.skipState.skipped = true;
8881
8820
  this.skipState.reason = intent.skipReason;
@@ -8883,7 +8822,7 @@ ${reason}`,
8883
8822
  } catch (err) {
8884
8823
  turnLog.warn(
8885
8824
  { err: err instanceof Error ? err.message : String(err) },
8886
- "dispatcher: turn-control intent fetch failed; the declared reply / skip for this turn are dropped"
8825
+ "dispatcher: turn-control intent fetch failed; the skip for this turn is dropped"
8887
8826
  );
8888
8827
  }
8889
8828
  };
@@ -8941,7 +8880,9 @@ ${reason}`,
8941
8880
  clearInterval(leaseTimer);
8942
8881
  };
8943
8882
  try {
8944
- for await (const event of this.adapter.runTurn(this.request, abortController.signal)) {
8883
+ for await (const event of this.adapter.runTurn(this.request, abortController.signal, {
8884
+ fresh: (reason) => this.freshRequest(reason)
8885
+ })) {
8945
8886
  transcript?.write(event);
8946
8887
  o.eventCounts[event.type] += 1;
8947
8888
  if (isContentBearingEvent(event)) o.contentBearingEvents += 1;
@@ -9026,43 +8967,6 @@ ${reason}`,
9026
8967
  { reason: this.skipState.reason, turnId, ok: o.okResult },
9027
8968
  "agent skipped turn (skip_turn)"
9028
8969
  );
9029
- try {
9030
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9031
- body: SKIPPED_MARKER_BODY,
9032
- kind: "skipped",
9033
- turnId,
9034
- seq: this.nextSeq(),
9035
- parentMessageId: payload.messageId
9036
- });
9037
- } catch (err) {
9038
- turnLog.warn(
9039
- { err: err instanceof Error ? err.message : String(err) },
9040
- "dispatcher: skipped-marker commit failed"
9041
- );
9042
- }
9043
- } else {
9044
- await committer.finalize(o.okResult);
9045
- if (!abortController.signal.aborted && o.okResult && !committer.finalEmitted) {
9046
- try {
9047
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9048
- body: SILENT_MARKER_BODY,
9049
- kind: "silent",
9050
- turnId,
9051
- seq: this.nextSeq(),
9052
- parentMessageId: payload.messageId,
9053
- // An explicit `reply_to` survives a wordless turn exactly as it
9054
- // survives a textual final (the auto-declaration does not — a
9055
- // marker has no answer to bind).
9056
- ...committer.turnControlFields("silent")
9057
- });
9058
- o.silentMarkerEmitted = true;
9059
- } catch (err) {
9060
- turnLog.warn(
9061
- { err: err instanceof Error ? err.message : String(err) },
9062
- "dispatcher: silent-marker commit failed"
9063
- );
9064
- }
9065
- }
9066
8970
  }
9067
8971
  } catch (err) {
9068
8972
  o.okResult = false;
@@ -9105,12 +9009,7 @@ ${reason}`,
9105
9009
  // CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
9106
9010
  // off this turn id, so the settle PATCH must carry it — the start PATCH
9107
9011
  // isn't enough. Always sent (every settle closes a real turn).
9108
- turnId,
9109
- // CT1046: and the trigger this turn answered, so the server retires the
9110
- // dispatch that authorized it. Always sent, unlike `lastSeenMessageId`
9111
- // below (which is a success-only catch-up advance): a failed or cancelled
9112
- // turn is just as over, and its evidence must die with it.
9113
- settledMessageId: payload.messageId
9012
+ turnId
9114
9013
  };
9115
9014
  if (o.turnUsage) {
9116
9015
  body.usage = o.turnUsage;
@@ -9124,10 +9023,8 @@ ${reason}`,
9124
9023
  if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !o.runtimeIncomplete && !this.skipState.skipped) {
9125
9024
  body.errorReason = o.resultReason.slice(0, 200);
9126
9025
  }
9127
- if (o.okResult) {
9128
- body.lastSeenMessageId = payload.messageId;
9129
- }
9130
- body.outcome = o.okResult ? "settled" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
9026
+ body.outcome = o.okResult ? committer.spoke ? "settled" : "passed" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
9027
+ if (body.outcome === "settled") body.lastMessageSeq = committer.lastMessageSeq;
9131
9028
  if (o.sessionDegraded) {
9132
9029
  body.degraded = true;
9133
9030
  }
@@ -9144,7 +9041,7 @@ ${reason}`,
9144
9041
  eventCounts: o.eventCounts,
9145
9042
  runtimeResultKind: o.runtimeResultKind,
9146
9043
  ...o.turnMcpInventory ? { mcpInventory: o.turnMcpInventory } : {},
9147
- finalSource: outcome === "skipped" || outcome === "cancelled" || o.silentMarkerEmitted ? "marker" : committer.finalSource
9044
+ finalSource: outcome === "skipped" || outcome === "cancelled" || !committer.finalEmitted ? "none" : committer.finalSource
9148
9045
  };
9149
9046
  body.diagnostics = o.settledDiagnostics;
9150
9047
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
@@ -9358,7 +9255,7 @@ function mapOpencodeProviders(json) {
9358
9255
  const modelId = typeof raw?.id === "string" ? raw.id : null;
9359
9256
  const prov = typeof raw?.providerID === "string" ? raw.providerID : providerId;
9360
9257
  if (!modelId || !prov) continue;
9361
- const context = raw.limit && typeof raw.limit === "object" && typeof raw.limit.context === "number" ? raw.limit.context : null;
9258
+ const context = raw.limit && typeof raw.limit === "object" && typeof raw.limit.context === "number" && raw.limit.context > 0 ? raw.limit.context : null;
9362
9259
  models.push({
9363
9260
  // The namespaced id storage holds + opencode addresses a model by
9364
9261
  // (`deepseek/deepseek-chat` → { providerID, modelID }).
@@ -9438,8 +9335,8 @@ var Outbox = class {
9438
9335
  this.enforceBounds();
9439
9336
  }
9440
9337
  // Every queued entry, oldest-first by `(enqueuedAt, turnId, seq)` so the
9441
- // drainer delivers `progress` before `final` within a turn and older turns
9442
- // before newer ones. Malformed files are dropped (logged) rather than
9338
+ // drainer delivers a turn's rows in the order they were minted, and older
9339
+ // turns before newer ones. Malformed files are dropped (logged) rather than
9443
9340
  // wedging the drain.
9444
9341
  list() {
9445
9342
  const dir2 = this.dir();
@@ -9465,8 +9362,9 @@ var Outbox = class {
9465
9362
  this.dropCorrupt(full);
9466
9363
  }
9467
9364
  }
9365
+ const rank = (e) => e.kind === "active-run" ? 1 : 0;
9468
9366
  entries.sort(
9469
- (a, b) => a.enqueuedAt - b.enqueuedAt || (a.turnId < b.turnId ? -1 : a.turnId > b.turnId ? 1 : 0) || a.seq - b.seq
9367
+ (a, b) => a.enqueuedAt - b.enqueuedAt || rank(a) - rank(b) || (a.turnId < b.turnId ? -1 : a.turnId > b.turnId ? 1 : 0) || a.seq - b.seq
9470
9368
  );
9471
9369
  return entries;
9472
9370
  }