@cabane/companion 0.6.86 → 0.6.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2647,6 +2647,35 @@ var CabaneApi = class {
2647
2647
  signal
2648
2648
  );
2649
2649
  }
2650
+ // CT1357: post ONE ordinary addressed message — not turn speech, no `turnId`
2651
+ // — so `dispatch` is honoured and the target agent is woken. The turn-commit
2652
+ // fork ignores `dispatch` (an act rides its own call now), which leaves this
2653
+ // as the only way a companion can address a peer OUTSIDE a running turn.
2654
+ //
2655
+ // The prepare-failure handback is exactly that case and the reason this
2656
+ // exists. It fires before the turn is ever admitted — the active-run PATCH
2657
+ // that admits it comes after the prepare hook — so `turnAct` is not available:
2658
+ // its route resolves the turn first and 404s when there is none. The failure
2659
+ // notice's own commit is what admits the turn, and by then it has also settled
2660
+ // it. An ordinary addressed post depends on no lease at all, which is what
2661
+ // makes it the right shape here.
2662
+ //
2663
+ // Retried like any durable write, and therefore REQUIRING `idempotencyKey`:
2664
+ // this is not outbox-queued (there is no `(turnId, seq)` for a non-turn row),
2665
+ // so without a key a committed request whose response was lost — or a
2666
+ // redelivered dispatch event — writes a second row and wakes the peer twice.
2667
+ // The server dedupes on `(conversation_id, idempotency_key)`: the repeat
2668
+ // returns the existing row and raises no second dispatch (CT239). Required,
2669
+ // not optional, so a future caller cannot omit it and silently lose the
2670
+ // guarantee.
2671
+ postAddressedMessage(workspaceId, conversationId, body, signal) {
2672
+ return this.request(
2673
+ "POST",
2674
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
2675
+ body,
2676
+ signal ? { retry: true, signal } : { retry: true }
2677
+ );
2678
+ }
2650
2679
  // SJ477: report one tool-activity transition (start / done / error) for the
2651
2680
  // live activity cards. Transient — the server publishes an `agent_activity`
2652
2681
  // SSE and writes no row. Agent-PAT authed; the URL `:agentId` must match the
@@ -7576,12 +7605,14 @@ var MAX_ASK_QUESTION_BYTES = 400;
7576
7605
  var MAX_ASK_HEADLINE_BYTES = 120;
7577
7606
  var MAX_ASK_ITEMS = 20;
7578
7607
  var MAX_ASK_OPTION_BYTES = 200;
7608
+ var MIN_ASK_OPTIONS = 2;
7609
+ var MAX_ASK_OPTIONS = 4;
7579
7610
  var MAX_WAKE_NOTE_BYTES = 2e3;
7580
7611
  var MAX_WITHDRAW_REASON_BYTES = 400;
7581
7612
  var MAX_SEND_MESSAGE_BYTES = 65536;
7582
7613
  var MAX_SKIP_REASON_BYTES = 500;
