@songsid/agend 2.1.4-beta.17 → 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.
@@ -54,6 +54,8 @@ export interface FleetContext {
54
54
  readonly classicChannels: ClassicChannelManager | null;
55
55
  getSysInfo(): SysInfo;
56
56
  getInstanceStatus(name: string): "running" | "paused" | "stopped" | "crashed";
57
+ /** Current cancellation epoch stamped onto direct daemon IPC deliveries. */
58
+ getDeliveryEpoch?(name: string): number;
57
59
  /** Subscription provider IDs used by running or paused fleet/Classic instances. */
58
60
  getActiveUsageProviderIds?(): ReadonlySet<string>;
59
61
  toggleFleetCollab(instanceName: string): boolean;
@@ -113,6 +113,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
113
113
  private warmCapEvicting;
114
114
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
115
115
  private idleGatedDeliveryTails;
116
+ /**
117
+ * Per-instance cancellation epoch for deliveries which have not reached the
118
+ * daemon yet. A cancel advances the epoch; queued work captures the old value
119
+ * and becomes a no-op, while messages arriving after the click capture the new
120
+ * value and remain deliverable.
121
+ */
122
+ private deliveryEpochs;
116
123
  /** Non-user work must observe a fresh idle snapshot after the latest delivery. */
117
124
  private lastDeliveryAt;
118
125
  /** State-cache updates wake event-driven idle waiters without busy polling. */
@@ -783,6 +790,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
783
790
  private reactDone;
784
791
  /** Interrupt an instance's current generation (cancel button / /cancel). */
785
792
  cancelInstance(instanceName: string): boolean;
793
+ getDeliveryEpoch(instanceName: string): number;
794
+ private isDeliveryEpochCurrent;
795
+ /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
796
+ private cancelPendingDeliveries;
786
797
  queueMirrorMessage(text: string): void;
787
798
  /** Push an SSE event to all connected Web UI clients. */
788
799
  emitSseEvent(event: string, data: unknown): void;
@@ -267,6 +267,13 @@ export class FleetManager {
267
267
  warmCapEvicting = new Set();
268
268
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
269
269
  idleGatedDeliveryTails = new Map();
270
+ /**
271
+ * Per-instance cancellation epoch for deliveries which have not reached the
272
+ * daemon yet. A cancel advances the epoch; queued work captures the old value
273
+ * and becomes a no-op, while messages arriving after the click capture the new
274
+ * value and remain deliverable.
275
+ */
276
+ deliveryEpochs = new Map();
270
277
  /** Non-user work must observe a fresh idle snapshot after the latest delivery. */
271
278
  lastDeliveryAt = new Map();
272
279
  /** State-cache updates wake event-driven idle waiters without busy polling. */
@@ -911,7 +918,7 @@ export class FleetManager {
911
918
  .finally(() => this.warmCapEvicting.delete(victim));
912
919
  }
913
920
  }
914
- waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
921
+ waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
915
922
  const isReady = () => {
916
923
  const snapshot = this.instanceStateCache.get(instanceName);
917
924
  return snapshot?.state === "idle"
@@ -933,8 +940,12 @@ export class FleetManager {
933
940
  this.instanceIdleWaiters.delete(instanceName);
934
941
  resolve(idle);
935
942
  };
936
- const check = () => { if (isReady())
937
- finish(true); };
943
+ const check = () => {
944
+ if (cancelled?.())
945
+ finish(false);
946
+ else if (isReady())
947
+ finish(true);
948
+ };
938
949
  const query = () => {
939
950
  const ipc = this.instanceIpcClients.get(instanceName);
940
951
  if (ipc?.connected) {
@@ -992,22 +1003,32 @@ export class FleetManager {
992
1003
  finish(false);
993
1004
  });
994
1005
  }
995
- async deliverWithIdleGate(instanceName, payload, timeoutMs) {
1006
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1007
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1008
+ return;
996
1009
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
997
1010
  if (this.lifecycle.isPaused(instanceName)) {
998
1011
  const wakeStartedAt = Date.now();
999
1012
  await this.lifecycle.wake(instanceName, 30_000);
1013
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1014
+ return;
1000
1015
  // Waking added one to the warm count — make room by evicting a different
1001
1016
  // LRU idle instance (never this one; it's about to work).
1002
1017
  this.enforceWarmCap(instanceName);
1003
1018
  // Never satisfy a post-wake gate from a stale pre-pause cache entry.
1004
1019
  idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
1005
1020
  }
1006
- const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
1021
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
1022
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1023
+ this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
1024
+ return;
1025
+ }
1007
1026
  if (!idle) {
1008
1027
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
1009
1028
  }
1010
- await this.sendWhenConnected(instanceName, payload);
1029
+ await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
1030
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1031
+ return;
1011
1032
  this.lastDeliveryAt.set(instanceName, Date.now());
1012
1033
  }
1013
1034
  /**
@@ -1026,7 +1047,9 @@ export class FleetManager {
1026
1047
  * instance, *including* when the socket happens to be up: otherwise a message
1027
1048
  * arriving after the reconnect could overtake one that has been waiting for it.
1028
1049
  */
1029
- async sendWhenConnected(instanceName, payload) {
1050
+ async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1051
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1052
+ return;
1030
1053
  const queued = this.ipcWaitTails.get(instanceName);
1031
1054
  if (!queued) {
1032
1055
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -1035,7 +1058,11 @@ export class FleetManager {
1035
1058
  }
1036
1059
  const attempt = (queued ?? Promise.resolve())
1037
1060
  .catch(() => { })
1038
- .then(() => this.sendAfterIpcReturns(instanceName, payload));
1061
+ .then(() => {
1062
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1063
+ return;
1064
+ return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
1065
+ });
1039
1066
  // The chain stores a settled-either-way promise so one failed delivery cannot
1040
1067
  // wedge every later one, and so `queued` above is safe to await unguarded.
1041
1068
  const tail = attempt.catch(() => { });
@@ -1052,10 +1079,12 @@ export class FleetManager {
1052
1079
  }
1053
1080
  }
1054
1081
  /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
1055
- async sendAfterIpcReturns(instanceName, payload) {
1082
+ async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1056
1083
  const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
1057
1084
  let warned = false;
1058
1085
  for (;;) {
1086
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1087
+ return;
1059
1088
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
1060
1089
  // so a cached reference would stay dead forever.
1061
1090
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -1073,6 +1102,8 @@ export class FleetManager {
1073
1102
  }
1074
1103
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
1075
1104
  async deliverToInstance(instanceName, payload, options = {}) {
1105
+ const deliveryEpoch = this.getDeliveryEpoch(instanceName);
1106
+ const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
1076
1107
  const meta = payload.meta && typeof payload.meta === "object"
1077
1108
  ? payload.meta
1078
1109
  : undefined;
@@ -1084,16 +1115,20 @@ export class FleetManager {
1084
1115
  if (!waitForIdle) {
1085
1116
  if (this.lifecycle.isPaused(instanceName)) {
1086
1117
  await this.lifecycle.wake(instanceName, 30_000);
1118
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1119
+ return;
1087
1120
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
1088
1121
  }
1089
- await this.sendWhenConnected(instanceName, payload);
1122
+ await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
1123
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1124
+ return;
1090
1125
  // A cross-instance item arriving before the daemon observes this turn as
1091
1126
  // working must not trust the stale idle snapshot from before the send.
1092
1127
  this.lastDeliveryAt.set(instanceName, Date.now());
1093
1128
  return;
1094
1129
  }
1095
1130
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
1096
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
1131
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
1097
1132
  this.idleGatedDeliveryTails.set(instanceName, delivery);
1098
1133
  try {
1099
1134
  await delivery;
@@ -2354,6 +2389,23 @@ export class FleetManager {
2354
2389
  });
2355
2390
  await data.respond(result);
2356
2391
  }
2392
+ else if (data.command === "btw") {
2393
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2394
+ if (!name) {
2395
+ await data.respond(t("classic.no_agent"));
2396
+ return;
2397
+ }
2398
+ const btwText = String(data.options?.message ?? "").trim();
2399
+ if (!btwText) {
2400
+ await data.respond(t("btw.usage"));
2401
+ return;
2402
+ }
2403
+ const result = this.topicCommands.sendBtw(name, btwText, {
2404
+ chatId: "", messageId: "", username: data.username ?? "user",
2405
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2406
+ });
2407
+ await data.respond(result);
2408
+ }
2357
2409
  else if (data.command === "clear") {
2358
2410
  await this.handleClearSlash(data, adapterId);
2359
2411
  }
@@ -2498,6 +2550,23 @@ export class FleetManager {
2498
2550
  });
2499
2551
  await data.respond(result);
2500
2552
  }
2553
+ else if (data.command === "btw") {
2554
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2555
+ if (!name) {
2556
+ await data.respond(t("classic.no_agent"));
2557
+ return;
2558
+ }
2559
+ const btwText = String(data.options?.message ?? "").trim();
2560
+ if (!btwText) {
2561
+ await data.respond(t("btw.usage"));
2562
+ return;
2563
+ }
2564
+ const result = this.topicCommands.sendBtw(name, btwText, {
2565
+ chatId: "", messageId: "", username: data.username ?? "user",
2566
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2567
+ });
2568
+ await data.respond(result);
2569
+ }
2501
2570
  }, this.logger, "adapter.slash_command"));
