@cabane/companion 0.6.105 → 0.6.107

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";
@@ -7139,6 +7147,67 @@ function isStringRecord2(v) {
7139
7147
  import {
7140
7148
  Codex
7141
7149
  } from "@openai/codex-sdk";
7150
+
7151
+ // packages/agent-runtime/src/codex/config-overrides.ts
7152
+ var TOML_BARE_KEY = /^[A-Za-z0-9_-]+$/;
7153
+ function formatKey(key) {
7154
+ return TOML_BARE_KEY.test(key) ? key : JSON.stringify(key);
7155
+ }
7156
+ function isTable(value) {
7157
+ return !!value && typeof value === "object" && !Array.isArray(value);
7158
+ }
7159
+ function tomlValue(value, path) {
7160
+ if (typeof value === "string") return JSON.stringify(value);
7161
+ if (typeof value === "number") {
7162
+ if (!Number.isFinite(value)) {
7163
+ throw new Error(`Codex config override at ${path} must be a finite number`);
7164
+ }
7165
+ return `${value}`;
7166
+ }
7167
+ if (typeof value === "boolean") return value ? "true" : "false";
7168
+ if (Array.isArray(value)) {
7169
+ return `[${value.map((item, i) => tomlValue(item, `${path}[${i}]`)).join(", ")}]`;
7170
+ }
7171
+ if (isTable(value)) {
7172
+ const parts = [];
7173
+ for (const [key, child] of Object.entries(value)) {
7174
+ if (!key) throw new Error("Codex config override keys must be non-empty strings");
7175
+ if (child === void 0) continue;
7176
+ parts.push(`${formatKey(key)} = ${tomlValue(child, `${path}.${key}`)}`);
7177
+ }
7178
+ return `{${parts.join(", ")}}`;
7179
+ }
7180
+ throw new Error(`Codex config override at ${path} has an unsupported type`);
7181
+ }
7182
+ function walk(table, prefix, out) {
7183
+ const entries = Object.entries(table).filter(([, child]) => child !== void 0);
7184
+ if (entries.length === 0) {
7185
+ if (prefix) out.push(`${prefix}={}`);
7186
+ return;
7187
+ }
7188
+ for (const [key] of entries) {
7189
+ if (!key) throw new Error("Codex config override keys must be non-empty strings");
7190
+ }
7191
+ if (entries.some(([key]) => !TOML_BARE_KEY.test(key))) {
7192
+ if (!prefix) {
7193
+ throw new Error("Codex config override has a non-bare key at the root");
7194
+ }
7195
+ out.push(`${prefix}=${tomlValue(Object.fromEntries(entries), prefix)}`);
7196
+ return;
7197
+ }
7198
+ for (const [key, child] of entries) {
7199
+ const path = prefix ? `${prefix}.${key}` : key;
7200
+ if (isTable(child)) walk(child, path, out);
7201
+ else out.push(`${path}=${tomlValue(child, path)}`);
7202
+ }
7203
+ }
7204
+ function buildConfigOverrides(config) {
7205
+ const out = [];
7206
+ walk(config, "", out);
7207
+ return out;
7208
+ }
7209
+
7210
+ // packages/agent-runtime/src/codex/transport.ts
7142
7211
  function buildSdkThreadOptions(spec) {
7143
7212
  return {
7144
7213
  ...spec.model ? { model: spec.model } : {},
@@ -7155,11 +7224,17 @@ function createSdkCodexTransport(opts = {}) {
7155
7224
  const codexOptions = {
7156
7225
  ...opts.apiKey ? { apiKey: opts.apiKey } : {},
7157
7226
  ...opts.codexPathOverride ? { codexPathOverride: opts.codexPathOverride } : {},
7158
- // The `--config` overrides (MCP servers + rmcp flag). Cast at the SDK
7159
- // boundary `CodexConfig` is a TOML-shaped plain object the SDK flattens
7160
- // to dotted `--config key=value` flags (verified against the SDK's
7161
- // `serializeConfigOverrides`).
7162
- config: spec.config
7227
+ // The `--config` overrides (MCP servers + rmcp flag). CT1501: rendered to
7228
+ // finished `--config` strings by US and passed through `configOverrides`,
7229
+ // the SDK's raw passthrough NOT through `config`, which would flatten
7230
+ // them itself. A user MCP server named `my.server` cannot go in an
7231
+ // override's dotted path at all (the CLI splits the path on `.` and
7232
+ // ignores TOML quoting), so it has to ride inside an inline-table value,
7233
+ // and the SDK's flattener cannot be steered into producing one. This is
7234
+ // also what makes the PUBLISHED companion correct, where the vendored
7235
+ // patch never reached — and why that patch is gone. See
7236
+ // `config-overrides.ts`.
7237
+ configOverrides: buildConfigOverrides(spec.config)
7163
7238
  };
7164
7239
  const codex = new Codex(codexOptions);
7165
7240
  const threadOptions = buildSdkThreadOptions(spec);
@@ -8537,6 +8612,68 @@ function readOutboxFloor(read, turnId, log) {
8537
8612
  }
8538
8613
  }
8539
8614
 
8615
+ // src/prepare-failure.ts
8616
+ import { spawn as spawn4 } from "child_process";
8617
+ import { hostname as hostname2 } from "os";
8618
+ function prepareFailureCause(error) {
8619
+ const message = error instanceof Error ? error.message : String(error);
8620
+ if (/timed out/i.test(message)) return "prepare hook timed out";
8621
+ if (/MODULE_NOT_FOUND|Cannot find (?:module|package)/.test(message))
8622
+ return "prepare hook module missing; check the configured command and its installation for this device";
8623
+ if (/not valid JSON|JSON must carry|produced no output/.test(message))
8624
+ return "prepare hook returned malformed output; expected a cwd path or JSON with a non-empty cwd";
8625
+ if (/ENOENT/.test(message)) return "prepare hook executable missing (ENOENT)";
8626
+ if (/EACCES/.test(message)) return "prepare hook executable is not accessible (EACCES)";
8627
+ const exit = message.match(/exited with code (\d+)/)?.[1];
8628
+ return exit ? `prepare hook exited with code ${exit}` : "prepare hook failed; inspect the device-local hook log";
8629
+ }
8630
+ async function runPrepareWithReporting(runner, hook, input, context, reportError) {
8631
+ try {
8632
+ return await runner(hook, input);
8633
+ } catch (error) {
8634
+ if (hook.failureReporter) {
8635
+ try {
8636
+ await reportFailure(hook.failureReporter, {
8637
+ workspaceId: input.workspaceId,
8638
+ conversationId: input.conversationId,
8639
+ agentId: input.agentId,
8640
+ agentUsername: input.agentUsername,
8641
+ turnId: context.turnId,
8642
+ deviceId: context.deviceId ?? hostname2(),
8643
+ cause: prepareFailureCause(error)
8644
+ });
8645
+ } catch {
8646
+ reportError();
8647
+ }
8648
+ }
8649
+ throw error;
8650
+ }
8651
+ }
8652
+ function reportFailure(config, body) {
8653
+ return new Promise((resolve2, reject) => {
8654
+ const child = spawn4(config.command, config.args ?? [], {
8655
+ stdio: ["pipe", "ignore", "ignore"],
8656
+ env: { ...process.env, ...config.env }
8657
+ });
8658
+ const timer = setTimeout(() => {
8659
+ child.kill("SIGKILL");
8660
+ reject(new Error("reporter timeout"));
8661
+ }, config.timeoutMs ?? 15e3);
8662
+ child.stdin.on("error", () => {
8663
+ });
8664
+ child.once("error", () => {
8665
+ clearTimeout(timer);
8666
+ reject(new Error("reporter failed to start"));
8667
+ });
8668
+ child.once("close", (code) => {
8669
+ clearTimeout(timer);
8670
+ if (code === 0) resolve2();
8671
+ else reject(new Error("reporter failed"));
8672
+ });
8673
+ child.stdin.end(JSON.stringify(body));
8674
+ });
8675
+ }
8676
+
8540
8677
  // src/prepared.ts
8541
8678
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
8542
8679
  import { join as join12 } from "path";
@@ -8767,7 +8904,7 @@ function pruneOld(dir2, retain) {
8767
8904
  }
8768
8905
 
8769
8906
  // src/turn-containment.ts
8770
- import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8907
+ import { execFile, execFileSync, spawn as spawn5 } from "child_process";
8771
8908
  import { readFileSync as readFileSync8 } from "fs";
8772
8909
  import { promisify } from "util";
8773
8910
  var execFileAsync = promisify(execFile);
@@ -8849,7 +8986,7 @@ function createTurnContainment(turnId, deps = {}) {
8849
8986
  command: options.command
8850
8987
  });
8851
8988
  return asContained(
8852
- spawn4(
8989
+ spawn5(
8853
8990
  "systemd-run",
8854
8991
  [
8855
8992
  "--user",
@@ -8876,7 +9013,7 @@ function createTurnContainment(turnId, deps = {}) {
8876
9013
  );
8877
9014
  }
8878
9015
  function spawnInGroup(options) {
8879
- const child = spawn4(options.command, options.args, {
9016
+ const child = spawn5(options.command, options.args, {
8880
9017
  ...options.cwd ? { cwd: options.cwd } : {},
8881
9018
  env: options.env,
8882
9019
  stdio: ["pipe", "pipe", "pipe"],
@@ -9578,18 +9715,28 @@ var TurnExecution = class {
9578
9715
  if (prepareHook.validateCached) {
9579
9716
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9580
9717
  try {
9581
- await runHook(prepareHook, {
9582
- workspaceId,
9583
- conversationId: payload.conversationId,
9584
- agentId: payload.agentId,
9585
- agentUsername: this.opts.agentUsername,
9586
- runtime: this.turnContext.runtime,
9587
- hostAccess: this.turnContext.policy.hostFs,
9588
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9589
- title: this.turnContext.conversation.title,
9590
- messageBody: this.turnContext.message.body,
9591
- prepared: cached2
9592
- });
9718
+ await runPrepareWithReporting(
9719
+ runHook,
9720
+ prepareHook,
9721
+ {
9722
+ workspaceId,
9723
+ conversationId: payload.conversationId,
9724
+ agentId: payload.agentId,
9725
+ agentUsername: this.opts.agentUsername,
9726
+ runtime: this.turnContext.runtime,
9727
+ hostAccess: this.turnContext.policy.hostFs,
9728
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9729
+ title: this.turnContext.conversation.title,
9730
+ messageBody: this.turnContext.message.body,
9731
+ prepared: cached2
9732
+ },
9733
+ { turnId, deviceId: this.opts.deviceId },
9734
+ () => {
9735
+ turnLog.error(
9736
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9737
+ );
9738
+ }
9739
+ );
9593
9740
  } catch (err) {
9594
9741
  const reason = err instanceof Error ? err.message : String(err);
9595
9742
  turnLog.debug({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
@@ -9625,24 +9772,34 @@ var TurnExecution = class {
9625
9772
  preparingTimer.unref?.();
9626
9773
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9627
9774
  try {
9628
- const result = await runHook(prepareHook, {
9629
- workspaceId,
9630
- conversationId: payload.conversationId,
9631
- agentId: payload.agentId,
9632
- agentUsername: this.opts.agentUsername,
9633
- runtime: this.turnContext.runtime,
9634
- hostAccess: this.turnContext.policy.hostFs,
9635
- // CT317/CT319: the trigger message's referenced-entry paths — what the
9636
- // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9637
- // an older API. The conversation anchor is gone (CT319).
9638
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9639
- title: this.turnContext.conversation.title,
9640
- // CT943: the dispatching message's text where an `env:` directive
9641
- // rides. The server has always sent the trigger body on the turn
9642
- // context (for the live feed); this is the first thing to read it as
9643
- // an INPUT, so a dispatch can ask for its environment in words.
9644
- messageBody: this.turnContext.message.body
9645
- });
9775
+ const result = await runPrepareWithReporting(
9776
+ runHook,
9777
+ prepareHook,
9778
+ {
9779
+ workspaceId,
9780
+ conversationId: payload.conversationId,
9781
+ agentId: payload.agentId,
9782
+ agentUsername: this.opts.agentUsername,
9783
+ runtime: this.turnContext.runtime,
9784
+ hostAccess: this.turnContext.policy.hostFs,
9785
+ // CT317/CT319: the trigger message's referenced-entry paths — what the
9786
+ // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9787
+ // an older API. The conversation anchor is gone (CT319).
9788
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9789
+ title: this.turnContext.conversation.title,
9790
+ // CT943: the dispatching message's text where an `env:` directive
9791
+ // rides. The server has always sent the trigger body on the turn
9792
+ // context (for the live feed); this is the first thing to read it as
9793
+ // an INPUT, so a dispatch can ask for its environment in words.
9794
+ messageBody: this.turnContext.message.body
9795
+ },
9796
+ { turnId, deviceId: this.opts.deviceId },
9797
+ () => {
9798
+ turnLog.error(
9799
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9800
+ );
9801
+ }
9802
+ );
9646
9803
  clearTimeout(preparingTimer);
9647
9804
  if (preparingStarted) reportPreparing("done");
9648
9805
  writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
@@ -10803,7 +10960,7 @@ var CompanionSupervisor = class {
10803
10960
  async start() {
10804
10961
  this.log.info(
10805
10962
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
10806
- `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
10963
+ `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname3()}`
10807
10964
  );
10808
10965
  const startupHarnessRefresh = this.refreshHarnessStatuses();
10809
10966
  this.trackHeartbeat(startupHarnessRefresh);
@@ -10822,7 +10979,7 @@ var CompanionSupervisor = class {
10822
10979
  token: this.config.deviceToken,
10823
10980
  log: this.log,
10824
10981
  lastEventId: null,
10825
- onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`),
10982
+ onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname3()}`),
10826
10983
  onMessage: async (ev) => {
10827
10984
  if (ev.event === "assignments_changed") {
10828
10985
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
@@ -11238,6 +11395,7 @@ var CompanionSupervisor = class {
11238
11395
  });
11239
11396
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
11240
11397
  return new Dispatcher({
11398
+ deviceId: this.deviceId ?? void 0,
11241
11399
  api: ctx.api,
11242
11400
  baseUrl: ctx.baseUrl,
11243
11401
  workspaceId: ctx.workspaceId,
@@ -11885,9 +12043,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11885
12043
  }
11886
12044
  function defaultReexec() {
11887
12045
  clearRuntimeState();
11888
- void import("child_process").then(({ spawn: spawn6 }) => {
12046
+ void import("child_process").then(({ spawn: spawn7 }) => {
11889
12047
  try {
11890
- const child = spawn6(process.execPath, process.argv.slice(1), {
12048
+ const child = spawn7(process.execPath, process.argv.slice(1), {
11891
12049
  stdio: "inherit",
11892
12050
  detached: false
11893
12051
  });
@@ -12142,7 +12300,7 @@ async function closeSurfaces(control, dashboard) {
12142
12300
  }
12143
12301
 
12144
12302
  // src/commands/daemon.ts
12145
- import { spawn as spawn5 } from "child_process";
12303
+ import { spawn as spawn6 } from "child_process";
12146
12304
  import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync3 } from "fs";
12147
12305
 
12148
12306
  // src/cli-entry.ts
@@ -12254,7 +12412,7 @@ function defaultSpawnDetached(args) {
12254
12412
  mkdirSync12(cabaneDir(), { recursive: true });
12255
12413
  const logFd = openSync3(companionLogPath(), "a");
12256
12414
  try {
12257
- return spawn5(process.execPath, [cliPath, ...args], {
12415
+ return spawn6(process.execPath, [cliPath, ...args], {
12258
12416
  detached: true,
12259
12417
  stdio: ["ignore", logFd, logFd],
12260
12418
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -12422,7 +12580,7 @@ function prePairFoundPhrase(harness) {
12422
12580
  }
12423
12581
  async function pairHere(opts, interactive) {
12424
12582
  const baseUrl = resolvePairBaseUrl(opts.server);
12425
- const label = deviceLabelFromHostname(hostname3());
12583
+ const label = deviceLabelFromHostname(hostname4());
12426
12584
  const aborter = new AbortController();
12427
12585
  const onSigint = () => aborter.abort();
12428
12586
  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";
@@ -6552,6 +6560,67 @@ function isStringRecord2(v) {
6552
6560
  import {
6553
6561
  Codex
6554
6562
  } from "@openai/codex-sdk";
6563
+
6564
+ // packages/agent-runtime/src/codex/config-overrides.ts
6565
+ var TOML_BARE_KEY = /^[A-Za-z0-9_-]+$/;
6566
+ function formatKey(key) {
6567
+ return TOML_BARE_KEY.test(key) ? key : JSON.stringify(key);
6568
+ }
6569
+ function isTable(value) {
6570
+ return !!value && typeof value === "object" && !Array.isArray(value);
6571
+ }
6572
+ function tomlValue(value, path) {
6573
+ if (typeof value === "string") return JSON.stringify(value);
6574
+ if (typeof value === "number") {
6575
+ if (!Number.isFinite(value)) {
6576
+ throw new Error(`Codex config override at ${path} must be a finite number`);
6577
+ }
6578
+ return `${value}`;
6579
+ }
6580
+ if (typeof value === "boolean") return value ? "true" : "false";
6581
+ if (Array.isArray(value)) {
6582
+ return `[${value.map((item, i) => tomlValue(item, `${path}[${i}]`)).join(", ")}]`;
6583
+ }
6584
+ if (isTable(value)) {
6585
+ const parts = [];
6586
+ for (const [key, child] of Object.entries(value)) {
6587
+ if (!key) throw new Error("Codex config override keys must be non-empty strings");
6588
+ if (child === void 0) continue;
6589
+ parts.push(`${formatKey(key)} = ${tomlValue(child, `${path}.${key}`)}`);
6590
+ }
6591
+ return `{${parts.join(", ")}}`;
6592
+ }
6593
+ throw new Error(`Codex config override at ${path} has an unsupported type`);
6594
+ }
6595
+ function walk(table, prefix, out) {
6596
+ const entries = Object.entries(table).filter(([, child]) => child !== void 0);
6597
+ if (entries.length === 0) {
6598
+ if (prefix) out.push(`${prefix}={}`);
6599
+ return;
6600
+ }
6601
+ for (const [key] of entries) {
6602
+ if (!key) throw new Error("Codex config override keys must be non-empty strings");
6603
+ }
6604
+ if (entries.some(([key]) => !TOML_BARE_KEY.test(key))) {
6605
+ if (!prefix) {
6606
+ throw new Error("Codex config override has a non-bare key at the root");
6607
+ }
6608
+ out.push(`${prefix}=${tomlValue(Object.fromEntries(entries), prefix)}`);
6609
+ return;
6610
+ }
6611
+ for (const [key, child] of entries) {
6612
+ const path = prefix ? `${prefix}.${key}` : key;
6613
+ if (isTable(child)) walk(child, path, out);
6614
+ else out.push(`${path}=${tomlValue(child, path)}`);
6615
+ }
6616
+ }
6617
+ function buildConfigOverrides(config) {
6618
+ const out = [];
6619
+ walk(config, "", out);
6620
+ return out;
6621
+ }
6622
+
6623
+ // packages/agent-runtime/src/codex/transport.ts
6555
6624
  function buildSdkThreadOptions(spec) {
6556
6625
  return {
6557
6626
  ...spec.model ? { model: spec.model } : {},
@@ -6568,11 +6637,17 @@ function createSdkCodexTransport(opts = {}) {
6568
6637
  const codexOptions = {
6569
6638
  ...opts.apiKey ? { apiKey: opts.apiKey } : {},
6570
6639
  ...opts.codexPathOverride ? { codexPathOverride: opts.codexPathOverride } : {},
6571
- // The `--config` overrides (MCP servers + rmcp flag). Cast at the SDK
6572
- // boundary `CodexConfig` is a TOML-shaped plain object the SDK flattens
6573
- // to dotted `--config key=value` flags (verified against the SDK's
6574
- // `serializeConfigOverrides`).
6575
- config: spec.config
6640
+ // The `--config` overrides (MCP servers + rmcp flag). CT1501: rendered to
6641
+ // finished `--config` strings by US and passed through `configOverrides`,
6642
+ // the SDK's raw passthrough NOT through `config`, which would flatten
6643
+ // them itself. A user MCP server named `my.server` cannot go in an
6644
+ // override's dotted path at all (the CLI splits the path on `.` and
6645
+ // ignores TOML quoting), so it has to ride inside an inline-table value,
6646
+ // and the SDK's flattener cannot be steered into producing one. This is
6647
+ // also what makes the PUBLISHED companion correct, where the vendored
6648
+ // patch never reached — and why that patch is gone. See
6649
+ // `config-overrides.ts`.
6650
+ configOverrides: buildConfigOverrides(spec.config)
6576
6651
  };
6577
6652
  const codex = new Codex(codexOptions);
6578
6653
  const threadOptions = buildSdkThreadOptions(spec);
@@ -7960,6 +8035,68 @@ function readOutboxFloor(read, turnId, log) {
7960
8035
  }
7961
8036
  }
7962
8037
 
8038
+ // src/prepare-failure.ts
8039
+ import { spawn as spawn4 } from "child_process";
8040
+ import { hostname } from "os";
8041
+ function prepareFailureCause(error) {
8042
+ const message = error instanceof Error ? error.message : String(error);
8043
+ if (/timed out/i.test(message)) return "prepare hook timed out";
8044
+ if (/MODULE_NOT_FOUND|Cannot find (?:module|package)/.test(message))
8045
+ return "prepare hook module missing; check the configured command and its installation for this device";
8046
+ if (/not valid JSON|JSON must carry|produced no output/.test(message))
8047
+ return "prepare hook returned malformed output; expected a cwd path or JSON with a non-empty cwd";
8048
+ if (/ENOENT/.test(message)) return "prepare hook executable missing (ENOENT)";
8049
+ if (/EACCES/.test(message)) return "prepare hook executable is not accessible (EACCES)";
8050
+ const exit = message.match(/exited with code (\d+)/)?.[1];
8051
+ return exit ? `prepare hook exited with code ${exit}` : "prepare hook failed; inspect the device-local hook log";
8052
+ }
8053
+ async function runPrepareWithReporting(runner, hook, input, context, reportError) {
8054
+ try {
8055
+ return await runner(hook, input);
8056
+ } catch (error) {
8057
+ if (hook.failureReporter) {
8058
+ try {
8059
+ await reportFailure(hook.failureReporter, {
8060
+ workspaceId: input.workspaceId,
8061
+ conversationId: input.conversationId,
8062
+ agentId: input.agentId,
8063
+ agentUsername: input.agentUsername,
8064
+ turnId: context.turnId,
8065
+ deviceId: context.deviceId ?? hostname(),
8066
+ cause: prepareFailureCause(error)
8067
+ });
8068
+ } catch {
8069
+ reportError();
8070
+ }
8071
+ }
8072
+ throw error;
8073
+ }
8074
+ }
8075
+ function reportFailure(config, body) {
8076
+ return new Promise((resolve2, reject) => {
8077
+ const child = spawn4(config.command, config.args ?? [], {
8078
+ stdio: ["pipe", "ignore", "ignore"],
8079
+ env: { ...process.env, ...config.env }
8080
+ });
8081
+ const timer = setTimeout(() => {
8082
+ child.kill("SIGKILL");
8083
+ reject(new Error("reporter timeout"));
8084
+ }, config.timeoutMs ?? 15e3);
8085
+ child.stdin.on("error", () => {
8086
+ });
8087
+ child.once("error", () => {
8088
+ clearTimeout(timer);
8089
+ reject(new Error("reporter failed to start"));
8090
+ });
8091
+ child.once("close", (code) => {
8092
+ clearTimeout(timer);
8093
+ if (code === 0) resolve2();
8094
+ else reject(new Error("reporter failed"));
8095
+ });
8096
+ child.stdin.end(JSON.stringify(body));
8097
+ });
8098
+ }
8099
+
7963
8100
  // src/prepared.ts
7964
8101
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7965
8102
  import { join as join12 } from "path";
@@ -8190,7 +8327,7 @@ function pruneOld(dir2, retain) {
8190
8327
  }
8191
8328
 
8192
8329
  // src/turn-containment.ts
8193
- import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8330
+ import { execFile, execFileSync, spawn as spawn5 } from "child_process";
8194
8331
  import { readFileSync as readFileSync8 } from "fs";
8195
8332
  import { promisify } from "util";
8196
8333
  var execFileAsync = promisify(execFile);
@@ -8272,7 +8409,7 @@ function createTurnContainment(turnId, deps = {}) {
8272
8409
  command: options.command
8273
8410
  });
8274
8411
  return asContained(
8275
- spawn4(
8412
+ spawn5(
8276
8413
  "systemd-run",
8277
8414
  [
8278
8415
  "--user",
@@ -8299,7 +8436,7 @@ function createTurnContainment(turnId, deps = {}) {
8299
8436
  );
8300
8437
  }
8301
8438
  function spawnInGroup(options) {
8302
- const child = spawn4(options.command, options.args, {
8439
+ const child = spawn5(options.command, options.args, {
8303
8440
  ...options.cwd ? { cwd: options.cwd } : {},
8304
8441
  env: options.env,
8305
8442
  stdio: ["pipe", "pipe", "pipe"],
@@ -9001,18 +9138,28 @@ var TurnExecution = class {
9001
9138
  if (prepareHook.validateCached) {
9002
9139
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9003
9140
  try {
9004
- await runHook(prepareHook, {
9005
- workspaceId,
9006
- conversationId: payload.conversationId,
9007
- agentId: payload.agentId,
9008
- agentUsername: this.opts.agentUsername,
9009
- runtime: this.turnContext.runtime,
9010
- hostAccess: this.turnContext.policy.hostFs,
9011
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9012
- title: this.turnContext.conversation.title,
9013
- messageBody: this.turnContext.message.body,
9014
- prepared: cached2
9015
- });
9141
+ await runPrepareWithReporting(
9142
+ runHook,
9143
+ prepareHook,
9144
+ {
9145
+ workspaceId,
9146
+ conversationId: payload.conversationId,
9147
+ agentId: payload.agentId,
9148
+ agentUsername: this.opts.agentUsername,
9149
+ runtime: this.turnContext.runtime,
9150
+ hostAccess: this.turnContext.policy.hostFs,
9151
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9152
+ title: this.turnContext.conversation.title,
9153
+ messageBody: this.turnContext.message.body,
9154
+ prepared: cached2
9155
+ },
9156
+ { turnId, deviceId: this.opts.deviceId },
9157
+ () => {
9158
+ turnLog.error(
9159
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9160
+ );
9161
+ }
9162
+ );
9016
9163
  } catch (err) {
9017
9164
  const reason = err instanceof Error ? err.message : String(err);
9018
9165
  turnLog.debug({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
@@ -9048,24 +9195,34 @@ var TurnExecution = class {
9048
9195
  preparingTimer.unref?.();
9049
9196
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9050
9197
  try {
9051
- const result = await runHook(prepareHook, {
9052
- workspaceId,
9053
- conversationId: payload.conversationId,
9054
- agentId: payload.agentId,
9055
- agentUsername: this.opts.agentUsername,
9056
- runtime: this.turnContext.runtime,
9057
- hostAccess: this.turnContext.policy.hostFs,
9058
- // CT317/CT319: the trigger message's referenced-entry paths — what the
9059
- // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9060
- // an older API. The conversation anchor is gone (CT319).
9061
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9062
- title: this.turnContext.conversation.title,
9063
- // CT943: the dispatching message's text where an `env:` directive
9064
- // rides. The server has always sent the trigger body on the turn
9065
- // context (for the live feed); this is the first thing to read it as
9066
- // an INPUT, so a dispatch can ask for its environment in words.
9067
- messageBody: this.turnContext.message.body
9068
- });
9198
+ const result = await runPrepareWithReporting(
9199
+ runHook,
9200
+ prepareHook,
9201
+ {
9202
+ workspaceId,
9203
+ conversationId: payload.conversationId,
9204
+ agentId: payload.agentId,
9205
+ agentUsername: this.opts.agentUsername,
9206
+ runtime: this.turnContext.runtime,
9207
+ hostAccess: this.turnContext.policy.hostFs,
9208
+ // CT317/CT319: the trigger message's referenced-entry paths — what the
9209
+ // tasker prepare hook keys its per-task env off. Defaults to `[]` for
9210
+ // an older API. The conversation anchor is gone (CT319).
9211
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9212
+ title: this.turnContext.conversation.title,
9213
+ // CT943: the dispatching message's text where an `env:` directive
9214
+ // rides. The server has always sent the trigger body on the turn
9215
+ // context (for the live feed); this is the first thing to read it as
9216
+ // an INPUT, so a dispatch can ask for its environment in words.
9217
+ messageBody: this.turnContext.message.body
9218
+ },
9219
+ { turnId, deviceId: this.opts.deviceId },
9220
+ () => {
9221
+ turnLog.error(
9222
+ "dispatcher: prepare failure reporter failed; original failure preserved"
9223
+ );
9224
+ }
9225
+ );
9069
9226
  clearTimeout(preparingTimer);
9070
9227
  if (preparingStarted) reportPreparing("done");
9071
9228
  writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
@@ -10226,7 +10383,7 @@ var CompanionSupervisor = class {
10226
10383
  async start() {
10227
10384
  this.log.info(
10228
10385
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
10229
- `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname()}`
10386
+ `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
10230
10387
  );
10231
10388
  const startupHarnessRefresh = this.refreshHarnessStatuses();
10232
10389
  this.trackHeartbeat(startupHarnessRefresh);
@@ -10245,7 +10402,7 @@ var CompanionSupervisor = class {
10245
10402
  token: this.config.deviceToken,
10246
10403
  log: this.log,
10247
10404
  lastEventId: null,
10248
- onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname()}`),
10405
+ onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`),
10249
10406
  onMessage: async (ev) => {
10250
10407
  if (ev.event === "assignments_changed") {
10251
10408
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
@@ -10661,6 +10818,7 @@ var CompanionSupervisor = class {
10661
10818
  });
10662
10819
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
10663
10820
  return new Dispatcher({
10821
+ deviceId: this.deviceId ?? void 0,
10664
10822
  api: ctx.api,
10665
10823
  baseUrl: ctx.baseUrl,
10666
10824
  workspaceId: ctx.workspaceId,
@@ -11308,9 +11466,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11308
11466
  }
11309
11467
  function defaultReexec() {
11310
11468
  clearRuntimeState();
11311
- void import("child_process").then(({ spawn: spawn5 }) => {
11469
+ void import("child_process").then(({ spawn: spawn6 }) => {
11312
11470
  try {
11313
- const child = spawn5(process.execPath, process.argv.slice(1), {
11471
+ const child = spawn6(process.execPath, process.argv.slice(1), {
11314
11472
  stdio: "inherit",
11315
11473
  detached: false
11316
11474
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.105",
3
+ "version": "0.6.107",
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",
@@ -29,8 +29,8 @@
29
29
  "prepublishOnly": "pnpm build"
30
30
  },
31
31
  "dependencies": {
32
- "@anthropic-ai/claude-agent-sdk": "0.3.263",
33
- "@openai/codex-sdk": "0.153.4",
32
+ "@anthropic-ai/claude-agent-sdk": "0.3.270",
33
+ "@openai/codex-sdk": "0.154.0",
34
34
  "@hono/node-server": "1.19.14",
35
35
  "@inquirer/prompts": "7.10.1",
36
36
  "commander": "12.1.0",