@cabane/companion 0.6.12 → 0.6.14

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.
Files changed (3) hide show
  1. package/dist/cli.js +118 -34
  2. package/dist/runtime.js +118 -34
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6278,7 +6278,7 @@ var ConnectorHealthStore = class {
6278
6278
 
6279
6279
  // src/dispatcher.ts
6280
6280
  import { randomUUID } from "crypto";
6281
- import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync10 } from "fs";
6281
+ import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync10, statSync as statSync2 } from "fs";
6282
6282
  import { join as join13 } from "path";
6283
6283
 
6284
6284
  // src/summon.ts
@@ -6295,6 +6295,8 @@ var SUB_AGENT_TOOL = "sub_agent";
6295
6295
  var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
6296
6296
  var WAKE_ME_TOOL = "wake_me";
6297
6297
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6298
+ var CANCEL_WAKE_TOOL = "cancel_wake";
6299
+ var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
6298
6300
  function createSummonState() {
6299
6301
  return { agentId: null };
6300
6302
  }
@@ -6305,7 +6307,19 @@ function createAskState() {
6305
6307
  return { targetUserId: null, question: null, headline: null, options: null, questions: null };
6306
6308
  }
6307
6309
  function createWakeState() {
6308
- return { afterSeconds: null, at: null, note: null };
6310
+ return { afterSeconds: null, at: null, note: null, cancelled: false };
6311
+ }
6312
+ function wakeCommitField(state) {
6313
+ if (state.cancelled) return { wake: { cancel: true } };
6314
+ const { afterSeconds, at, note } = state;
6315
+ if (!note || afterSeconds === null && at === null) return {};
6316
+ return {
6317
+ wake: {
6318
+ ...afterSeconds !== null ? { afterSeconds } : {},
6319
+ ...at !== null ? { at } : {},
6320
+ note
6321
+ }
6322
+ };
6309
6323
  }
6310
6324
  function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
6311
6325
  return createSdkMcpServer({
@@ -6492,6 +6506,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6492
6506
  wakeState.afterSeconds = args.afterSeconds ?? null;
6493
6507
  wakeState.at = args.at ?? null;
6494
6508
  wakeState.note = args.note;
6509
+ wakeState.cancelled = false;
6495
6510
  return {
6496
6511
  content: [
6497
6512
  {
@@ -6505,6 +6520,31 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6505
6520
  };
6506
6521
  },
6507
6522
  { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6523
+ ),
6524
+ // CT990: the inverse. No arguments — it can only ever stand down the
6525
+ // caller's own wake in the conversation it's holding a turn in.
6526
+ tool(
6527
+ CANCEL_WAKE_TOOL,
6528
+ 'Stand down your own wake \u2014 the inverse of `wake_me`. No arguments: it means "when this turn settles, leave no wake armed for me in this conversation." Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. It writes the same per-turn slot as `wake_me`, so the two are last-call-wins against each other: `cancel_wake` then `wake_me` leaves the NEW wake armed, `wake_me` then `cancel_wake` leaves nothing armed. Like `wake_me` it takes effect when the turn SETTLES, not now. It reaches only your own wake in this conversation \u2014 never a peer\'s, and never a recurring schedule (those are `cabane.schedules.*`).',
6529
+ {},
6530
+ async () => {
6531
+ wakeState.cancelled = true;
6532
+ wakeState.afterSeconds = null;
6533
+ wakeState.at = null;
6534
+ wakeState.note = null;
6535
+ return {
6536
+ content: [
6537
+ {
6538
+ type: "text",
6539
+ text: JSON.stringify({
6540
+ cancelled: true,
6541
+ note: "Recorded. Any wake you have armed in this conversation is stood down when this turn ends. If you had none, nothing happens."
6542
+ })
6543
+ }
6544
+ ]
6545
+ };
6546
+ },
6547
+ { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6508
6548
  )
6509
6549
  ] : []
6510
6550
  ]
@@ -6580,7 +6620,7 @@ async function writeCodexInstructionsFile(contents) {
6580
6620
  }
6581
6621
 
6582
6622
  // src/prepared.ts
6583
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
6623
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
6584
6624
  import { join as join10 } from "path";
6585
6625
  function dirFor(workspaceId) {
6586
6626
  return join10(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
@@ -6615,6 +6655,9 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
6615
6655
  "utf8"
6616
6656
  );
6617
6657
  }
6658
+ function clearPrepared(workspaceId, conversationId, agentId) {
6659
+ rmSync4(pathFor3(workspaceId, conversationId, agentId), { force: true });
6660
+ }
6618
6661
 
6619
6662
  // src/secrets.ts
6620
6663
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
@@ -6708,7 +6751,7 @@ function resolveMcpSecrets(mcpServers, store) {
6708
6751
  }
6709
6752
 
6710
6753
  // src/transcript-writer.ts
6711
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync4 } from "fs";
6754
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync5 } from "fs";
6712
6755
  import { join as join12 } from "path";