2502
2571
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2503
2572
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2800,6 +2869,23 @@ export class FleetManager {
2800
2869
  });
2801
2870
  await data.respond(result);
2802
2871
  }
2872
+ else if (data.command === "btw") {
2873
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2874
+ if (!name) {
2875
+ await data.respond(t("classic.no_agent"));
2876
+ return;
2877
+ }
2878
+ const btwText = String(data.options?.message ?? "").trim();
2879
+ if (!btwText) {
2880
+ await data.respond(t("btw.usage"));
2881
+ return;
2882
+ }
2883
+ const result = this.topicCommands.sendBtw(name, btwText, {
2884
+ chatId: "", messageId: "", username: data.username ?? "user",
2885
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2886
+ });
2887
+ await data.respond(result);
2888
+ }
2803
2889
  }, this.logger, `adapter[${adapterId}].slash_command`));
2804
2890
  await adapter.start();
2805
2891
  if (channelConfig.group_id) {
@@ -3385,6 +3471,24 @@ export class FleetManager {
3385
3471
  await msgAdapter?.sendText(chatId, result);
3386
3472
  return;
3387
3473
  }
3474
+ // /btw — Claude Code's native side-thread command. Like /steer it is
3475
+ // not admin-gated, but it remains a distinct raw CLI command so Claude
3476
+ // does not fold the question into the active task.
3477
+ if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
3478
+ const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3479
+ if (!btwName) {
3480
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3481
+ return;
3482
+ }
3483
+ const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
3484
+ if (!btwContent) {
3485
+ await msgAdapter?.sendText(chatId, t("btw.usage"));
3486
+ return;
3487
+ }
3488
+ const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
3489
+ await msgAdapter?.sendText(chatId, result);
3490
+ return;
3491
+ }
3388
3492
  // Handle /clear command (admin only) — unlike /compact this starts a
