@cabane/companion 0.6.48 → 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
@@ -3599,7 +3599,7 @@ var TurnPump = class {
3599
3599
  };
3600
3600
  }
3601
3601
  opts;
3602
- // `finalEmitted` gates the end-of-turn empty-final promotion;
3602
+ // `finalEmitted` gates the end-of-turn progress promotion;
3603
3603
  // `lastProgressBody` is what we promote to `final` when a clean turn ended on
3604
3604
  // a tool call with no closing text.
3605
3605
  emittedFinal = false;
@@ -3614,28 +3614,28 @@ var TurnPump = class {
3614
3614
  // frozen committer test — drive. Each callback applies the choreography and
3615
3615
  // commits through the injected sink.
3616
3616
  sink;
3617
- // End-of-turn empty-final promotion. A clean turn that ended on a tool call
3618
- // with no closing text still needs exactly one `final` row (so the user gets
3619
- // one notification and the drawer closes the turn). Promote the last progress
3620
- // body when we have one (the drawer collapses the dup), else the host's
3621
- // minimal note. Skipped when cancelled or already final. The held-text flush
3622
- // that precedes it is a classification concern, driven by the caller before
3623
- // 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.
3624
3624
  async finalize(ok) {
3625
- if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
3626
- const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
3625
+ if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
3626
+ const body = this.lastProgressBody;
3627
3627
  const seq = this.opts.nextSeq();
3628
3628
  try {
3629
3629
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3630
3630
  this.emittedFinal = true;
3631
3631
  this.finalReplyBody = body;
3632
- this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3632
+ this.emittedFinalSource = "progress_promotion";
3633
3633
  } catch (err) {
3634
- this.opts.onError?.(err, "empty-final");
3634
+ this.opts.onError?.(err, "progress-promotion");
3635
3635
  }
3636
3636
  }
3637
3637
  // Whether the turn has committed its `final` row — read by the host to decide
3638
- // whether the empty-final path or a marker is owed.
3638
+ // whether a marker is owed.
3639
3639
  get finalEmitted() {
3640
3640
  return this.emittedFinal;
3641
3641
  }
@@ -7274,7 +7274,6 @@ function pruneOld(dir2, retain) {
7274
7274
  }
7275
7275
 
7276
7276
  // src/turn-committer.ts
7277
- var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
7278
7277
  var TurnCommitter = class {
7279
7278
  constructor(deps) {
7280
7279
  this.deps = deps;
@@ -7295,9 +7294,7 @@ var TurnCommitter = class {
7295
7294
  turnId: deps.turnId,
7296
7295
  seq,
7297
7296
  parentMessageId: deps.parentMessageId,
7298
- ...kind === "final" ? this.summonField() : {},
7299
- ...kind === "final" ? this.askField() : {},
7300
- ...kind === "final" ? this.wakeField() : {}
7297
+ ...kind === "final" ? this.turnControlFields() : {}
7301
7298
  },
7302
7299
  deps.signal
7303
7300
  );
@@ -7344,11 +7341,10 @@ var TurnCommitter = class {
7344
7341
  commit,
7345
7342
  signal: deps.signal,
7346
7343
  nextSeq: deps.nextSeq,
7347
- emptyFinalBody: EMPTY_FINAL_BODY,
7348
7344
  onError: (err) => {
7349
7345
  deps.log.warn(
7350
7346
  { err: err instanceof Error ? err.message : String(err) },
7351
- "dispatcher: empty-final commit failed"
7347
+ "dispatcher: progress-promotion commit failed"
7352
7348
  );
7353
7349
  }
7354
7350
  });
@@ -7374,11 +7370,11 @@ var TurnCommitter = class {
7374
7370
  this.onError(err, event.type);
7375
7371
  }
7376
7372
  }
7377
- // 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
7378
7374
  // job (it emits the closing reply as a `text` event before `result`), so this
7379
- // only runs the pump's empty-final: a clean turn that ended on a tool call with
7380
- // no closing text still gets exactly one `final`. Guards on the abort signal, so
7381
- // 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.
7382
7378
  async finalize(okResult) {
7383
7379
  await this.pump.finalize(okResult);
7384
7380
  }