6713
6756
  function transcriptsDir() {
6714
6757
  return join12(cabaneDir(), "transcripts");
@@ -6779,7 +6822,7 @@ function pruneOld(dir2, retain) {
6779
6822
  const drop = files.sort().slice(0, files.length - retain);
6780
6823
  for (const f of drop) {
6781
6824
  try {
6782
- rmSync4(join12(dir2, f), { force: true });
6825
+ rmSync5(join12(dir2, f), { force: true });
6783
6826
  } catch {
6784
6827
  }
6785
6828
  }
@@ -6941,16 +6984,13 @@ var TurnCommitter = class {
6941
6984
  // null = no wake this turn → attach nothing). The server computes `fire_at` and
6942
6985
  // arms the CT441 schedule. Kept independent of summon/ask — a turn could
6943
6986
  // conceivably ask AND arm a wake.
6987
+ //
6988
+ // CT990: the field now carries three states, and the third can't be spelled by
6989
+ // absence — attaching NOTHING means "leave the standing wake alone", which is
6990
+ // exactly what a stand-down must not do. So a `cancel_wake` turn attaches the
6991
+ // discriminated `{ cancel: true }` payload instead.
6944
6992
  wakeField() {
6945
- const { afterSeconds, at, note } = this.deps.wakeState;
6946
- if (!note || afterSeconds === null && at === null) return {};
6947
- return {
6948
- wake: {
6949
- ...afterSeconds !== null ? { afterSeconds } : {},
6950
- ...at !== null ? { at } : {},
6951
- note
6952
- }
6953
- };
6993
+ return wakeCommitField(this.deps.wakeState);
6954
6994
  }
6955
6995
  };
6956
6996
 
@@ -7080,6 +7120,21 @@ var SKIPPED_MARKER_BODY = "(skipped)";
7080
7120
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7081
7121
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7082
7122
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
7123
+ function checkoutState(cwd) {
7124
+ if (!cwd) return { ok: false, reason: "no checkout was resolved for this turn" };
7125
+ if (!existsSync9(cwd)) return { ok: false, reason: `the checkout directory is gone (${cwd})` };
7126
+ const gitPath = join13(cwd, ".git");
7127
+ if (!existsSync9(gitPath)) return { ok: false, reason: `${cwd} holds no git metadata` };
7128
+ let stat;
7129
+ try {
7130
+ stat = statSync2(gitPath);
7131
+ } catch (error) {
7132
+ return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
7133
+ }
7134
+ if (stat.isDirectory() && !existsSync9(join13(gitPath, "HEAD")))
7135
+ return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
7136
+ return { ok: true, reason: "usable" };
7137
+ }
7083
7138
  function runKey(conversationId, agentId) {
7084
7139
  return `${conversationId}|${agentId}`;
7085
7140
  }
@@ -7249,7 +7304,15 @@ var Dispatcher = class {
7249
7304
  const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
7250
7305
  const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? { dispatch: turnContext.dispatchedByAgentId } : {};
7251
7306
  if (prepareHook) {
7252
- const cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
7307
+ let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
7308
+ if (cached2 && !checkoutState(cached2.cwd).ok) {
7309
+ turnLog.warn(
7310
+ { cwd: cached2.cwd, reason: checkoutState(cached2.cwd).reason },
7311
+ "dispatcher: the prepared checkout is no longer usable \u2014 re-running the prepare hook to restore it"
7312
+ );
7313
+ clearPrepared(workspaceId, payload.conversationId, payload.agentId);
7314
+ cached2 = null;
7315
+ }
7253
7316
  if (cached2) {
7254
7317
  effectiveCwd = cached2.cwd;
7255
7318
  hookEnv = cached2.env;
@@ -7510,11 +7573,12 @@ ${reason}`,
7510
7573
  ...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
7511
7574
  harnessFingerprint: turnContext.runtime
7512
7575
  });
7513
- turnLog[proof.ok ? "info" : "error"](
7514
- { workspaceProof: proof, checkout: effectiveCwd ?? null },
7515
- `dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
7576
+ const checkout = checkoutState(effectiveCwd);
7577
+ turnLog[proof.ok && checkout.ok ? "info" : "error"](
7578
+ { workspaceProof: proof, checkout: effectiveCwd ?? null, checkoutState: checkout },
7579
+ `dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}, checkout ${checkout.ok ? "usable" : checkout.reason}`
7516
7580
  );
7517
- if (effectiveCwd) {
7581
+ if (effectiveCwd && checkout.ok) {
7518
7582
  try {
7519
7583
  const diagnosticDir = join13(effectiveCwd, ".git", "cabane");
7520
7584
  mkdirSync10(diagnosticDir, { recursive: true });
@@ -7539,6 +7603,23 @@ ${reason}`,
7539
7603
  );
7540
7604
  }
7541
7605
  }
7606
+ if (!checkout.ok) {
7607
+ const reason = `checkout_missing: ${checkout.reason}; task=${hookEnv.CABANE_TASK_ID}; recovery=re-dispatch this conversation (the prepare hook re-provisions the environment)`;
7608
+ try {
7609
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7610
+ body: `**Couldn't prepare your environment.** ${reason}`,
7611
+ kind: "final",
7612
+ turnId,
7613
+ parentMessageId: payload.messageId
7614
+ });
7615
+ } catch (postErr) {
7616
+ turnLog.warn(
7617
+ { err: postErr instanceof Error ? postErr.message : String(postErr) },
7618
+ "dispatcher: checkout-missing notice post failed"
7619
+ );
7620
+ }
7621
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7622
+ }
7542
7623
  if (!proof.ok) {
7543
7624
  const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
7544
7625
  try {
@@ -7627,9 +7708,17 @@ ${reason}`,
7627
7708
  }
7628
7709
  }
7629
7710
  if (intent.wake) {
7630
- wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
7631
- wakeState.at = intent.wake.at ?? null;
7632
- wakeState.note = intent.wake.note;
7711
+ if ("cancel" in intent.wake) {
7712
+ wakeState.cancelled = true;
7713
+ wakeState.afterSeconds = null;
7714
+ wakeState.at = null;
7715
+ wakeState.note = null;
7716
+ } else {
7717
+ wakeState.cancelled = false;
7718
+ wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
7719
+ wakeState.at = intent.wake.at ?? null;
7720
+ wakeState.note = intent.wake.note;
7721
+ }
7633
7722
  }
7634
7723
  if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
7635
7724
  if (intent.skipped) {
@@ -7720,12 +7809,7 @@ ${reason}`,
7720
7809
  { reason: skipState.reason, turnId, ok: okResult },
7721
7810
  "agent skipped turn (skip_turn)"
7722
7811
  );
7723
- const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
7724
- const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
7725
- ...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
7726
- ...wakeAt !== null ? { at: wakeAt } : {},
7727
- note: wakeNote
7728
- } : void 0;
7812
+ const { wake: skipWake } = wakeCommitField(wakeState);
7729
7813
  try {
7730
7814
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7731
7815
  body: SKIPPED_MARKER_BODY,
@@ -8100,7 +8184,7 @@ import {
8100
8184
  readdirSync as readdirSync2,
8101
8185
  readFileSync as readFileSync8,
8102
8186
  renameSync as renameSync3,
8103
- rmSync as rmSync5,
8187
+ rmSync as rmSync6,
8104
8188
  writeFileSync as writeFileSync7
8105
8189
  } from "fs";
8106
8190
  import { join as join14 } from "path";
@@ -8134,7 +8218,7 @@ var Outbox = class {
8134
8218
  renameSync3(tmp, target);
8135
8219
  } catch (err) {
8136
8220
  try {
8137
- rmSync5(tmp, { force: true });
8221
+ rmSync6(tmp, { force: true });
8138
8222
  } catch {
8139
8223
  }
8140
8224
  this.log?.warn(
@@ -8181,7 +8265,7 @@ var Outbox = class {
8181
8265
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
8182
8266
  remove(turnId, seq) {
8183
8267
  try {
8184
- rmSync5(this.fileFor(turnId, seq), { force: true });
8268
+ rmSync6(this.fileFor(turnId, seq), { force: true });
8185
8269
  } catch {
8186
8270
  }
8187
8271
  }
@@ -8200,7 +8284,7 @@ var Outbox = class {
8200
8284
  "companion outbox: dropping unreadable entry"
8201
8285
  );
8202
8286
  try {
8203
- rmSync5(full, { force: true });
8287
+ rmSync6(full, { force: true });
8204
8288
  } catch {
8205
8289
  }
8206
8290
  }
@@ -9267,7 +9351,7 @@ function handleUncaught(log, err, origin) {
9267
9351
  }
9268
9352
 
9269
9353
  // src/crash-marker.ts
9270
- import { existsSync as existsSync11, mkdirSync as mkdirSync12, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
9354
+ import { existsSync as existsSync11, mkdirSync as mkdirSync12, readFileSync as readFileSync9, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
9271
9355
  import { join as join15 } from "path";
9272
9356
  function crashMarkerPath() {
9273
9357
  return join15(cabaneDir(), "last-error.json");
@@ -9282,7 +9366,7 @@ function recordCrash(rec2) {
9282
9366
  function clearCrash() {
9283
9367
  try {
9284
9368
  const path3 = crashMarkerPath();
9285
- if (existsSync11(path3)) rmSync6(path3, { force: true });
9369
+ if (existsSync11(path3)) rmSync7(path3, { force: true });
9286
9370
  } catch {
9287
9371
  }
9288
9372
  }
package/dist/runtime.js CHANGED
@@ -5931,7 +5931,7 @@ var ConnectorHealthStore = class {
5931
5931
 
5932
5932
  // src/dispatcher.ts
5933
5933
  import { randomUUID } from "crypto";
5934
- import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
5934
+ import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9, statSync as statSync2 } from "fs";
5935
5935
  import { join as join13 } from "path";
5936
5936
 
5937
5937
  // src/summon.ts
@@ -5948,6 +5948,8 @@ var SUB_AGENT_TOOL = "sub_agent";
5948
5948
  var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
5949
5949
  var WAKE_ME_TOOL = "wake_me";
5950
5950
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
5951
+ var CANCEL_WAKE_TOOL = "cancel_wake";
5952
+ var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
5951
5953
  function createSummonState() {
5952
5954
  return { agentId: null };
5953
5955
  }
@@ -5958,7 +5960,19 @@ function createAskState() {
5958
5960
  return { targetUserId: null, question: null, headline: null, options: null, questions: null };
5959
5961
  }
5960
5962
  function createWakeState() {
5961
- return { afterSeconds: null, at: null, note: null };
5963
+ return { afterSeconds: null, at: null, note: null, cancelled: false };
5964
+ }
5965
+ function wakeCommitField(state) {
5966
+ if (state.cancelled) return { wake: { cancel: true } };
5967
+ const { afterSeconds, at, note } = state;
5968
+ if (!note || afterSeconds === null && at === null) return {};
5969
+ return {
5970
+ wake: {
5971
+ ...afterSeconds !== null ? { afterSeconds } : {},
5972
+ ...at !== null ? { at } : {},
5973
+ note
5974
+ }
5975
+ };
5962
5976
  }
5963
5977
  function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
5964
5978
  return createSdkMcpServer({
@@ -6145,6 +6159,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6145
6159
  wakeState.afterSeconds = args.afterSeconds ?? null;
6146
6160
  wakeState.at = args.at ?? null;
6147
6161
  wakeState.note = args.note;
6162
+ wakeState.cancelled = false;
6148
6163
  return {
6149
6164
  content: [
6150
6165
  {
@@ -6158,6 +6173,31 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6158
6173
  };
6159
6174
  },
6160
6175
  { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6176
+ ),
6177
+ // CT990: the inverse. No arguments — it can only ever stand down the
6178
+ // caller's own wake in the conversation it's holding a turn in.
6179
+ tool(
6180
+ CANCEL_WAKE_TOOL,
6181
+ 'Stand down your own wake \u2014 the inverse of `wake_me`. No arguments: it means "when this turn settles, leave no wake armed for me in this conversation." Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. It writes the same per-turn slot as `wake_me`, so the two are last-call-wins against each other: `cancel_wake` then `wake_me` leaves the NEW wake armed, `wake_me` then `cancel_wake` leaves nothing armed. Like `wake_me` it takes effect when the turn SETTLES, not now. It reaches only your own wake in this conversation \u2014 never a peer\'s, and never a recurring schedule (those are `cabane.schedules.*`).',
6182
+ {},
6183
+ async () => {
6184
+ wakeState.cancelled = true;
6185
+ wakeState.afterSeconds = null;
6186
+ wakeState.at = null;
6187
+ wakeState.note = null;
6188
+ return {
6189
+ content: [
6190
+ {
6191
+ type: "text",
6192
+ text: JSON.stringify({
6193
+ cancelled: true,
6194
+ note: "Recorded. Any wake you have armed in this conversation is stood down when this turn ends. If you had none, nothing happens."
6195
+ })
6196
+ }
6197
+ ]
6198
+ };
6199
+ },
6200
+ { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6161
6201
  )
6162
6202
  ] : []
6163
6203
  ]
@@ -6233,7 +6273,7 @@ async function writeCodexInstructionsFile(contents) {
6233
6273
  }
6234
6274
 
6235
6275
  // src/prepared.ts
6236
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
6276
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
6237
6277
  import { join as join10 } from "path";
6238
6278
  function dirFor(workspaceId) {
6239
6279
  return join10(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
@@ -6268,6 +6308,9 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
6268
6308
  "utf8"
6269
6309
  );
6270
6310
  }
6311
+ function clearPrepared(workspaceId, conversationId, agentId) {
6312
+ rmSync4(pathFor3(workspaceId, conversationId, agentId), { force: true });
6313
+ }
6271
6314
 
6272
6315
  // src/secrets.ts
6273
6316
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
@@ -6361,7 +6404,7 @@ function resolveMcpSecrets(mcpServers, store) {
6361
6404
  }
6362
6405
 
6363
6406
  // src/transcript-writer.ts
6364
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync4 } from "fs";
6407
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync5 } from "fs";
6365
6408
  import { join as join12 } from "path";
6366
6409
  function transcriptsDir() {
6367
6410
  return join12(cabaneDir(), "transcripts");
@@ -6432,7 +6475,7 @@ function pruneOld(dir2, retain) {
6432
6475
  const drop = files.sort().slice(0, files.length - retain);
6433
6476
  for (const f of drop) {
6434
6477
  try {
6435
- rmSync4(join12(dir2, f), { force: true });
6478
+ rmSync5(join12(dir2, f), { force: true });
6436
6479
  } catch {
6437
6480
  }
6438
6481
  }
@@ -6594,16 +6637,13 @@ var TurnCommitter = class {
6594
6637
  // null = no wake this turn → attach nothing). The server computes `fire_at` and
6595
6638
  // arms the CT441 schedule. Kept independent of summon/ask — a turn could
6596
6639
  // conceivably ask AND arm a wake.
6640
+ //
6641
+ // CT990: the field now carries three states, and the third can't be spelled by
6642
+ // absence — attaching NOTHING means "leave the standing wake alone", which is
6643
+ // exactly what a stand-down must not do. So a `cancel_wake` turn attaches the
6644
+ // discriminated `{ cancel: true }` payload instead.
6597
6645
  wakeField() {
6598
- const { afterSeconds, at, note } = this.deps.wakeState;
6599
- if (!note || afterSeconds === null && at === null) return {};
6600
- return {
6601
- wake: {
6602
- ...afterSeconds !== null ? { afterSeconds } : {},
6603
- ...at !== null ? { at } : {},
6604
- note
6605
- }
6606
- };
6646
+ return wakeCommitField(this.deps.wakeState);
6607
6647
  }
6608
6648
  };
6609
6649
 
@@ -6733,6 +6773,21 @@ var SKIPPED_MARKER_BODY = "(skipped)";
6733
6773
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
6734
6774
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
6735
6775
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
6776
+ function checkoutState(cwd) {
6777
+ if (!cwd) return { ok: false, reason: "no checkout was resolved for this turn" };
6778
+ if (!existsSync9(cwd)) return { ok: false, reason: `the checkout directory is gone (${cwd})` };
6779
+ const gitPath = join13(cwd, ".git");
6780
+ if (!existsSync9(gitPath)) return { ok: false, reason: `${cwd} holds no git metadata` };
6781
+ let stat;
6782
+ try {
6783
+ stat = statSync2(gitPath);
6784
+ } catch (error) {
6785
+ return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
6786
+ }
6787
+ if (stat.isDirectory() && !existsSync9(join13(gitPath, "HEAD")))
6788
+ return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
6789
+ return { ok: true, reason: "usable" };
6790
+ }
6736
6791
  function runKey(conversationId, agentId) {
6737
6792
  return `${conversationId}|${agentId}`;
6738
6793
  }
@@ -6902,7 +6957,15 @@ var Dispatcher = class {
6902
6957
  const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
6903
6958
  const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? { dispatch: turnContext.dispatchedByAgentId } : {};
6904
6959
  if (prepareHook) {
6905
- const cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
6960
+ let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
6961
+ if (cached2 && !checkoutState(cached2.cwd).ok) {
6962
+ turnLog.warn(
6963
+ { cwd: cached2.cwd, reason: checkoutState(cached2.cwd).reason },
6964
+ "dispatcher: the prepared checkout is no longer usable \u2014 re-running the prepare hook to restore it"
6965
+ );
6966
+ clearPrepared(workspaceId, payload.conversationId, payload.agentId);
6967
+ cached2 = null;
6968
+ }
6906
6969
  if (cached2) {
6907
6970
  effectiveCwd = cached2.cwd;
6908
6971
  hookEnv = cached2.env;
@@ -7163,11 +7226,12 @@ ${reason}`,
7163
7226
  ...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
7164
7227
  harnessFingerprint: turnContext.runtime
7165
7228
  });
7166
- turnLog[proof.ok ? "info" : "error"](
7167
- { workspaceProof: proof, checkout: effectiveCwd ?? null },
7168
- `dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
7229
+ const checkout = checkoutState(effectiveCwd);
7230
+ turnLog[proof.ok && checkout.ok ? "info" : "error"](
7231
+ { workspaceProof: proof, checkout: effectiveCwd ?? null, checkoutState: checkout },
7232
+ `dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}, checkout ${checkout.ok ? "usable" : checkout.reason}`
7169
7233
  );
7170
- if (effectiveCwd) {
7234
+ if (effectiveCwd && checkout.ok) {
7171
7235
  try {
7172
7236
  const diagnosticDir = join13(effectiveCwd, ".git", "cabane");
7173
7237
  mkdirSync9(diagnosticDir, { recursive: true });
@@ -7192,6 +7256,23 @@ ${reason}`,
7192
7256
  );
7193
7257
  }
7194
7258
  }
7259
+ if (!checkout.ok) {
7260
+ const reason = `checkout_missing: ${checkout.reason}; task=${hookEnv.CABANE_TASK_ID}; recovery=re-dispatch this conversation (the prepare hook re-provisions the environment)`;
7261
+ try {
7262
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7263
+ body: `**Couldn't prepare your environment.** ${reason}`,
7264
+ kind: "final",
7265
+ turnId,
7266
+ parentMessageId: payload.messageId
7267
+ });
7268
+ } catch (postErr) {
7269
+ turnLog.warn(
7270
+ { err: postErr instanceof Error ? postErr.message : String(postErr) },
7271
+ "dispatcher: checkout-missing notice post failed"
7272
+ );
7273
+ }
7274
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7275
+ }
7195
7276
  if (!proof.ok) {
7196
7277
  const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
7197
7278
  try {
@@ -7280,9 +7361,17 @@ ${reason}`,
7280
7361
  }
7281
7362
  }
7282
7363
  if (intent.wake) {
7283
- wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
7284
- wakeState.at = intent.wake.at ?? null;
7285
- wakeState.note = intent.wake.note;
7364
+ if ("cancel" in intent.wake) {
7365
+ wakeState.cancelled = true;
7366
+ wakeState.afterSeconds = null;
7367
+ wakeState.at = null;
7368
+ wakeState.note = null;
7369
+ } else {
7370
+ wakeState.cancelled = false;
7371
+ wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
7372
+ wakeState.at = intent.wake.at ?? null;
7373
+ wakeState.note = intent.wake.note;
7374
+ }
7286
7375
  }
7287
7376
  if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
7288
7377
  if (intent.skipped) {
@@ -7373,12 +7462,7 @@ ${reason}`,
7373
7462
  { reason: skipState.reason, turnId, ok: okResult },
7374
7463
  "agent skipped turn (skip_turn)"
7375
7464
  );
7376
- const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
7377
- const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
7378
- ...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
7379
- ...wakeAt !== null ? { at: wakeAt } : {},
7380
- note: wakeNote
7381
- } : void 0;
7465
+ const { wake: skipWake } = wakeCommitField(wakeState);
7382
7466
  try {
7383
7467
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7384
7468
  body: SKIPPED_MARKER_BODY,
@@ -7753,7 +7837,7 @@ import {
7753
7837
  readdirSync as readdirSync2,
7754
7838
  readFileSync as readFileSync8,
7755
7839
  renameSync as renameSync3,
7756
- rmSync as rmSync5,
7840
+ rmSync as rmSync6,
7757
7841
  writeFileSync as writeFileSync7
7758
7842
  } from "fs";
7759
7843
  import { join as join14 } from "path";
@@ -7787,7 +7871,7 @@ var Outbox = class {
7787
7871
  renameSync3(tmp, target);
7788
7872
  } catch (err) {
7789
7873
  try {
7790
- rmSync5(tmp, { force: true });
7874
+ rmSync6(tmp, { force: true });
7791
7875
  } catch {
7792
7876
  }
7793
7877
  this.log?.warn(
@@ -7834,7 +7918,7 @@ var Outbox = class {
7834
7918
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
7835
7919
  remove(turnId, seq) {
7836
7920
  try {
7837
- rmSync5(this.fileFor(turnId, seq), { force: true });
7921
+ rmSync6(this.fileFor(turnId, seq), { force: true });
7838
7922
  } catch {
7839
7923
  }
7840
7924
  }
@@ -7853,7 +7937,7 @@ var Outbox = class {
7853
7937
  "companion outbox: dropping unreadable entry"
7854
7938
  );
7855
7939
  try {
7856
- rmSync5(full, { force: true });
7940
+ rmSync6(full, { force: true });
7857
7941
  } catch {
7858
7942
  }
7859
7943
  }
@@ -8920,7 +9004,7 @@ function handleUncaught(log, err, origin) {
8920
9004
  }
8921
9005
 
8922
9006
  // src/crash-marker.ts
8923
- import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
9007
+ import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
8924
9008
  import { join as join15 } from "path";
8925
9009
  function crashMarkerPath() {
8926
9010
  return join15(cabaneDir(), "last-error.json");
@@ -8935,7 +9019,7 @@ function recordCrash(rec) {
8935
9019
  function clearCrash() {
8936
9020
  try {
8937
9021
  const path3 = crashMarkerPath();
8938
- if (existsSync11(path3)) rmSync6(path3, { force: true });
9022
+ if (existsSync11(path3)) rmSync7(path3, { force: true });
8939
9023
  } catch {
8940
9024
  }
8941
9025
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
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",