@cabane/companion 0.6.8 → 0.6.10

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
@@ -79,7 +79,20 @@ var prepareHookSchema = z.object({
79
79
  // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
80
80
  // default is generous; a hook that hangs past this is killed and the turn
81
81
  // fails with a clear timeout message rather than pinning the companion.
82
- timeoutMs: z.number().int().positive().optional()
82
+ timeoutMs: z.number().int().positive().optional(),
83
+ // CT943: re-invoke the hook on turns where a prepared result already exists,
84
+ // in VALIDATE mode — the cached result is handed back as `prepared` and the
85
+ // hook's stdout is ignored, so the cwd provably cannot move. Its only power
86
+ // is to exit non-zero and fail the turn.
87
+ //
88
+ // Why it exists: the hook runs exactly once per (conversation, agent), so a
89
+ // later message asking for a different environment can't be honoured. Without
90
+ // this, it is silently ignored — the host knows and says nothing, which is
91
+ // the worst available outcome. An opt-in hook can now refuse it out loud.
92
+ //
93
+ // Default OFF, and deliberately: an operator hook that provisions
94
+ // unconditionally must not start running on every turn because it upgraded.
95
+ validateCached: z.boolean().optional()
83
96
  }).strict();
84
97
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
85
98
  var prepareResultSchema = z.object({
@@ -143,7 +156,12 @@ var runPrepareHook = (hook, input) => {
143
156
  // CT317: newline-joined — workspace paths never contain a newline, so a
144
157
  // hook splits on `\n` unambiguously. Empty string when none.
145
158
  CABANE_TRIGGER_ENTRY_PATHS: input.triggerEntryPaths.join("\n"),
146
- CABANE_CONVERSATION_TITLE: input.title ?? ""
159
+ CABANE_CONVERSATION_TITLE: input.title ?? "",
160
+ // CT943: the dispatching message's text, and — in validate mode only —
161
+ // the prepared result the hook is being asked to check this dispatch
162
+ // against. Both empty/absent on an ordinary first run.
163
+ CABANE_TRIGGER_MESSAGE: input.messageBody ?? "",
164
+ ...input.prepared ? { CABANE_PREPARED: JSON.stringify(input.prepared) } : {}
147
165
  }
148
166
  });
149
167
  } catch (err) {
@@ -181,6 +199,10 @@ var runPrepareHook = (hook, input) => {
181
199
  );
182
200
  return;
183
201
  }
