@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.
- package/dist/channel/adapters/discord.js +7 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/daemon.d.ts +25 -2
- package/dist/daemon.js +129 -23
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +11 -0
- package/dist/fleet-manager.js +146 -13
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +10 -0
- package/dist/locale.js.map +1 -1
- package/dist/topic-commands.d.ts +4 -0
- package/dist/topic-commands.js +45 -0
- package/dist/topic-commands.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-context.d.ts
CHANGED
|
@@ -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;
|
package/dist/fleet-manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/fleet-manager.js
CHANGED
|
@@ -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 = () => {
|
|
937
|
-
|
|
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(() =>
|
|
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,
|
|
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,
|
|
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({
|
|
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: {
|
|
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)
|