@songsid/agend 2.1.4-beta.16 → 2.1.4-beta.18

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/daemon.js CHANGED
@@ -775,6 +775,12 @@ export class Daemon extends EventEmitter {
775
775
  * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
776
776
  * reach the pane — {@link paneWriteLock} does. */
777
777
  pasteLock = Promise.resolve();
778
+ /**
779
+ * Fleet cancellation epoch. Queue entries retain the epoch they arrived with;
780
+ * advancing it invalidates only work received before the cancel, never a new
781
+ * user message which arrives immediately afterwards.
782
+ */
783
+ deliveryEpoch = 0;
778
784
  /** Mutual exclusion for *every* write into the pane, whichever subsystem it
779
785
  * comes from. See PaneWriteLock for why interleaving is destructive. */
780
786
  paneWriteLock = new PaneWriteLock();
@@ -1000,36 +1006,33 @@ export class Daemon extends EventEmitter {
1000
1006
  // Fleet manager routed a message to us (topic mode)
1001
1007
  const meta = msg.meta;
1002
1008
  const targetSession = msg.targetSession;
1009
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
1003
1010
  void this.wake().then(() => {
1004
- this.pushChannelMessage(msg.content, meta, targetSession);
1011
+ this.pushChannelMessage(msg.content, meta, targetSession, deliveryEpoch);
1005
1012
  }).catch(err => {
1006
1013
  this.logger.error({ err: err.message }, "Wake failed for inbound delivery");
1007
1014
  });
1008
1015
  }
1009
1016
  else if (msg.type === "raw_paste") {
1010
1017
  // Paste raw text directly to CLI without [user:] wrapping.
1011
- if (this.tmux) {
1012
- const rawText = msg.content;
1013
- this.pasteLock = this.pasteLock.then(async () => {
1014
- await this.wake();
1015
- await this.deliverMessage(rawText);
1016
- this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
1017
- }).catch(err => {
1018
- this.logger.warn({ err: err.message }, "raw_paste delivery error");
1019
- });
1020
- }
1018
+ this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch));
1021
1019
  }
1022
1020
  else if (msg.type === "config_update") {
1023
1021
  this.applyConfigUpdate(msg.config);
1024
1022
  }
1025
1023
  else if (msg.type === "steer") {
1026
1024
  const meta = (msg.meta ?? {});
1027
- this.steerMessage(msg.content, meta);
1025
+ this.steerMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
1026
+ }
1027
+ else if (msg.type === "btw") {
1028
+ const meta = (msg.meta ?? {});
1029
+ this.btwMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
1028
1030
  }
1029
1031
  else if (msg.type === "fleet_schedule_trigger") {
1030
1032
  const payload = msg.payload;
1031
1033
  const meta = msg.meta;
1032
- void this.wake().then(() => this.pushChannelMessage(payload.message, meta)).catch(err => {
1034
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
1035
+ void this.wake().then(() => this.pushChannelMessage(payload.message, meta, undefined, deliveryEpoch)).catch(err => {
1033
1036
  this.logger.error({ err: err.message }, "Wake failed for scheduled delivery");
1034
1037
  });
1035
1038
  }
@@ -1871,6 +1874,46 @@ export class Daemon extends EventEmitter {
1871
1874
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
1872
1875
  await this.tmux?.sendSpecialKey(cancelKey);
1873
1876
  }
1877
+ /**
1878
+ * Drop every not-yet-started pane delivery which predates a user cancel.
1879
+ * Promise chains cannot be removed, so entries become generation-checked
1880
+ * no-ops. An entry already past its check is allowed to finish its current PTY
1881
+ * transaction; the interrupt key still cancels the CLI generation.
1882
+ */
1883
+ clearPendingDeliveries(fleetEpoch) {
1884
+ this.deliveryEpoch = fleetEpoch === undefined
1885
+ ? this.deliveryEpoch + 1
1886
+ : Math.max(this.deliveryEpoch, fleetEpoch);
1887
+ this.logger.info({ deliveryEpoch: this.deliveryEpoch }, "Pending delivery queue cleared by user cancel");
1888
+ }
1889
+ captureDeliveryEpoch(value) {
1890
+ const epoch = typeof value === "number" && Number.isSafeInteger(value) && value >= 0
1891
+ ? value
1892
+ : this.deliveryEpoch;
1893
+ if (epoch > this.deliveryEpoch)
1894
+ this.deliveryEpoch = epoch;
1895
+ return epoch;
1896
+ }
1897
+ isDeliveryEpochCurrent(epoch) {
1898
+ return epoch === this.deliveryEpoch;
1899
+ }
1900
+ queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch) {
1901
+ if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch))
1902
+ return;
1903
+ this.pasteLock = this.pasteLock.then(async () => {
1904
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
1905
+ this.logger.info("Pending raw delivery dropped by user cancel");
1906
+ return;
1907
+ }
1908
+ await this.wake();
1909
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
1910
+ return;
1911
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
1912
+ this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
1913
+ }).catch(err => {
1914
+ this.logger.warn({ err: err.message }, "raw_paste delivery error");
1915
+ });
1916
+ }
1874
1917
  /** Send the backend-specific graceful quit command/key sequence. */