202
+ if (input.prepared) {
203
+ resolve(input.prepared);
204
+ return;
205
+ }
184
206
  try {
185
207
  resolve(parsePrepareOutput(stdout));
186
208
  } catch (err) {
@@ -3857,7 +3879,7 @@ var OPENCODE_HOST_TOOLS = [
3857
3879
  var OPENCODE_WEB_TOOLS = ["webfetch"];
3858
3880
  var OPENCODE_SUBAGENT_TOOLS = ["task"];
3859
3881
  var OPENCODE_SCHEDULING_TOOLS = [];
3860
- var OPENCODE_UI_PROMPT_TOOLS = [];
3882
+ var OPENCODE_UI_PROMPT_TOOLS = ["question"];
3861
3883
  function opencodeToolPolicy(policy) {
3862
3884
  const tools = {};
3863
3885
  const deny = (names) => {
@@ -6292,9 +6314,11 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6292
6314
  tools: [
6293
6315
  tool(
6294
6316
  SUMMON_AGENT_TOOL,
6295
- "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` (discover handles + ids via `list_agents`). The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `create_conversation` / `post_message` with their `dispatch` field instead.",
6317
+ "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` \u2014 every agent's handle and id is on the roster in your turn context. The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead.",
6296
6318
  {
6297
- agentId: z12.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
6319
+ agentId: z12.string().uuid().describe(
6320
+ "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
6321
+ )
6298
6322
  },
6299
6323
  async (args) => {
6300
6324
  summonState.agentId = args.agentId;
@@ -6324,9 +6348,11 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6324
6348
  ...askState ? [
6325
6349
  tool(
6326
6350
  ASK_TOOL,
6327
- "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 get it from `mcp__cabane__list_members`). 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\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted 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. An option can be a short button label or a whole sentence. Provide EITHER `question` (single) or `questions` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
6351
+ "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\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted 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. An option can be a short button label or a whole sentence. Provide EITHER `question` (single) or `questions` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
6328
6352
  {
6329
- targetUserId: z12.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
6353
+ targetUserId: z12.string().uuid().describe(
6354
+ "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
6355
+ ),
6330
6356
  question: z12.string().min(1).max(400).optional().describe(
6331
6357
  "SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
6332
6358
  ),
@@ -6401,7 +6427,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6401
6427
  "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
6402
6428
  ),
6403
6429
  agentId: z12.string().uuid().optional().describe(
6404
- "Optional peer to run the sub-agent as (a workspace agent id from `list_agents`); omit to spawn yourself with a fresh context window."
6430
+ "Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
6405
6431
  ),
6406
6432
  title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6407
6433
  },
@@ -7065,7 +7091,7 @@ function describeSubAgentError(status2, body) {
7065
7091
  case "callout_depth_exceeded":
7066
7092
  return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
7067
7093
  case "dispatch_agent_not_found":
7068
- return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
7094
+ return "sub_agent: no live agent in this workspace matches that `agentId`. Check the roster in your turn context, or omit `agentId` to spawn yourself.";
7069
7095
  case "dispatch_return_requires_turn":
7070
7096
  case "dispatch_return_requires_agent":
7071
7097
  case "dispatch_return_requires_dispatch":
@@ -7225,6 +7251,41 @@ var Dispatcher = class {
7225
7251
  if (cached2) {
7226
7252
  effectiveCwd = cached2.cwd;
7227
7253
  hookEnv = cached2.env;
7254
+ if (prepareHook.validateCached) {
7255
+ const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
7256
+ try {
7257
+ await runHook(prepareHook, {
7258
+ workspaceId,
7259
+ conversationId: payload.conversationId,
7260
+ agentId: payload.agentId,
7261
+ agentUsername: this.opts.agentUsername,
7262
+ runtime: turnContext.runtime,
7263
+ triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7264
+ title: turnContext.conversation.title,
7265
+ messageBody: message.body,
7266
+ prepared: cached2
7267
+ });
7268
+ } catch (err) {
7269
+ const reason = err instanceof Error ? err.message : String(err);
7270
+ turnLog.error({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
7271
+ try {
7272
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7273
+ body: `${PREPARE_FAILED_PREFIX}
7274
+
7275
+ ${reason}`,
7276
+ kind: "final",
7277
+ turnId,
7278
+ parentMessageId: payload.messageId
7279
+ });
7280
+ } catch (postErr) {
7281
+ turnLog.warn(
7282
+ { err: postErr instanceof Error ? postErr.message : String(postErr) },
7283
+ "dispatcher: prepare-rejection post failed"
7284
+ );
7285
+ }
7286
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `prepare_failed: ${reason}`);
7287
+ }
7288
+ }
7228
7289
  } else {
7229
7290
  const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
7230
7291
  let preparingStarted = false;
@@ -7263,7 +7324,12 @@ var Dispatcher = class {
7263
7324
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
7264
7325
  // an older API. The conversation anchor is gone (CT319).
7265
7326
  triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7266
- title: turnContext.conversation.title
7327
+ title: turnContext.conversation.title,
7328
+ // CT943: the dispatching message's text — where an `env:` directive
7329
+ // rides. The server has always sent the trigger body on the turn
7330
+ // context (for the live feed); this is the first thing to read it as
7331
+ // an INPUT, so a dispatch can ask for its environment in words.
7332
+ messageBody: message.body
7267
7333
  });
7268
7334
  clearTimeout(preparingTimer);
7269
7335
  if (preparingStarted) reportPreparing("done");
@@ -63,7 +63,20 @@ var prepareHookSchema = z.object({
63
63
  // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
64
64
  // default is generous; a hook that hangs past this is killed and the turn
65
65
  // fails with a clear timeout message rather than pinning the companion.
66
- timeoutMs: z.number().int().positive().optional()
66
+ timeoutMs: z.number().int().positive().optional(),
67
+ // CT943: re-invoke the hook on turns where a prepared result already exists,
68
+ // in VALIDATE mode — the cached result is handed back as `prepared` and the
69
+ // hook's stdout is ignored, so the cwd provably cannot move. Its only power
70
+ // is to exit non-zero and fail the turn.
71
+ //
72
+ // Why it exists: the hook runs exactly once per (conversation, agent), so a
73
+ // later message asking for a different environment can't be honoured. Without
74
+ // this, it is silently ignored — the host knows and says nothing, which is
75
+ // the worst available outcome. An opt-in hook can now refuse it out loud.
76
+ //
77
+ // Default OFF, and deliberately: an operator hook that provisions
78
+ // unconditionally must not start running on every turn because it upgraded.
79
+ validateCached: z.boolean().optional()
67
80
  }).strict();
68
81
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
69
82
  var prepareResultSchema = z.object({
package/dist/runtime.js CHANGED
@@ -72,7 +72,20 @@ var prepareHookSchema = z.object({
72
72
  // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
73
73
  // default is generous; a hook that hangs past this is killed and the turn
74
74
  // fails with a clear timeout message rather than pinning the companion.
75
- timeoutMs: z.number().int().positive().optional()
75
+ timeoutMs: z.number().int().positive().optional(),
76
+ // CT943: re-invoke the hook on turns where a prepared result already exists,
77
+ // in VALIDATE mode — the cached result is handed back as `prepared` and the
78
+ // hook's stdout is ignored, so the cwd provably cannot move. Its only power
79
+ // is to exit non-zero and fail the turn.
80
+ //
81
+ // Why it exists: the hook runs exactly once per (conversation, agent), so a
82
+ // later message asking for a different environment can't be honoured. Without
83
+ // this, it is silently ignored — the host knows and says nothing, which is
84
+ // the worst available outcome. An opt-in hook can now refuse it out loud.
85
+ //
86
+ // Default OFF, and deliberately: an operator hook that provisions
87
+ // unconditionally must not start running on every turn because it upgraded.
88
+ validateCached: z.boolean().optional()
76
89
  }).strict();
77
90
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
78
91
  var prepareResultSchema = z.object({
@@ -136,7 +149,12 @@ var runPrepareHook = (hook, input) => {
136
149
  // CT317: newline-joined — workspace paths never contain a newline, so a
137
150
  // hook splits on `\n` unambiguously. Empty string when none.
138
151
  CABANE_TRIGGER_ENTRY_PATHS: input.triggerEntryPaths.join("\n"),
139
- CABANE_CONVERSATION_TITLE: input.title ?? ""
152
+ CABANE_CONVERSATION_TITLE: input.title ?? "",
153
+ // CT943: the dispatching message's text, and — in validate mode only —
154
+ // the prepared result the hook is being asked to check this dispatch
155
+ // against. Both empty/absent on an ordinary first run.
156
+ CABANE_TRIGGER_MESSAGE: input.messageBody ?? "",
157
+ ...input.prepared ? { CABANE_PREPARED: JSON.stringify(input.prepared) } : {}
140
158
  }
141
159
  });
142
160
  } catch (err) {
@@ -174,6 +192,10 @@ var runPrepareHook = (hook, input) => {
174
192
  );
175
193
  return;
176
194
  }
195
+ if (input.prepared) {
196
+ resolve(input.prepared);
197
+ return;
198
+ }
177
199
  try {
178
200
  resolve(parsePrepareOutput(stdout));
179
201
  } catch (err) {
@@ -3510,7 +3532,7 @@ var OPENCODE_HOST_TOOLS = [
3510
3532
  var OPENCODE_WEB_TOOLS = ["webfetch"];
3511
3533
  var OPENCODE_SUBAGENT_TOOLS = ["task"];
3512
3534
  var OPENCODE_SCHEDULING_TOOLS = [];
3513
- var OPENCODE_UI_PROMPT_TOOLS = [];
3535
+ var OPENCODE_UI_PROMPT_TOOLS = ["question"];
3514
3536
  function opencodeToolPolicy(policy) {
3515
3537
  const tools = {};
3516
3538
  const deny = (names) => {
@@ -5945,9 +5967,11 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
5945
5967
  tools: [
5946
5968
  tool(
5947
5969
  SUMMON_AGENT_TOOL,
5948
- "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` (discover handles + ids via `list_agents`). The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `create_conversation` / `post_message` with their `dispatch` field instead.",
5970
+ "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` \u2014 every agent's handle and id is on the roster in your turn context. The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead.",
5949
5971
  {
5950
- agentId: z12.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
5972
+ agentId: z12.string().uuid().describe(
5973
+ "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
5974
+ )
5951
5975
  },
5952
5976
  async (args) => {
5953
5977
  summonState.agentId = args.agentId;
@@ -5977,9 +6001,11 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
5977
6001
  ...askState ? [
5978
6002
  tool(
5979
6003
  ASK_TOOL,
5980
- "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 get it from `mcp__cabane__list_members`). 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\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted 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. An option can be a short button label or a whole sentence. Provide EITHER `question` (single) or `questions` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
6004
+ "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\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted 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. An option can be a short button label or a whole sentence. Provide EITHER `question` (single) or `questions` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
5981
6005
  {
5982
- targetUserId: z12.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
6006
+ targetUserId: z12.string().uuid().describe(
6007
+ "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
6008
+ ),
5983
6009
  question: z12.string().min(1).max(400).optional().describe(
5984
6010
  "SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
5985
6011
  ),
@@ -6054,7 +6080,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6054
6080
  "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
6055
6081
  ),
6056
6082
  agentId: z12.string().uuid().optional().describe(
6057
- "Optional peer to run the sub-agent as (a workspace agent id from `list_agents`); omit to spawn yourself with a fresh context window."
6083
+ "Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
6058
6084
  ),
6059
6085
  title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6060
6086
  },
@@ -6718,7 +6744,7 @@ function describeSubAgentError(status, body) {
6718
6744
  case "callout_depth_exceeded":
6719
6745
  return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
6720
6746
  case "dispatch_agent_not_found":
6721
- return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
6747
+ return "sub_agent: no live agent in this workspace matches that `agentId`. Check the roster in your turn context, or omit `agentId` to spawn yourself.";
6722
6748
  case "dispatch_return_requires_turn":
6723
6749
  case "dispatch_return_requires_agent":
6724
6750
  case "dispatch_return_requires_dispatch":
@@ -6878,6 +6904,41 @@ var Dispatcher = class {
6878
6904
  if (cached2) {
6879
6905
  effectiveCwd = cached2.cwd;
6880
6906
  hookEnv = cached2.env;
6907
+ if (prepareHook.validateCached) {
6908
+ const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
6909
+ try {
6910
+ await runHook(prepareHook, {
6911
+ workspaceId,
6912
+ conversationId: payload.conversationId,
6913
+ agentId: payload.agentId,
6914
+ agentUsername: this.opts.agentUsername,
6915
+ runtime: turnContext.runtime,
6916
+ triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
6917
+ title: turnContext.conversation.title,
6918
+ messageBody: message.body,
6919
+ prepared: cached2
6920
+ });
6921
+ } catch (err) {
6922
+ const reason = err instanceof Error ? err.message : String(err);
6923
+ turnLog.error({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
6924
+ try {
6925
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
6926
+ body: `${PREPARE_FAILED_PREFIX}
6927
+
6928
+ ${reason}`,
6929
+ kind: "final",
6930
+ turnId,
6931
+ parentMessageId: payload.messageId
6932
+ });
6933
+ } catch (postErr) {
6934
+ turnLog.warn(
6935
+ { err: postErr instanceof Error ? postErr.message : String(postErr) },
6936
+ "dispatcher: prepare-rejection post failed"
6937
+ );
6938
+ }
6939
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `prepare_failed: ${reason}`);
6940
+ }
6941
+ }
6881
6942
  } else {
6882
6943
  const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
6883
6944
  let preparingStarted = false;
@@ -6916,7 +6977,12 @@ var Dispatcher = class {
6916
6977
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
6917
6978
  // an older API. The conversation anchor is gone (CT319).
6918
6979
  triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
6919
- title: turnContext.conversation.title
6980
+ title: turnContext.conversation.title,
6981
+ // CT943: the dispatching message's text — where an `env:` directive
6982
+ // rides. The server has always sent the trigger body on the turn
6983
+ // context (for the live feed); this is the first thing to read it as
6984
+ // an INPUT, so a dispatch can ask for its environment in words.
6985
+ messageBody: message.body
6920
6986
  });
6921
6987
  clearTimeout(preparingTimer);
6922
6988
  if (preparingStarted) reportPreparing("done");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
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",