@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/channel/adapters/discord.js +7 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/cli.js +58 -8
- package/dist/cli.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/doctor.d.ts +26 -0
- package/dist/doctor.js +209 -0
- package/dist/doctor.js.map +1 -0
- 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/service-installer.d.ts +11 -0
- package/dist/service-installer.js +83 -18
- package/dist/service-installer.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/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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 —
|
|
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(() =>
|
|
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.
|