@cabane/companion 0.6.106 → 0.6.108

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
@@ -93,7 +93,15 @@ var prepareHookSchema = z.object({
93
93
  //
94
94
  // Default OFF, and deliberately: an operator hook that provisions
95
95
  // unconditionally must not start running on every turn because it upgraded.
96
- validateCached: z.boolean().optional()
96
+ validateCached: z.boolean().optional(),
97
+ // Independent operator-owned reporter. Receives sanitized failure metadata
98
+ // on stdin; never re-enters this hook and cannot change the turn outcome.
99
+ failureReporter: z.object({
100
+ command: z.string().min(1),
101
+ args: z.array(z.string()).optional(),
102
+ env: z.record(z.string(), z.string()).optional(),
103
+ timeoutMs: z.number().int().positive().max(3e4).optional()
104
+ }).strict().optional()
97
105
  }).strict();
98
106
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
99
107
  var prepareResultSchema = z.object({
@@ -1769,8 +1777,8 @@ function requestEnrollmentCode(baseUrl, opts = {}) {
1769
1777
  ...opts.label ? { label: opts.label } : {}
1770
1778
  });
1771
1779
  }
1772
- function deviceLabelFromHostname(hostname4) {
1773
- const trimmed = hostname4.trim().replace(/\.local$/i, "");
1780
+ function deviceLabelFromHostname(hostname5) {
1781
+ const trimmed = hostname5.trim().replace(/\.local$/i, "");
1774
1782
  if (trimmed.length === 0) return void 0;
1775
1783
  return trimmed.slice(0, 120);
1776
1784
  }
@@ -2097,7 +2105,7 @@ async function logs(opts = {}) {
2097
2105
  }
2098
2106
 
2099
2107
  // src/commands/start.ts
2100
- import { hostname as hostname3 } from "os";
2108
+ import { hostname as hostname4 } from "os";
2101
2109
 
2102
2110
  // src/runtime.ts
2103
2111
  import { randomUUID as randomUUID5 } from "crypto";
@@ -2662,7 +2670,7 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
2662
2670
  }
2663
2671
 
2664
2672
  // src/supervisor.ts
2665
- import { hostname as hostname2 } from "os";
2673
+ import { hostname as hostname3 } from "os";
2666
2674
 
2667
2675
  // packages/agent-runtime/src/failure.ts
2668
2676
  import { z as z4 } from "zod";
@@ -4383,6 +4391,15 @@ var MAX_ASK_QUESTION_BYTES = 400;
4383
4391
  var MAX_ASK_HEADLINE_BYTES = 120;
4384
4392
  var MAX_ASK_ITEMS = 20;
4385
4393
  var MAX_ASK_OPTION_BYTES = 200;
4394
+ var TASK_STAGES = [
4395
+ "proposed",
4396
+ "todo",
4397
+ "in_progress",
4398
+ "in_review",
4399
+ "done",
4400
+ "dropped",
4401
+ "deferred"
4402
+ ];
4386
4403
  var MIN_ASK_OPTIONS = 2;
4387
4404
  var MAX_ASK_OPTIONS = 4;
4388
4405
  var MAX_WAKE_NOTE_BYTES = 2e3;
@@ -4407,11 +4424,19 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
4407
4424
  targetUserId: "The workspace member (human) to ask \u2014 a user id, from your turn context's roster.",
