@cabane/companion 0.6.47 → 0.6.49

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
@@ -149,6 +149,7 @@ var runPrepareHook = (hook, input) => {
149
149
  CABANE_AGENT_ID: input.agentId,
150
150
  CABANE_AGENT_USERNAME: input.agentUsername,
151
151
  CABANE_RUNTIME: input.runtime ?? "",
152
+ CABANE_HOST_ACCESS: input.hostAccess ? "1" : "0",
152
153
  // CT319: the conversation anchor is gone. These are kept as DEPRECATED
153
154
  // back-compat constants so an old user prepare script that still reads
154
155
  // them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
@@ -3598,7 +3599,7 @@ var TurnPump = class {
3598
3599
  };
3599
3600
  }
3600
3601
  opts;
3601
- // `finalEmitted` gates the end-of-turn empty-final promotion;
3602
+ // `finalEmitted` gates the end-of-turn progress promotion;
3602
3603
  // `lastProgressBody` is what we promote to `final` when a clean turn ended on
3603
3604
  // a tool call with no closing text.
3604
3605
  emittedFinal = false;
@@ -3613,28 +3614,28 @@ var TurnPump = class {
3613
3614
  // frozen committer test — drive. Each callback applies the choreography and
3614
3615
  // commits through the injected sink.
3615
3616
  sink;
3616
- // End-of-turn empty-final promotion. A clean turn that ended on a tool call
3617
- // with no closing text still needs exactly one `final` row (so the user gets
3618
- // one notification and the drawer closes the turn). Promote the last progress
3619
- // body when we have one (the drawer collapses the dup), else the host's
3620
- // minimal note. Skipped when cancelled or already final. The held-text flush
3621
- // that precedes it is a classification concern, driven by the caller before
3622
- // this runs.
3617
+ // End-of-turn progress promotion. A clean turn that emitted interim text but
3618
+ // no closing text promotes the agent's own last progress body (the drawer
3619
+ // collapses the duplicate). If the agent emitted no words, emit no message:
3620
+ // Cabane never attributes host-authored text to an agent, in any turn shape,
3621
+ // for any reason. The host closes that wordless turn with a marker instead.
3622
+ // Skipped when cancelled or already final. The held-text flush that precedes
3623
+ // this is a classification concern, driven by the caller before this runs.
3623
3624
  async finalize(ok) {
3624
- if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
3625
- const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
3625
+ if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
3626
+ const body = this.lastProgressBody;
3626
3627
  const seq = this.opts.nextSeq();
3627
3628
  try {
3628
3629
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3629
3630
  this.emittedFinal = true;
3630
3631
  this.finalReplyBody = body;
3631
- this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3632
+ this.emittedFinalSource = "progress_promotion";
3632
3633
  } catch (err) {
3633
- this.opts.onError?.(err, "empty-final");
3634
+ this.opts.onError?.(err, "progress-promotion");
3634
3635
  }
3635
3636
  }
3636
3637
  // Whether the turn has committed its `final` row — read by the host to decide
3637
- // whether the empty-final path or a marker is owed.
3638
+ // whether a marker is owed.
3638
3639
  get finalEmitted() {
3639
3640
  return this.emittedFinal;
3640
3641
  }
@@ -5777,7 +5778,8 @@ var ENV_ENVELOPE_KEYS = [
5777
5778
  "CABANE_PLAYGROUND_BIN",
5778
5779
  "CABANE_CONVERSATION_ID",
5779
5780
  "CABANE_AGENT_ID",
5780
- "CABANE_CONVERSATION_TITLE"
5781
+ "CABANE_CONVERSATION_TITLE",
5782
+ "CABANE_HOST_ACCESS"
5781
5783
  ];
5782
5784
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
5783
5785
  const { policy, config } = req;
@@ -7272,7 +7274,6 @@ function pruneOld(dir2, retain) {
7272
7274
  }
7273
7275
 
7274
7276
  // src/turn-committer.ts
7275
- var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
7276
7277
  var TurnCommitter = class {
7277
7278
  constructor(deps) {
7278
7279
  this.deps = deps;
@@ -7293,9 +7294,7 @@ var TurnCommitter = class {
7293
7294
  turnId: deps.turnId,
7294
7295
  seq,
7295
7296
  parentMessageId: deps.parentMessageId,
7296
- ...kind === "final" ? this.summonField() : {},
7297
- ...kind === "final" ? this.askField() : {},
7298
- ...kind === "final" ? this.wakeField() : {}
7297
+ ...kind === "final" ? this.turnControlFields() : {}
7299
7298
  },
7300
7299
  deps.signal
7301
7300
  );
@@ -7342,11 +7341,10 @@ var TurnCommitter = class {
7342
7341
  commit,
7343
7342
  signal: deps.signal,
7344
7343
  nextSeq: deps.nextSeq,
7345
- emptyFinalBody: EMPTY_FINAL_BODY,
7346
7344
  onError: (err) => {
7347
7345
  deps.log.warn(
7348
7346
  { err: err instanceof Error ? err.message : String(err) },
7349
- "dispatcher: empty-final commit failed"
7347
+ "dispatcher: progress-promotion commit failed"
7350
7348
  );
7351
7349
  }
7352
7350
  });
@@ -7372,11 +7370,11 @@ var TurnCommitter = class {
7372
7370
  this.onError(err, event.type);
7373
7371
  }
7374
7372
  }
