@cabane/companion 0.6.21 → 0.6.23

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/README.md CHANGED
@@ -142,7 +142,7 @@ A `coding` agent needs a directory to run in — a path that only exists on your
142
142
  "agents": {
143
143
  "my-coding-agent": {
144
144
  "cwd": "/Users/you/code/project", // working directory the agent runs in
145
- "prepareHook": "git -C /Users/you/code/project pull --ff-only", // optional: runs before each turn
145
+ "prepareHook": { "command": "/Users/you/bin/prepare-turn" }, // optional: resolves the cwd before each turn
146
146
  },
147
147
  },
148
148
  }
@@ -150,6 +150,19 @@ A `coding` agent needs a directory to run in — a path that only exists on your
150
150
 
151
151
  A malformed `agents` block fails `cabane-companion start` immediately with a message pointing at the exact field — it won't silently fall back.
152
152
 
153
+ **A hook that is the same for every agent goes at the top level instead.** If your hook works out the directory itself — from the conversation and agent ids the Companion hands it — then listing every agent by name is a roster you have to remember to update, and an agent you forget gets no hook at all and starts its turn wherever the Companion happens to be. So `prepareHook` can sit beside `agents` rather than inside it:
154
+
155
+ ```jsonc
156
+ {
157
+ "prepareHook": { "command": "/Users/you/bin/prepare-turn" }, // every agent on this device
158
+ "agents": {
159
+ "special-case": { "prepareHook": { "command": "/Users/you/bin/other-hook" } }, // wins for this one
160
+ },
161
+ }
162
+ ```
163
+
164
+ The agent's own entry wins when it names a hook; otherwise the device-level one applies. It survives a re-pair along with the rest of your local settings, and `cabane-companion status` prints it. A hook that fails still fails the turn loudly either way.
165
+
153
166
  ### Auto-memory (off by default)
154
167
 
155
168
  Claude Code's **auto-memory** — where the agent writes notes to a local `~/.claude` memory directory and recalls them on later turns — is **off by default** on every Companion. The stance is that memory-shaped things belong in your Cabane workspace (a shared, versioned `ABOUT.md`-style file or doc), not a private local directory that no one else can see. If you run your own Companion and want your normal Claude Code memory workflow back, set `claudeCode.autoMemory: true` on the agent — the Companion then stops overriding it and your own `~/.claude/settings.json` governs auto-memory as usual (this bites in `coding` mode, where your project settings are read):