4408
4425
  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\`. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
4409
4426
  headline: `SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in \`?\` ("Should Ledger deploy once Mia approves?"), 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. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
4410
- options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they".`,
4427
+ options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they". An option may instead be \`{ label, move: { task, status } }\` \u2014 a MOVE of a task: clicking it is the person making that move, and the task's next stage owner is told for you. A wrapper carrying a move is ONE question about ONE task, and you are NOT woken when the move applies.`,
4411
4428
  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.`,
4412
4429
  "questions.headline": `The one-sentence question ("Should Ledger deploy once Mia approves?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
4413
4430
  "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters. NOT a report. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
4414
- "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they".`,
4431
+ "options.label": `What the option SAYS. The destination stage is drawn beside it as a pill, so the label is the decision (\`Accept\`), not a restatement of the move.`,
4432
+ "options.move": `Makes this option a MOVE of a task: clicking it is the person making that move, recorded as theirs. The next stage's owner is told for you, and you are not woken.`,
4433
+ "options.move.task": `The task the move applies to \u2014 its reference (\`CT12\`) or its uuid. Every move in one ask names the SAME task.`,
4434
+ "options.move.status": `The stage the task goes to when the person clicks this option. A move the task cannot make from where it is now is refused when you raise the ask, by name.`,
4435
+ "questions.options.label": `What the option SAYS. The destination stage is drawn beside it as a pill, so the label is the decision (\`Accept\`), not a restatement of the move.`,
4436
+ "questions.options.move": `See \`options.move\`. An ask carrying a move has exactly one question.`,
4437
+ "questions.options.move.task": `The task the move applies to \u2014 its reference (\`CT12\`) or its uuid. Every move in one ask names the SAME task.`,
4438
+ "questions.options.move.status": `The stage the task goes to when the person clicks this option. A move the task cannot make from where it is now is refused when you raise the ask, by name.`,
4439
+ "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they". An option may instead be \`{ label, move: { task, status } }\` \u2014 see the single-question form; an ask carrying a move has exactly one question.`,
4415
4440
  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.",
4416
4441
  "replaces.askId": "The id of the open ask to withdraw.",
4417
4442
  "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.`
@@ -6649,10 +6674,15 @@ var CODEX_ADDENDUM = [
6649
6674
  "absent without attempting discovery and invocation, and if a call fails, report the recorded",
6650
6675
  "error.",
6651
6676
  turnControlPromptGuidance("mcp__cabane_companion__"),
6652
- "The ancillary tools are",
6653
- "`mcp__cabane__list_workspaces` /",
6654
- "`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
6655
- "possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
6677
+ // CT1552: every ancillary tool is written the way Codex's own tool list shows
6678
+ // it — qualified. The bare `read_binary` / `upload` / … this list used to mix
6679
+ // in sat two lines under the instruction to invoke the exact qualified names,
6680
+ // so the sentence contradicted itself. The bare `query` / `mutate` below stay:
6681
+ // that sentence is about operation names, after the qualified forms are given.
6682
+ "The ancillary tools are `mcp__cabane__list_workspaces`, `mcp__cabane__read_binary`,",
6683
+ "`mcp__cabane__upload`, `mcp__cabane__begin_upload`, `mcp__cabane__finalize_upload` and",
6684
+ "`mcp__cabane__mint_render_token` \u2014 all possibly deferred too.",
6685
+ "There is no Cabane `read`/`write`/`search` tool \u2014 those are",
6656
6686
  "operation NAMES you pass to `query` / `mutate`. One more harness fact: you can interleave",
6657
6687
  "narration",
6658
6688
  "with tool calls, but your final message is the turn's answer \u2014 write your",
@@ -8388,12 +8418,34 @@ function createTurnControlMcpServer(acts, skipState) {
8388
8418
  targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
8389
8419
  question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
8390
8420
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
8391
- 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),
8421
+ options: z13.array(
8422
+ z13.union([
8423
+ z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
8424
+ z13.object({
8425
+ label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
8426
+ move: z13.object({
8427
+ task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
8428
+ status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
8429
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
8430
+ })
8431
+ ])
8432
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
8392
8433
  questions: z13.array(
8393
8434
  z13.object({
8394
8435
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
8395
8436
  body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
8396
- 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"])
8437
+ options: z13.array(
8438
+ z13.union([
8439
+ z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
8440
+ z13.object({
8441
+ label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
8442
+ move: z13.object({
8443
+ task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
8444
+ status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
8445
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
8446
+ })
8447
+ ])
8448
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
8397
8449
  })
8398
8450
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
8399
8451
  replaces: z13.object({
@@ -8604,6 +8656,68 @@ function readOutboxFloor(read, turnId, log) {
8604
8656
  }
8605
8657
  }
8606
8658
 
8659
+ // src/prepare-failure.ts
8660
+ import { spawn as spawn4 } from "child_process";
8661
+ import { hostname as hostname2 } from "os";
8662
+ function prepareFailureCause(error) {
8663
+ const message = error instanceof Error ? error.message : String(error);
8664
+ if (/timed out/i.test(message)) return "prepare hook timed out";
8665
+ if (/MODULE_NOT_FOUND|Cannot find (?:module|package)/.test(message))
8666
+ return "prepare hook module missing; check the configured command and its installation for this device";
8667
+ if (/not valid JSON|JSON must carry|produced no output/.test(message))
8668
+ return "prepare hook returned malformed output; expected a cwd path or JSON with a non-empty cwd";
8669
+ if (/ENOENT/.test(message)) return "prepare hook executable missing (ENOENT)";
8670
+ if (/EACCES/.test(message)) return "prepare hook executable is not accessible (EACCES)";
8671
+ const exit = message.match(/exited with code (\d+)/)?.[1];
8672
+ return exit ? `prepare hook exited with code ${exit}` : "prepare hook failed; inspect the device-local hook log";
8673
+ }
8674
+ async function runPrepareWithReporting(runner, hook, input, context, reportError) {
8675
+ try {
8676
+ return await runner(hook, input);
8677
+ } catch (error) {
8678
+ if (hook.failureReporter) {
8679
+ try {
8680
+ await reportFailure(hook.failureReporter, {
8681
+ workspaceId: input.workspaceId,
8682
+ conversationId: input.conversationId,
8683
+ agentId: input.agentId,
8684
+ agentUsername: input.agentUsername,
8685
+ turnId: context.turnId,
8686
+ deviceId: context.deviceId ?? hostname2(),
8687
+ cause: prepareFailureCause(error)
8688
+ });
8689
+ } catch {
8690
+ reportError();
8691
+ }
8692
+ }
8693
+ throw error;
8694
+ }
8695
+ }
8696
+ function reportFailure(config, body) {
8697
+ return new Promise((resolve2, reject) => {
8698
+ const child = spawn4(config.command, config.args ?? [], {
8699
+ stdio: ["pipe", "ignore", "ignore"],
8700
+ env: { ...process.env, ...config.env }
8701
+ });
8702
+ const timer = setTimeout(() => {
8703
+ child.kill("SIGKILL");
8704
+ reject(new Error("reporter timeout"));
8705
+ }, config.timeoutMs ?? 15e3);
8706
+ child.stdin.on("error", () => {
8707
+ });
8708
+ child.once("error", () => {
8709
+ clearTimeout(timer);
8710
+ reject(new Error("reporter failed to start"));
8711
+ });
8712
+ child.once("close", (code) => {
8713
+ clearTimeout(timer);
8714
+ if (code === 0) resolve2();
8715
+ else reject(new Error("reporter failed"));
8716
+ });
8717
+ child.stdin.end(JSON.stringify(body));
8718
+ });
8719
+ }
8720
+
8607
8721
  // src/prepared.ts
8608
8722
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
8609
8723
  import { join as join12 } from "path";
@@ -8834,7 +8948,7 @@ function pruneOld(dir2, retain) {
8834
8948
  }
8835
8949
 
8836
8950
  // src/turn-containment.ts
8837
- import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8951
+ import { execFile, execFileSync, spawn as spawn5 } from "child_process";
8838
8952
  import { readFileSync as readFileSync8 } from "fs";
8839
8953
  import { promisify } from "util";
8840
8954
  var execFileAsync = promisify(execFile);
@@ -8916,7 +9030,7 @@ function createTurnContainment(turnId, deps = {}) {
8916
9030
  command: options.command
8917
9031
  });
8918
9032
  return asContained(
8919
- spawn4(
9033
+ spawn5(
8920
9034
  "systemd-run",
8921
9035
  [
8922
9036
  "--user",
@@ -8943,7 +9057,7 @@ function createTurnContainment(turnId, deps = {}) {
8943
9057
  );
8944
9058
  }
8945
9059
  function spawnInGroup(options) {
8946
- const child = spawn4(options.command, options.args, {
9060
+ const child = spawn5(options.command, options.args, {
8947
9061
  ...options.cwd ? { cwd: options.cwd } : {},
8948
9062
  env: options.env,
8949
9063
  stdio: ["pipe", "pipe", "pipe"],
@@ -9645,18 +9759,28 @@ var TurnExecution = class {
9645
9759
  if (prepareHook.validateCached) {
9646
9760
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9647
9761
  try {
9648
- await runHook(prepareHook, {
9649
- workspaceId,
9650
- conversationId: payload.conversationId,
9651
- agentId: payload.agentId,
9652
- agentUsername: this.opts.agentUsername,
9653
- runtime: this.turnContext.runtime,
9654
- hostAccess: this.turnContext.policy.hostFs,
9655
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9656
- title: this.turnContext.conversation.title,
9657
- messageBody: this.turnContext.message.body,
9658
- prepared: cached2
9659
- });
9762
+ await runPrepareWithReporting(
9763
+ runHook,
9764
+ prepareHook,
9765
+ {
9766
+ workspaceId,
9767
+ conversationId: payload.conversationId,
9768
+ agentId: payload.agentId,
9769
+ agentUsername: this.opts.agentUsername,
9770
+ runtime: this.turnContext.runtime,
9771
+ hostAccess: this.turnContext.policy.hostFs,
9772
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9773
+ title: this.turnContext.conversation.title,
9774
+ messageBody: this.turnContext.message.body,
9775
+ prepared: cached2
9776
+ },
9777
+ { turnId, deviceId: this.opts.deviceId },
9778
+ () => {
9779
+ turnLog.error(
9780
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9781
+ );
9782
+ }
9783
+ );
9660
9784
  } catch (err) {
9661
9785
  const reason = err instanceof Error ? err.message : String(err);
9662
9786
  turnLog.debug({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
@@ -9692,24 +9816,34 @@ var TurnExecution = class {
9692
9816
  preparingTimer.unref?.();
9693
9817
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9694
9818
  try {
9695
- const result = await runHook(prepareHook, {
9696
- workspaceId,
9697
- conversationId: payload.conversationId,
9698
- agentId: payload.agentId,
9699
- agentUsername: this.opts.agentUsername,
9700
- runtime: this.turnContext.runtime,
9701
- hostAccess: this.turnContext.policy.hostFs,
9702
- // CT317/CT319: the trigger message's referenced-entry paths — what the
9703
- // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9704
- // an older API. The conversation anchor is gone (CT319).
9705
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9706
- title: this.turnContext.conversation.title,
9707
- // CT943: the dispatching message's text — where an `env:` directive
9708
- // rides. The server has always sent the trigger body on the turn
9709
- // context (for the live feed); this is the first thing to read it as
9710
- // an INPUT, so a dispatch can ask for its environment in words.
9711
- messageBody: this.turnContext.message.body
9712
- });
9819
+ const result = await runPrepareWithReporting(
9820
+ runHook,
9821
+ prepareHook,
9822
+ {
9823
+ workspaceId,
9824
+ conversationId: payload.conversationId,
9825
+ agentId: payload.agentId,
9826
+ agentUsername: this.opts.agentUsername,
9827
+ runtime: this.turnContext.runtime,
9828
+ hostAccess: this.turnContext.policy.hostFs,
9829
+ // CT317/CT319: the trigger message's referenced-entry paths — what the
9830
+ // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9831
+ // an older API. The conversation anchor is gone (CT319).
9832
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9833
+ title: this.turnContext.conversation.title,
9834
+ // CT943: the dispatching message's text — where an `env:` directive
9835
+ // rides. The server has always sent the trigger body on the turn
9836
+ // context (for the live feed); this is the first thing to read it as
9837
+ // an INPUT, so a dispatch can ask for its environment in words.
9838
+ messageBody: this.turnContext.message.body
9839
+ },
9840
+ { turnId, deviceId: this.opts.deviceId },
9841
+ () => {
9842
+ turnLog.error(
9843
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9844
+ );
9845
+ }
9846
+ );
9713
9847
  clearTimeout(preparingTimer);
9714
9848
  if (preparingStarted) reportPreparing("done");
9715
9849
  writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
@@ -10870,7 +11004,7 @@ var CompanionSupervisor = class {
10870
11004
  async start() {
10871
11005
  this.log.info(
10872
11006
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
10873
- `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
11007
+ `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname3()}`
10874
11008
  );
10875
11009
  const startupHarnessRefresh = this.refreshHarnessStatuses();
10876
11010
  this.trackHeartbeat(startupHarnessRefresh);
@@ -10889,7 +11023,7 @@ var CompanionSupervisor = class {
10889
11023
  token: this.config.deviceToken,
10890
11024
  log: this.log,
10891
11025
  lastEventId: null,
10892
- onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`),
11026
+ onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname3()}`),
10893
11027
  onMessage: async (ev) => {
10894
11028
  if (ev.event === "assignments_changed") {
10895
11029
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
@@ -11305,6 +11439,7 @@ var CompanionSupervisor = class {
11305
11439
  });
11306
11440
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
11307
11441
  return new Dispatcher({
11442
+ deviceId: this.deviceId ?? void 0,
11308
11443
  api: ctx.api,
11309
11444
  baseUrl: ctx.baseUrl,
11310
11445
  workspaceId: ctx.workspaceId,
@@ -11952,9 +12087,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11952
12087
  }
11953
12088
  function defaultReexec() {
11954
12089
  clearRuntimeState();
11955
- void import("child_process").then(({ spawn: spawn6 }) => {
12090
+ void import("child_process").then(({ spawn: spawn7 }) => {
11956
12091
  try {
11957
- const child = spawn6(process.execPath, process.argv.slice(1), {
12092
+ const child = spawn7(process.execPath, process.argv.slice(1), {
11958
12093
  stdio: "inherit",
11959
12094
  detached: false
11960
12095
  });
@@ -12209,7 +12344,7 @@ async function closeSurfaces(control, dashboard) {
12209
12344
  }
12210
12345
 
12211
12346
  // src/commands/daemon.ts
12212
- import { spawn as spawn5 } from "child_process";
12347
+ import { spawn as spawn6 } from "child_process";
12213
12348
  import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync3 } from "fs";
12214
12349
 
12215
12350
  // src/cli-entry.ts
@@ -12321,7 +12456,7 @@ function defaultSpawnDetached(args) {
12321
12456
  mkdirSync12(cabaneDir(), { recursive: true });
12322
12457
  const logFd = openSync3(companionLogPath(), "a");
12323
12458
  try {
12324
- return spawn5(process.execPath, [cliPath, ...args], {
12459
+ return spawn6(process.execPath, [cliPath, ...args], {
12325
12460
  detached: true,
12326
12461
  stdio: ["ignore", logFd, logFd],
12327
12462
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -12489,7 +12624,7 @@ function prePairFoundPhrase(harness) {
12489
12624
  }
12490
12625
  async function pairHere(opts, interactive) {
12491
12626
  const baseUrl = resolvePairBaseUrl(opts.server);
12492
- const label = deviceLabelFromHostname(hostname3());
12627
+ const label = deviceLabelFromHostname(hostname4());
12493
12628
  const aborter = new AbortController();
12494
12629
  const onSigint = () => aborter.abort();
12495
12630
  process.on("SIGINT", onSigint);
@@ -76,7 +76,15 @@ var prepareHookSchema = z.object({
76
76
  //
77
77
  // Default OFF, and deliberately: an operator hook that provisions
78
78
  // unconditionally must not start running on every turn because it upgraded.
79
- validateCached: z.boolean().optional()
79
+ validateCached: z.boolean().optional(),
80
+ // Independent operator-owned reporter. Receives sanitized failure metadata
81
+ // on stdin; never re-enters this hook and cannot change the turn outcome.
82
+ failureReporter: z.object({
83
+ command: z.string().min(1),
84
+ args: z.array(z.string()).optional(),
85
+ env: z.record(z.string(), z.string()).optional(),
86
+ timeoutMs: z.number().int().positive().max(3e4).optional()
87
+ }).strict().optional()
80
88
  }).strict();
81
89
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
82
90
  var prepareResultSchema = z.object({
package/dist/runtime.js CHANGED
@@ -85,7 +85,15 @@ var prepareHookSchema = z.object({
85
85
  //
86
86
  // Default OFF, and deliberately: an operator hook that provisions
87
87
  // unconditionally must not start running on every turn because it upgraded.
88
- validateCached: z.boolean().optional()
88
+ validateCached: z.boolean().optional(),
89
+ // Independent operator-owned reporter. Receives sanitized failure metadata
90
+ // on stdin; never re-enters this hook and cannot change the turn outcome.
91
+ failureReporter: z.object({
92
+ command: z.string().min(1),
93
+ args: z.array(z.string()).optional(),
94
+ env: z.record(z.string(), z.string()).optional(),
95
+ timeoutMs: z.number().int().positive().max(3e4).optional()
96
+ }).strict().optional()
89
97
  }).strict();
90
98
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
91
99
  var prepareResultSchema = z.object({
@@ -1996,7 +2004,7 @@ async function verifyRuntime(state, requestImpl = controlRequest) {
1996
2004
  }
1997
2005
 
1998
2006
  // src/supervisor.ts
1999
- import { hostname } from "os";
2007
+ import { hostname as hostname2 } from "os";
2000
2008
 
2001
2009
  // packages/agent-runtime/src/failure.ts
2002
2010
  import { z as z3 } from "zod";
@@ -3796,6 +3804,15 @@ var MAX_ASK_QUESTION_BYTES = 400;
3796
3804
  var MAX_ASK_HEADLINE_BYTES = 120;
3797
3805
  var MAX_ASK_ITEMS = 20;
3798
3806
  var MAX_ASK_OPTION_BYTES = 200;
3807
+ var TASK_STAGES = [
3808
+ "proposed",
3809
+ "todo",
3810
+ "in_progress",
3811
+ "in_review",
3812
+ "done",
3813
+ "dropped",
3814
+ "deferred"
3815
+ ];
3799
3816
  var MIN_ASK_OPTIONS = 2;
3800
3817
  var MAX_ASK_OPTIONS = 4;
3801
3818
  var MAX_WAKE_NOTE_BYTES = 2e3;
@@ -3820,11 +3837,19 @@ var TURN_CONTROL_FIELD_DESCRIPTIONS = {
3820
3837
  targetUserId: "The workspace member (human) to ask \u2014 a user id, from your turn context's roster.",
3821
3838
  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\`. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
3822
3839
  headline: `SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in \`?\` ("Should Ledger deploy once Mia approves?"), 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. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
3823
- options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they".`,
3840
+ options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they". An option may instead be \`{ label, move: { task, status } }\` \u2014 a MOVE of a task: clicking it is the person making that move, and the task's next stage owner is told for you. A wrapper carrying a move is ONE question about ONE task, and you are NOT woken when the move applies.`,
3824
3841
  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.`,
3825
3842
  "questions.headline": `The one-sentence question ("Should Ledger deploy once Mia approves?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
3826
3843
  "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters. NOT a report. Say "I" for yourself; name anyone else described, never "you", "we" or "they".`,
3827
- "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they".`,
3844
+ "options.label": `What the option SAYS. The destination stage is drawn beside it as a pill, so the label is the decision (\`Accept\`), not a restatement of the move.`,
3845
+ "options.move": `Makes this option a MOVE of a task: clicking it is the person making that move, recorded as theirs. The next stage's owner is told for you, and you are not woken.`,
3846
+ "options.move.task": `The task the move applies to \u2014 its reference (\`CT12\`) or its uuid. Every move in one ask names the SAME task.`,
3847
+ "options.move.status": `The stage the task goes to when the person clicks this option. A move the task cannot make from where it is now is refused when you raise the ask, by name.`,
3848
+ "questions.options.label": `What the option SAYS. The destination stage is drawn beside it as a pill, so the label is the decision (\`Accept\`), not a restatement of the move.`,
3849
+ "questions.options.move": `See \`options.move\`. An ask carrying a move has exactly one question.`,
3850
+ "questions.options.move.task": `The task the move applies to \u2014 its reference (\`CT12\`) or its uuid. Every move in one ask names the SAME task.`,
3851
+ "questions.options.move.status": `The stage the task goes to when the person clicks this option. A move the task cannot make from where it is now is refused when you raise the ask, by name.`,
3852
+ "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must stand alone: "I" for yourself, anyone else responsible for its action by name \u2014 never "you", "we" or "they". An option may instead be \`{ label, move: { task, status } }\` \u2014 see the single-question form; an ask carrying a move has exactly one question.`,
3828
3853
  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.",
3829
3854
  "replaces.askId": "The id of the open ask to withdraw.",
3830
3855
  "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.`
@@ -6062,10 +6087,15 @@ var CODEX_ADDENDUM = [
6062
6087
  "absent without attempting discovery and invocation, and if a call fails, report the recorded",
6063
6088
  "error.",
6064
6089
  turnControlPromptGuidance("mcp__cabane_companion__"),
6065
- "The ancillary tools are",
6066
- "`mcp__cabane__list_workspaces` /",
6067
- "`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
6068
- "possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
6090
+ // CT1552: every ancillary tool is written the way Codex's own tool list shows
6091
+ // it — qualified. The bare `read_binary` / `upload` / … this list used to mix
6092
+ // in sat two lines under the instruction to invoke the exact qualified names,
6093
+ // so the sentence contradicted itself. The bare `query` / `mutate` below stay:
6094
+ // that sentence is about operation names, after the qualified forms are given.
6095
+ "The ancillary tools are `mcp__cabane__list_workspaces`, `mcp__cabane__read_binary`,",
6096
+ "`mcp__cabane__upload`, `mcp__cabane__begin_upload`, `mcp__cabane__finalize_upload` and",
6097
+ "`mcp__cabane__mint_render_token` \u2014 all possibly deferred too.",
6098
+ "There is no Cabane `read`/`write`/`search` tool \u2014 those are",
6069
6099
  "operation NAMES you pass to `query` / `mutate`. One more harness fact: you can interleave",
6070
6100
  "narration",
6071
6101
  "with tool calls, but your final message is the turn's answer \u2014 write your",
@@ -7811,12 +7841,34 @@ function createTurnControlMcpServer(acts, skipState) {
7811
7841
  targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
7812
7842
  question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
7813
7843
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
7814
- 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),
7844
+ options: z13.array(
7845
+ z13.union([
7846
+ z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
7847
+ z13.object({
7848
+ label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
7849
+ move: z13.object({
7850
+ task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
7851
+ status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
7852
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
7853
+ })
7854
+ ])
7855
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7815
7856
  questions: z13.array(
7816
7857
  z13.object({
7817
7858
  headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
7818
7859
  body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
7819
- 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"])
7860
+ options: z13.array(
7861
+ z13.union([
7862
+ z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
7863
+ z13.object({
7864
+ label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
7865
+ move: z13.object({
7866
+ task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
7867
+ status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
7868
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
7869
+ })
7870
+ ])
7871
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7820
7872
  })
7821
7873
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
7822
7874
  replaces: z13.object({
@@ -8027,6 +8079,68 @@ function readOutboxFloor(read, turnId, log) {
8027
8079
  }
8028
8080
  }
8029
8081
 
8082
+ // src/prepare-failure.ts
8083
+ import { spawn as spawn4 } from "child_process";
8084
+ import { hostname } from "os";
8085
+ function prepareFailureCause(error) {
8086
+ const message = error instanceof Error ? error.message : String(error);
8087
+ if (/timed out/i.test(message)) return "prepare hook timed out";
8088
+ if (/MODULE_NOT_FOUND|Cannot find (?:module|package)/.test(message))
8089
+ return "prepare hook module missing; check the configured command and its installation for this device";
8090
+ if (/not valid JSON|JSON must carry|produced no output/.test(message))
8091
+ return "prepare hook returned malformed output; expected a cwd path or JSON with a non-empty cwd";
8092
+ if (/ENOENT/.test(message)) return "prepare hook executable missing (ENOENT)";
8093
+ if (/EACCES/.test(message)) return "prepare hook executable is not accessible (EACCES)";
8094
+ const exit = message.match(/exited with code (\d+)/)?.[1];
8095
+ return exit ? `prepare hook exited with code ${exit}` : "prepare hook failed; inspect the device-local hook log";
8096
+ }
8097
+ async function runPrepareWithReporting(runner, hook, input, context, reportError) {
8098
+ try {
8099
+ return await runner(hook, input);
8100
+ } catch (error) {
8101
+ if (hook.failureReporter) {
8102
+ try {
8103
+ await reportFailure(hook.failureReporter, {
8104
+ workspaceId: input.workspaceId,
8105
+ conversationId: input.conversationId,
8106
+ agentId: input.agentId,
8107
+ agentUsername: input.agentUsername,
8108
+ turnId: context.turnId,
8109
+ deviceId: context.deviceId ?? hostname(),
8110
+ cause: prepareFailureCause(error)
8111
+ });
8112
+ } catch {
8113
+ reportError();
8114
+ }
8115
+ }
8116
+ throw error;
8117
+ }
8118
+ }
8119
+ function reportFailure(config, body) {
8120
+ return new Promise((resolve2, reject) => {
8121
+ const child = spawn4(config.command, config.args ?? [], {
8122
+ stdio: ["pipe", "ignore", "ignore"],
8123
+ env: { ...process.env, ...config.env }
8124
+ });
8125
+ const timer = setTimeout(() => {
8126
+ child.kill("SIGKILL");
8127
+ reject(new Error("reporter timeout"));
8128
+ }, config.timeoutMs ?? 15e3);
8129
+ child.stdin.on("error", () => {
8130
+ });
8131
+ child.once("error", () => {
8132
+ clearTimeout(timer);
8133
+ reject(new Error("reporter failed to start"));
8134
+ });
8135
+ child.once("close", (code) => {
8136
+ clearTimeout(timer);
8137
+ if (code === 0) resolve2();
8138
+ else reject(new Error("reporter failed"));
8139
+ });
8140
+ child.stdin.end(JSON.stringify(body));
8141
+ });
8142
+ }
8143
+
8030
8144
  // src/prepared.ts
8031
8145
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
8032
8146
  import { join as join12 } from "path";
@@ -8257,7 +8371,7 @@ function pruneOld(dir2, retain) {
8257
8371
  }
8258
8372
 
8259
8373
  // src/turn-containment.ts
8260
- import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8374
+ import { execFile, execFileSync, spawn as spawn5 } from "child_process";
8261
8375
  import { readFileSync as readFileSync8 } from "fs";
8262
8376
  import { promisify } from "util";
8263
8377
  var execFileAsync = promisify(execFile);
@@ -8339,7 +8453,7 @@ function createTurnContainment(turnId, deps = {}) {
8339
8453
  command: options.command
8340
8454
  });
8341
8455
  return asContained(
8342
- spawn4(
8456
+ spawn5(
8343
8457
  "systemd-run",
8344
8458
  [
8345
8459
  "--user",
@@ -8366,7 +8480,7 @@ function createTurnContainment(turnId, deps = {}) {
8366
8480
  );
8367
8481
  }
8368
8482
  function spawnInGroup(options) {
8369
- const child = spawn4(options.command, options.args, {
8483
+ const child = spawn5(options.command, options.args, {
8370
8484
  ...options.cwd ? { cwd: options.cwd } : {},
8371
8485
  env: options.env,
8372
8486
  stdio: ["pipe", "pipe", "pipe"],
@@ -9068,18 +9182,28 @@ var TurnExecution = class {
9068
9182
  if (prepareHook.validateCached) {
9069
9183
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9070
9184
  try {
9071
- await runHook(prepareHook, {
9072
- workspaceId,
9073
- conversationId: payload.conversationId,
9074
- agentId: payload.agentId,
9075
- agentUsername: this.opts.agentUsername,
9076
- runtime: this.turnContext.runtime,
9077
- hostAccess: this.turnContext.policy.hostFs,
9078
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9079
- title: this.turnContext.conversation.title,
9080
- messageBody: this.turnContext.message.body,
9081
- prepared: cached2
9082
- });
9185
+ await runPrepareWithReporting(
9186
+ runHook,
9187
+ prepareHook,
9188
+ {
9189
+ workspaceId,
9190
+ conversationId: payload.conversationId,
9191
+ agentId: payload.agentId,
9192
+ agentUsername: this.opts.agentUsername,
9193
+ runtime: this.turnContext.runtime,
9194
+ hostAccess: this.turnContext.policy.hostFs,
9195
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9196
+ title: this.turnContext.conversation.title,
9197
+ messageBody: this.turnContext.message.body,
9198
+ prepared: cached2
9199
+ },
9200
+ { turnId, deviceId: this.opts.deviceId },
9201
+ () => {
9202
+ turnLog.error(
9203
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9204
+ );
9205
+ }
9206
+ );
9083
9207
  } catch (err) {
9084
9208
  const reason = err instanceof Error ? err.message : String(err);
9085
9209
  turnLog.debug({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
@@ -9115,24 +9239,34 @@ var TurnExecution = class {
9115
9239
  preparingTimer.unref?.();
9116
9240
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9117
9241
  try {
9118
- const result = await runHook(prepareHook, {
9119
- workspaceId,
9120
- conversationId: payload.conversationId,
9121
- agentId: payload.agentId,
9122
- agentUsername: this.opts.agentUsername,
9123
- runtime: this.turnContext.runtime,
9124
- hostAccess: this.turnContext.policy.hostFs,
9125
- // CT317/CT319: the trigger message's referenced-entry paths — what the
9126
- // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9127
- // an older API. The conversation anchor is gone (CT319).
9128
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9129
- title: this.turnContext.conversation.title,
9130
- // CT943: the dispatching message's text — where an `env:` directive
9131
- // rides. The server has always sent the trigger body on the turn
9132
- // context (for the live feed); this is the first thing to read it as
9133
- // an INPUT, so a dispatch can ask for its environment in words.
9134
- messageBody: this.turnContext.message.body
9135
- });
9242
+ const result = await runPrepareWithReporting(
9243
+ runHook,
9244
+ prepareHook,
9245
+ {
9246
+ workspaceId,
9247
+ conversationId: payload.conversationId,
9248
+ agentId: payload.agentId,
9249
+ agentUsername: this.opts.agentUsername,
9250
+ runtime: this.turnContext.runtime,
9251
+ hostAccess: this.turnContext.policy.hostFs,
9252
+ // CT317/CT319: the trigger message's referenced-entry paths — what the
9253
+ // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9254
+ // an older API. The conversation anchor is gone (CT319).
9255
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9256
+ title: this.turnContext.conversation.title,
9257
+ // CT943: the dispatching message's text — where an `env:` directive
9258
+ // rides. The server has always sent the trigger body on the turn
9259
+ // context (for the live feed); this is the first thing to read it as
9260
+ // an INPUT, so a dispatch can ask for its environment in words.
9261
+ messageBody: this.turnContext.message.body
9262
+ },
9263
+ { turnId, deviceId: this.opts.deviceId },
9264
+ () => {
9265
+ turnLog.error(
9266
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9267
+ );
9268
+ }
9269
+ );
9136
9270
  clearTimeout(preparingTimer);
9137
9271
  if (preparingStarted) reportPreparing("done");
9138
9272
  writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
@@ -10293,7 +10427,7 @@ var CompanionSupervisor = class {
10293
10427
  async start() {
10294
10428
  this.log.info(
10295
10429
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
10296
- `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname()}`
10430
+ `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
10297
10431
  );
10298
10432
  const startupHarnessRefresh = this.refreshHarnessStatuses();
10299
10433
  this.trackHeartbeat(startupHarnessRefresh);
@@ -10312,7 +10446,7 @@ var CompanionSupervisor = class {
10312
10446
  token: this.config.deviceToken,
10313
10447
  log: this.log,
10314
10448
  lastEventId: null,
10315
- onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname()}`),
10449
+ onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`),
10316
10450
  onMessage: async (ev) => {
10317
10451
  if (ev.event === "assignments_changed") {
10318
10452
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
@@ -10728,6 +10862,7 @@ var CompanionSupervisor = class {
10728
10862
  });
10729
10863
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
10730
10864
  return new Dispatcher({
10865
+ deviceId: this.deviceId ?? void 0,
10731
10866
  api: ctx.api,
10732
10867
  baseUrl: ctx.baseUrl,
10733
10868
  workspaceId: ctx.workspaceId,
@@ -11375,9 +11510,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11375
11510
  }
11376
11511
  function defaultReexec() {
11377
11512
  clearRuntimeState();
11378
- void import("child_process").then(({ spawn: spawn5 }) => {
11513
+ void import("child_process").then(({ spawn: spawn6 }) => {
11379
11514
  try {
11380
- const child = spawn5(process.execPath, process.argv.slice(1), {
11515
+ const child = spawn6(process.execPath, process.argv.slice(1), {
11381
11516
  stdio: "inherit",
11382
11517
  detached: false
11383
11518
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.106",
3
+ "version": "0.6.108",
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",