7375
- // End-of-turn empty-final promotion. The held-text flush is now the adapter's
7373
+ // End-of-turn progress promotion. The held-text flush is now the adapter's
7376
7374
  // job (it emits the closing reply as a `text` event before `result`), so this
7377
- // only runs the pump's empty-final: a clean turn that ended on a tool call with
7378
- // no closing text still gets exactly one `final`. Guards on the abort signal, so
7379
- // on cancel no `final` is forced.
7375
+ // only asks the pump to promote agent-authored interim text. A wordless turn
7376
+ // stays wordless and the dispatcher closes it with a marker. Guards on the
7377
+ // abort signal, so on cancel no `final` is forced.
7380
7378
  async finalize(okResult) {
7381
7379
  await this.pump.finalize(okResult);
7382
7380
  }
@@ -7387,6 +7385,19 @@ var TurnCommitter = class {
7387
7385
  get finalSource() {
7388
7386
  return this.pump.finalSource;
7389
7387
  }
7388
+ get finalEmitted() {
7389
+ return this.pump.finalEmitted;
7390
+ }
7391
+ // A closing textual reply and a wordless terminal marker carry the same
7392
+ // per-turn control state. Keep this one projection so ask/wake/summon cannot
7393
+ // silently diverge when the agent ends without words.
7394
+ turnControlFields() {
7395
+ return {
7396
+ ...this.summonField(),
7397
+ ...this.askField(),
7398
+ ...this.wakeField()
7399
+ };
7400
+ }
7390
7401
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7391
7402
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7392
7403
  // server strips it again and resolves / ignores an unknown id.
@@ -7615,6 +7626,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
7615
7626
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
7616
7627
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
7617
7628
  var SKIPPED_MARKER_BODY = "(skipped)";
7629
+ var SILENT_MARKER_BODY = "(no reply)";
7618
7630
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7619
7631
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7620
7632
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -7861,6 +7873,7 @@ var Dispatcher = class {
7861
7873
  agentId: payload.agentId,
7862
7874
  agentUsername: this.opts.agentUsername,
7863
7875
  runtime: turnContext.runtime,
7876
+ hostAccess: turnContext.policy.hostFs,
7864
7877
  triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7865
7878
  title: turnContext.conversation.title,
7866
7879
  messageBody: message.body,
@@ -7922,6 +7935,7 @@ ${reason}`,
7922
7935
  agentId: payload.agentId,
7923
7936
  agentUsername: this.opts.agentUsername,
7924
7937
  runtime: turnContext.runtime,
7938
+ hostAccess: turnContext.policy.hostFs,
7925
7939
  // CT317/CT319: the trigger message's referenced-entry paths — what the
7926
7940
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
7927
7941
  // an older API. The conversation anchor is gone (CT319).
@@ -7965,7 +7979,10 @@ ${reason}`,
7965
7979
  }
7966
7980
  }
7967
7981
  }
7968
- const turnEnv = hookEnv;
7982
+ const turnEnv = {
7983
+ ...hookEnv,
7984
+ CABANE_HOST_ACCESS: turnContext.policy.hostFs ? "1" : "0"
7985
+ };
7969
7986
  const key = runKey(payload.conversationId, payload.agentId);
7970
7987
  const abortController = new AbortController();
7971
7988
  this.aborts.set(key, abortController);