3389
3493
  // fresh conversation and intentionally discards the current history.
3390
3494
  if (text === "/clear" || text.startsWith("/clear@")) {
@@ -3831,7 +3935,11 @@ export class FleetManager {
3831
3935
  if (silent) {
3832
3936
  const ipc = this.instanceIpcClients.get(target);
3833
3937
  if (ipc) {
3834
- ipc.send({ type: "raw_paste", content: message });
3938
+ ipc.send({
3939
+ type: "raw_paste",
3940
+ content: message,
3941
+ delivery_epoch: this.getDeliveryEpoch(target),
3942
+ });
3835
3943
  this.scheduler.recordRun(id, "delivered");
3836
3944
  this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
3837
3945
  }
@@ -3846,10 +3954,19 @@ export class FleetManager {
3846
3954
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
3847
3955
  const deliver = async () => {
3848
3956
  try {
3957
+ // A schedule has no live inbound adapter context. Seed the daemon with
3958
+ // the target instance's configured world so replies use its persona
3959
+ // after a fresh start instead of falling back to channels[0].
3960
+ const adapterId = this.getInstanceAdapterId(target);
3849
3961
  await this.deliverToInstance(target, {
3850
3962
  type: "fleet_schedule_trigger",
3851
3963
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
3852
- meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
3964
+ meta: {
3965
+ chat_id: reply_chat_id,
3966
+ thread_id: reply_thread_id,
3967
+ user: "scheduler",
3968
+ ...(adapterId ? { adapter_id: adapterId } : {}),
3969
+ },
3853
3970
  }, { waitForIdle: true });
3854
3971
  // A scheduled trigger also puts the instance to work — show a cancel button.
3855
3972
  void this.sendCancelButton(target);
@@ -5731,11 +5848,27 @@ export class FleetManager {
5731
5848
  const daemon = this.daemons.get(instanceName);
5732
5849
  if (!daemon)
5733
5850
  return false;
5851
+ const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
5852
+ daemon.clearPendingDeliveries?.(deliveryEpoch);
5734
5853
  daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
5735
5854
  this.lastInboundMsg.delete(instanceName);
5736
5855
  this.clearCancelButton(instanceName);
5737
5856
  return true;
5738
5857
  }
5858
+ getDeliveryEpoch(instanceName) {
5859
+ return this.deliveryEpochs.get(instanceName) ?? 0;
5860
+ }
5861
+ isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
5862
+ return deliveryEpoch === this.getDeliveryEpoch(instanceName);
5863
+ }
5864
+ /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
5865
+ cancelPendingDeliveries(instanceName) {
5866
+ const next = this.getDeliveryEpoch(instanceName) + 1;
5867
+ this.deliveryEpochs.set(instanceName, next);
5868
+ for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
5869
+ check();
5870
+ return next;
5871
+ }
5739
5872
  queueMirrorMessage(text) {
5740
5873
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
5741
5874
  if (mirrorTopicId == null || !this.adapter)