@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/cli.js CHANGED
@@ -2707,8 +2707,12 @@ var CabaneApi = class {
2707
2707
  // it), so it is passed down rather than inferred here. It gates the COST of
2708
2708
  // the aggregate, never the tenancy scoping, which the route applies
2709
2709
  // unconditionally.
2710
- getTurnContext(conversationId, messageId2, turnId, resumed) {
2711
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "");
2710
+ // `fresh` asks for the context a session that holds NOTHING needs: the read
2711
+ // from the start of the conversation, the agent's own words included, and
2712
+ // the first-turn material — what the adapter runs on when it refused to
2713
+ // resume the stored session and is starting a fresh one.
2714
+ getTurnContext(conversationId, messageId2, turnId, resumed, fresh) {
2715
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "") + (fresh ? "&fresh=1" : "");
2712
2716
  return this.request("GET", `/api/agent/turn-context?${q}`);
2713
2717
  }
2714
2718
  // CT1380: the local half of a resumed turn's seq floor (see
@@ -2717,6 +2721,18 @@ var CabaneApi = class {
2717
2721
  outboxMaxSeqForTurn(turnId) {
2718
2722
  return this.opts.outbox?.maxSeqForTurn(turnId) ?? 0;
2719
2723
  }
2724
+ // Whether this turn's speech is still waiting in the outbox — the half of
2725
+ // "did the span speak" the server cannot see: a first message absorbed by a
2726
+ // transient failure before the crash is on disk here, spent and undelivered,
2727
+ // and the resumed run must not commit its next note as first words again.
2728
+ // A runtime notice queued under the turn is not speech.
2729
+ outboxHasSpeechForTurn(turnId) {
2730
+ const outbox = this.opts.outbox;
2731
+ if (!outbox) return false;
2732
+ return outbox.list().some(
2733
+ (e) => e.turnId === turnId && e.kind === "message" && e.body?.kind !== "runtime_notice"
2734
+ );
2735
+ }
2720
2736
  // CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
2721
2737
  // (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
2722
2738
  // server-side (the URL MCP surface) rather than the dispatcher's in-memory
@@ -2842,6 +2858,23 @@ var CabaneApi = class {
2842
2858
  async durableActiveRunWrite(path, conversationId, agentId, body) {
2843
2859
  const outbox = this.opts.outbox;
2844
2860
  const key = activeRunOutboxKey(conversationId, agentId);
2861
+ const turnId = body?.turnId;
2862
+ if (outbox && typeof turnId === "string" && outbox.maxSeqForTurn(turnId) > 0) {
2863
+ outbox.persist({
2864
+ enqueuedAt: Date.now(),
2865
+ turnId: key,
2866
+ seq: ACTIVE_RUN_OUTBOX_SEQ,
2867
+ method: "PATCH",
2868
+ path,
2869
+ body,
2870
+ kind: "active-run"
2871
+ });
2872
+ this.opts.log?.warn(
2873
+ { conversationId, agentId, turnId },
2874
+ "companion: settle queued behind this turn's undelivered commits (drains in order)"
2875
+ );
2876
+ return;
2877
+ }
2845
2878
  try {
2846
2879
  await this.request("PATCH", path, body, { retry: true });
2847
2880
  outbox?.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
@@ -2883,16 +2916,11 @@ var CabaneApi = class {
2883
2916
  // (transport errors + 5xx), never a 4xx, so no `(turnId, kind)` row is
2884
2917
  // double-posted. The caller threads its turn abort signal so a cancel
2885
2918
  // mid-commit aborts the in-flight POST rather than letting it land.
2886
- // CT11: `kind` includes `'stopped'` for the terminal cancel markersame
2887
- // wire shape as `progress`/`final`, distinguished only by `kind` so the chat
2888
- // drawer's turn-group renderer treats it as a closing row. CT1295: this
2889
- // companion no longer WRITES that kind — the server does, in the transaction
2890
- // that terminalizes the stopped turn — so the value survives here only as
2891
- // wire vocabulary the server also speaks. `seq` is the companion's per-turn
2919
+ // Workzone: a commit is a message — the agent's first or last words or the
2920
+ // runtime's own line (`runtime_notice`). The words between tool calls go
2921
+ // through `reportText`, the trail's door. `seq` is the companion's per-turn
2892
2922
  // monotonic counter, stamped on the row so the merged timeline orders the
2893
- // commit deterministically against the persisted tool/thinking rows. Both
2894
- // fields are optional on the wire — an older companion that didn't mint seq
2895
- // still validates (the server defaults to 0).
2923
+ // commit deterministically against the persisted tool/thinking rows.
2896
2924
  postTurnMessage(workspaceId, conversationId, body, signal) {
2897
2925
  return this.durableCommit(
2898
2926
  "message",
@@ -2903,35 +2931,6 @@ var CabaneApi = class {
2903
2931
  signal
2904
2932
  );
2905
2933
  }
2906
- // CT1357: post ONE ordinary addressed message — not turn speech, no `turnId`
2907
- // — so `dispatch` is honoured and the target agent is woken. The turn-commit
2908
- // fork ignores `dispatch` (an act rides its own call now), which leaves this
2909
- // as the only way a companion can address a peer OUTSIDE a running turn.
2910
- //
2911
- // The prepare-failure handback is exactly that case and the reason this
2912
- // exists. It fires before the turn is ever admitted — the active-run PATCH
2913
- // that admits it comes after the prepare hook — so `turnAct` is not available:
2914
- // its route resolves the turn first and 404s when there is none. The failure
2915
- // notice's own commit is what admits the turn, and by then it has also settled
2916
- // it. An ordinary addressed post depends on no lease at all, which is what
2917
- // makes it the right shape here.
2918
- //
2919
- // Retried like any durable write, and therefore REQUIRING `idempotencyKey`:
2920
- // this is not outbox-queued (there is no `(turnId, seq)` for a non-turn row),
2921
- // so without a key a committed request whose response was lost — or a
2922
- // redelivered dispatch event — writes a second row and wakes the peer twice.
2923
- // The server dedupes on `(conversation_id, idempotency_key)`: the repeat
2924
- // returns the existing row and raises no second dispatch (CT239). Required,
2925
- // not optional, so a future caller cannot omit it and silently lose the
2926
- // guarantee.
2927
- postAddressedMessage(workspaceId, conversationId, body, signal) {
2928
- return this.request(
2929
- "POST",
2930
- `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
2931
- body,
2932
- signal ? { retry: true, signal } : { retry: true }
2933
- );
2934
- }
2935
2934
  // SJ477: report one tool-activity transition (start / done / error) for the
2936
2935
  // live activity cards. Transient — the server publishes an `agent_activity`
2937
2936
  // SSE and writes no row. Agent-PAT authed; the URL `:agentId` must match the
@@ -2961,6 +2960,20 @@ var CabaneApi = class {
2961
2960
  signal
2962
2961
  );
2963
2962
  }
2963
+ // Workzone: report one text the agent wrote between tool calls — the trail's
2964
+ // door. Durable like a thinking row (same outbox, same `(turnId, seq)`
2965
+ // replay), and never a message: the server writes it to `message_activity`
2966
+ // under the span and nothing lands, stamps or wakes on it.
2967
+ reportText(workspaceId, conversationId, agentId, body, signal) {
2968
+ return this.durableCommit(
2969
+ "text",
2970
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/text`,
2971
+ body,
2972
+ body.turnId,
2973
+ body.seq,
2974
+ signal
2975
+ );
2976
+ }
2964
2977
  // SJ493: fetch the agent's self-view — identity + operating context. The
2965
2978
  // companion calls this on each dispatch to get its `systemPrompt` (composed
2966
2979
  // server-side from the bundled default + the agent's charter) rather than
@@ -4014,20 +4027,24 @@ var THINKING_TEXT_MAX_CHARS = 3900;
4014
4027
  var TurnPump = class {
4015
4028
  constructor(opts) {
4016
4029
  this.opts = opts;
4030
+ this.hasSpoken = opts.spoke ?? false;
4017
4031
  this.sink = {
4018
4032
  onAssistantText: async (evt) => {
4019
4033
  if (opts.signal.aborted) return;
4020
4034
  const body = normalizeCommitText(evt.text);
4021
4035
  if (!body) return;
4022
- const kind = evt.final ? "final" : "progress";
4023
4036
  const seq = opts.nextSeq();
4024
- await opts.commit.commitMessage({ body, kind, seq });
4037
+ if (!evt.final && this.hasSpoken) {
4038
+ await opts.commit.reportText({ text: body, seq });
4039
+ return;
4040
+ }
4041
+ await opts.commit.commitMessage({ body, kind: "message", seq });
4042
+ this.hasSpoken = true;
4025
4043
  if (evt.final) {
4026
4044
  this.emittedFinal = true;
4045
+ this.lastSeq = seq;
4027
4046
  this.finalReplyBody = body;
4028
4047
  this.emittedFinalSource = "runtime_text";
4029
- } else {
4030
- this.lastProgressBody = body;
4031
4048
  }
4032
4049
  },
4033
4050
  onToolActivity: async (evt) => {
@@ -4064,11 +4081,12 @@ var TurnPump = class {
4064
4081
  };
4065
4082
  }
4066
4083
  opts;
4067
- // `finalEmitted` gates the end-of-turn progress promotion;
4068
- // `lastProgressBody` is what we promote to `final` when a clean turn ended on
4069
- // a tool call with no closing text.
4084
+ // A message has landed under this span — its first words. Every later
4085
+ // non-terminal text is the trail. Seeded from the host on a resume.
4086
+ hasSpoken;
4087
+ // The last words landed, and their `seq` — what the settle declares.
4070
4088
  emittedFinal = false;
4071
- lastProgressBody = null;
4089
+ lastSeq = null;
4072
4090
  // The turn's final reply text, captured for the dashboard feed / logging.
4073
4091
  finalReplyBody = null;
4074
4092
  emittedFinalSource = "none";
@@ -4079,31 +4097,20 @@ var TurnPump = class {
4079
4097
  // frozen committer test — drive. Each callback applies the choreography and
4080
4098
  // commits through the injected sink.
4081
4099
  sink;
4082
- // End-of-turn progress promotion. A clean turn that emitted interim text but
4083
- // no closing text promotes the agent's own last progress body (the drawer
4084
- // collapses the duplicate). If the agent emitted no words, emit no message:
4085
- // Cabane never attributes host-authored text to an agent, in any turn shape,
4086
- // for any reason. The host closes that wordless turn with a marker instead.
4087
- // Skipped when cancelled or already final. The held-text flush that precedes
4088
- // this is a classification concern, driven by the caller before this runs.
4089
- async finalize(ok) {
4090
- if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
4091
- const body = this.lastProgressBody;
4092
- const seq = this.opts.nextSeq();
4093
- try {
4094
- await this.opts.commit.commitMessage({ body, kind: "final", seq });
4095
- this.emittedFinal = true;
4096
- this.finalReplyBody = body;
4097
- this.emittedFinalSource = "progress_promotion";
4098
- } catch (err) {
4099
- this.opts.onError?.(err, "progress-promotion");
4100
- }
4100
+ // Whether a message has landed under this span first words, at least. A
4101
+ // span that spoke settles; one that never did is a pass.
4102
+ get spoke() {
4103
+ return this.hasSpoken;
4101
4104
  }
4102
- // Whether the turn has committed its `final` row — read by the host to decide
4103
- // whether a marker is owed.
4105
+ // Whether the turn has committed its last words.
4104
4106
  get finalEmitted() {
4105
4107
  return this.emittedFinal;
4106
4108
  }
4109
+ // The `seq` of the last words, for the settle to declare — or null when the
4110
+ // span ended without any. Nothing is put in their place.
4111
+ get lastMessageSeq() {
4112
+ return this.lastSeq;
4113
+ }
4107
4114
  // The turn's final reply text, or null if none landed (failure / cancel /
4108
4115
  // empty). Used for the dashboard feed's reply preview.
4109
4116
  get replyBody() {
@@ -4572,7 +4579,11 @@ function sdkEvents(queryFn, req, signal, built, degraded) {
4572
4579
  degraded
4573
4580
  });
4574
4581
  }
4575
- async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded) {
4582
+ async function freshRequest(req, reason, hooks) {
4583
+ const recomposed = hooks?.fresh ? await hooks.fresh(reason) : null;
4584
+ return recomposed ? { req: { ...req, ...recomposed, session: null }, degraded: false } : { req: { ...req, session: null }, degraded: true };
4585
+ }
4586
+ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded, hooks) {
4576
4587
  let emitted = false;
4577
4588
  for await (const event of sdkEvents(queryFn, req, signal, initial, degraded)) {
4578
4589
  const recoverResume = initial.resume !== null && !emitted && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
@@ -4586,9 +4597,10 @@ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOpti
4586
4597
  discardedSessionId: initial.resume
4587
4598
  }
4588
4599
  );
4589
- const freshReq = { ...req, session: null };
4590
- const fresh = buildClaudeCodeOptions(freshReq, augmentOptions);
4591
- yield* sdkEvents(queryFn, freshReq, signal, fresh, true);
4600
+ const fresh = await freshRequest(req, "session_start_failed", hooks);
4601
+ if (signal.aborted) return;
4602
+ const options = buildClaudeCodeOptions(fresh.req, augmentOptions);
4603
+ yield* sdkEvents(queryFn, fresh.req, signal, options, fresh.degraded);
4592
4604
  return;
4593
4605
  }
4594
4606
  emitted = true;
@@ -4601,23 +4613,32 @@ function createClaudeCodeAdapter(deps = {}) {
4601
4613
  name: "claude-code",
4602
4614
  promptAddendum: () => CLAUDE_CODE_ADDENDUM,
4603
4615
  dialectSchema: claudeCodeDialectSchema,
4604
- runTurn(req, signal) {
4605
- const { options, resume, freshReason } = buildClaudeCodeOptions(req, deps.augmentOptions);
4606
- if (freshReason === "cwd_mismatch") {
4616
+ async *runTurn(request, signal, hooks) {
4617
+ let req = request;
4618
+ let built = buildClaudeCodeOptions(req, deps.augmentOptions);
4619
+ if (built.freshReason === "cwd_mismatch") {
4607
4620
  deps.onWarn?.(
4608
4621
  "claude-code adapter: stored session cwd no longer matches the current environment \u2014 starting a fresh session (SJ527 guard)",
4609
4622
  { cwd: req.local.cwd }
4610
4623
  );
4611
4624
  }
4612
- const degraded = freshReason !== void 0 && freshReason !== "no_session";
4613
- return runWithResumeRecovery(
4625
+ let degraded = false;
4626
+ if (built.freshReason !== void 0 && built.freshReason !== "no_session") {
4627
+ const fresh = await freshRequest(req, built.freshReason, hooks);
4628
+ if (signal.aborted) return;
4629
+ req = fresh.req;
4630
+ degraded = fresh.degraded;
4631
+ built = buildClaudeCodeOptions(req, deps.augmentOptions);
4632
+ }
4633
+ yield* runWithResumeRecovery(
4614
4634
  queryFn,
4615
4635
  req,
4616
4636
  signal,
4617
- { options, resume, freshReason },
4637
+ built,
4618
4638
  deps.augmentOptions,
4619
4639
  deps.onWarn,
4620
- degraded
4640
+ degraded,
4641
+ hooks
4621
4642
  );
4622
4643
  }
4623
4644
  };
@@ -5779,7 +5800,8 @@ function createOpencodeAdapter(deps = {}) {
5779
5800
  // argument went with the named tool surface it selected against.
5780
5801
  promptAddendum: () => OPENCODE_ADDENDUM,
5781
5802
  dialectSchema: opencodeDialectSchema,
5782
- async *runTurn(req, signal) {
5803
+ async *runTurn(request, signal, hooks) {
5804
+ let req = request;
5783
5805
  if (!transport) {
5784
5806
  yield {
5785
5807
  type: "result",
@@ -5797,7 +5819,15 @@ function createOpencodeAdapter(deps = {}) {
5797
5819
  { directory }
5798
5820
  );
5799
5821
  }
5800
- const degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
5822
+ let degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
5823
+ if ("fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session" && hooks?.fresh) {
5824
+ const recomposed = await hooks.fresh(decision.reason);
5825
+ if (signal.aborted) return;
5826
+ if (recomposed) {
5827
+ req = { ...req, ...recomposed, session: null };
5828
+ degraded = false;
5829
+ }
5830
+ }
5801
5831
  const spec = buildRunSpec(req, resume);
5802
5832
  const result = await transport.run(spec, signal);
5803
5833
  if (signal.aborted) return;
@@ -6804,7 +6834,8 @@ function createCodexAdapter(deps = {}) {
6804
6834
  // argument went with the named tool surface it selected against.
6805
6835
  promptAddendum: () => CODEX_ADDENDUM,
6806
6836
  dialectSchema: codexDialectSchema,
6807
- async *runTurn(req, signal) {
6837
+ async *runTurn(request, signal, hooks) {
6838
+ let req = request;
6808
6839
  if (!transport) {
6809
6840
  yield {
6810
6841
  type: "result",
@@ -6823,7 +6854,15 @@ function createCodexAdapter(deps = {}) {
6823
6854
  { directory }
6824
6855
  );
6825
6856
  }
6826
- const degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
6857
+ let degraded = "fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session";
6858
+ if ("fresh" in decision && decision.reason !== void 0 && decision.reason !== "no_session" && hooks?.fresh) {
6859
+ const recomposed = await hooks.fresh(decision.reason);
6860
+ if (signal.aborted) return;
6861
+ if (recomposed) {
6862
+ req = { ...req, ...recomposed, session: null };
6863
+ degraded = false;
6864
+ }
6865
+ }
6827
6866
  let instructions = null;
6828
6867
  if (!req.policy.hostFs) {
6829
6868
  if (!deps.writeInstructionsFile) {
@@ -7865,14 +7904,11 @@ var MIN_ASK_OPTIONS = 2;
7865
7904
  var MAX_ASK_OPTIONS = 4;
7866
7905
  var MAX_WAKE_NOTE_BYTES = 2e3;
7867
7906
  var MAX_WITHDRAW_REASON_BYTES = 400;
7868
- var MAX_SEND_MESSAGE_BYTES = 65536;
7869
7907
  var MAX_SKIP_REASON_BYTES = 500;
7870
7908
  var TURN_CONTROL_TOOL_DESCRIPTIONS = {
7871
- 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.`,
7909
+ 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.`,
7872
7910
  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.*\`).`,
7873
- 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.`,
7874
- 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.`,
7875
- 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.`,
7911
+ 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.`,
7876
7912
  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.`,
7877
7913
  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.`
7878
7914
  };
@@ -7891,13 +7927,6 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
7891
7927
  "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.`
7892
7928
  },
7893
7929
  cancel_wake: {},
7894
- reply_to: {
7895
- messageId: "The owed ask message id from this turn context."
7896
- },
7897
- send: {
7898
- agentId: "The peer agent to address \u2014 a workspace agent id, from your turn context's roster.",
7899
- message: `The complete new request the peer should receive and act on. Up to ${MAX_SEND_MESSAGE_BYTES} characters.`
7900
- },
7901
7930
  skip_turn: {
7902
7931
  reason: `Short reason you are declining \u2014 used for telemetry/debugging, never posted. Up to ${MAX_SKIP_REASON_BYTES} characters.`
7903
7932
  },
@@ -7915,34 +7944,20 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
7915
7944
  // src/turn-control-tools.ts
7916
7945
  import { z as z13 } from "zod";
7917
7946
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
7918
- var SEND_TOOL = "send";
7919
- var SEND_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SEND_TOOL}`;
7920
7947
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
7921
7948
  var SKIP_TURN_TOOL = "skip_turn";
7922
7949
  var ASK_TOOL = "ask";
7923
7950
  var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
7924
7951
  var WITHDRAW_ASK_TOOL = "withdraw_ask";
7925
7952
  var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
7926
- var REPLY_TO_TOOL = "reply_to";
7927
- var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
7928
7953
  var WAKE_ME_TOOL = "wake_me";
7929
7954
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
7930
7955
  var CANCEL_WAKE_TOOL = "cancel_wake";
7931
7956
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7932
7957
  var TURN_ACT_TIMEOUT_MS = 45e3;
7933
- function createReplyState() {
7934
- return { answersMessageId: null };
7935
- }
7936
7958
  function createSkipState() {
7937
7959
  return { skipped: false, reason: null };
7938
7960
  }
7939
- function resolveDeclaredReplyField(input) {
7940
- const explicit = input.replyState.answersMessageId;
7941
- if (explicit) return { answersMessageId: explicit };
7942
- if (input.kind !== "final") return {};
7943
- if (!input.owedReplyMessageId) return {};
7944
- return { answersMessageId: input.owedReplyMessageId, answersAutoDeclared: true };
7945
- }
7946
7961
  function toolError(text) {
7947
7962
  return { isError: true, content: [{ type: "text", text }] };
7948
7963
  }
@@ -7972,7 +7987,7 @@ async function performAct(acts, act) {
7972
7987
  if (!result.ok) return toolError(JSON.stringify(result));
7973
7988
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7974
7989
  }
7975
- function createTurnControlMcpServer(acts, skipState, replyState) {
7990
+ function createTurnControlMcpServer(acts, skipState) {
7976
7991
  const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7977
7992
  const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
7978
7993
  const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
@@ -7980,42 +7995,6 @@ function createTurnControlMcpServer(acts, skipState, replyState) {
7980
7995
  name: COMPANION_LOCAL_MCP_SERVER,
7981
7996
  version: "0.0.0",
7982
7997
  tools: [
7983
- tool(
7984
- SEND_TOOL,
7985
- TURN_CONTROL_TOOL_DESCRIPTIONS.send,
7986
- {
7987
- agentId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.send.agentId),
7988
- message: z13.string().min(1).max(MAX_SEND_MESSAGE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.send.message)
7989
- },
7990
- async (args) => performAct(acts, {
7991
- kind: "send",
7992
- callId: randomUUID(),
7993
- agentId: args.agentId,
7994
- message: args.message
7995
- }),
7996
- { annotations: ACT_TOOL, alwaysLoad: true }
7997
- ),
7998
- ...replyState ? [
7999
- tool(
8000
- REPLY_TO_TOOL,
8001
- TURN_CONTROL_TOOL_DESCRIPTIONS.reply_to,
8002
- {
8003
- messageId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.reply_to.messageId)
8004
- },
8005
- async (args) => {
8006
- replyState.answersMessageId = args.messageId;
8007
- return {
8008
- content: [
8009
- {
8010
- type: "text",
8011
- text: JSON.stringify({ answersMessageId: args.messageId })
8012
- }
8013
- ]
8014
- };
8015
- },
8016
- { annotations: RECORDER_TOOL, alwaysLoad: true }
8017
- )
8018
- ] : [],
8019
7998
  ...skipState ? [
8020
7999
  tool(
8021
8000
  SKIP_TURN_TOOL,
@@ -8542,8 +8521,7 @@ var TurnCommitter = class {
8542
8521
  kind,
8543
8522
  turnId: deps.turnId,
8544
8523
  seq,
8545
- parentMessageId: deps.parentMessageId,
8546
- ...kind === "final" ? this.turnControlFields("final") : {}
8524
+ parentMessageId: deps.parentMessageId
8547
8525
  },
8548
8526
  deps.signal
8549
8527
  );
@@ -8584,19 +8562,22 @@ var TurnCommitter = class {
8584
8562
  { turnId: deps.turnId, text, seq },
8585
8563
  deps.signal
8586
8564
  );
8565
+ },
8566
+ reportText: async ({ text, seq }) => {
8567
+ await deps.api.reportText(
8568
+ deps.workspaceId,
8569
+ deps.conversationId,
8570
+ deps.agentId,
8571
+ { turnId: deps.turnId, text, seq },
8572
+ deps.signal
8573
+ );
8587
8574
  }
8588
8575
  };
8589
8576
  this.pump = new TurnPump({
8590
8577
  commit,
8591
8578
  signal: deps.signal,
8592
8579
  nextSeq: deps.nextSeq,
8593
- onError: (err) => {
8594
- deps.log.warn(
8595
- { err: err instanceof Error ? err.message : String(err) },
8596
- "dispatcher: progress-promotion commit failed"
8597
- );
8598
- deps.onCommitFailed?.(err);
8599
- }
8580
+ ...deps.spoke !== void 0 ? { spoke: deps.spoke } : {}
8600
8581
  });
8601
8582
  this.emit = sinkEmitter(this.pump.sink, this.onError);
8602
8583
  }
@@ -8620,15 +8601,17 @@ var TurnCommitter = class {
8620
8601
  this.onError(err, event.type);
8621
8602
  }
8622
8603
  }
8623
- // End-of-turn progress promotion. The held-text flush is now the adapter's
8624
- // job (it emits the closing reply as a `text` event before `result`), so this
8625
- // only asks the pump to promote agent-authored interim text. A wordless turn
8626
- // stays wordless and the dispatcher closes it with a `silent`/`skipped`
8627
- // marker (CT1295: a CANCELLED turn is closed by the SERVER's `stopped`
8628
- // marker, not from this side). Guards on the abort signal, so on cancel no
8629
- // `final` is forced.
8630
- async finalize(okResult) {
8631
- await this.pump.finalize(okResult);
8604
+ // A message has landed under this span its first words at least. A span
8605
+ // that spoke settles; one that never did is a pass. Nothing is promoted at
8606
+ // the end of a turn: the held-text flush is the adapter's (it emits the
8607
+ // closing reply as a terminal `text` event before `result`), and a span
8608
+ // that ended on working text ends without last words.
8609
+ get spoke() {
8610
+ return this.pump.spoke;
8611
+ }
8612
+ // The `seq` of the last words, for the settle to declare; null when none.
8613
+ get lastMessageSeq() {
8614
+ return this.pump.lastMessageSeq;
8632
8615
  }
8633
8616
  // The turn's final reply text, or null if none landed.
8634
8617
  get replyBody() {
@@ -8640,26 +8623,6 @@ var TurnCommitter = class {
8640
8623
  get finalEmitted() {
8641
8624
  return this.pump.finalEmitted;
8642
8625
  }
8643
- // A closing textual reply and a wordless terminal marker carry the same
8644
- // declared-reply projection. The KIND matters: the auto-declared reply binds
8645
- // only a textual `final` — a wordless turn has no answer to bind, and the
8646
- // server's mute-settle notice speaks for it.
8647
- turnControlFields(kind) {
8648
- return this.answersField(kind);
8649
- }
8650
- // The declared reply: explicit `reply_to` first, else the runtime's own
8651
- // declaration for the turn that just answers — see
8652
- // `resolveDeclaredReplyField` for the whole rule and its reasons. CT1224: an
8653
- // auto-declaration is flagged as such on the wire, because the server drops
8654
- // it when the turn performed an outward act (CT1354: any of them, read off
8655
- // the ledger). An explicit `reply_to` carries no flag and always stands.
8656
- answersField(kind) {
8657
- return resolveDeclaredReplyField({
8658
- kind,
8659
- owedReplyMessageId: this.deps.owedReplyMessageId,
8660
- replyState: this.deps.replyState
8661
- });
8662
- }
8663
8626
  };
8664
8627
 
8665
8628
  // src/turn-runtime-integrity.ts
@@ -8673,7 +8636,7 @@ async function postIncompleteNotice(ctx, harness, resolution) {
8673
8636
  try {
8674
8637
  await ctx.postTurnMessage(ctx.workspaceId, ctx.conversationId, {
8675
8638
  body: turnIncompleteCopy(harness),
8676
- kind: "final",
8639
+ kind: "runtime_notice",
8677
8640
  turnId: ctx.turnId,
8678
8641
  parentMessageId: ctx.parentMessageId
8679
8642
  });
@@ -8742,12 +8705,9 @@ async function absorbBundledLoss(ctx, aborted, outcome) {
8742
8705
 
8743
8706
  // src/turn-execution.ts
8744
8707
  var PREPARING_TOOL_NAME = "preparing";
8745
- 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:";
8746
8708
  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:";
8747
8709
  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:";
8748
8710
  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`;
8749
- var SKIPPED_MARKER_BODY = "(skipped)";
8750
- var SILENT_MARKER_BODY = "(no reply)";
8751
8711
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8752
8712
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8753
8713
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -8768,7 +8728,6 @@ function initialOutcome() {
8768
8728
  contentBearingEvents: 0,
8769
8729
  latestSessionState: null,
8770
8730
  settledDiagnostics: null,
8771
- silentMarkerEmitted: false,
8772
8731
  timeoutReason: null,
8773
8732
  leaseLost: false,
8774
8733
  runtimeIncomplete: false
@@ -8817,13 +8776,15 @@ var TurnExecution = class {
8817
8776
  // `turnId` is set on every dispatch. `resumedFromSeq` is what it seeded from.
8818
8777
  resumed;
8819
8778
  resumedFromSeq;
8779
+ // Whether the span this resumed run continues already has first words —
8780
+ // from the server (`turnSpoke`) or from speech still queued in the outbox.
8781
+ resumedSpoke = false;
8820
8782
  turnContext;
8821
8783
  resolvedMcpServers;
8822
8784
  effectiveCwd;
8823
8785
  hookEnv;
8824
8786
  turnEnv;
8825
8787
  skipState;
8826
- replyState;
8827
8788
  turnControlServer;
8828
8789
  request;
8829
8790
  adapter;
@@ -8841,9 +8802,9 @@ var TurnExecution = class {
8841
8802
  disarmWatchdogs = () => {
8842
8803
  };
8843
8804
  // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
8844
- // PATCH returns — the mailroom now holds this turn as the conversation's
8845
- // running one, so every exit after this point must settle THE TURN, not just
8846
- // clear the participant flag.
8805
+ // PATCH returns — the lease is granted and this is the pair's running span,
8806
+ // so every exit after this point must settle THE SPAN, not just clear the
8807
+ // participant flag.
8847
8808
  admitted = false;
8848
8809
  concluded(reason, errorReason) {
8849
8810
  return new TurnConcluded(reason, errorReason);
@@ -8881,14 +8842,16 @@ var TurnExecution = class {
8881
8842
  return this.report();
8882
8843
  }
8883
8844
  async concludeBeforeRun(reason, errorReason) {
8884
- const { payload, turnLog, startedAt } = this;
8885
- const body = {
8886
- activeRunStartedAt: null,
8887
- // CT1046: this dispatch is over before it ran — retire its evidence so it
8888
- // can't authorize a turn-token mint afterwards.
8889
- settledMessageId: payload.messageId
8890
- };
8845
+ const { payload, turnLog, startedAt, turnId } = this;
8846
+ const body = { activeRunStartedAt: null, turnId, outcome: "failed" };
8891
8847
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
8848
+ const context = this.turnContext;
8849
+ if (context?.readThrough !== void 0) {
8850
+ body.readThrough = context.readThrough;
8851
+ if (context.readRevision !== void 0) body.readRevision = context.readRevision;
8852
+ } else {
8853
+ body.readThrough = payload.messageId;
8854
+ }
8892
8855
  try {
8893
8856
  await this.opts.api.setActiveRun(
8894
8857
  this.opts.workspaceId,
@@ -8907,21 +8870,18 @@ var TurnExecution = class {
8907
8870
  return { ok: false, durationMs, reason };
8908
8871
  }
8909
8872
  // The post-admission analogue of `concludeBeforeRun` (Codo's stack review,
8910
- // blocking finding #2): this turn WAS admitted — the mailroom holds it as
8911
- // the conversation's running turn — so the clear must NAME the turn and
8912
- // DECLARE its terminal outcome. A bare pre-run clear here would reset the
8913
- // participant flag while the turn metadata stayed running, the dispatch
8914
- // stayed live, and the queue stayed held until the reaper. `outcome`
8915
- // follows the settle wire's rule: a failure names its reason; without one
8916
- // the turn settled the runtime-unavailable exit posts its user-facing
8917
- // `final` before concluding, so the turn genuinely produced its terminal
8918
- // output and `settled` releases the queue honestly.
8873
+ // blocking finding #2): this turn WAS admitted — it holds the conversation's
8874
+ // lease — so the clear must NAME the turn and DECLARE its terminal outcome.
8875
+ // A bare pre-run clear here would reset the participant flag while the span
8876
+ // stayed running until the lost sweep. `outcome` follows the settle wire's
8877
+ // rule: a failure names its reason; without one the turn settled — the
8878
+ // runtime-unavailable exit posts its user-facing `final` before concluding,
8879
+ // so the turn genuinely produced its terminal output.
8919
8880
  async concludeAdmittedRun(reason, errorReason) {
8920
8881
  const { payload, turnLog, startedAt, turnId } = this;
8921
8882
  const body = {
8922
8883
  activeRunStartedAt: null,
8923
8884
  turnId,
8924
- settledMessageId: payload.messageId,
8925
8885
  // CT1379: `runtimeIncomplete` is a real failure that deliberately carries
8926
8886
  // no `errorReason`, so the inference alone would read it as settled.
8927
8887
  outcome: errorReason || this.outcome.runtimeIncomplete ? "failed" : "settled"
@@ -8947,6 +8907,7 @@ var TurnExecution = class {
8947
8907
  async fetchContext() {
8948
8908
  const { payload, turnId, turnLog } = this;
8949
8909
  const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8910
+ const outboxSpoke = this.resumed && this.opts.api.outboxHasSpeechForTurn(turnId);
8950
8911
  let turnContext;
8951
8912
  try {
8952
8913
  turnContext = await this.opts.api.getTurnContext(
@@ -8974,12 +8935,14 @@ var TurnExecution = class {
8974
8935
  const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
8975
8936
  this.resumedFromSeq = resolveSeqFloor(sources, { turnId, log: turnLog });
8976
8937
  this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
8938
+ this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
8939
+ if (this.resumedSpoke) turnLog.info({ turnId }, "dispatcher: resumed span already spoke");
8977
8940
  }
8978
8941
  }
8979
8942
  gateTrigger() {
8980
8943
  const { payload, turnLog } = this;
8981
8944
  const message = this.turnContext.message;
8982
- const isDispatchableTrigger = message.role === "user" || message.role === "agent" || message.role === "system" && message.hasPrimaryDispatch === true;
8945
+ const isDispatchableTrigger = message.role === "user" || message.role === "agent" || message.role === "system";
8983
8946
  if (!isDispatchableTrigger) {
8984
8947
  turnLog.warn({ role: message.role }, "dispatcher: trigger role not dispatchable \u2014 skipping");
8985
8948
  throw this.concluded("unexpected_role", UNEXPECTED_ROLE_REASON);
@@ -9005,7 +8968,7 @@ var TurnExecution = class {
9005
8968
  try {
9006
8969
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9007
8970
  body: `${MISSING_SECRET_PREFIX} ${list}`,
9008
- kind: "final",
8971
+ kind: "runtime_notice",
9009
8972
  turnId: randomUUID2(),
9010
8973
  // CT113: even a turn that fails before it runs answers a message.
9011
8974
  parentMessageId: payload.messageId
@@ -9035,32 +8998,6 @@ var TurnExecution = class {
9035
8998
  effectiveCwd = void 0;
9036
8999
  }
9037
9000
  let hookEnv;
9038
- const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
9039
- const sendPrepareFailureHandback = async () => {
9040
- if (!this.turnContext.dispatchedByAgentId || triggerIsPrepareFailure) return;
9041
- try {
9042
- await this.opts.api.postAddressedMessage(workspaceId, payload.conversationId, {
9043
- body: `${PREPARE_FAILED_PREFIX}
9044
-
9045
- The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`,
9046
- dispatch: this.turnContext.dispatchedByAgentId,
9047
- // Keyed on the TRIGGER, not the turn: a redelivered dispatch event is
9048
- // the replay this has to survive, and it mints a fresh `turnId` while
9049
- // carrying the same `messageId`. One handback per failed pickup of a
9050
- // given trigger — which is also CT992's rule, that the first failure
9051
- // is the one carrying information. A genuine re-dispatch after a
9052
- // repair authors a NEW trigger message, so it keys differently and
9053
- // hands back again.
9054
- idempotencyKey: `prepare-failure-handback:${payload.messageId}`,
9055
- parentMessageId: payload.messageId
9056
- });
9057
- } catch (err) {
9058
- turnLog.warn(
9059
- { err: err instanceof Error ? err.message : String(err) },
9060
- "dispatcher: prepare-failure handback failed"
9061
- );
9062
- }
9063
- };
9064
9001
  if (prepareHook) {
9065
9002
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
9066
9003
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -9092,23 +9029,8 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
9092
9029
  } catch (err) {
9093
9030
  const reason = err instanceof Error ? err.message : String(err);
9094
9031
  turnLog.error({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
9095
- try {
9096
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9097
- body: `${PREPARE_FAILED_PREFIX}
9098
-
9099
- ${reason}`,
9100
- kind: "final",
9101
- turnId,
9102
- parentMessageId: payload.messageId
9103
- });
9104
- } catch (postErr) {
9105
- turnLog.warn(
9106
- { err: postErr instanceof Error ? postErr.message : String(postErr) },
9107
- "dispatcher: prepare-rejection post failed"
9108
- );
9109
- }
9110
- await sendPrepareFailureHandback();
9111
- throw this.concluded(`prepare_failed: ${reason}`);
9032
+ const failReason = `prepare_failed: ${reason}`;
9033
+ throw this.concluded(failReason, failReason);
9112
9034
  }
9113
9035
  }
9114
9036
  } else {
@@ -9167,25 +9089,8 @@ ${reason}`,
9167
9089
  if (preparingStarted) reportPreparing("error");
9168
9090
  const reason = err instanceof Error ? err.message : String(err);
9169
9091
  turnLog.error({ err: reason }, "dispatcher: prepare hook failed");
9170
- try {
9171
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9172
- body: `${PREPARE_FAILED_PREFIX}
9173
-
9174
- ${reason}`,
9175
- kind: "final",
9176
- turnId,
9177
- // CT113: stamp the parent even on the prepare-failed close.
9178
- parentMessageId: payload.messageId
9179
- });
9180
- } catch (postErr) {
9181
- turnLog.warn(
9182
- { err: postErr instanceof Error ? postErr.message : String(postErr) },
9183
- "dispatcher: prepare-failure post failed"
9184
- );
9185
- }
9186
- await sendPrepareFailureHandback();
9187
9092
  const failReason = `prepare_failed: ${reason}`;
9188
- throw this.concluded(failReason);
9093
+ throw this.concluded(failReason, failReason);
9189
9094
  }
9190
9095
  }
9191
9096
  }
@@ -9212,7 +9117,12 @@ ${reason}`,
9212
9117
  // already emitted a "preparing" activity row for this turn above (it
9213
9118
  // runs before this flip), which would otherwise make the sweep mistake
9214
9119
  // this live turn for an abandoned one and close it with a `stopped`.
9215
- turnId
9120
+ turnId,
9121
+ // The newest message this turn's read covered: the lease records it,
9122
+ // and settle moves the cursor to it. Omitted when the server sent
9123
+ // none (an older API), so the cursor stays where it was.
9124
+ ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
9125
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {}
9216
9126
  });
9217
9127
  } catch (err) {
9218
9128
  const refusal = leaseRefusal(err);
@@ -9235,11 +9145,9 @@ ${reason}`,
9235
9145
  buildRequest() {
9236
9146
  const { payload, workspaceId } = this;
9237
9147
  const skipState = this.skipState = createSkipState();
9238
- const replyState = this.replyState = createReplyState();
9239
9148
  const turnControlServer = createTurnControlMcpServer(
9240
9149
  { api: this.opts.api, workspaceId, turnId: this.turnId },
9241
- skipState,
9242
- replyState
9150
+ skipState
9243
9151
  );
9244
9152
  this.request = buildCompanionTurnRequest({
9245
9153
  turnContext: this.turnContext,
@@ -9308,7 +9216,7 @@ ${reason}`,
9308
9216
  try {
9309
9217
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9310
9218
  body: `${RUNTIME_UNAVAILABLE_PREFIX} ${err.message}`,
9311
- kind: "final",
9219
+ kind: "runtime_notice",
9312
9220
  turnId,
9313
9221
  parentMessageId: payload.messageId
9314
9222
  });
@@ -9323,6 +9231,43 @@ ${reason}`,
9323
9231
  throw this.concluded(`runtime_unavailable:${err.runtime}`);
9324
9232
  }
9325
9233
  }
9234
+ // The adapter refused to resume this pair's stored session and is starting a
9235
+ // fresh one. The request it holds was composed for a session that had the
9236
+ // conversation so far — the read is what landed since the cursor, the
9237
+ // agent's own words left out because the session held them. A fresh session
9238
+ // holds none of it, so fetch the context again as a first turn on an empty
9239
+ // session (the whole conversation, own words included, the first-turn
9240
+ // material) and hand the adapter that to run on — before it speaks or acts.
9241
+ // The lease already granted stands; the read reaches at least its mark. A
9242
+ // fetch that fails returns null and the adapter falls back to the request
9243
+ // it has, flagged degraded, so the server rewinds the cursor at settle.
9244
+ async freshRequest(reason) {
9245
+ const { payload, turnId, turnLog } = this;
9246
+ try {
9247
+ const context = await this.opts.api.getTurnContext(
9248
+ payload.conversationId,
9249
+ payload.messageId,
9250
+ turnId,
9251
+ false,
9252
+ true
9253
+ );
9254
+ turnLog.warn(
9255
+ { reason },
9256
+ "dispatcher: stored session not resumed \u2014 the turn context was recomposed for a fresh session"
9257
+ );
9258
+ return {
9259
+ systemPrompt: context.systemPrompt,
9260
+ prompt: context.prompt,
9261
+ content: context.content
9262
+ };
9263
+ } catch (err) {
9264
+ turnLog.error(
9265
+ { reason, err: err instanceof Error ? err.message : String(err) },
9266
+ "dispatcher: could not recompose the turn context for a fresh session \u2014 running on the incremental one, degraded"
9267
+ );
9268
+ return null;
9269
+ }
9270
+ }
9326
9271
  async execute() {
9327
9272
  const { payload, workspaceId, turnId, turnLog, startedAt } = this;
9328
9273
  const o = this.outcome;
@@ -9354,10 +9299,7 @@ ${reason}`,
9354
9299
  signal: abortController.signal,
9355
9300
  log: turnLog,
9356
9301
  nextSeq: this.nextSeq,
9357
- replyState: this.replyState,
9358
- // The ledger-derived owed reply, for the runtime's own declaration when
9359
- // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
9360
- owedReplyMessageId: this.turnContext.owedReplyMessageId ?? null,
9302
+ spoke: this.resumedSpoke,
9361
9303
  // CT1292: a commit that didn't land may mean this turn's lease is gone.
9362
9304
  // `noteCommitFailed` is a field slot the lease watchdog fills when it
9363
9305
  // arms (it starts as a no-op) — the committer only fires it from inside
@@ -9376,9 +9318,6 @@ ${reason}`,
9376
9318
  payload.agentId,
9377
9319
  turnId
9378
9320
  );
9379
- if (intent.answersMessageId) {
9380
- this.replyState.answersMessageId = intent.answersMessageId;
9381
- }
9382
9321
  if (intent.skipped) {
9383
9322
  this.skipState.skipped = true;
9384
9323
  this.skipState.reason = intent.skipReason;
@@ -9386,7 +9325,7 @@ ${reason}`,
9386
9325
  } catch (err) {
9387
9326
  turnLog.warn(
9388
9327
  { err: err instanceof Error ? err.message : String(err) },
9389
- "dispatcher: turn-control intent fetch failed; the declared reply / skip for this turn are dropped"
9328
+ "dispatcher: turn-control intent fetch failed; the skip for this turn is dropped"
9390
9329
  );
9391
9330
  }
9392
9331
  };
@@ -9444,7 +9383,9 @@ ${reason}`,
9444
9383
  clearInterval(leaseTimer);
9445
9384
  };
9446
9385
  try {
9447
- for await (const event of this.adapter.runTurn(this.request, abortController.signal)) {
9386
+ for await (const event of this.adapter.runTurn(this.request, abortController.signal, {
9387
+ fresh: (reason) => this.freshRequest(reason)
9388
+ })) {
9448
9389
  transcript2?.write(event);
9449
9390
  o.eventCounts[event.type] += 1;
9450
9391
  if (isContentBearingEvent(event)) o.contentBearingEvents += 1;
@@ -9529,43 +9470,6 @@ ${reason}`,
9529
9470
  { reason: this.skipState.reason, turnId, ok: o.okResult },
9530
9471
  "agent skipped turn (skip_turn)"
9531
9472
  );
9532
- try {
9533
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9534
- body: SKIPPED_MARKER_BODY,
9535
- kind: "skipped",
9536
- turnId,
9537
- seq: this.nextSeq(),
9538
- parentMessageId: payload.messageId
9539
- });
9540
- } catch (err) {
9541
- turnLog.warn(
9542
- { err: err instanceof Error ? err.message : String(err) },
9543
- "dispatcher: skipped-marker commit failed"
9544
- );
9545
- }
9546
- } else {
9547
- await committer.finalize(o.okResult);
9548
- if (!abortController.signal.aborted && o.okResult && !committer.finalEmitted) {
9549
- try {
9550
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
9551
- body: SILENT_MARKER_BODY,
9552
- kind: "silent",
9553
- turnId,
9554
- seq: this.nextSeq(),
9555
- parentMessageId: payload.messageId,
9556
- // An explicit `reply_to` survives a wordless turn exactly as it
9557
- // survives a textual final (the auto-declaration does not — a
9558
- // marker has no answer to bind).
9559
- ...committer.turnControlFields("silent")
9560
- });
9561
- o.silentMarkerEmitted = true;
9562
- } catch (err) {
9563
- turnLog.warn(
9564
- { err: err instanceof Error ? err.message : String(err) },
9565
- "dispatcher: silent-marker commit failed"
9566
- );
9567
- }
9568
- }
9569
9473
  }
9570
9474
  } catch (err) {
9571
9475
  o.okResult = false;
@@ -9608,12 +9512,7 @@ ${reason}`,
9608
9512
  // CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
9609
9513
  // off this turn id, so the settle PATCH must carry it — the start PATCH
9610
9514
  // isn't enough. Always sent (every settle closes a real turn).
9611
- turnId,
9612
- // CT1046: and the trigger this turn answered, so the server retires the
9613
- // dispatch that authorized it. Always sent, unlike `lastSeenMessageId`
9614
- // below (which is a success-only catch-up advance): a failed or cancelled
9615
- // turn is just as over, and its evidence must die with it.
9616
- settledMessageId: payload.messageId
9515
+ turnId
9617
9516
  };
9618
9517
  if (o.turnUsage) {
9619
9518
  body.usage = o.turnUsage;
@@ -9627,10 +9526,8 @@ ${reason}`,
9627
9526
  if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !o.runtimeIncomplete && !this.skipState.skipped) {
9628
9527
  body.errorReason = o.resultReason.slice(0, 200);
9629
9528
  }
9630
- if (o.okResult) {
9631
- body.lastSeenMessageId = payload.messageId;
9632
- }
9633
- body.outcome = o.okResult ? "settled" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
9529
+ body.outcome = o.okResult ? committer.spoke ? "settled" : "passed" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
9530
+ if (body.outcome === "settled") body.lastMessageSeq = committer.lastMessageSeq;
9634
9531
  if (o.sessionDegraded) {
9635
9532
  body.degraded = true;
9636
9533
  }
@@ -9647,7 +9544,7 @@ ${reason}`,
9647
9544
  eventCounts: o.eventCounts,
9648
9545
  runtimeResultKind: o.runtimeResultKind,
9649
9546
  ...o.turnMcpInventory ? { mcpInventory: o.turnMcpInventory } : {},
9650
- finalSource: outcome === "skipped" || outcome === "cancelled" || o.silentMarkerEmitted ? "marker" : committer.finalSource
9547
+ finalSource: outcome === "skipped" || outcome === "cancelled" || !committer.finalEmitted ? "none" : committer.finalSource
9651
9548
  };
9652
9549
  body.diagnostics = o.settledDiagnostics;
9653
9550
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
@@ -9861,7 +9758,7 @@ function mapOpencodeProviders(json) {
9861
9758
  const modelId = typeof raw?.id === "string" ? raw.id : null;
9862
9759
  const prov = typeof raw?.providerID === "string" ? raw.providerID : providerId;
9863
9760
  if (!modelId || !prov) continue;
9864
- const context = raw.limit && typeof raw.limit === "object" && typeof raw.limit.context === "number" ? raw.limit.context : null;
9761
+ const context = raw.limit && typeof raw.limit === "object" && typeof raw.limit.context === "number" && raw.limit.context > 0 ? raw.limit.context : null;
9865
9762
  models.push({
9866
9763
  // The namespaced id storage holds + opencode addresses a model by
9867
9764
  // (`deepseek/deepseek-chat` → { providerID, modelID }).
@@ -9941,8 +9838,8 @@ var Outbox = class {
9941
9838
  this.enforceBounds();
9942
9839
  }
9943
9840
  // Every queued entry, oldest-first by `(enqueuedAt, turnId, seq)` so the
9944
- // drainer delivers `progress` before `final` within a turn and older turns
9945
- // before newer ones. Malformed files are dropped (logged) rather than
9841
+ // drainer delivers a turn's rows in the order they were minted, and older
9842
+ // turns before newer ones. Malformed files are dropped (logged) rather than
9946
9843
  // wedging the drain.
9947
9844
  list() {
9948
9845
  const dir2 = this.dir();
@@ -9968,8 +9865,9 @@ var Outbox = class {
9968
9865
  this.dropCorrupt(full);
9969
9866
  }
9970
9867
  }
9868
+ const rank = (e) => e.kind === "active-run" ? 1 : 0;
9971
9869
  entries.sort(
9972
- (a, b) => a.enqueuedAt - b.enqueuedAt || (a.turnId < b.turnId ? -1 : a.turnId > b.turnId ? 1 : 0) || a.seq - b.seq
9870
+ (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
9973
9871
  );
9974
9872
  return entries;
9975
9873
  }