1875
1918
  async sendQuitSequence() {
1876
1919
  if (!this.tmux || !this.backend)
@@ -2785,7 +2828,9 @@ export class Daemon extends EventEmitter {
2785
2828
  * The steered text goes through formatInboundMessage so the agent sees a
2786
2829
  * normal inbound message, with a steering notice prepended for context.
2787
2830
  */
2788
- steerMessage(content, meta) {
2831
+ steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
2832
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2833
+ return;
2789
2834
  this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
2790
2835
  this.pendingWork.recordInbound();
2791
2836
  this.recordRecentUserMessage(content, meta);
@@ -2797,20 +2842,60 @@ export class Daemon extends EventEmitter {
2797
2842
  ? { chatId: meta.thread_id || chatId, messageId }
2798
2843
  : undefined;
2799
2844
  this.steerLock = this.steerLock.then(async () => {
2845
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2846
+ return;
2800
2847
  await this.wake();
2801
- if (await this.deliverMessage(formatted, status, { steer: true })) {
2848
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2849
+ return;
2850
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
2802
2851
  this.markTurnStarted(meta, formatted);
2803
2852
  }
2804
2853
  }).catch(err => {
2805
2854
  this.logger.warn({ err: err.message }, "steer delivery error");
2806
2855
  });
2807
2856
  }
2857
+ /**
2858
+ * /btw: submit a separately-labelled side question immediately while asking
2859
+ * the agent not to change the active task. Claude's native `/btw` opens a
2860
+ * branchAndResume panel whose answer never reaches the channel or transcript,
2861
+ * so channel delivery intentionally uses a normal wrapped inbound instead.
2862
+ */
2863
+ btwMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
2864
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2865
+ return;
2866
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
2867
+ this.pendingWork.recordInbound();
2868
+ this.recordRecentUserMessage(content, meta);
2869
+ const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
2870
+ + this.formatInboundMessage(content, meta);
2871
+ const chatId = meta.chat_id;
2872
+ const messageId = meta.message_id;
2873
+ const status = (chatId && messageId)
2874
+ ? { chatId: meta.thread_id || chatId, messageId }
2875
+ : undefined;
2876
+ this.steerLock = this.steerLock.then(async () => {
2877
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2878
+ return;
2879
+ await this.wake();
2880
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2881
+ return;
2882
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
2883
+ this.markTurnStarted(meta, formatted);
2884
+ }
2885
+ }).catch(err => {
2886
+ this.logger.warn({ err: err.message }, "btw delivery error");
2887
+ });
2888
+ }
2808
2889
  /**
2809
2890
  * Push an inbound channel message to a specific MCP session.
2810
2891
  * If targetSession is provided, only send to the matching socket.
2811
2892
  * Otherwise send to the instance's own session (this.name).
2812
2893
  */
2813
- pushChannelMessage(content, meta, _targetSession) {
2894
+ pushChannelMessage(content, meta, _targetSession, deliveryEpoch = this.deliveryEpoch) {
2895
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2896
+ this.logger.info("Pending channel delivery dropped by user cancel");
2897
+ return;
2898
+ }
2814
2899
  if (!this.tmux) {
2815
2900
  this.logger.warn("Cannot push channel message: tmux not running");
2816
2901
  return;
@@ -2844,7 +2929,9 @@ export class Daemon extends EventEmitter {
2844
2929
  const rawText = content.slice(5);
2845
2930
  this.logger.info({ user }, "Raw paste from topic mode user");
2846
2931
  this.pasteLock = this.pasteLock.then(async () => {
2847
- if (await this.deliverMessage(rawText))
2932
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2933
+ return;
2934
+ if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
2848
2935
  this.markTurnStarted(meta, rawText);
2849
2936
  }).catch(err => {
2850
2937
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
@@ -2854,8 +2941,7 @@ export class Daemon extends EventEmitter {
2854
2941
  const formatted = this.formatInboundMessage(content, meta);
2855
2942
  // Serialize deliveries: each message waits for the previous to complete,
2856
2943
  // and each waits for the CLI to be idle before pasting. Messages are never
2857
- // dropped for age — a long-busy CLI just queues them until it frees up
2858
- // (the user can press Cancel to interrupt and let the queue drain sooner).
2944
+ // dropped for age — only an explicit user Cancel invalidates queued work.
2859
2945
  const chatId = meta.chat_id;
2860
2946
  const messageId = meta.message_id;
2861
2947
  const wasQueued = this.pasteQueueDepth > 0;
@@ -2868,12 +2954,20 @@ export class Daemon extends EventEmitter {
2868
2954
  }
2869
2955
  this.pasteLock = this.pasteLock.then(async () => {
2870
2956
  try {
2957
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2958
+ this.logger.info("Pending channel delivery dropped by user cancel");
2959
+ return;
2960
+ }
2871
2961
  if (this.config.pre_task_command) {
2872
- await this.deliverMessage(this.config.pre_task_command);
2962
+ await this.deliverMessage(this.config.pre_task_command, undefined, { deliveryEpoch });
2963
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2964
+ return;
2873
2965
  }
2874
2966
  if (this.pendingInstructionsNotice) {
2875
2967
  this.pendingInstructionsNotice = false;
2876
- await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
2968
+ await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), undefined, { deliveryEpoch });
2969
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2970
+ return;
2877
2971
  }
2878
2972
  const status = (chatId && messageId)
2879
2973
  ? { chatId: meta.thread_id || chatId, messageId }
@@ -2881,7 +2975,7 @@ export class Daemon extends EventEmitter {
2881
2975
  // A fresh delivery begins a fresh turn — its bubble must not inherit
2882
2976
  // the previous turn's tool list.
2883
2977
  this.resetToolProgress();
2884
- if (await this.deliverMessage(formatted, status))
2978
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch }))
2885
2979
  this.markTurnStarted(meta, formatted);
2886
2980
  }
2887
2981
  finally {
@@ -2909,6 +3003,10 @@ export class Daemon extends EventEmitter {
2909
3003
  * serial in both cases so separate PTY writes can never overlap.
2910
3004
  */
2911
3005
  async deliverMessage(formatted, status, opts) {
3006
+ const cancelled = () => opts?.deliveryEpoch !== undefined
3007
+ && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
3008
+ if (cancelled())
3009
+ return false;
2912
3010
  // Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
2913
3011
  const fenceCount = (formatted.match(/```/g) || []).length;
2914
3012
  if (fenceCount % 2 !== 0) {
@@ -2917,6 +3015,8 @@ export class Daemon extends EventEmitter {
2917
3015
  }
2918
3016
  // Before anything reads the window id: a spawn in progress is about to change it.
2919
3017
  await this.waitForSpawnToSettle();
3018
+ if (cancelled())
3019
+ return false;
2920
3020
  let windowId = this.getWindowId();
2921
3021
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
2922
3022
  let handingOffToNativeQueue = false;
@@ -2938,6 +3038,8 @@ export class Daemon extends EventEmitter {
2938
3038
  else {
2939
3039
  this.logger.debug("CLI busy — queuing message until idle");
2940
3040
  const becameIdle = await this.controlClient.waitUntilIdle(windowId);
3041
+ if (cancelled())
3042
+ return false;
2941
3043
  if (!becameIdle) {
2942
3044
  // The pane never freed up. Report the failure instead of pasting into a
2943
3045
  // wedged CLI (where the text would sit unsubmitted and the next message
@@ -2953,7 +3055,11 @@ export class Daemon extends EventEmitter {
2953
3055
  // held under the pane lock — holding it across the idle wait (up to 30 min)
2954
3056
  // would starve the runtime-dialog dismisser, which is often the very thing
2955
3057
  // that would let the pane go idle again.
2956
- return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
3058
+ return this.paneWriteLock.run(async () => {
3059
+ if (cancelled())
3060
+ return false;
3061
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3062
+ });
2957
3063
  }
2958
3064
  /**
2959
3065
  * The write half of a delivery: paste → Enter → confirm, with retries.