package/dist/cli.js CHANGED
@@ -278,6 +278,22 @@ var companionConfigSchema = z2.object({
278
278
  // agentId / username / `slug/username`. Hand-added by the operator; the companion
279
279
  // never writes this (it only persists credentials + the device, elsewhere).
280
280
  agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
281
+ // CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
282
+ // assigned to this device, unless that agent's own `agents` row names a hook of
283
+ // its own.
284
+ //
285
+ // Until now `prepareHook` existed only under `agents[<id>]`, which made the
286
+ // config a second silent roster: a device whose provisioning is genuinely
287
+ // per-device (ours resolves a directory from the conversation and agent ids the
288
+ // companion already hands the hook) still had to list every agent by id, and an
289
+ // agent nobody remembered to add got no hook at all — no error, no log, just a
290
+ // turn that started in the operator's `$HOME`. That is how @dale woke in
291
+ // `/home/joshed` on 2026-08-17 with a perfectly correct dispatch.
292
+ //
293
+ // Absent → nothing changes for anyone, which is why this can land inert: a
294
+ // device with no device-level entry behaves exactly as it did before. A hook
295
+ // that fails still fails the dispatch loudly, as now.
296
+ prepareHook: prepareHookSchema.optional(),
281
297
  // Dashboard settings (all optional). dashboardPort: preferred bind port (next
282
298
  // free one if taken); autoOpen: whether `start` opens the browser (the
283
299
  // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
@@ -340,7 +356,9 @@ function migrateConnectedHarnesses(cfg, claudeOnPath2) {
340
356
  }
341
357
  function localAgentConfig(cfg, agent) {
342
358
  const map = cfg.agents ?? {};
343
- return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
359
+ const row = map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
360
+ if (!cfg.prepareHook || row.prepareHook) return row;
361
+ return { ...row, prepareHook: cfg.prepareHook };
344
362
  }
345
363
  function loadConfig() {
346
364
  const path = configPath();
@@ -400,10 +418,11 @@ function loadConfigTolerant() {
400
418
  }
401
419
  const strict = companionConfigSchema.safeParse(parsed);
402
420
  if (strict.success) {
403
- const { agents: agents2, dashboardPort, autoOpen, logLevel, claudeCode: claudeCode2 } = strict.data;
421
+ const { agents: agents2, prepareHook: prepareHook2, dashboardPort, autoOpen, logLevel, claudeCode: claudeCode2 } = strict.data;
404
422
  return {
405
423
  local: {
406
424
  ...agents2 !== void 0 ? { agents: agents2 } : {},
425
+ ...prepareHook2 !== void 0 ? { prepareHook: prepareHook2 } : {},
407
426
  ...dashboardPort !== void 0 ? { dashboardPort } : {},
408
427
  ...autoOpen !== void 0 ? { autoOpen } : {},
409
428
  ...logLevel !== void 0 ? { logLevel } : {},
@@ -417,6 +436,8 @@ function loadConfigTolerant() {
417
436
  const local = {};
418
437
  const agents = z2.record(z2.string(), localAgentConfigSchema).safeParse(obj.agents);
419
438
  if (agents.success) local.agents = agents.data;
439
+ const prepareHook = prepareHookSchema.safeParse(obj.prepareHook);
440
+ if (prepareHook.success) local.prepareHook = prepareHook.data;
420
441
  if (typeof obj.dashboardPort === "number") local.dashboardPort = obj.dashboardPort;
421
442
  if (typeof obj.autoOpen === "boolean") local.autoOpen = obj.autoOpen;
422
443
  if (obj.logLevel === "warn" || obj.logLevel === "info" || obj.logLevel === "debug") {
@@ -6834,10 +6855,11 @@ function describeSubAgentError(status2, body) {
6834
6855
  var Dispatcher = class {
6835
6856
  constructor(opts) {
6836
6857
  this.opts = opts;
6858
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
6837
6859
  }
6838
6860
  opts;
6839
6861
  // SJ383: per-(conversation, agent) abort registry.
6840
- aborts = /* @__PURE__ */ new Map();
6862
+ aborts;
6841
6863
  notifyStart(info) {
6842
6864
  try {
6843
6865
  this.opts.observer?.onStart(info);
@@ -8587,6 +8609,7 @@ var CompanionSupervisor = class {
8587
8609
  outbox,
8588
8610
  log: this.log
8589
8611
  });
8612
+ const aborts = /* @__PURE__ */ new Map();
8590
8613
  const dispatcher = this.buildDispatcher({
8591
8614
  api,
8592
8615
  baseUrl: this.config.baseUrl,
@@ -8595,7 +8618,8 @@ var CompanionSupervisor = class {
8595
8618
  agentId: it.agentId,
8596
8619
  agentUsername: it.agentUsername,
8597
8620
  credential,
8598
- runConfig
8621
+ runConfig,
8622
+ aborts
8599
8623
  });
8600
8624
  const drain2 = this.makeDrain(api, it.agentId);
8601
8625
  wr.agents.set(it.agentId, {
@@ -8605,6 +8629,7 @@ var CompanionSupervisor = class {
8605
8629
  credential,
8606
8630
  runConfig,
8607
8631
  api,
8632
+ aborts,
8608
8633
  dispatcher,
8609
8634
  cancelDrain: drain2.cancel,
8610
8635
  kickDrain: drain2.kick
@@ -8640,7 +8665,12 @@ var CompanionSupervisor = class {
8640
8665
  return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
8641
8666
  }
8642
8667
  buildDispatcher(ctx) {
8643
- if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8668
+ const local = localAgentConfig(this.config, {
8669
+ agentId: ctx.agentId,
8670
+ agentUsername: ctx.agentUsername,
8671
+ workspaceSlug: ctx.workspaceSlug
8672
+ });
8673
+ if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
8644
8674
  return new Dispatcher({
8645
8675
  api: ctx.api,
8646
8676
  baseUrl: ctx.baseUrl,
@@ -8649,11 +8679,8 @@ var CompanionSupervisor = class {
8649
8679
  agentId: ctx.agentId,
8650
8680
  agentUsername: ctx.agentUsername,
8651
8681
  credential: ctx.credential,
8652
- local: localAgentConfig(this.config, {
8653
- agentId: ctx.agentId,
8654
- agentUsername: ctx.agentUsername,
8655
- workspaceSlug: ctx.workspaceSlug
8656
- }),
8682
+ local,
8683
+ aborts: ctx.aborts,
8657
8684
  runConfig: ctx.runConfig,
8658
8685
  log: this.log,
8659
8686
  // CT833: register the claude-code adapter only when this device actually
@@ -8923,6 +8950,7 @@ var CompanionSupervisor = class {
8923
8950
  if (!next) return;
8924
8951
  this.config = next;
8925
8952
  this.log.level = next.logLevel ?? "debug";
8953
+ this.rebuildDispatchers();
8926
8954
  void this.refreshAssignments();
8927
8955
  }
8928
8956
  // The dashboard "refresh assignments" control — an immediate pull.
@@ -9020,8 +9048,8 @@ var CompanionSupervisor = class {
9020
9048
  }
9021
9049
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
9022
9050
  // the runner (its api/outbox/chains/SSE) intact. Used after a runtime-enabling
9023
- // config change so the adapter registry rebuilt per turn from the dispatcher's
9024
- // constructor optspicks up the new opencode/codex adapter.
9051
+ // config change so constructor-captured options — per-agent cwd/prepareHook and
9052
+ // the adapter registry pick up their new values without an app restart.
9025
9053
  rebuildDispatchers() {
9026
9054
  for (const wr of this.workspaces.values()) {
9027
9055
  for (const runner of wr.agents.values()) {
@@ -9033,7 +9061,8 @@ var CompanionSupervisor = class {
9033
9061
  agentId: runner.agentId,
9034
9062
  agentUsername: runner.username,
9035
9063
  credential: runner.credential,
9036
- runConfig: runner.runConfig
9064
+ runConfig: runner.runConfig,
9065
+ aborts: runner.aborts
9037
9066
  });
9038
9067
  }
9039
9068
  }
@@ -9441,6 +9470,10 @@ async function status() {
9441
9470
  names.length > 0 ? `secrets: ${names.length} declared (${names.join(", ")})
9442
9471
  ` : "secrets: none declared \u2014 add `${VAR}` values to ~/.cabane/secrets.json for MCP servers that need them\n"
9443
9472
  );
9473
+ if (cfg.prepareHook) {
9474
+ process.stdout.write(`prepare hook: ${cfg.prepareHook.command} (device-level, all agents)
9475
+ `);
9476
+ }
9444
9477
  const overrides = Object.keys(cfg.agents ?? {});
9445
9478
  if (overrides.length > 0) {
9446
9479
  process.stdout.write(`local overrides for: ${overrides.join(", ")}
@@ -144,6 +144,22 @@ var companionConfigSchema = z2.object({
144
144
  // agentId / username / `slug/username`. Hand-added by the operator; the companion
145
145
  // never writes this (it only persists credentials + the device, elsewhere).
146
146
  agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
147
+ // CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
148
+ // assigned to this device, unless that agent's own `agents` row names a hook of
149
+ // its own.
150
+ //
151
+ // Until now `prepareHook` existed only under `agents[<id>]`, which made the
152
+ // config a second silent roster: a device whose provisioning is genuinely
153
+ // per-device (ours resolves a directory from the conversation and agent ids the
154
+ // companion already hands the hook) still had to list every agent by id, and an
155
+ // agent nobody remembered to add got no hook at all — no error, no log, just a
156
+ // turn that started in the operator's `$HOME`. That is how @dale woke in
157
+ // `/home/joshed` on 2026-08-17 with a perfectly correct dispatch.
158
+ //
159
+ // Absent → nothing changes for anyone, which is why this can land inert: a
160
+ // device with no device-level entry behaves exactly as it did before. A hook
161
+ // that fails still fails the dispatch loudly, as now.
162
+ prepareHook: prepareHookSchema.optional(),
147
163
  // Dashboard settings (all optional). dashboardPort: preferred bind port (next
148
164
  // free one if taken); autoOpen: whether `start` opens the browser (the
149
165
  // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
@@ -252,10 +268,11 @@ function loadConfigTolerant() {
252
268
  }
253
269
  const strict = companionConfigSchema.safeParse(parsed);
254
270
  if (strict.success) {
255
- const { agents: agents2, dashboardPort, autoOpen, logLevel, claudeCode: claudeCode2 } = strict.data;
271
+ const { agents: agents2, prepareHook: prepareHook2, dashboardPort, autoOpen, logLevel, claudeCode: claudeCode2 } = strict.data;
256
272
  return {
257
273
  local: {
258
274
  ...agents2 !== void 0 ? { agents: agents2 } : {},
275
+ ...prepareHook2 !== void 0 ? { prepareHook: prepareHook2 } : {},
259
276
  ...dashboardPort !== void 0 ? { dashboardPort } : {},
260
277
  ...autoOpen !== void 0 ? { autoOpen } : {},
261
278
  ...logLevel !== void 0 ? { logLevel } : {},
@@ -269,6 +286,8 @@ function loadConfigTolerant() {
269
286
  const local = {};
270
287
  const agents = z2.record(z2.string(), localAgentConfigSchema).safeParse(obj.agents);
271
288
  if (agents.success) local.agents = agents.data;
289
+ const prepareHook = prepareHookSchema.safeParse(obj.prepareHook);
290
+ if (prepareHook.success) local.prepareHook = prepareHook.data;
272
291
  if (typeof obj.dashboardPort === "number") local.dashboardPort = obj.dashboardPort;
273
292
  if (typeof obj.autoOpen === "boolean") local.autoOpen = obj.autoOpen;
274
293
  if (obj.logLevel === "warn" || obj.logLevel === "info" || obj.logLevel === "debug") {
package/dist/runtime.js CHANGED
@@ -271,6 +271,22 @@ var companionConfigSchema = z2.object({
271
271
  // agentId / username / `slug/username`. Hand-added by the operator; the companion
272
272
  // never writes this (it only persists credentials + the device, elsewhere).
273
273
  agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
274
+ // CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
275
+ // assigned to this device, unless that agent's own `agents` row names a hook of
276
+ // its own.
277
+ //
278
+ // Until now `prepareHook` existed only under `agents[<id>]`, which made the
279
+ // config a second silent roster: a device whose provisioning is genuinely
280
+ // per-device (ours resolves a directory from the conversation and agent ids the
281
+ // companion already hands the hook) still had to list every agent by id, and an
282
+ // agent nobody remembered to add got no hook at all — no error, no log, just a
283
+ // turn that started in the operator's `$HOME`. That is how @dale woke in
284
+ // `/home/joshed` on 2026-08-17 with a perfectly correct dispatch.
285
+ //
286
+ // Absent → nothing changes for anyone, which is why this can land inert: a
287
+ // device with no device-level entry behaves exactly as it did before. A hook
288
+ // that fails still fails the dispatch loudly, as now.
289
+ prepareHook: prepareHookSchema.optional(),
274
290
  // Dashboard settings (all optional). dashboardPort: preferred bind port (next
275
291
  // free one if taken); autoOpen: whether `start` opens the browser (the
276
292
  // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
@@ -333,7 +349,9 @@ function migrateConnectedHarnesses(cfg, claudeOnPath2) {
333
349
  }
334
350
  function localAgentConfig(cfg, agent) {
335
351
  const map = cfg.agents ?? {};
336
- return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
352
+ const row = map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
353
+ if (!cfg.prepareHook || row.prepareHook) return row;
354
+ return { ...row, prepareHook: cfg.prepareHook };
337
355
  }
338
356
  function loadConfig() {
339
357
  const path = configPath();
@@ -6456,10 +6474,11 @@ function describeSubAgentError(status, body) {
6456
6474
  var Dispatcher = class {
6457
6475
  constructor(opts) {
6458
6476
  this.opts = opts;
6477
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
6459
6478
  }
6460
6479
  opts;
6461
6480
  // SJ383: per-(conversation, agent) abort registry.
6462
- aborts = /* @__PURE__ */ new Map();
6481
+ aborts;
6463
6482
  notifyStart(info) {
6464
6483
  try {
6465
6484
  this.opts.observer?.onStart(info);
@@ -8209,6 +8228,7 @@ var CompanionSupervisor = class {
8209
8228
  outbox,
8210
8229
  log: this.log
8211
8230
  });
8231
+ const aborts = /* @__PURE__ */ new Map();
8212
8232
  const dispatcher = this.buildDispatcher({
8213
8233
  api,
8214
8234
  baseUrl: this.config.baseUrl,
@@ -8217,7 +8237,8 @@ var CompanionSupervisor = class {
8217
8237
  agentId: it.agentId,
8218
8238
  agentUsername: it.agentUsername,
8219
8239
  credential,
8220
- runConfig
8240
+ runConfig,
8241
+ aborts
8221
8242
  });
8222
8243
  const drain2 = this.makeDrain(api, it.agentId);
8223
8244
  wr.agents.set(it.agentId, {
@@ -8227,6 +8248,7 @@ var CompanionSupervisor = class {
8227
8248
  credential,
8228
8249
  runConfig,
8229
8250
  api,
8251
+ aborts,
8230
8252
  dispatcher,
8231
8253
  cancelDrain: drain2.cancel,
8232
8254
  kickDrain: drain2.kick
@@ -8262,7 +8284,12 @@ var CompanionSupervisor = class {
8262
8284
  return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
8263
8285
  }
8264
8286
  buildDispatcher(ctx) {
8265
- if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8287
+ const local = localAgentConfig(this.config, {
8288
+ agentId: ctx.agentId,
8289
+ agentUsername: ctx.agentUsername,
8290
+ workspaceSlug: ctx.workspaceSlug
8291
+ });
8292
+ if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
8266
8293
  return new Dispatcher({
8267
8294
  api: ctx.api,
8268
8295
  baseUrl: ctx.baseUrl,
@@ -8271,11 +8298,8 @@ var CompanionSupervisor = class {
8271
8298
  agentId: ctx.agentId,
8272
8299
  agentUsername: ctx.agentUsername,
8273
8300
  credential: ctx.credential,
8274
- local: localAgentConfig(this.config, {
8275
- agentId: ctx.agentId,
8276
- agentUsername: ctx.agentUsername,
8277
- workspaceSlug: ctx.workspaceSlug
8278
- }),
8301
+ local,
8302
+ aborts: ctx.aborts,
8279
8303
  runConfig: ctx.runConfig,
8280
8304
  log: this.log,
8281
8305
  // CT833: register the claude-code adapter only when this device actually
@@ -8545,6 +8569,7 @@ var CompanionSupervisor = class {
8545
8569
  if (!next) return;
8546
8570
  this.config = next;
8547
8571
  this.log.level = next.logLevel ?? "debug";
8572
+ this.rebuildDispatchers();
8548
8573
  void this.refreshAssignments();
8549
8574
  }
8550
8575
  // The dashboard "refresh assignments" control — an immediate pull.
@@ -8642,8 +8667,8 @@ var CompanionSupervisor = class {
8642
8667
  }
8643
8668
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
8644
8669
  // the runner (its api/outbox/chains/SSE) intact. Used after a runtime-enabling
8645
- // config change so the adapter registry rebuilt per turn from the dispatcher's
8646
- // constructor optspicks up the new opencode/codex adapter.
8670
+ // config change so constructor-captured options — per-agent cwd/prepareHook and
8671
+ // the adapter registry pick up their new values without an app restart.
8647
8672
  rebuildDispatchers() {
8648
8673
  for (const wr of this.workspaces.values()) {
8649
8674
  for (const runner of wr.agents.values()) {
@@ -8655,7 +8680,8 @@ var CompanionSupervisor = class {
8655
8680
  agentId: runner.agentId,
8656
8681
  agentUsername: runner.username,
8657
8682
  credential: runner.credential,
8658
- runConfig: runner.runConfig
8683
+ runConfig: runner.runConfig,
8684
+ aborts: runner.aborts
8659
8685
  });
8660
8686
  }
8661
8687
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.21",
3
+ "version": "0.6.23",
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",