@@ -8028,9 +8045,8 @@ ${reason}`,
8028
8045
  ...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
8029
8046
  // SJ524: the hook-resolved cwd overrides the static local cwd.
8030
8047
  ...effectiveCwd ? { cwd: effectiveCwd } : {},
8031
- // CT804: `turnEnv` = the prepare-hook env plus the per-turn checkout-local
8032
- // TMPDIR (falls back to `hookEnv` when no cwd was resolved).
8033
- ...turnEnv ? { env: turnEnv } : {},
8048
+ // CT1103: the prepare-hook env plus the authoritative host-access token.
8049
+ env: turnEnv,
8034
8050
  mcpServers: resolvedMcpServers,
8035
8051
  summonServer,
8036
8052
  // CT238: this turn's conversation, forwarded as the active-conversation
@@ -8268,6 +8284,7 @@ ${reason}`,
8268
8284
  let contentBearingEvents = 0;
8269
8285
  let latestSessionState = request.session;
8270
8286
  let settledDiagnostics = null;
8287
+ let silentMarkerEmitted = false;
8271
8288
  const committer = new TurnCommitter({
8272
8289
  api: this.opts.api,
8273
8290
  workspaceId,
@@ -8459,6 +8476,27 @@ ${reason}`,
8459
8476
  }
8460
8477
  } else {
8461
8478
  await committer.finalize(okResult);
8479
+ if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
8480
+ try {
8481
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8482
+ body: SILENT_MARKER_BODY,
8483
+ kind: "silent",
8484
+ turnId,
8485
+ seq: nextSeq(),
8486
+ parentMessageId: payload.messageId,
8487
+ // ask, wake and summon must survive a wordless turn exactly as
8488
+ // they survive a textual final; dropping one can strand a person
8489
+ // or the next actor with no visible failure.
8490
+ ...committer.turnControlFields()
8491
+ });
8492
+ silentMarkerEmitted = true;
8493
+ } catch (err) {
8494
+ turnLog.warn(
8495
+ { err: err instanceof Error ? err.message : String(err) },
8496
+ "dispatcher: silent-marker commit failed"
8497
+ );
8498
+ }
8499
+ }
8462
8500
  }
8463
8501
  } catch (err) {
8464
8502
  okResult = false;
@@ -8549,7 +8587,7 @@ ${reason}`,
8549
8587
  sessionFingerprint: fingerprintSessionState(latestSessionState),
8550
8588
  eventCounts,
8551
8589
  runtimeResultKind,
8552
- finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
8590
+ finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
8553
8591
  };
8554
8592
  body.diagnostics = settledDiagnostics;
8555
8593
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
package/dist/runtime.js CHANGED
@@ -141,6 +141,7 @@ var runPrepareHook = (hook, input) => {
141
141
  CABANE_AGENT_ID: input.agentId,
142
142
  CABANE_AGENT_USERNAME: input.agentUsername,
143
143
  CABANE_RUNTIME: input.runtime ?? "",
144
+ CABANE_HOST_ACCESS: input.hostAccess ? "1" : "0",
144
145
  // CT319: the conversation anchor is gone. These are kept as DEPRECATED
145
146
  // back-compat constants so an old user prepare script that still reads
146
147
  // them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
@@ -3106,7 +3107,7 @@ var TurnPump = class {
3106
3107
  };
3107
3108
  }
3108
3109
  opts;
3109
- // `finalEmitted` gates the end-of-turn empty-final promotion;
3110
+ // `finalEmitted` gates the end-of-turn progress promotion;
3110
3111
  // `lastProgressBody` is what we promote to `final` when a clean turn ended on
3111
3112
  // a tool call with no closing text.
3112
3113
  emittedFinal = false;
@@ -3121,28 +3122,28 @@ var TurnPump = class {
3121
3122
  // frozen committer test — drive. Each callback applies the choreography and
3122
3123
  // commits through the injected sink.
3123
3124
  sink;
3124
- // End-of-turn empty-final promotion. A clean turn that ended on a tool call
3125
- // with no closing text still needs exactly one `final` row (so the user gets
3126
- // one notification and the drawer closes the turn). Promote the last progress
3127
- // body when we have one (the drawer collapses the dup), else the host's
3128
- // minimal note. Skipped when cancelled or already final. The held-text flush
3129
- // that precedes it is a classification concern, driven by the caller before
3130
- // this runs.
3125
+ // End-of-turn progress promotion. A clean turn that emitted interim text but
3126
+ // no closing text promotes the agent's own last progress body (the drawer
3127
+ // collapses the duplicate). If the agent emitted no words, emit no message:
3128
+ // Cabane never attributes host-authored text to an agent, in any turn shape,
3129
+ // for any reason. The host closes that wordless turn with a marker instead.
3130
+ // Skipped when cancelled or already final. The held-text flush that precedes
3131
+ // this is a classification concern, driven by the caller before this runs.
3131
3132
  async finalize(ok) {
3132
- if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
3133
- const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
3133
+ if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
3134
+ const body = this.lastProgressBody;
3134
3135
  const seq = this.opts.nextSeq();
3135
3136
  try {
3136
3137
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3137
3138
  this.emittedFinal = true;
3138
3139
  this.finalReplyBody = body;
3139
- this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3140
+ this.emittedFinalSource = "progress_promotion";
3140
3141
  } catch (err) {
3141
- this.opts.onError?.(err, "empty-final");
3142
+ this.opts.onError?.(err, "progress-promotion");
3142
3143
  }
3143
3144
  }
3144
3145
  // Whether the turn has committed its `final` row — read by the host to decide
3145
- // whether the empty-final path or a marker is owed.
3146
+ // whether a marker is owed.
3146
3147
  get finalEmitted() {
3147
3148
  return this.emittedFinal;
3148
3149
  }
@@ -5285,7 +5286,8 @@ var ENV_ENVELOPE_KEYS = [
5285
5286
  "CABANE_PLAYGROUND_BIN",
5286
5287
  "CABANE_CONVERSATION_ID",
5287
5288
  "CABANE_AGENT_ID",
5288
- "CABANE_CONVERSATION_TITLE"
5289
+ "CABANE_CONVERSATION_TITLE",
5290
+ "CABANE_HOST_ACCESS"
5289
5291
  ];
5290
5292
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
5291
5293
  const { policy, config } = req;
@@ -6780,7 +6782,6 @@ function pruneOld(dir2, retain) {
6780
6782
  }
6781
6783
 
6782
6784
  // src/turn-committer.ts
6783
- var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
6784
6785
  var TurnCommitter = class {
6785
6786
  constructor(deps) {
6786
6787
  this.deps = deps;
@@ -6801,9 +6802,7 @@ var TurnCommitter = class {
6801
6802
  turnId: deps.turnId,
6802
6803
  seq,
6803
6804
  parentMessageId: deps.parentMessageId,
6804
- ...kind === "final" ? this.summonField() : {},
6805
- ...kind === "final" ? this.askField() : {},
6806
- ...kind === "final" ? this.wakeField() : {}
6805
+ ...kind === "final" ? this.turnControlFields() : {}
6807
6806
  },
6808
6807
  deps.signal
6809
6808
  );
@@ -6850,11 +6849,10 @@ var TurnCommitter = class {
6850
6849
  commit,
6851
6850
  signal: deps.signal,
6852
6851
  nextSeq: deps.nextSeq,
6853
- emptyFinalBody: EMPTY_FINAL_BODY,
6854
6852
  onError: (err) => {
6855
6853
  deps.log.warn(
6856
6854
  { err: err instanceof Error ? err.message : String(err) },
6857
- "dispatcher: empty-final commit failed"
6855
+ "dispatcher: progress-promotion commit failed"
6858
6856
  );
6859
6857
  }
6860
6858
  });
@@ -6880,11 +6878,11 @@ var TurnCommitter = class {
6880
6878
  this.onError(err, event.type);
6881
6879
  }
6882
6880
  }
6883
- // End-of-turn empty-final promotion. The held-text flush is now the adapter's
6881
+ // End-of-turn progress promotion. The held-text flush is now the adapter's
6884
6882
  // job (it emits the closing reply as a `text` event before `result`), so this
6885
- // only runs the pump's empty-final: a clean turn that ended on a tool call with
6886
- // no closing text still gets exactly one `final`. Guards on the abort signal, so
6887
- // on cancel no `final` is forced.
6883
+ // only asks the pump to promote agent-authored interim text. A wordless turn
6884
+ // stays wordless and the dispatcher closes it with a marker. Guards on the
6885
+ // abort signal, so on cancel no `final` is forced.
6888
6886
  async finalize(okResult) {
6889
6887
  await this.pump.finalize(okResult);
6890
6888
  }
@@ -6895,6 +6893,19 @@ var TurnCommitter = class {
6895
6893
  get finalSource() {
6896
6894
  return this.pump.finalSource;
6897
6895
  }
6896
+ get finalEmitted() {
6897
+ return this.pump.finalEmitted;
6898
+ }
6899
+ // A closing textual reply and a wordless terminal marker carry the same
6900
+ // per-turn control state. Keep this one projection so ask/wake/summon cannot
6901
+ // silently diverge when the agent ends without words.
6902
+ turnControlFields() {
6903
+ return {
6904
+ ...this.summonField(),
6905
+ ...this.askField(),
6906
+ ...this.wakeField()
6907
+ };
6908
+ }
6898
6909
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
6899
6910
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
6900
6911
  // server strips it again and resolves / ignores an unknown id.
@@ -7123,6 +7134,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
7123
7134
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
7124
7135
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
7125
7136
  var SKIPPED_MARKER_BODY = "(skipped)";
7137
+ var SILENT_MARKER_BODY = "(no reply)";
7126
7138
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7127
7139
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7128
7140
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -7369,6 +7381,7 @@ var Dispatcher = class {
7369
7381
  agentId: payload.agentId,
7370
7382
  agentUsername: this.opts.agentUsername,
7371
7383
  runtime: turnContext.runtime,
7384
+ hostAccess: turnContext.policy.hostFs,
7372
7385
  triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7373
7386
  title: turnContext.conversation.title,
7374
7387
  messageBody: message.body,
@@ -7430,6 +7443,7 @@ ${reason}`,
7430
7443
  agentId: payload.agentId,
7431
7444
  agentUsername: this.opts.agentUsername,
7432
7445
  runtime: turnContext.runtime,
7446
+ hostAccess: turnContext.policy.hostFs,
7433
7447
  // CT317/CT319: the trigger message's referenced-entry paths — what the
7434
7448
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
7435
7449
  // an older API. The conversation anchor is gone (CT319).
@@ -7473,7 +7487,10 @@ ${reason}`,
7473
7487
  }
7474
7488
  }
7475
7489
  }