@@ -7389,6 +7385,19 @@ var TurnCommitter = class {
7389
7385
  get finalSource() {
7390
7386
  return this.pump.finalSource;
7391
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
+ }
7392
7401
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7393
7402
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7394
7403
  // server strips it again and resolves / ignores an unknown id.
@@ -7617,6 +7626,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
7617
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:";
7618
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`;
7619
7628
  var SKIPPED_MARKER_BODY = "(skipped)";
7629
+ var SILENT_MARKER_BODY = "(no reply)";
7620
7630
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7621
7631
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7622
7632
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -8274,6 +8284,7 @@ ${reason}`,
8274
8284
  let contentBearingEvents = 0;
8275
8285
  let latestSessionState = request.session;
8276
8286
  let settledDiagnostics = null;
8287
+ let silentMarkerEmitted = false;
8277
8288
  const committer = new TurnCommitter({
8278
8289
  api: this.opts.api,
8279
8290
  workspaceId,
@@ -8465,6 +8476,27 @@ ${reason}`,
8465
8476
  }
8466
8477
  } else {
8467
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
+ }
8468
8500
  }
8469
8501
  } catch (err) {
8470
8502
  okResult = false;
@@ -8555,7 +8587,7 @@ ${reason}`,
8555
8587
  sessionFingerprint: fingerprintSessionState(latestSessionState),
8556
8588
  eventCounts,
8557
8589
  runtimeResultKind,
8558
- finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
8590
+ finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
8559
8591
  };
8560
8592
  body.diagnostics = settledDiagnostics;
8561
8593
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
package/dist/runtime.js CHANGED
@@ -3107,7 +3107,7 @@ var TurnPump = class {
3107
3107
  };
3108
3108
  }
3109
3109
  opts;
3110
- // `finalEmitted` gates the end-of-turn empty-final promotion;
3110
+ // `finalEmitted` gates the end-of-turn progress promotion;
3111
3111
  // `lastProgressBody` is what we promote to `final` when a clean turn ended on
3112
3112
  // a tool call with no closing text.
3113
3113
  emittedFinal = false;
@@ -3122,28 +3122,28 @@ var TurnPump = class {
3122
3122
  // frozen committer test — drive. Each callback applies the choreography and
3123
3123
  // commits through the injected sink.
3124
3124
  sink;
3125
- // End-of-turn empty-final promotion. A clean turn that ended on a tool call
3126
- // with no closing text still needs exactly one `final` row (so the user gets
3127
- // one notification and the drawer closes the turn). Promote the last progress
3128
- // body when we have one (the drawer collapses the dup), else the host's
3129
- // minimal note. Skipped when cancelled or already final. The held-text flush
3130
- // that precedes it is a classification concern, driven by the caller before
3131
- // 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.
3132
3132
  async finalize(ok) {
3133
- if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
3134
- const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
3133
+ if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
3134
+ const body = this.lastProgressBody;
3135
3135
  const seq = this.opts.nextSeq();
3136
3136
  try {
3137
3137
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3138
3138
  this.emittedFinal = true;
3139
3139
  this.finalReplyBody = body;
3140
- this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3140
+ this.emittedFinalSource = "progress_promotion";
3141
3141
  } catch (err) {
3142
- this.opts.onError?.(err, "empty-final");
3142
+ this.opts.onError?.(err, "progress-promotion");
3143
3143
  }
3144
3144
  }
3145
3145
  // Whether the turn has committed its `final` row — read by the host to decide
3146
- // whether the empty-final path or a marker is owed.
3146
+ // whether a marker is owed.
3147
3147
  get finalEmitted() {
3148
3148
  return this.emittedFinal;
3149
3149
  }
@@ -6782,7 +6782,6 @@ function pruneOld(dir2, retain) {
6782
6782
  }
6783
6783
 
6784
6784
  // src/turn-committer.ts
6785
- var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
6786
6785
  var TurnCommitter = class {
6787
6786
  constructor(deps) {
6788
6787
  this.deps = deps;
@@ -6803,9 +6802,7 @@ var TurnCommitter = class {
6803
6802
  turnId: deps.turnId,
6804
6803
  seq,
6805
6804
  parentMessageId: deps.parentMessageId,
6806
- ...kind === "final" ? this.summonField() : {},
6807
- ...kind === "final" ? this.askField() : {},
6808
- ...kind === "final" ? this.wakeField() : {}
6805
+ ...kind === "final" ? this.turnControlFields() : {}
6809
6806
  },
6810
6807
  deps.signal
6811
6808
  );
@@ -6852,11 +6849,10 @@ var TurnCommitter = class {
6852
6849
  commit,
6853
6850
  signal: deps.signal,
6854
6851
  nextSeq: deps.nextSeq,
6855
- emptyFinalBody: EMPTY_FINAL_BODY,
6856
6852
  onError: (err) => {
6857
6853
  deps.log.warn(
6858
6854
  { err: err instanceof Error ? err.message : String(err) },
6859
- "dispatcher: empty-final commit failed"
6855
+ "dispatcher: progress-promotion commit failed"
6860
6856
  );
6861
6857
  }
6862
6858
  });
@@ -6882,11 +6878,11 @@ var TurnCommitter = class {
6882
6878
  this.onError(err, event.type);
6883
6879
  }
6884
6880
  }
6885
- // 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
6886
6882
  // job (it emits the closing reply as a `text` event before `result`), so this
6887
- // only runs the pump's empty-final: a clean turn that ended on a tool call with
6888
- // no closing text still gets exactly one `final`. Guards on the abort signal, so
6889
- // 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.
6890
6886
  async finalize(okResult) {
6891
6887
  await this.pump.finalize(okResult);
6892
6888
  }
@@ -6897,6 +6893,19 @@ var TurnCommitter = class {
6897
6893
  get finalSource() {
6898
6894
  return this.pump.finalSource;
6899
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
+ }
6900
6909
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
6901
6910
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
6902
6911
  // server strips it again and resolves / ignores an unknown id.
@@ -7125,6 +7134,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
7125
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:";
7126
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`;
7127
7136
  var SKIPPED_MARKER_BODY = "(skipped)";
7137
+ var SILENT_MARKER_BODY = "(no reply)";
7128
7138
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7129
7139
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7130
7140
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -7782,6 +7792,7 @@ ${reason}`,
7782
7792
  let contentBearingEvents = 0;
7783
7793
  let latestSessionState = request.session;
7784
7794
  let settledDiagnostics = null;
7795
+ let silentMarkerEmitted = false;
7785
7796
  const committer = new TurnCommitter({
7786
7797
  api: this.opts.api,
7787
7798
  workspaceId,
@@ -7973,6 +7984,27 @@ ${reason}`,
7973
7984
  }
7974
7985
  } else {
7975
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
+ }
7976
8008
  }
7977
8009
  } catch (err) {
7978
8010
  okResult = false;
@@ -8063,7 +8095,7 @@ ${reason}`,
8063
8095
  sessionFingerprint: fingerprintSessionState(latestSessionState),
8064
8096
  eventCounts,
8065
8097
  runtimeResultKind,
8066
- finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
8098
+ finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
8067
8099
  };
8068
8100
  body.diagnostics = settledDiagnostics;
8069
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.48",
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",