7583
7614
  var TURN_CONTROL_TOOL_DESCRIPTIONS = {
7584
- 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 2\u20134 \`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.`,
7615
+ 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.`,
7585
7616
  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.*\`).`,
7586
7617
  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.`,
7587
7618
  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.`,
@@ -7594,11 +7625,11 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
7594
7625
  targetUserId: "The workspace member (human) to ask \u2014 a user id, from your turn context's roster.",
7595
7626
  question: `SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only). Provide EITHER this or \`questions\`, not both. Put the crisp one-sentence question in \`headline\`.`,
7596
7627
  headline: `SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in \`?\` ("Do we go to prod?"), up to ${MAX_ASK_HEADLINE_BYTES} characters. What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in \`question\`). Strongly encouraged.`,
7597
- options: `SINGLE-question form: optional 2\u20134 suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7628
+ options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7598
7629
  questions: `MULTI-question form: 1\u2013${MAX_ASK_ITEMS} questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or \`question\`/\`headline\`/\`options\`, not both.`,
7599
7630
  "questions.headline": `The one-sentence question ("Do we go to prod?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters.`,
7600
7631
  "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters. NOT a report.`,
7601
- "questions.options": `Optional 2\u20134 one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7632
+ "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7602
7633
  replaces: "Withdraw a stale ask in this conversation as part of raising this one. Both land in a single write, so the person goes from the old question to the new one and never sees neither or both.",
7603
7634
  "replaces.askId": "The id of the open ask to withdraw.",
7604
7635
  "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.`
@@ -7753,12 +7784,12 @@ function createTurnControlMcpServer(acts, skipState, replyState) {
7753
7784
  targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
7754
7785
  question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
7755
7786
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
7756
- options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(2).max(4).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7787
+ options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7757
7788
  questions: z13.array(
7758
7789
  z13.object({
7759
7790
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
7760
7791
  body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
7761
- options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(2).max(4).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7792
+ options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7762
7793
  })
7763
7794
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
7764
7795
  replaces: z13.object({
@@ -8615,12 +8646,31 @@ var TurnExecution = class {
8615
8646
  }
8616
8647
  let hookEnv;
8617
8648
  const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
8618
- const prepareFailureDispatch = this.turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8619
- dispatch: this.turnContext.dispatchedByAgentId,
8620
- dispatchBody: `${PREPARE_FAILED_PREFIX}
8649
+ const sendPrepareFailureHandback = async () => {
8650
+ if (!this.turnContext.dispatchedByAgentId || triggerIsPrepareFailure) return;
8651
+ try {
8652
+ await this.opts.api.postAddressedMessage(workspaceId, payload.conversationId, {
8653
+ body: `${PREPARE_FAILED_PREFIX}
8621
8654
 
8622
- The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
8623
- } : {};
8655
+ The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`,
8656
+ dispatch: this.turnContext.dispatchedByAgentId,
8657
+ // Keyed on the TRIGGER, not the turn: a redelivered dispatch event is
8658
+ // the replay this has to survive, and it mints a fresh `turnId` while
8659
+ // carrying the same `messageId`. One handback per failed pickup of a
8660
+ // given trigger — which is also CT992's rule, that the first failure
8661
+ // is the one carrying information. A genuine re-dispatch after a
8662
+ // repair authors a NEW trigger message, so it keys differently and
8663
+ // hands back again.
8664
+ idempotencyKey: `prepare-failure-handback:${payload.messageId}`,
8665
+ parentMessageId: payload.messageId
8666
+ });
8667
+ } catch (err) {
8668
+ turnLog.warn(
8669
+ { err: err instanceof Error ? err.message : String(err) },
8670
+ "dispatcher: prepare-failure handback failed"
8671
+ );
8672
+ }
8673
+ };
8624
8674
  if (prepareHook) {
8625
8675
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8626
8676
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8659,8 +8709,7 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
8659
8709
  ${reason}`,
8660
8710
  kind: "final",
8661
8711
  turnId,
8662
- parentMessageId: payload.messageId,
8663
- ...prepareFailureDispatch
8712
+ parentMessageId: payload.messageId
8664
8713
  });
8665
8714
  } catch (postErr) {
8666
8715
  turnLog.warn(
@@ -8668,6 +8717,7 @@ ${reason}`,
8668
8717
  "dispatcher: prepare-rejection post failed"
8669
8718
  );
8670
8719
  }
8720
+ await sendPrepareFailureHandback();
8671
8721
  throw this.concluded(`prepare_failed: ${reason}`);
8672
8722
  }
8673
8723
  }
@@ -8735,8 +8785,7 @@ ${reason}`,
8735
8785
  kind: "final",
8736
8786
  turnId,
8737
8787
  // CT113: stamp the parent even on the prepare-failed close.
8738
- parentMessageId: payload.messageId,
8739
- ...prepareFailureDispatch
8788
+ parentMessageId: payload.messageId
8740
8789
  });
8741
8790
  } catch (postErr) {
8742
8791
  turnLog.warn(
@@ -8744,6 +8793,7 @@ ${reason}`,
8744
8793
  "dispatcher: prepare-failure post failed"
8745
8794
  );
8746
8795
  }
8796
+ await sendPrepareFailureHandback();
8747
8797
  const failReason = `prepare_failed: ${reason}`;
8748
8798
  throw this.concluded(failReason);
8749
8799
  }
package/dist/runtime.js CHANGED
@@ -2065,6 +2065,35 @@ var CabaneApi = class {
2065
2065
  signal
2066
2066
  );
2067
2067
  }
2068
+ // CT1357: post ONE ordinary addressed message — not turn speech, no `turnId`
2069
+ // — so `dispatch` is honoured and the target agent is woken. The turn-commit
2070
+ // fork ignores `dispatch` (an act rides its own call now), which leaves this
2071
+ // as the only way a companion can address a peer OUTSIDE a running turn.
2072
+ //
2073
+ // The prepare-failure handback is exactly that case and the reason this
2074
+ // exists. It fires before the turn is ever admitted — the active-run PATCH
2075
+ // that admits it comes after the prepare hook — so `turnAct` is not available:
2076
+ // its route resolves the turn first and 404s when there is none. The failure
2077
+ // notice's own commit is what admits the turn, and by then it has also settled
2078
+ // it. An ordinary addressed post depends on no lease at all, which is what
2079
+ // makes it the right shape here.
2080
+ //
2081
+ // Retried like any durable write, and therefore REQUIRING `idempotencyKey`:
2082
+ // this is not outbox-queued (there is no `(turnId, seq)` for a non-turn row),
2083
+ // so without a key a committed request whose response was lost — or a
2084
+ // redelivered dispatch event — writes a second row and wakes the peer twice.
2085
+ // The server dedupes on `(conversation_id, idempotency_key)`: the repeat
2086
+ // returns the existing row and raises no second dispatch (CT239). Required,
2087
+ // not optional, so a future caller cannot omit it and silently lose the
2088
+ // guarantee.
2089
+ postAddressedMessage(workspaceId, conversationId, body, signal) {
2090
+ return this.request(
2091
+ "POST",
2092
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
2093
+ body,
2094
+ signal ? { retry: true, signal } : { retry: true }
2095
+ );
2096
+ }
2068
2097
  // SJ477: report one tool-activity transition (start / done / error) for the
2069
2098
  // live activity cards. Transient — the server publishes an `agent_activity`
2070
2099
  // SSE and writes no row. Agent-PAT authed; the URL `:agentId` must match the
@@ -7073,12 +7102,14 @@ var MAX_ASK_QUESTION_BYTES = 400;
7073
7102
  var MAX_ASK_HEADLINE_BYTES = 120;
7074
7103
  var MAX_ASK_ITEMS = 20;
7075
7104
  var MAX_ASK_OPTION_BYTES = 200;
7105
+ var MIN_ASK_OPTIONS = 2;
7106
+ var MAX_ASK_OPTIONS = 4;
7076
7107
  var MAX_WAKE_NOTE_BYTES = 2e3;
7077
7108
  var MAX_WITHDRAW_REASON_BYTES = 400;
7078
7109
  var MAX_SEND_MESSAGE_BYTES = 65536;
7079
7110
  var MAX_SKIP_REASON_BYTES = 500;
7080
7111
  var TURN_CONTROL_TOOL_DESCRIPTIONS = {
7081
- 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 2\u20134 \`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.`,
7112
+ 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.`,
7082
7113
  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.*\`).`,
7083
7114
  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.`,
7084
7115
  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.`,
@@ -7091,11 +7122,11 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
7091
7122
  targetUserId: "The workspace member (human) to ask \u2014 a user id, from your turn context's roster.",
7092
7123
  question: `SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only). Provide EITHER this or \`questions\`, not both. Put the crisp one-sentence question in \`headline\`.`,
7093
7124
  headline: `SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in \`?\` ("Do we go to prod?"), up to ${MAX_ASK_HEADLINE_BYTES} characters. What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in \`question\`). Strongly encouraged.`,
7094
- options: `SINGLE-question form: optional 2\u20134 suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7125
+ options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7095
7126
  questions: `MULTI-question form: 1\u2013${MAX_ASK_ITEMS} questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or \`question\`/\`headline\`/\`options\`, not both.`,
7096
7127
  "questions.headline": `The one-sentence question ("Do we go to prod?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters.`,
7097
7128
  "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters. NOT a report.`,
7098
- "questions.options": `Optional 2\u20134 one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7129
+ "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each.`,
7099
7130
  replaces: "Withdraw a stale ask in this conversation as part of raising this one. Both land in a single write, so the person goes from the old question to the new one and never sees neither or both.",
7100
7131
  "replaces.askId": "The id of the open ask to withdraw.",
7101
7132
  "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.`
@@ -7250,12 +7281,12 @@ function createTurnControlMcpServer(acts, skipState, replyState) {
7250
7281
  targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
7251
7282
  question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
7252
7283
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
7253
- options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(2).max(4).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7284
+ options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7254
7285
  questions: z13.array(
7255
7286
  z13.object({
7256
7287
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
7257
7288
  body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
7258
- options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(2).max(4).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7289
+ options: z13.array(z13.string().min(1).max(MAX_ASK_OPTION_BYTES)).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7259
7290
  })
7260
7291
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
7261
7292
  replaces: z13.object({
@@ -8112,12 +8143,31 @@ var TurnExecution = class {
8112
8143
  }
8113
8144
  let hookEnv;
8114
8145
  const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
8115
- const prepareFailureDispatch = this.turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8116
- dispatch: this.turnContext.dispatchedByAgentId,
8117
- dispatchBody: `${PREPARE_FAILED_PREFIX}
8146
+ const sendPrepareFailureHandback = async () => {
8147
+ if (!this.turnContext.dispatchedByAgentId || triggerIsPrepareFailure) return;
8148
+ try {
8149
+ await this.opts.api.postAddressedMessage(workspaceId, payload.conversationId, {
8150
+ body: `${PREPARE_FAILED_PREFIX}
8118
8151
 
8119
- The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
8120
- } : {};
8152
+ The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`,
8153
+ dispatch: this.turnContext.dispatchedByAgentId,
8154
+ // Keyed on the TRIGGER, not the turn: a redelivered dispatch event is
8155
+ // the replay this has to survive, and it mints a fresh `turnId` while
8156
+ // carrying the same `messageId`. One handback per failed pickup of a
8157
+ // given trigger — which is also CT992's rule, that the first failure
8158
+ // is the one carrying information. A genuine re-dispatch after a
8159
+ // repair authors a NEW trigger message, so it keys differently and
8160
+ // hands back again.
8161
+ idempotencyKey: `prepare-failure-handback:${payload.messageId}`,
8162
+ parentMessageId: payload.messageId
8163
+ });
8164
+ } catch (err) {
8165
+ turnLog.warn(
8166
+ { err: err instanceof Error ? err.message : String(err) },
8167
+ "dispatcher: prepare-failure handback failed"
8168
+ );
8169
+ }
8170
+ };
8121
8171
  if (prepareHook) {
8122
8172
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8123
8173
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8156,8 +8206,7 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
8156
8206
  ${reason}`,
8157
8207
  kind: "final",
8158
8208
  turnId,
8159
- parentMessageId: payload.messageId,
8160
- ...prepareFailureDispatch
8209
+ parentMessageId: payload.messageId
8161
8210
  });
8162
8211
  } catch (postErr) {
8163
8212
  turnLog.warn(
@@ -8165,6 +8214,7 @@ ${reason}`,
8165
8214
  "dispatcher: prepare-rejection post failed"
8166
8215
  );
8167
8216
  }
8217
+ await sendPrepareFailureHandback();
8168
8218
  throw this.concluded(`prepare_failed: ${reason}`);
8169
8219
  }
8170
8220
  }
@@ -8232,8 +8282,7 @@ ${reason}`,
8232
8282
  kind: "final",
8233
8283
  turnId,
8234
8284
  // CT113: stamp the parent even on the prepare-failed close.
8235
- parentMessageId: payload.messageId,
8236
- ...prepareFailureDispatch
8285
+ parentMessageId: payload.messageId
8237
8286
  });
8238
8287
  } catch (postErr) {
8239
8288
  turnLog.warn(
@@ -8241,6 +8290,7 @@ ${reason}`,
8241
8290
  "dispatcher: prepare-failure post failed"
8242
8291
  );
8243
8292
  }
8293
+ await sendPrepareFailureHandback();
8244
8294
  const failReason = `prepare_failed: ${reason}`;
8245
8295
  throw this.concluded(failReason);
8246
8296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.86",
3
+ "version": "0.6.88",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",