7476
- const turnEnv = hookEnv;
7490
+ const turnEnv = {
7491
+ ...hookEnv,
7492
+ CABANE_HOST_ACCESS: turnContext.policy.hostFs ? "1" : "0"
7493
+ };
7477
7494
  const key = runKey(payload.conversationId, payload.agentId);
7478
7495
  const abortController = new AbortController();
7479
7496
  this.aborts.set(key, abortController);
@@ -7536,9 +7553,8 @@ ${reason}`,
7536
7553
  ...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
7537
7554
  // SJ524: the hook-resolved cwd overrides the static local cwd.
7538
7555
  ...effectiveCwd ? { cwd: effectiveCwd } : {},
7539
- // CT804: `turnEnv` = the prepare-hook env plus the per-turn checkout-local
7540
- // TMPDIR (falls back to `hookEnv` when no cwd was resolved).
7541
- ...turnEnv ? { env: turnEnv } : {},
7556
+ // CT1103: the prepare-hook env plus the authoritative host-access token.
7557
+ env: turnEnv,
7542
7558
  mcpServers: resolvedMcpServers,
7543
7559
  summonServer,
7544
7560
  // CT238: this turn's conversation, forwarded as the active-conversation
@@ -7776,6 +7792,7 @@ ${reason}`,
7776
7792
  let contentBearingEvents = 0;
7777
7793
  let latestSessionState = request.session;
7778
7794
  let settledDiagnostics = null;
7795
+ let silentMarkerEmitted = false;
7779
7796
  const committer = new TurnCommitter({
7780
7797
  api: this.opts.api,
7781
7798
  workspaceId,
@@ -7967,6 +7984,27 @@ ${reason}`,
7967
7984
  }
7968
7985
  } else {
7969
7986
  await committer.finalize(okResult);
7987
+ if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
7988
+ try {
7989
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7990
+ body: SILENT_MARKER_BODY,
7991
+ kind: "silent",
7992
+ turnId,
7993
+ seq: nextSeq(),
7994
+ parentMessageId: payload.messageId,
7995
+ // ask, wake and summon must survive a wordless turn exactly as
7996
+ // they survive a textual final; dropping one can strand a person
7997
+ // or the next actor with no visible failure.
7998
+ ...committer.turnControlFields()
7999
+ });
8000
+ silentMarkerEmitted = true;
8001
+ } catch (err) {
8002
+ turnLog.warn(
8003
+ { err: err instanceof Error ? err.message : String(err) },
8004
+ "dispatcher: silent-marker commit failed"
8005
+ );
8006
+ }
8007
+ }
7970
8008
  }
7971
8009
  } catch (err) {
7972
8010
  okResult = false;
@@ -8057,7 +8095,7 @@ ${reason}`,
8057
8095
  sessionFingerprint: fingerprintSessionState(latestSessionState),
8058
8096
  eventCounts,
8059
8097
  runtimeResultKind,
8060
- finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
8098
+ finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
8061
8099
  };
8062
8100
  body.diagnostics = settledDiagnostics;
8063
8101
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.47",
3
+ "version": "0.6.49",
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",