@qwen-code/channel-base 0.19.3 → 0.19.4
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/README.md +111 -35
- package/dist/AcpBridge.d.ts +10 -16
- package/dist/AcpBridge.js +30 -1
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +48 -0
- package/dist/ChannelAgentBridge.js +2 -0
- package/dist/ChannelAgentBridge.js.map +1 -0
- package/dist/ChannelBase.d.ts +157 -6
- package/dist/ChannelBase.js +1372 -51
- package/dist/ChannelBase.js.map +1 -1
- package/dist/ChannelLoopScheduler.d.ts +44 -0
- package/dist/ChannelLoopScheduler.js +275 -0
- package/dist/ChannelLoopScheduler.js.map +1 -0
- package/dist/ChannelLoopStore.d.ts +47 -0
- package/dist/ChannelLoopStore.js +195 -0
- package/dist/ChannelLoopStore.js.map +1 -0
- package/dist/DaemonChannelBridge.d.ts +2 -2
- package/dist/DaemonChannelBridge.js +16 -2
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SenderGate.d.ts +1 -0
- package/dist/SenderGate.js +13 -0
- package/dist/SenderGate.js.map +1 -1
- package/dist/SessionRouter.d.ts +14 -13
- package/dist/SessionRouter.js +202 -48
- package/dist/SessionRouter.js.map +1 -1
- package/dist/group-history-store.d.ts +26 -0
- package/dist/group-history-store.js +242 -0
- package/dist/group-history-store.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/sanitize.d.ts +60 -0
- package/dist/sanitize.js +117 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/types.d.ts +30 -3
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
1
2
|
import { BlockStreamer } from './BlockStreamer.js';
|
|
2
3
|
import { GroupGate } from './GroupGate.js';
|
|
4
|
+
import { GroupHistoryStore } from './group-history-store.js';
|
|
3
5
|
import { SenderGate } from './SenderGate.js';
|
|
4
6
|
import { PairingStore } from './PairingStore.js';
|
|
5
7
|
import { SessionRouter } from './SessionRouter.js';
|
|
8
|
+
import { getGlobalQwenDir } from './paths.js';
|
|
9
|
+
import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, } from './sanitize.js';
|
|
10
|
+
import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
|
|
11
|
+
/**
|
|
12
|
+
* Max time /clear waits for a cancelled in-flight turn to wind down before
|
|
13
|
+
* purging anyway. A wedged ACP child (stuck tool call, not reading stdin, or
|
|
14
|
+
* crashed without closing) can leave active.done unresolved forever; without
|
|
15
|
+
* this bound /clear — and the whole channel — would hang. Safe because the
|
|
16
|
+
* purge runs regardless and the generation is bumped, so a turn that settles
|
|
17
|
+
* later is already invalidated.
|
|
18
|
+
*/
|
|
19
|
+
export const CLEAR_CANCEL_TIMEOUT_MS = 3000;
|
|
20
|
+
const GROUP_HISTORY_CONTEXT_MARKER = '[Chat messages since your last reply - for context]';
|
|
21
|
+
const CURRENT_MESSAGE_MARKER = '[Current message - respond to this]';
|
|
22
|
+
const GROUP_HISTORY_ENTRY_TEXT_LIMIT = 1000;
|
|
23
|
+
const GROUP_HISTORY_ENTRY_METADATA_LIMIT = 256;
|
|
24
|
+
const LOOP_CANCEL_GRACE_MS = 5000;
|
|
25
|
+
/**
|
|
26
|
+
* Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
|
|
27
|
+
* plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
|
|
28
|
+
* `/compress-fast`, `/git:commit`) parse as commands. Shared by parseCommand and
|
|
29
|
+
* isSlashCommand below so the two classifiers can't drift apart.
|
|
30
|
+
*/
|
|
31
|
+
const COMMAND_TOKEN_CHARS = 'a-zA-Z0-9_:-';
|
|
32
|
+
/** parseCommand: capture the leading `/command` token (+ optional `@botname`) and the rest as args. */
|
|
33
|
+
const PARSE_COMMAND_RE = new RegExp(`^\\/([${COMMAND_TOKEN_CHARS}]+)(?:@\\S+)?\\s*(.*)`, 's');
|
|
34
|
+
/** isSlashCommand: the first whitespace-delimited token alone must be a pure command token. */
|
|
35
|
+
const COMMAND_TOKEN_RE = new RegExp(`^[${COMMAND_TOKEN_CHARS}]+(?:@\\S+)?$`);
|
|
36
|
+
const LOOP_ADD_RE = /^"([^"]+)"\s+(.+)$/su;
|
|
37
|
+
const MAX_LOOP_JOBS_PER_TARGET = 10;
|
|
38
|
+
const MAX_LOOP_PROMPT_CHARS = 4000;
|
|
39
|
+
function parseLoopAddArgs(args) {
|
|
40
|
+
const match = args.trim().match(LOOP_ADD_RE);
|
|
41
|
+
if (!match)
|
|
42
|
+
return null;
|
|
43
|
+
const cron = match[1].trim();
|
|
44
|
+
const prompt = match[2].trim();
|
|
45
|
+
return cron && prompt ? { cron, prompt } : null;
|
|
46
|
+
}
|
|
6
47
|
export class ChannelBase {
|
|
7
48
|
config;
|
|
8
49
|
bridge;
|
|
@@ -12,19 +53,42 @@ export class ChannelBase {
|
|
|
12
53
|
name;
|
|
13
54
|
/** Resolved proxy URL, available to subclasses for adapter-specific clients. */
|
|
14
55
|
proxy;
|
|
56
|
+
channelMemory;
|
|
57
|
+
groupHistory;
|
|
58
|
+
loopController;
|
|
15
59
|
instructedSessions = new Set();
|
|
16
60
|
commands = new Map();
|
|
17
61
|
/** Per-session promise chain to serialize prompt + send (followup mode). */
|
|
18
62
|
sessionQueues = new Map();
|
|
63
|
+
registerBridgeEvents;
|
|
64
|
+
/**
|
|
65
|
+
* Per-session generation, bumped by /clear. A queued followup turn captures the
|
|
66
|
+
* generation when it enqueues and bails if /clear bumped it before the turn ran,
|
|
67
|
+
* so a cleared session can't be resurrected by an already-queued prompt.
|
|
68
|
+
*/
|
|
69
|
+
sessionGenerations = new Map();
|
|
19
70
|
/** Per-session active prompt tracking for dispatch modes. */
|
|
20
71
|
activePrompts = new Map();
|
|
21
72
|
/** Per-session message buffer for collect mode. */
|
|
22
73
|
collectBuffers = new Map();
|
|
74
|
+
bridgeToolCallListener = (event) => {
|
|
75
|
+
const target = this.router.getTarget(event.sessionId);
|
|
76
|
+
if (target) {
|
|
77
|
+
this.onToolCall(target.chatId, event);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
bridgeSessionDiedListener = (event) => {
|
|
81
|
+
this.onSessionDied(event.sessionId);
|
|
82
|
+
};
|
|
23
83
|
constructor(name, config, bridge, options) {
|
|
24
84
|
this.name = name;
|
|
25
85
|
this.config = config;
|
|
26
86
|
this.bridge = bridge;
|
|
27
87
|
this.proxy = options?.proxy;
|
|
88
|
+
this.channelMemory = options?.channelMemory;
|
|
89
|
+
this.groupHistory = new GroupHistoryStore(options?.groupHistoryPath ??
|
|
90
|
+
join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
|
|
91
|
+
this.loopController = options?.loopController;
|
|
28
92
|
this.groupGate = new GroupGate(config.groupPolicy, config.groups);
|
|
29
93
|
const pairingStore = config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined;
|
|
30
94
|
this.gate = new SenderGate(config.senderPolicy, config.allowedUsers, pairingStore);
|
|
@@ -32,22 +96,254 @@ export class ChannelBase {
|
|
|
32
96
|
options?.router ||
|
|
33
97
|
new SessionRouter(bridge, config.cwd, config.sessionScope);
|
|
34
98
|
this.registerSharedCommands();
|
|
35
|
-
// When running standalone
|
|
99
|
+
// When running standalone, register bridge listeners directly.
|
|
36
100
|
// In gateway mode, the ChannelManager dispatches events instead.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
101
|
+
this.registerBridgeEvents =
|
|
102
|
+
options?.registerBridgeEvents ?? !options?.router;
|
|
103
|
+
if (this.registerBridgeEvents) {
|
|
104
|
+
this.attachBridgeEvents(bridge);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
supportsProactiveSend() {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
supportsProactiveTarget(target) {
|
|
111
|
+
return target.threadId === undefined;
|
|
112
|
+
}
|
|
113
|
+
async pushProactive(target, text) {
|
|
114
|
+
if (target.threadId) {
|
|
115
|
+
throw new Error('Channel does not support proactive loop messages for threaded targets.');
|
|
44
116
|
}
|
|
117
|
+
await this.sendMessage(target.chatId, text);
|
|
45
118
|
}
|
|
46
119
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
47
120
|
setBridge(bridge) {
|
|
121
|
+
if (this.registerBridgeEvents) {
|
|
122
|
+
this.detachBridgeEvents(this.bridge);
|
|
123
|
+
}
|
|
124
|
+
this.router.setBridge(bridge);
|
|
48
125
|
this.bridge = bridge;
|
|
126
|
+
if (this.registerBridgeEvents) {
|
|
127
|
+
this.attachBridgeEvents(bridge);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async runLoopPrompt(job, options = {}) {
|
|
131
|
+
if (!this.supportsProactiveSend()) {
|
|
132
|
+
throw new Error('Channel does not support proactive loop messages.');
|
|
133
|
+
}
|
|
134
|
+
if (this.config.sessionScope === 'single') {
|
|
135
|
+
await this.loopController?.disable(job.id);
|
|
136
|
+
throw new Error('Loop messages are not supported with single session scope.');
|
|
137
|
+
}
|
|
138
|
+
if (job.channelName !== this.name) {
|
|
139
|
+
throw new Error(`Loop ${job.id} belongs to ${job.channelName}, not ${this.name}.`);
|
|
140
|
+
}
|
|
141
|
+
if (!this.supportsProactiveTarget(job.target)) {
|
|
142
|
+
throw new Error('Channel does not support proactive loop messages for this chat target.');
|
|
143
|
+
}
|
|
144
|
+
if (!this.isStoredLoopTargetAuthorized(job.target, job.createdBy)) {
|
|
145
|
+
await this.loopController?.disable(job.id);
|
|
146
|
+
throw new Error(`Loop ${job.id} target is no longer authorized.`);
|
|
147
|
+
}
|
|
148
|
+
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd);
|
|
149
|
+
const label = sanitizeQuotedText(job.label || job.id, 80);
|
|
150
|
+
const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
|
|
151
|
+
let promptText = `[Loop "${label}" created by ${createdBy}]\n\n${sanitizePromptText(job.prompt)}`;
|
|
152
|
+
const shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
|
|
153
|
+
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
154
|
+
const generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
155
|
+
const current = prev.then(async () => {
|
|
156
|
+
if ((this.sessionGenerations.get(sessionId) ?? 0) !== generation) {
|
|
157
|
+
process.stderr.write(`[${this.name}] dropped loop ${job.id} for session ${sessionId}: session was cleared before it ran\n`);
|
|
158
|
+
throw new ChannelLoopSkippedError('loop dropped because session was cleared before it ran');
|
|
159
|
+
}
|
|
160
|
+
if (options.shouldContinue && !(await options.shouldContinue())) {
|
|
161
|
+
throw new ChannelLoopSkippedError('loop dropped because it is no longer enabled');
|
|
162
|
+
}
|
|
163
|
+
let shouldClaimSessionContext = false;
|
|
164
|
+
if (shouldPrependSessionContext) {
|
|
165
|
+
const context = [];
|
|
166
|
+
let sessionContextReady = true;
|
|
167
|
+
if (this.channelMemory &&
|
|
168
|
+
this.isSenderAuthorizedForChannelMemory(job.target.senderId) &&
|
|
169
|
+
(!this.isSharedSessionTarget(job.target) ||
|
|
170
|
+
this.config.senderPolicy === 'allowlist')) {
|
|
171
|
+
try {
|
|
172
|
+
const memoryText = (await this.channelMemory.readChannelMemory({
|
|
173
|
+
channelName: this.name,
|
|
174
|
+
chatId: job.target.chatId,
|
|
175
|
+
threadId: job.target.threadId,
|
|
176
|
+
})).trim();
|
|
177
|
+
if (memoryText) {
|
|
178
|
+
context.push(`Channel memory for this chat:\n${sanitizePromptText(memoryText)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
process.stderr.write(`[${this.name}] channel memory read failed for loop ${job.id} chat ${sanitizeLogText(job.target.chatId, 64)}: ${sanitizeLogText(this.channelMemoryErrorMessage(error), 200)}\n`);
|
|
183
|
+
this.instructedSessions.delete(sessionId);
|
|
184
|
+
sessionContextReady = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (this.config.instructions) {
|
|
188
|
+
context.push(this.config.instructions);
|
|
189
|
+
}
|
|
190
|
+
if (context.length > 0) {
|
|
191
|
+
promptText = `${context.join('\n\n')}\n\n${promptText}`;
|
|
192
|
+
}
|
|
193
|
+
if (sessionContextReady) {
|
|
194
|
+
shouldClaimSessionContext = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if ((this.sessionGenerations.get(sessionId) ?? 0) !== generation) {
|
|
198
|
+
process.stderr.write(`[${this.name}] dropped loop ${job.id} for session ${sessionId}: session was cleared before it ran\n`);
|
|
199
|
+
throw new ChannelLoopSkippedError('loop dropped because session was cleared before it ran');
|
|
200
|
+
}
|
|
201
|
+
if (shouldClaimSessionContext) {
|
|
202
|
+
this.instructedSessions.add(sessionId);
|
|
203
|
+
}
|
|
204
|
+
let doneResolve = () => { };
|
|
205
|
+
const done = new Promise((resolve) => {
|
|
206
|
+
doneResolve = resolve;
|
|
207
|
+
});
|
|
208
|
+
const promptState = {
|
|
209
|
+
cancelled: false,
|
|
210
|
+
done,
|
|
211
|
+
resolve: doneResolve,
|
|
212
|
+
chatId: job.target.chatId,
|
|
213
|
+
messageId: job.id,
|
|
214
|
+
};
|
|
215
|
+
this.activePrompts.set(sessionId, promptState);
|
|
216
|
+
this.onPromptStart(job.target.chatId, sessionId);
|
|
217
|
+
const onChunk = (sid, chunk) => {
|
|
218
|
+
if (sid === sessionId && !promptState.cancelled) {
|
|
219
|
+
this.onResponseChunk(job.target.chatId, chunk, sessionId);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const promptBridge = this.bridge;
|
|
223
|
+
promptBridge.on('textChunk', onChunk);
|
|
224
|
+
try {
|
|
225
|
+
const response = await this.runLoopBridgePrompt(promptBridge, sessionId, promptText, promptState, job.id, options.timeoutMs);
|
|
226
|
+
if (promptState.cancelRequested && !promptState.cancelled) {
|
|
227
|
+
const cancelled = await promptState.cancelRequested;
|
|
228
|
+
if (cancelled) {
|
|
229
|
+
promptState.cancelled = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (promptState.cancelled) {
|
|
233
|
+
throw new ChannelLoopSkippedError('loop cancelled before delivery');
|
|
234
|
+
}
|
|
235
|
+
if (options.shouldContinue && !(await options.shouldContinue())) {
|
|
236
|
+
throw new ChannelLoopSkippedError('loop dropped before delivery');
|
|
237
|
+
}
|
|
238
|
+
if (response) {
|
|
239
|
+
await this.pushProactive(job.target, response);
|
|
240
|
+
}
|
|
241
|
+
return response;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
promptBridge.off('textChunk', onChunk);
|
|
245
|
+
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
246
|
+
if (!promptState.clearEvicted) {
|
|
247
|
+
try {
|
|
248
|
+
this.onPromptEnd(job.target.chatId, sessionId);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
process.stderr.write(`[${this.name}] onPromptEnd threw in loop ${job.id} for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (stillCurrent) {
|
|
255
|
+
this.activePrompts.delete(sessionId);
|
|
256
|
+
}
|
|
257
|
+
promptState.resolve();
|
|
258
|
+
const buffer = this.collectBuffers.get(sessionId);
|
|
259
|
+
if (stillCurrent && buffer && buffer.length > 0) {
|
|
260
|
+
this.collectBuffers.delete(sessionId);
|
|
261
|
+
const lost = buffer.length;
|
|
262
|
+
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
263
|
+
const lastEnvelope = buffer[buffer.length - 1].envelope;
|
|
264
|
+
const syntheticEnvelope = {
|
|
265
|
+
...lastEnvelope,
|
|
266
|
+
text: coalesced,
|
|
267
|
+
alreadyPrefixed: true,
|
|
268
|
+
referencedText: undefined,
|
|
269
|
+
attachments: undefined,
|
|
270
|
+
imageBase64: undefined,
|
|
271
|
+
imageMimeType: undefined,
|
|
272
|
+
};
|
|
273
|
+
this.handleInbound(syntheticEnvelope).catch((err) => {
|
|
274
|
+
process.stderr.write(`[${this.name}] dropped ${lost} buffered message(s) after loop ${job.id} for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
this.sessionQueues.set(sessionId, current.then(() => undefined).catch(() => { }));
|
|
280
|
+
return current;
|
|
281
|
+
}
|
|
282
|
+
async runLoopBridgePrompt(promptBridge, sessionId, promptText, promptState, jobId, timeoutMs) {
|
|
283
|
+
const prompt = promptBridge.prompt(sessionId, promptText, {});
|
|
284
|
+
prompt.catch(() => { });
|
|
285
|
+
if (timeoutMs === undefined) {
|
|
286
|
+
return prompt;
|
|
287
|
+
}
|
|
288
|
+
let timer;
|
|
289
|
+
try {
|
|
290
|
+
return await Promise.race([
|
|
291
|
+
prompt,
|
|
292
|
+
new Promise((_, reject) => {
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
reject(new Error('loop timed out'));
|
|
295
|
+
}, timeoutMs);
|
|
296
|
+
timer.unref?.();
|
|
297
|
+
}),
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
if (err instanceof Error && err.message === 'loop timed out') {
|
|
302
|
+
promptState.cancelled = true;
|
|
303
|
+
await this.cancelTimedOutLoopPrompt(promptBridge, sessionId, jobId);
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async cancelTimedOutLoopPrompt(promptBridge, sessionId, jobId) {
|
|
312
|
+
let graceTimer;
|
|
313
|
+
try {
|
|
314
|
+
const cancelled = await Promise.race([
|
|
315
|
+
promptBridge.cancelSession(sessionId).then(() => true),
|
|
316
|
+
new Promise((resolve) => {
|
|
317
|
+
graceTimer = setTimeout(() => resolve(false), LOOP_CANCEL_GRACE_MS);
|
|
318
|
+
graceTimer.unref?.();
|
|
319
|
+
}),
|
|
320
|
+
]);
|
|
321
|
+
if (!cancelled) {
|
|
322
|
+
this.router.removeSessionId(sessionId);
|
|
323
|
+
this.instructedSessions.delete(sessionId);
|
|
324
|
+
process.stderr.write(`[${this.name}] retired timed out loop ${jobId} session ${sessionId} after cancel did not settle\n`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (cancelErr) {
|
|
328
|
+
process.stderr.write(`[${this.name}] cancelSession failed for timed out loop ${jobId} in session ${sessionId}: ${cancelErr instanceof Error ? cancelErr.message : cancelErr}\n`);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
clearTimeout(graceTimer);
|
|
332
|
+
}
|
|
49
333
|
}
|
|
50
334
|
onToolCall(_chatId, _event) { }
|
|
335
|
+
onSessionDied(sessionId) {
|
|
336
|
+
this.router.removeSessionId(sessionId);
|
|
337
|
+
this.instructedSessions.delete(sessionId);
|
|
338
|
+
}
|
|
339
|
+
attachBridgeEvents(bridge) {
|
|
340
|
+
bridge.on('toolCall', this.bridgeToolCallListener);
|
|
341
|
+
bridge.on('sessionDied', this.bridgeSessionDiedListener);
|
|
342
|
+
}
|
|
343
|
+
detachBridgeEvents(bridge) {
|
|
344
|
+
bridge.off('toolCall', this.bridgeToolCallListener);
|
|
345
|
+
bridge.off('sessionDied', this.bridgeSessionDiedListener);
|
|
346
|
+
}
|
|
51
347
|
/**
|
|
52
348
|
* Called when a prompt actually begins processing (inside the session queue).
|
|
53
349
|
* Override to show a platform-specific working indicator (e.g., typing, reaction).
|
|
@@ -83,6 +379,14 @@ export class ChannelBase {
|
|
|
83
379
|
}
|
|
84
380
|
registerCancelCommand(name = 'cancel') {
|
|
85
381
|
this.registerCommand(name, async (envelope) => {
|
|
382
|
+
// /cancel aborts an in-flight turn — destructive in a shared session, where
|
|
383
|
+
// it would otherwise let any member kill another user's running turn. Gate it
|
|
384
|
+
// to authorized senders like /clear (auth gate only — no confirm step). A
|
|
385
|
+
// non-shared (1:1) session is always authorized, so behavior is unchanged.
|
|
386
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
387
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can cancel requests in this shared session.');
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
86
390
|
const activeSessionId = this.findActiveSessionId(envelope);
|
|
87
391
|
if (!activeSessionId) {
|
|
88
392
|
await this.sendMessage(envelope.chatId, 'No request is currently running.');
|
|
@@ -114,38 +418,270 @@ export class ChannelBase {
|
|
|
114
418
|
}
|
|
115
419
|
/** Register shared slash commands. Called from constructor. */
|
|
116
420
|
registerSharedCommands() {
|
|
117
|
-
const
|
|
118
|
-
const removedIds = this.router.removeSession(this.name, envelope.senderId, envelope.chatId);
|
|
421
|
+
const doClear = async (envelope) => {
|
|
422
|
+
const removedIds = this.router.removeSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
423
|
+
this.clearPendingGroupHistory(envelope);
|
|
119
424
|
if (removedIds.length > 0) {
|
|
120
425
|
for (const id of removedIds) {
|
|
426
|
+
// Audit: clearing a SHARED session wipes the conversation for every
|
|
427
|
+
// participant, so record who triggered it (sanitized display name +
|
|
428
|
+
// stable senderId) and which session — mirrors the file's stderr audit
|
|
429
|
+
// style. A 1:1 DM clear only touches the caller, so it isn't logged.
|
|
430
|
+
if (this.isSharedSession(envelope)) {
|
|
431
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
432
|
+
process.stderr.write(`[${this.name}] shared session ${id} cleared by ${who} (sender ${envelope.senderId})\n`);
|
|
433
|
+
}
|
|
434
|
+
// Bump the generation up-front (before any await) so a followup turn
|
|
435
|
+
// already queued onto this session sees a stale generation and bails
|
|
436
|
+
// instead of running bridge.prompt() against the cleared session.
|
|
437
|
+
this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
|
|
438
|
+
// Cancel an in-flight turn (and drop its buffered follow-ups) before
|
|
439
|
+
// purging, so a running prompt can't deliver a stale response into —
|
|
440
|
+
// or resurrect via collect-drain — the just-cleared session.
|
|
441
|
+
const active = this.activePrompts.get(id);
|
|
442
|
+
this.collectBuffers.delete(id);
|
|
443
|
+
if (active) {
|
|
444
|
+
// Bounded cancel + wind-down wait; purge regardless of the result.
|
|
445
|
+
const settled = await this.cancelAndAwaitActive(active, id);
|
|
446
|
+
if (!settled) {
|
|
447
|
+
// Wedged: the turn never wound down within the bound. Surface it —
|
|
448
|
+
// otherwise a zombie bridge.prompt() lingers in the child with zero
|
|
449
|
+
// observability ("/clear worked" but a turn is still pinned).
|
|
450
|
+
// Include the originating chat/message (sanitized — platform IDs can
|
|
451
|
+
// be attacker-influenced) so oncall can correlate the wedged turn. Both
|
|
452
|
+
// are read defensively (fallback / omitted) so a partial entry can't
|
|
453
|
+
// crash /clear, the recovery path.
|
|
454
|
+
const wedgedChat = active.chatId
|
|
455
|
+
? sanitizeLogText(active.chatId, 64)
|
|
456
|
+
: 'unknown';
|
|
457
|
+
const wedgedMessage = active.messageId
|
|
458
|
+
? `, message ${sanitizeLogText(active.messageId, 64)}`
|
|
459
|
+
: '';
|
|
460
|
+
process.stderr.write(`[${this.name}] /clear abandoned a wedged turn for session ${id} (chat ${wedgedChat}${wedgedMessage}): it did not wind down within ${CLEAR_CANCEL_TIMEOUT_MS}ms\n`);
|
|
461
|
+
// The wedged turn's finally may run much later (or never), so clean
|
|
462
|
+
// up its OWN platform indicator now, while no replacement exists yet.
|
|
463
|
+
// Mark it clearEvicted FIRST so the late finally skips onPromptEnd — a
|
|
464
|
+
// turn the user starts after this /clear owns the chat indicator by
|
|
465
|
+
// then, and re-running cleanup would clobber it.
|
|
466
|
+
active.clearEvicted = true;
|
|
467
|
+
// onPromptEnd runs adapter cleanup (platform API calls that can throw).
|
|
468
|
+
// Swallow + audit any throw: an uncaught one would abort the purge
|
|
469
|
+
// below, leaving this turn in activePrompts so its late finally sees it
|
|
470
|
+
// as still-current (`stillCurrent || !clearEvicted`) and re-runs
|
|
471
|
+
// onPromptEnd anyway. Letting the purge proceed makes the turn
|
|
472
|
+
// non-current, so the clearEvicted guard then skips correctly.
|
|
473
|
+
try {
|
|
474
|
+
this.onPromptEnd(active.chatId, id, active.messageId);
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
process.stderr.write(`[${this.name}] onPromptEnd threw during /clear eviction for session ${id}: ${err instanceof Error ? err.message : err}\n`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Purge every per-session map (all keyed by sessionId) so a
|
|
482
|
+
// long-running gateway doesn't leak dead entries after /clear.
|
|
121
483
|
this.instructedSessions.delete(id);
|
|
484
|
+
// The queue's tail resolves only after every turn queued before this
|
|
485
|
+
// /clear has dequeued and bailed on the bumped generation. Capture it
|
|
486
|
+
// before deletion so we can reclaim sessionGenerations[id] once it
|
|
487
|
+
// drains — otherwise the bumped entry leaks for the gateway's lifetime.
|
|
488
|
+
const drained = this.sessionQueues.get(id);
|
|
489
|
+
const bumpedGeneration = this.sessionGenerations.get(id);
|
|
490
|
+
this.sessionQueues.delete(id);
|
|
491
|
+
this.activePrompts.delete(id);
|
|
492
|
+
if (drained) {
|
|
493
|
+
// Deferred, never awaited: a wedged turn that never drains must not
|
|
494
|
+
// block /clear (the entry just lingers, as before). The guards skip
|
|
495
|
+
// reclamation if a newer turn re-queued onto this id or another
|
|
496
|
+
// /clear re-bumped it, so an entry a queued turn still needs is never
|
|
497
|
+
// deleted out from under it.
|
|
498
|
+
void drained.then(() => {
|
|
499
|
+
if (!this.sessionQueues.has(id) &&
|
|
500
|
+
this.sessionGenerations.get(id) === bumpedGeneration) {
|
|
501
|
+
this.sessionGenerations.delete(id);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
// Nothing was ever queued for this session, so no turn can read the
|
|
507
|
+
// bumped value — reclaim it immediately.
|
|
508
|
+
this.sessionGenerations.delete(id);
|
|
509
|
+
}
|
|
122
510
|
}
|
|
123
|
-
await this.sendMessage(envelope.chatId, 'Session cleared.
|
|
511
|
+
await this.sendMessage(envelope.chatId, 'Session cleared. The next message starts a fresh conversation.');
|
|
124
512
|
}
|
|
125
513
|
else {
|
|
126
514
|
await this.sendMessage(envelope.chatId, 'No active session to clear.');
|
|
127
515
|
}
|
|
516
|
+
};
|
|
517
|
+
// For a shared session, clearing it affects everyone who shares it: restrict
|
|
518
|
+
// it to authorized senders (config.allowedUsers, when set) and require an
|
|
519
|
+
// explicit "confirm". DMs on per-user/thread scope and per-user groups clear
|
|
520
|
+
// directly — there /clear only touches the caller's own session.
|
|
521
|
+
const clearHandler = async (envelope, args) => {
|
|
522
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
523
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can clear this shared session.');
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
if (this.isSharedSession(envelope) && args.toLowerCase() !== 'confirm') {
|
|
527
|
+
await this.sendMessage(envelope.chatId, 'This clears the shared session for everyone who shares it. Re-send with "confirm" (e.g. /clear confirm) to proceed.');
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
await doClear(envelope);
|
|
128
531
|
return true;
|
|
129
532
|
};
|
|
130
533
|
this.registerCommand('clear', clearHandler);
|
|
131
534
|
this.registerCommand('reset', clearHandler);
|
|
132
535
|
this.registerCommand('new', clearHandler);
|
|
536
|
+
// Read-only: report the current (possibly group-shared) session and workspace.
|
|
537
|
+
// For a shared session, gate it to authorized senders like /clear — /who
|
|
538
|
+
// leaks the workspace basename, so non-members shouldn't see it either.
|
|
539
|
+
this.registerCommand('who', async (envelope) => {
|
|
540
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
541
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
const active = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
545
|
+
// `single` collapses EVERY DM and group to one `__single__` session, so it
|
|
546
|
+
// is shared channel-wide regardless of where the /who came from — report
|
|
547
|
+
// that explicitly (a group `single` session understates its blast radius as
|
|
548
|
+
// "shared by this group"). Other scopes keep their existing wording.
|
|
549
|
+
const scopeNote = this.config.sessionScope === 'single'
|
|
550
|
+
? ' (shared channel-wide)'
|
|
551
|
+
: this.isSharedSession(envelope)
|
|
552
|
+
? envelope.isGroup
|
|
553
|
+
? ' (shared by this group)'
|
|
554
|
+
: ''
|
|
555
|
+
: envelope.isGroup
|
|
556
|
+
? ' (private to you)'
|
|
557
|
+
: '';
|
|
558
|
+
await this.sendMessage(envelope.chatId, [
|
|
559
|
+
`Channel: ${this.name}`,
|
|
560
|
+
// Only the basename — don't leak the absolute cwd to group members.
|
|
561
|
+
`Workspace: ${basename(this.config.cwd)}`,
|
|
562
|
+
`Session: ${active ? 'active' : 'none'}${scopeNote}`,
|
|
563
|
+
].join('\n'));
|
|
564
|
+
return true;
|
|
565
|
+
});
|
|
566
|
+
this.registerCommand('remember-channel', async (envelope, args) => {
|
|
567
|
+
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if (envelope.isGroup) {
|
|
571
|
+
await this.sendMessage(envelope.chatId, 'Channel memory cannot be changed in group chats.');
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
if (args.trim() === '') {
|
|
575
|
+
await this.sendMessage(envelope.chatId, 'Usage: /remember-channel <text>');
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
const channelMemory = await this.getChannelMemory(envelope);
|
|
579
|
+
if (!channelMemory) {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
await channelMemory.appendChannelMemory(this.channelMemoryTarget(envelope), args.trim());
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
const message = this.channelMemoryErrorMessage(error);
|
|
587
|
+
this.logChannelMemoryError('save', envelope, message);
|
|
588
|
+
await this.sendMessage(envelope.chatId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
this.invalidateSessionContext(envelope);
|
|
592
|
+
await this.sendMessage(envelope.chatId, 'Channel memory updated.');
|
|
593
|
+
return true;
|
|
594
|
+
});
|
|
595
|
+
this.registerCommand('channel-memory', async (envelope) => {
|
|
596
|
+
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
if (envelope.isGroup) {
|
|
600
|
+
await this.sendMessage(envelope.chatId, 'Channel memory cannot be shown in group chats.');
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
const channelMemory = await this.getChannelMemory(envelope);
|
|
604
|
+
if (!channelMemory) {
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
let text;
|
|
608
|
+
try {
|
|
609
|
+
text = (await channelMemory.readChannelMemory(this.channelMemoryTarget(envelope))).trim();
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
const message = this.channelMemoryErrorMessage(error);
|
|
613
|
+
this.logChannelMemoryError('read', envelope, message);
|
|
614
|
+
await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
await this.sendMessage(envelope.chatId, text === '' ? 'No channel memory saved.' : sanitizePromptText(text));
|
|
618
|
+
return true;
|
|
619
|
+
});
|
|
620
|
+
this.registerCommand('forget-channel', async (envelope, args) => {
|
|
621
|
+
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
if (envelope.isGroup) {
|
|
625
|
+
await this.sendMessage(envelope.chatId, 'Channel memory cannot be changed in group chats.');
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
if (args.toLowerCase() !== 'confirm') {
|
|
629
|
+
await this.sendMessage(envelope.chatId, 'This clears channel memory for this chat. Re-send with "confirm" (e.g. /forget-channel confirm) to proceed.');
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
const channelMemory = await this.getChannelMemory(envelope);
|
|
633
|
+
if (!channelMemory) {
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
let result;
|
|
637
|
+
try {
|
|
638
|
+
result = await channelMemory.clearChannelMemory(this.channelMemoryTarget(envelope));
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
const message = this.channelMemoryErrorMessage(error);
|
|
642
|
+
this.logChannelMemoryError('clear', envelope, message);
|
|
643
|
+
await this.sendMessage(envelope.chatId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
this.invalidateSessionContext(envelope);
|
|
647
|
+
await this.sendMessage(envelope.chatId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
|
|
648
|
+
return true;
|
|
649
|
+
});
|
|
133
650
|
this.registerCommand('help', async (envelope) => {
|
|
134
651
|
const lines = [
|
|
135
652
|
'Commands:',
|
|
136
653
|
'/help — Show this help',
|
|
137
|
-
|
|
654
|
+
this.isSharedSession(envelope)
|
|
655
|
+
? '/clear confirm — Clear the shared session (aliases: /reset, /new)'
|
|
656
|
+
: '/clear — Clear your session (aliases: /reset, /new)',
|
|
657
|
+
'/who — Show current session & workspace',
|
|
138
658
|
'/status — Show session info',
|
|
659
|
+
'/remember-channel <text> — Save memory for this chat',
|
|
660
|
+
'/channel-memory — Show memory for this chat',
|
|
661
|
+
'/forget-channel confirm — Clear memory for this chat',
|
|
139
662
|
];
|
|
140
663
|
// Platform-specific commands (registered by adapters, not shared ones)
|
|
141
|
-
const sharedCmds = new Set([
|
|
664
|
+
const sharedCmds = new Set([
|
|
665
|
+
'help',
|
|
666
|
+
'clear',
|
|
667
|
+
'reset',
|
|
668
|
+
'new',
|
|
669
|
+
'who',
|
|
670
|
+
'status',
|
|
671
|
+
'remember-channel',
|
|
672
|
+
'channel-memory',
|
|
673
|
+
'forget-channel',
|
|
674
|
+
]);
|
|
142
675
|
const platformCmds = [...this.commands.keys()].filter((c) => !sharedCmds.has(c));
|
|
143
676
|
if (platformCmds.length > 0) {
|
|
144
677
|
for (const cmd of platformCmds) {
|
|
145
678
|
lines.push(`/${cmd}`);
|
|
146
679
|
}
|
|
147
680
|
}
|
|
148
|
-
const
|
|
681
|
+
const sessionId = this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
682
|
+
const agentCommands = sessionId
|
|
683
|
+
? this.getAgentCommandsForSession(sessionId)
|
|
684
|
+
: this.bridge.availableCommands;
|
|
149
685
|
if (agentCommands.length > 0) {
|
|
150
686
|
lines.push('', 'Agent commands (forwarded to Qwen Code):');
|
|
151
687
|
for (const cmd of agentCommands) {
|
|
@@ -157,7 +693,13 @@ export class ChannelBase {
|
|
|
157
693
|
return true;
|
|
158
694
|
});
|
|
159
695
|
this.registerCommand('status', async (envelope) => {
|
|
160
|
-
|
|
696
|
+
// For a shared session, gate it to authorized senders like /who — /status
|
|
697
|
+
// reports session & access state, so non-members shouldn't read it either.
|
|
698
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
699
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
const hasSession = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
161
703
|
const policy = this.config.senderPolicy;
|
|
162
704
|
const lines = [
|
|
163
705
|
`Session: ${hasSession ? 'active' : 'none'}`,
|
|
@@ -167,6 +709,205 @@ export class ChannelBase {
|
|
|
167
709
|
await this.sendMessage(envelope.chatId, lines.join('\n'));
|
|
168
710
|
return true;
|
|
169
711
|
});
|
|
712
|
+
this.registerCommand('loop', async (envelope, args) => this.handleLoopCommand(envelope, args));
|
|
713
|
+
}
|
|
714
|
+
async handleLoopCommand(envelope, args) {
|
|
715
|
+
if (!this.loopController) {
|
|
716
|
+
await this.sendMessage(envelope.chatId, 'Loops are not available.');
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
720
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can use loops in this shared session.');
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
const [subcommand = '', ...rest] = args.trim().split(/\s+/u);
|
|
724
|
+
switch (subcommand.toLowerCase()) {
|
|
725
|
+
case 'add':
|
|
726
|
+
return this.handleLoopAdd(envelope, rest.join(' '));
|
|
727
|
+
case 'list':
|
|
728
|
+
return this.handleLoopList(envelope);
|
|
729
|
+
case 'inspect':
|
|
730
|
+
return this.handleLoopInspect(envelope, rest[0]);
|
|
731
|
+
case 'cancel':
|
|
732
|
+
return this.handleLoopCancel(envelope, rest[0]);
|
|
733
|
+
default:
|
|
734
|
+
await this.sendMessage(envelope.chatId, 'Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>');
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
async handleLoopAdd(envelope, args) {
|
|
739
|
+
if (!this.loopController)
|
|
740
|
+
return true;
|
|
741
|
+
if (!this.supportsProactiveSend()) {
|
|
742
|
+
await this.sendMessage(envelope.chatId, 'This channel does not support proactive loop messages.');
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
if (this.config.sessionScope === 'single') {
|
|
746
|
+
await this.sendMessage(envelope.chatId, 'Loops are not supported when sessionScope is single.');
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
const parsed = parseLoopAddArgs(args);
|
|
750
|
+
if (!parsed) {
|
|
751
|
+
await this.sendMessage(envelope.chatId, 'Usage: /loop add "<cron>" <prompt>');
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
this.loopController.validateCron(parsed.cron);
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
await this.sendMessage(envelope.chatId, `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`);
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
const target = this.loopTargetFromEnvelope(envelope);
|
|
762
|
+
if (!this.supportsProactiveTarget(target)) {
|
|
763
|
+
await this.sendMessage(envelope.chatId, 'This channel does not support proactive loop messages for this chat target.');
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
const prompt = sanitizePromptText(parsed.prompt.trim());
|
|
767
|
+
if (Array.from(prompt).length > MAX_LOOP_PROMPT_CHARS) {
|
|
768
|
+
await this.sendMessage(envelope.chatId, `Loop prompt is too long; keep it under ${MAX_LOOP_PROMPT_CHARS} characters.`);
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
const input = {
|
|
772
|
+
channelName: this.name,
|
|
773
|
+
target,
|
|
774
|
+
cwd: this.config.cwd,
|
|
775
|
+
cron: parsed.cron,
|
|
776
|
+
prompt,
|
|
777
|
+
label: truncateLoopLabel(prompt),
|
|
778
|
+
recurring: true,
|
|
779
|
+
createdBy: sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown'),
|
|
780
|
+
};
|
|
781
|
+
let job;
|
|
782
|
+
if (this.loopController.createForTarget) {
|
|
783
|
+
job = await this.loopController.createForTarget(input, MAX_LOOP_JOBS_PER_TARGET);
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
const existingJobs = await this.loopController.listForTarget(this.name, target);
|
|
787
|
+
if (existingJobs.filter((existingJob) => existingJob.enabled).length <
|
|
788
|
+
MAX_LOOP_JOBS_PER_TARGET) {
|
|
789
|
+
job = await this.loopController.create(input);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (!job) {
|
|
793
|
+
await this.sendMessage(envelope.chatId, `Too many loops for this chat. Cancel an existing loop before adding another.`);
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
await this.sendMessage(envelope.chatId, `Loop ${job.id}: ${job.cron}`);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
async handleLoopList(envelope) {
|
|
800
|
+
if (!this.loopController)
|
|
801
|
+
return true;
|
|
802
|
+
const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
|
|
803
|
+
if (jobs.length === 0) {
|
|
804
|
+
await this.sendMessage(envelope.chatId, 'No loops.');
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
await this.sendMessage(envelope.chatId, jobs.map((job) => this.formatLoopListLine(job)).join('\n'));
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
async handleLoopInspect(envelope, id) {
|
|
811
|
+
if (!this.loopController)
|
|
812
|
+
return true;
|
|
813
|
+
if (!id) {
|
|
814
|
+
await this.sendMessage(envelope.chatId, 'Usage: /loop inspect <id>');
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
|
|
818
|
+
const job = jobs.find((candidate) => candidate.id === id);
|
|
819
|
+
if (!job) {
|
|
820
|
+
await this.sendMessage(envelope.chatId, `No loop ${id}.`);
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
const lines = [
|
|
824
|
+
`Loop ${job.id}`,
|
|
825
|
+
`Status: ${job.enabled ? 'enabled' : 'disabled'}, last=${this.lastLoopStatus(job)}`,
|
|
826
|
+
`Cron: ${job.cron}`,
|
|
827
|
+
`Next: ${this.formatNextFireTime(job)}`,
|
|
828
|
+
`Runs: ${job.runCount}`,
|
|
829
|
+
`Created by: ${job.createdBy}`,
|
|
830
|
+
`Created: ${job.createdAt}`,
|
|
831
|
+
];
|
|
832
|
+
if (job.lastFinishedAt) {
|
|
833
|
+
lines.push(`Last finished: ${job.lastFinishedAt}`);
|
|
834
|
+
}
|
|
835
|
+
if (job.lastError) {
|
|
836
|
+
lines.push(`Last error: ${job.lastError}`);
|
|
837
|
+
}
|
|
838
|
+
if (job.lastResultPreview) {
|
|
839
|
+
lines.push(`Last result: ${job.lastResultPreview}`);
|
|
840
|
+
}
|
|
841
|
+
lines.push(`Prompt: ${job.prompt}`);
|
|
842
|
+
await this.sendMessage(envelope.chatId, lines.join('\n'));
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
formatLoopListLine(job) {
|
|
846
|
+
const fields = [
|
|
847
|
+
job.id,
|
|
848
|
+
job.cron,
|
|
849
|
+
job.enabled ? 'enabled' : 'disabled',
|
|
850
|
+
`last=${this.lastLoopStatus(job)}`,
|
|
851
|
+
`next=${this.formatNextFireTime(job)}`,
|
|
852
|
+
`runs=${job.runCount}`,
|
|
853
|
+
];
|
|
854
|
+
if (job.label)
|
|
855
|
+
fields.push(job.label);
|
|
856
|
+
return fields.join(' ');
|
|
857
|
+
}
|
|
858
|
+
lastLoopStatus(job) {
|
|
859
|
+
if (job.runningSince)
|
|
860
|
+
return 'running';
|
|
861
|
+
return job.lastStatus ?? 'never';
|
|
862
|
+
}
|
|
863
|
+
formatNextFireTime(job) {
|
|
864
|
+
try {
|
|
865
|
+
return this.loopController?.nextFireTime?.(job).toISOString() ?? 'n/a';
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return 'invalid cron';
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
async handleLoopCancel(envelope, id) {
|
|
872
|
+
if (!this.loopController)
|
|
873
|
+
return true;
|
|
874
|
+
if (!id) {
|
|
875
|
+
await this.sendMessage(envelope.chatId, 'Usage: /loop cancel <id>');
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
|
|
879
|
+
const match = jobs.find((job) => job.id === id);
|
|
880
|
+
const disabled = match ? await this.loopController.disable(id) : false;
|
|
881
|
+
await this.sendMessage(envelope.chatId, disabled ? `Cancelled loop ${id}.` : `No loop ${id}.`);
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
loopTargetFromEnvelope(envelope) {
|
|
885
|
+
return {
|
|
886
|
+
channelName: this.name,
|
|
887
|
+
senderId: envelope.senderId,
|
|
888
|
+
chatId: envelope.chatId,
|
|
889
|
+
threadId: envelope.threadId,
|
|
890
|
+
isGroup: envelope.isGroup === true,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
isStoredLoopTargetAuthorized(target, senderName) {
|
|
894
|
+
if (target.isGroup === undefined) {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
const envelope = {
|
|
898
|
+
channelName: this.name,
|
|
899
|
+
senderId: target.senderId,
|
|
900
|
+
senderName,
|
|
901
|
+
chatId: target.chatId,
|
|
902
|
+
text: '',
|
|
903
|
+
threadId: target.threadId,
|
|
904
|
+
isGroup: target.isGroup === true,
|
|
905
|
+
isMentioned: true,
|
|
906
|
+
isReplyToBot: true,
|
|
907
|
+
};
|
|
908
|
+
return (this.groupGate.check(envelope).allowed &&
|
|
909
|
+
this.gate.isAllowed(target.senderId) &&
|
|
910
|
+
this.isAuthorizedForSharedSession(envelope));
|
|
170
911
|
}
|
|
171
912
|
/** Check if a message text matches a registered local command. */
|
|
172
913
|
isLocalCommand(text) {
|
|
@@ -179,23 +920,346 @@ export class ChannelBase {
|
|
|
179
920
|
? sessionId
|
|
180
921
|
: undefined;
|
|
181
922
|
}
|
|
923
|
+
channelMemoryTarget(envelope) {
|
|
924
|
+
return {
|
|
925
|
+
channelName: this.name,
|
|
926
|
+
chatId: envelope.chatId,
|
|
927
|
+
threadId: envelope.threadId,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
invalidateSessionContext(envelope) {
|
|
931
|
+
const sessionId = this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
932
|
+
if (sessionId) {
|
|
933
|
+
this.instructedSessions.delete(sessionId);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
dropQueuedTurnIfStale(sessionId, generation, envelope) {
|
|
937
|
+
if ((this.sessionGenerations.get(sessionId) ?? 0) === generation) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
// Surface the drop — otherwise an unanswered queued message vanishes
|
|
941
|
+
// silently, making "my message was never answered" undiagnosable.
|
|
942
|
+
// envelope.text is attacker-controlled, so neutralize it with the shared
|
|
943
|
+
// log sanitizer: it renders newlines visibly and strips the C0/DEL controls
|
|
944
|
+
// PLUS PROMPT_UNSAFE_INVISIBLES — the C1 block (notably NEL U+0085, a line
|
|
945
|
+
// break that could forge an extra [channel] log line), the Unicode line/
|
|
946
|
+
// paragraph separators U+2028/U+2029, and the bidi overrides — any of which
|
|
947
|
+
// would otherwise inject, overwrite, or reorder an operator's audit line.
|
|
948
|
+
// Same helper as the QQ audit log, so the defense can't drift between sites.
|
|
949
|
+
const loggedText = sanitizeLogText(envelope.text, 80);
|
|
950
|
+
process.stderr.write(`[${this.name}] dropped queued turn from ${envelope.senderId} for session ${sessionId}: session was cleared before it ran (text: ${loggedText})\n`);
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
isAuthorizedForChannelMemory(envelope) {
|
|
954
|
+
return this.isSenderAuthorizedForChannelMemory(envelope.senderId);
|
|
955
|
+
}
|
|
956
|
+
isSenderAuthorizedForChannelMemory(senderId) {
|
|
957
|
+
return (this.config.allowedUsers.length > 0 &&
|
|
958
|
+
this.config.allowedUsers.includes(senderId));
|
|
959
|
+
}
|
|
960
|
+
async ensureChannelMemoryAuthorized(envelope) {
|
|
961
|
+
if (!this.isAuthorizedForChannelMemory(envelope)) {
|
|
962
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can manage channel memory.');
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
async getChannelMemory(envelope) {
|
|
968
|
+
if (!this.channelMemory) {
|
|
969
|
+
await this.sendMessage(envelope.chatId, 'Channel memory is not configured for this channel.');
|
|
970
|
+
return undefined;
|
|
971
|
+
}
|
|
972
|
+
return this.channelMemory;
|
|
973
|
+
}
|
|
974
|
+
channelMemoryErrorMessage(error) {
|
|
975
|
+
return error instanceof Error ? error.message : String(error);
|
|
976
|
+
}
|
|
977
|
+
channelMemoryUserErrorMessage() {
|
|
978
|
+
return 'An error occurred while accessing channel memory.';
|
|
979
|
+
}
|
|
980
|
+
logChannelMemoryError(action, envelope, message) {
|
|
981
|
+
process.stderr.write(`[${this.name}] channel memory ${action} failed for sender=${sanitizeLogText(envelope.senderId, 80)} chat=${sanitizeLogText(envelope.chatId, 80)} thread=${sanitizeLogText(envelope.threadId ?? '', 80)}: ${sanitizeLogText(message, 200)}\n`);
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Whether the resolved session is SHARED across senders. `single` collapses
|
|
985
|
+
* the whole channel to one `__single__` session for EVERY sender — group OR
|
|
986
|
+
* DM — so it is ALWAYS shared (even a DM maps to `__single__`). `thread` is
|
|
987
|
+
* shared only in a group (a DM maps to the lone caller's own chat). `user` is
|
|
988
|
+
* per-sender, never shared. Drives both the destructive-/clear confirm gate
|
|
989
|
+
* and the host-shell (`!`) gate.
|
|
990
|
+
*/
|
|
991
|
+
isSharedSession(envelope) {
|
|
992
|
+
return this.isSharedSessionTarget(envelope);
|
|
993
|
+
}
|
|
994
|
+
isSharedSessionTarget(target) {
|
|
995
|
+
return (this.config.sessionScope === 'single' ||
|
|
996
|
+
(target.isGroup === true && this.config.sessionScope === 'thread'));
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Whether `envelope.senderId` may act on the resolved session's destructive or
|
|
1000
|
+
* workspace-leaking commands (/clear, /who). A SHARED session with a non-empty
|
|
1001
|
+
* allowedUsers list is restricted to those members; a per-user session, or one
|
|
1002
|
+
* with no allowlist, is unrestricted. Shared verbatim by /clear and /who so the
|
|
1003
|
+
* gate can't drift; each caller sends its own rejection wording.
|
|
1004
|
+
*/
|
|
1005
|
+
isAuthorizedForSharedSession(envelope) {
|
|
1006
|
+
if (!this.isSharedSession(envelope))
|
|
1007
|
+
return true;
|
|
1008
|
+
const authorized = this.config.allowedUsers;
|
|
1009
|
+
return authorized.length === 0 || authorized.includes(envelope.senderId);
|
|
1010
|
+
}
|
|
1011
|
+
stopActiveStreaming(active, sessionId, reason) {
|
|
1012
|
+
try {
|
|
1013
|
+
active.stopStreaming?.();
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
process.stderr.write(`[${this.name}] stopStreaming threw during ${reason} for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Cancel the active turn and wait (bounded) for it to wind down. Stops the
|
|
1021
|
+
* BlockStreamer so buffered text can't leak via the idle timer, then fires a
|
|
1022
|
+
* best-effort cancelSession (NOT awaited — a wedged child/daemon can leave the
|
|
1023
|
+
* request pending forever). Returns true if active.done settled first, false
|
|
1024
|
+
* if the CLEAR_CANCEL_TIMEOUT_MS bound won (the turn never wound down). Used by
|
|
1025
|
+
* /clear, which genuinely EVICTS the session and so must proceed even when the
|
|
1026
|
+
* turn is wedged. Steer no longer uses this: it best-effort cancels then chains
|
|
1027
|
+
* the new turn behind the old one (see handleInbound), so it never needs to
|
|
1028
|
+
* proceed past a still-active turn.
|
|
1029
|
+
*/
|
|
1030
|
+
async cancelAndAwaitActive(active, sessionId) {
|
|
1031
|
+
active.cancelled = true;
|
|
1032
|
+
this.stopActiveStreaming(active, sessionId, 'cancel');
|
|
1033
|
+
// Fire-and-forget, but LOG the IPC failure: a swallowed reason leaves a
|
|
1034
|
+
// wedged turn undiagnosable (operator sees only the wind-down timeout below
|
|
1035
|
+
// with no cause).
|
|
1036
|
+
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
1037
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (clear/await): ${err instanceof Error ? err.message : err}\n`);
|
|
1038
|
+
});
|
|
1039
|
+
let timer;
|
|
1040
|
+
const settled = await Promise.race([
|
|
1041
|
+
active.done.then(() => true),
|
|
1042
|
+
new Promise((resolve) => {
|
|
1043
|
+
timer = setTimeout(() => resolve(false), CLEAR_CANCEL_TIMEOUT_MS);
|
|
1044
|
+
}),
|
|
1045
|
+
]);
|
|
1046
|
+
clearTimeout(timer);
|
|
1047
|
+
return settled;
|
|
1048
|
+
}
|
|
182
1049
|
/**
|
|
183
1050
|
* Parse a slash command from message text.
|
|
184
|
-
* Returns { command, args } or null if not a slash command.
|
|
1051
|
+
* Returns { command, raw, args } or null if not a slash command. `command` is
|
|
1052
|
+
* lowercased for case-insensitive LOCAL dispatch (registerCommand lowercases the
|
|
1053
|
+
* names it stores); `raw` keeps the typed case so agent-command matching can be
|
|
1054
|
+
* CASE-SENSITIVE, mirroring the CLI's parseSlashCommand (`cmd.name === part`).
|
|
185
1055
|
*/
|
|
186
1056
|
parseCommand(text) {
|
|
187
|
-
|
|
1057
|
+
// Trim first so a leading-whitespace slash command (common from IME /
|
|
1058
|
+
// copy-paste, e.g. " /help") parses, and so this agrees with isSlashCommand
|
|
1059
|
+
// (which already trims). Otherwise isSlashCommand suppresses the [sender] tag
|
|
1060
|
+
// while parseCommand returns null, leaking the command to the agent unattributed.
|
|
1061
|
+
const trimmed = text.trim();
|
|
1062
|
+
if (!trimmed.startsWith('/'))
|
|
188
1063
|
return null;
|
|
189
|
-
// Handle /command@botname format (Telegram groups)
|
|
190
|
-
|
|
1064
|
+
// Handle /command@botname format (Telegram groups). The token allows `-` and
|
|
1065
|
+
// `:` so hyphenated and namespaced agent commands (e.g. /compress-fast,
|
|
1066
|
+
// /git:commit) still parse as commands rather than being treated as text
|
|
1067
|
+
// (charset shared with isSlashCommand via PARSE_COMMAND_RE).
|
|
1068
|
+
const match = trimmed.match(PARSE_COMMAND_RE);
|
|
191
1069
|
if (!match)
|
|
192
1070
|
return null;
|
|
193
|
-
return {
|
|
1071
|
+
return {
|
|
1072
|
+
command: match[1].toLowerCase(),
|
|
1073
|
+
raw: match[1],
|
|
1074
|
+
args: match[2].trim(),
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Whether `text` is a real slash command rather than prose that merely starts
|
|
1079
|
+
* with `/`. A command's first whitespace-delimited token must match
|
|
1080
|
+
* parseCommand()'s charset — `[a-zA-Z0-9_:-]+`, plus an optional `@botname`
|
|
1081
|
+
* suffix — and not be a `//` line comment or `/*` block comment. Slash-prefixed
|
|
1082
|
+
* paths (`/tmp/foo`), comments, and a bare `/` are prose and keep their
|
|
1083
|
+
* `[sender]` tag.
|
|
1084
|
+
*
|
|
1085
|
+
* Intentionally stricter than the CLI's looser classifier (cli
|
|
1086
|
+
* `ui/utils/commandUtils.ts`), which forwards any non-comment, non-path
|
|
1087
|
+
* `/<token>` (e.g. `/café`, a zero-width-laden token). Such inputs aren't
|
|
1088
|
+
* runnable commands, and in a SHARED group session forwarding them unattributed
|
|
1089
|
+
* is worse than a redundant tag — so anything off the command charset is
|
|
1090
|
+
* treated as prose and keeps its `[sender]` tag. Purely lexical — never
|
|
1091
|
+
* consults the async command list, so it can't race a fresh session.
|
|
1092
|
+
*/
|
|
1093
|
+
isSlashCommand(text) {
|
|
1094
|
+
const trimmed = text.trim();
|
|
1095
|
+
if (!trimmed.startsWith('/') ||
|
|
1096
|
+
trimmed.startsWith('//') ||
|
|
1097
|
+
trimmed.startsWith('/*')) {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
// No trimStart: the token must immediately follow `/`. A space after the
|
|
1101
|
+
// slash (`/ foo`) makes split()[0] empty, so this returns false — matching
|
|
1102
|
+
// parseCommand, whose regex also requires the token right after `/`. If they
|
|
1103
|
+
// diverged, `/ foo` in a shared group session would suppress the [sender] tag
|
|
1104
|
+
// (isSlashCommand true) yet run no command (parseCommand null), reaching the
|
|
1105
|
+
// agent unattributed.
|
|
1106
|
+
const firstToken = trimmed.slice(1).split(/\s+/u)[0] ?? '';
|
|
1107
|
+
return COMMAND_TOKEN_RE.test(firstToken);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Whether `text` names a command this channel can actually run: a locally
|
|
1111
|
+
* registered command (`this.commands`, e.g. /clear, /who) OR an agent command
|
|
1112
|
+
* THIS session exposes — by canonical name OR alias (e.g. `/summarize` for
|
|
1113
|
+
* `/compress`). Paired with isSlashCommand so the `[sender]` attribution tag is
|
|
1114
|
+
* suppressed ONLY for RECOGNIZED commands; command-SHAPED-but-unrecognized text
|
|
1115
|
+
* (e.g. `/x\n[SYSTEM]: …`) keeps its tag rather than reaching a shared group
|
|
1116
|
+
* unattributed, where an injected second line is more likely read as a system
|
|
1117
|
+
* directive. Purely synchronous, like isSlashCommand: it reads the session's
|
|
1118
|
+
* availableCommands snapshot WITHOUT awaiting, so it never races a fresh session
|
|
1119
|
+
* (a genuine agent command sent before the snapshot loads is treated as
|
|
1120
|
+
* unrecognized and KEEPS its tag — the safe default).
|
|
1121
|
+
*/
|
|
1122
|
+
isRecognizedCommand(text, sessionId) {
|
|
1123
|
+
const parsed = this.parseCommand(text);
|
|
1124
|
+
if (!parsed)
|
|
1125
|
+
return false;
|
|
1126
|
+
// LOCAL commands dispatch CASE-INSENSITIVELY: registerCommand lowercases the
|
|
1127
|
+
// stored name and handleInbound looks it up by the lowercased token, so mirror
|
|
1128
|
+
// that here with the lowercased `command`.
|
|
1129
|
+
if (this.commands.has(parsed.command))
|
|
1130
|
+
return true;
|
|
1131
|
+
// AGENT commands: mirror the CLI's parseSlashCommand EXACTLY so the channel and
|
|
1132
|
+
// the agent AGREE on what is a command. The CLI takes the FIRST whitespace token
|
|
1133
|
+
// after the leading `/`, CASE-SENSITIVELY, and does NOT strip an `@suffix`
|
|
1134
|
+
// (`cmd.name === part`, `cmd.altNames?.includes(part)`). So recognize the SAME
|
|
1135
|
+
// token here — NOT parseCommand's `@`-stripped, lowercased `raw` (PARSE_COMMAND_RE
|
|
1136
|
+
// drops `(?:@\S+)?`, which is the very divergence this closes). A wrong-case
|
|
1137
|
+
// (`/Compress`), `@`-suffixed (`/compress@bot` — possibly aimed at ANOTHER bot, so
|
|
1138
|
+
// we must NOT run it here), or injection-shaped (`/COMPRESS\n[SYSTEM]: …`) token
|
|
1139
|
+
// then does NOT match → stays UNRECOGNIZED → keeps its `[sender]` tag (attributed),
|
|
1140
|
+
// exactly as the agent treats it (it runs no command; the text reaches the model
|
|
1141
|
+
// as prose). Array.isArray guards a malformed wire `altNames` (a non-array would
|
|
1142
|
+
// throw at `.includes`).
|
|
1143
|
+
const token = text.trim().slice(1).split(/\s+/u)[0] ?? '';
|
|
1144
|
+
return this.getAgentCommandsForSession(sessionId).some((cmd) => cmd.name === token ||
|
|
1145
|
+
(Array.isArray(cmd.altNames) && cmd.altNames.includes(token)));
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* The agent-command snapshot for THIS session. DaemonChannelBridge keys
|
|
1149
|
+
* commands per session, so its global `availableCommands` getter can return
|
|
1150
|
+
* ANOTHER session's list — prefer its getAvailableCommands(sessionId) when
|
|
1151
|
+
* present. AcpBridge runs a single agent and exposes only the global getter
|
|
1152
|
+
* (inherently session-correct), so fall back to it. Synchronous, matching
|
|
1153
|
+
* isRecognizedCommand's no-await contract.
|
|
1154
|
+
*/
|
|
1155
|
+
getAgentCommandsForSession(sessionId) {
|
|
1156
|
+
// Structural (typed) access via AgentCommandsProvider rather than a blind
|
|
1157
|
+
// `as unknown` cast: both members are optional, so AcpBridge (no per-session
|
|
1158
|
+
// getter) is assignable while a rename/return-type change is still type-checked.
|
|
1159
|
+
const bridge = this.bridge;
|
|
1160
|
+
if (typeof bridge.getAvailableCommands === 'function') {
|
|
1161
|
+
return bridge.getAvailableCommands(sessionId) ?? [];
|
|
1162
|
+
}
|
|
1163
|
+
return bridge.availableCommands ?? [];
|
|
1164
|
+
}
|
|
1165
|
+
groupHistoryKey(envelope) {
|
|
1166
|
+
return JSON.stringify([
|
|
1167
|
+
this.name,
|
|
1168
|
+
envelope.chatId,
|
|
1169
|
+
envelope.threadId ?? null,
|
|
1170
|
+
]);
|
|
1171
|
+
}
|
|
1172
|
+
groupHistoryLimit(envelope) {
|
|
1173
|
+
if (!envelope.isGroup) {
|
|
1174
|
+
return 0;
|
|
1175
|
+
}
|
|
1176
|
+
const groupCfg = this.config.groups[envelope.chatId];
|
|
1177
|
+
const wildcardGroupCfg = this.config.groups['*'];
|
|
1178
|
+
const configured = groupCfg?.groupHistoryLimit ??
|
|
1179
|
+
wildcardGroupCfg?.groupHistoryLimit ??
|
|
1180
|
+
this.config.groupHistoryLimit ??
|
|
1181
|
+
0;
|
|
1182
|
+
if (!Number.isFinite(configured) || configured <= 0) {
|
|
1183
|
+
return 0;
|
|
1184
|
+
}
|
|
1185
|
+
return Math.floor(configured);
|
|
1186
|
+
}
|
|
1187
|
+
recordPendingGroupHistory(envelope) {
|
|
1188
|
+
const limit = this.groupHistoryLimit(envelope);
|
|
1189
|
+
if (limit <= 0 || envelope.text.trim().length === 0) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
const senderId = truncateGroupHistoryField(envelope.senderId);
|
|
1193
|
+
if (!this.gate.isAllowed(senderId)) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const entry = {
|
|
1197
|
+
senderId,
|
|
1198
|
+
senderName: truncateGroupHistoryField(envelope.senderName),
|
|
1199
|
+
text: envelope.text.slice(0, GROUP_HISTORY_ENTRY_TEXT_LIMIT),
|
|
1200
|
+
messageId: envelope.messageId === undefined
|
|
1201
|
+
? undefined
|
|
1202
|
+
: truncateGroupHistoryField(envelope.messageId),
|
|
1203
|
+
timestamp: Date.now(),
|
|
1204
|
+
};
|
|
1205
|
+
try {
|
|
1206
|
+
this.groupHistory.record(this.groupHistoryKey(envelope), entry, limit);
|
|
1207
|
+
}
|
|
1208
|
+
catch (err) {
|
|
1209
|
+
process.stderr.write(`[${this.name}] failed to record group history for chat ${sanitizeLogText(envelope.chatId, 64)}: ${err instanceof Error ? err.message : err}\n`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
drainPendingGroupHistory(envelope) {
|
|
1213
|
+
const limit = this.groupHistoryLimit(envelope);
|
|
1214
|
+
if (limit <= 0) {
|
|
1215
|
+
return [];
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
return this.groupHistory.drain(this.groupHistoryKey(envelope), limit);
|
|
1219
|
+
}
|
|
1220
|
+
catch (err) {
|
|
1221
|
+
process.stderr.write(`[${this.name}] failed to drain group history for chat ${sanitizeLogText(envelope.chatId, 64)}: ${err instanceof Error ? err.message : err}\n`);
|
|
1222
|
+
return [];
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
clearPendingGroupHistory(envelope) {
|
|
1226
|
+
if (!envelope.isGroup && this.config.sessionScope !== 'single') {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
if (this.config.sessionScope === 'single') {
|
|
1231
|
+
this.groupHistory.clearAll();
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
this.groupHistory.clear(this.groupHistoryKey(envelope));
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
catch (err) {
|
|
1238
|
+
process.stderr.write(`[${this.name}] failed to clear group history for chat ${sanitizeLogText(envelope.chatId, 64)}: ${err instanceof Error ? err.message : err}\n`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
prependGroupHistoryContext(promptText, entries) {
|
|
1242
|
+
if (entries.length === 0) {
|
|
1243
|
+
return promptText;
|
|
1244
|
+
}
|
|
1245
|
+
const lines = entries.filter((entry) => this.gate.isAllowed(entry.senderId));
|
|
1246
|
+
if (lines.length === 0) {
|
|
1247
|
+
return promptText;
|
|
1248
|
+
}
|
|
1249
|
+
const formatted = lines.map((entry) => {
|
|
1250
|
+
const who = sanitizeSenderName(entry.senderName || entry.senderId);
|
|
1251
|
+
const text = sanitizeQuotedText(entry.text, GROUP_HISTORY_ENTRY_TEXT_LIMIT);
|
|
1252
|
+
return `- [${who}] ${text}`;
|
|
1253
|
+
});
|
|
1254
|
+
return `${GROUP_HISTORY_CONTEXT_MARKER}\n${formatted.join('\n')}\n\n${CURRENT_MESSAGE_MARKER}\n${promptText}`;
|
|
194
1255
|
}
|
|
195
1256
|
async handleInbound(envelope) {
|
|
196
1257
|
// 1. Group gate: policy + allowlist + mention gating
|
|
197
1258
|
const groupResult = this.groupGate.check(envelope);
|
|
198
1259
|
if (!groupResult.allowed) {
|
|
1260
|
+
if (groupResult.reason === 'mention_required') {
|
|
1261
|
+
this.recordPendingGroupHistory(envelope);
|
|
1262
|
+
}
|
|
199
1263
|
return; // silently drop — no pairing, no reply
|
|
200
1264
|
}
|
|
201
1265
|
// 2. Sender gate: allowlist / pairing / open
|
|
@@ -217,14 +1281,41 @@ export class ChannelBase {
|
|
|
217
1281
|
}
|
|
218
1282
|
// Unrecognized commands fall through to the agent
|
|
219
1283
|
}
|
|
1284
|
+
// 3.5. Bang (!) shell command — refuse outside a private 1:1 chat BEFORE
|
|
1285
|
+
// resolving a session, so a refused command never creates or persists one.
|
|
1286
|
+
// Phase 0 has no per-sender trust model (the [sender] marker is NOT a trust
|
|
1287
|
+
// boundary). Any group is multi-operator — even a user-scope group, which is
|
|
1288
|
+
// NOT a "shared session" — so an allowed member could `!rm -rf /` the host.
|
|
1289
|
+
const bangText = envelope.text.trimStart();
|
|
1290
|
+
if (bangText.startsWith('!')) {
|
|
1291
|
+
if (envelope.isGroup || this.isSharedSession(envelope)) {
|
|
1292
|
+
// Audit a blocked host-shell attempt — a group/shared member trying `!`
|
|
1293
|
+
// is security-relevant, so surface it to operators. Sanitize the display
|
|
1294
|
+
// name (attacker-controlled) and do NOT echo the command payload.
|
|
1295
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
1296
|
+
process.stderr.write(`[${this.name}] blocked ! shell command from ${who} (sender ${envelope.senderId}) in chat ${sanitizeLogText(envelope.chatId, 64)}\n`);
|
|
1297
|
+
}
|
|
1298
|
+
if (envelope.isGroup) {
|
|
1299
|
+
await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in group chats.');
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
// A single-scope DM collapses every DM to one channel-wide session, so it
|
|
1303
|
+
// is multi-operator too despite not being a group.
|
|
1304
|
+
if (this.isSharedSession(envelope)) {
|
|
1305
|
+
await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in shared sessions.');
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
220
1309
|
const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd);
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
1310
|
+
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
1311
|
+
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
1312
|
+
// above, before the session was resolved.
|
|
1313
|
+
if (bangText.startsWith('!')) {
|
|
1314
|
+
const cmd = bangText.slice(1).trim();
|
|
1315
|
+
const bridgeShellCommand = this.bridge.shellCommand;
|
|
1316
|
+
if (cmd && bridgeShellCommand) {
|
|
226
1317
|
try {
|
|
227
|
-
const result =
|
|
1318
|
+
const result = await bridgeShellCommand(sessionId, cmd);
|
|
228
1319
|
const longestRun = Math.max(0, ...Array.from((result.output || '').matchAll(/`+/g), (m) => m[0].length));
|
|
229
1320
|
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
230
1321
|
const output = result.output
|
|
@@ -241,10 +1332,45 @@ export class ChannelBase {
|
|
|
241
1332
|
return;
|
|
242
1333
|
}
|
|
243
1334
|
}
|
|
1335
|
+
const recognizedSlashCommand = this.isSlashCommand(envelope.text) &&
|
|
1336
|
+
this.isRecognizedCommand(envelope.text, sessionId);
|
|
244
1337
|
// Prepend referenced (quoted) message text for reply context
|
|
245
1338
|
let promptText = envelope.text;
|
|
1339
|
+
// Multiplayer attribution: when a session can carry multiple humans, tag each
|
|
1340
|
+
// turn with the speaker so the agent can tell members apart. That is any group
|
|
1341
|
+
// AND any single-scope DM — `single` collapses every sender's DM into one
|
|
1342
|
+
// __single__ session (the same multi-operator case the !-gate, /clear confirm
|
|
1343
|
+
// and /who already treat as shared), so without a tag it would merge different
|
|
1344
|
+
// people into one unattributed conversation. NOT gated on isSharedSession:
|
|
1345
|
+
// that is false for a user-scope GROUP, which still needs attribution. Sanitize
|
|
1346
|
+
// the name so a crafted nick can't break out of the [..] tag or inject
|
|
1347
|
+
// newlines. Skipped for a per-user 1:1 chat and for already-prefixed re-entries
|
|
1348
|
+
// (collect-mode coalescing). The tag is also suppressed for a real slash
|
|
1349
|
+
// command — a [sender] prefix would stop it from parsing — but ONLY when it is
|
|
1350
|
+
// BOTH a genuine command SHAPE (isSlashCommand) AND a RECOGNIZED command
|
|
1351
|
+
// (isRecognizedCommand: a locally registered or agent-exposed command, by
|
|
1352
|
+
// canonical name OR alias, for THIS session — matched EXACTLY as the agent's
|
|
1353
|
+
// parseSlashCommand does, so the two never diverge). Command-shaped-but-
|
|
1354
|
+
// unrecognized text like `/x\n[SYSTEM]: …` (token matches the charset but no such
|
|
1355
|
+
// command exists) KEEPS its tag, so its injected second line can't reach a shared
|
|
1356
|
+
// group unattributed and pose as a system directive. Slash-prefixed paths
|
|
1357
|
+
// (/tmp/foo) and comments (//…, /*…*/) are prose, so they stay attributed too.
|
|
1358
|
+
// Both checks are synchronous (no await), so this never races the async command
|
|
1359
|
+
// list — see isRecognizedCommand for the no-await tradeoff.
|
|
1360
|
+
if ((envelope.isGroup || this.config.sessionScope === 'single') &&
|
|
1361
|
+
!envelope.alreadyPrefixed &&
|
|
1362
|
+
!recognizedSlashCommand) {
|
|
1363
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
1364
|
+
promptText = `[${who}] ${sanitizePromptText(promptText)}`;
|
|
1365
|
+
}
|
|
246
1366
|
if (envelope.referencedText) {
|
|
247
|
-
|
|
1367
|
+
// Quoted text is attacker-controlled. sanitizeQuotedText strips C0/DEL
|
|
1368
|
+
// controls, Unicode line/paragraph separators (U+2028/U+2029) and bidi
|
|
1369
|
+
// overrides, and the wrapper's own `"[]` delimiters, then caps length -
|
|
1370
|
+
// so a crafted quote can't inject newlines/instructions, close the
|
|
1371
|
+
// [Replying to: "..."] wrapper, flip text direction, or balloon the prompt.
|
|
1372
|
+
const quoted = sanitizeQuotedText(envelope.referencedText, 500);
|
|
1373
|
+
promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
|
|
248
1374
|
}
|
|
249
1375
|
// Resolve attachments: extract image for bridge, append file paths to text
|
|
250
1376
|
let imageBase64 = envelope.imageBase64;
|
|
@@ -258,25 +1384,40 @@ export class ChannelBase {
|
|
|
258
1384
|
}
|
|
259
1385
|
else if (att.filePath) {
|
|
260
1386
|
const label = att.type === 'file' ? 'file' : att.type;
|
|
261
|
-
|
|
262
|
-
|
|
1387
|
+
// The filename is attacker-supplied (e.g. DingTalk), so neutralize both
|
|
1388
|
+
// the human-readable label and the on-disk path as they enter the
|
|
1389
|
+
// prompt. They need DIFFERENT rules: the quoted fileName label is just
|
|
1390
|
+
// prose, so sanitizeQuotedText (which also strips `"[]`) is fine — but
|
|
1391
|
+
// the rendered filePath must stay byte-resolvable. Brackets, quotes and
|
|
1392
|
+
// spaces are VALID, common path chars (e.g. `app/[slug]/page.tsx`), so
|
|
1393
|
+
// stripping them would advertise a path that doesn't exist on disk and
|
|
1394
|
+
// break the agent's read-file tool. sanitizePromptPath preserves them
|
|
1395
|
+
// and removes ONLY what could break/reorder the `saved to:` line
|
|
1396
|
+
// (CR/LF, C0/DEL, Unicode line/para separators, bidi overrides).
|
|
1397
|
+
const name = att.fileName
|
|
1398
|
+
? ` "${sanitizeQuotedText(att.fileName, 128)}"`
|
|
1399
|
+
: '';
|
|
1400
|
+
const renderedPath = sanitizePromptPath(att.filePath);
|
|
1401
|
+
filePaths.push(`User sent a ${label}${name}. It has been saved to: ${renderedPath}`);
|
|
263
1402
|
}
|
|
264
1403
|
}
|
|
265
1404
|
if (filePaths.length > 0) {
|
|
266
1405
|
promptText = promptText + '\n\n' + filePaths.join('\n');
|
|
267
1406
|
}
|
|
268
1407
|
}
|
|
269
|
-
// Prepend channel instructions on first message of a session
|
|
270
|
-
if (this.config.instructions && !this.instructedSessions.has(sessionId)) {
|
|
271
|
-
promptText = `${this.config.instructions}\n\n${promptText}`;
|
|
272
|
-
this.instructedSessions.add(sessionId);
|
|
273
|
-
}
|
|
274
1408
|
// Resolve dispatch mode: per-group override → channel config → default
|
|
275
1409
|
const groupCfg = envelope.isGroup
|
|
276
1410
|
? this.config.groups[envelope.chatId] || this.config.groups['*']
|
|
277
1411
|
: undefined;
|
|
278
1412
|
const mode = groupCfg?.dispatchMode || this.config.dispatchMode || 'steer';
|
|
279
1413
|
const active = this.activePrompts.get(sessionId);
|
|
1414
|
+
// Diagnostic watchdog for a steered turn that chains behind a wedged
|
|
1415
|
+
// predecessor. Chain-and-wait (option a) means a hung predecessor bridge.prompt()
|
|
1416
|
+
// silently deadlocks this session with no log; this surfaces that. Armed only in
|
|
1417
|
+
// the steer branch, disarmed as the first statement of the chained `.then()` once
|
|
1418
|
+
// the predecessor's tail resolves. Diagnostic-only — it does NOT touch the
|
|
1419
|
+
// chain-and-wait concurrency invariant.
|
|
1420
|
+
let steerWatchdog;
|
|
280
1421
|
if (active) {
|
|
281
1422
|
// A prompt is already running for this session
|
|
282
1423
|
switch (mode) {
|
|
@@ -291,12 +1432,64 @@ export class ChannelBase {
|
|
|
291
1432
|
return;
|
|
292
1433
|
}
|
|
293
1434
|
case 'steer': {
|
|
294
|
-
//
|
|
1435
|
+
// Authorization gate (mirrors /cancel): steer = cancel-running +
|
|
1436
|
+
// send-new, so without this an UNAUTHORIZED member of a shared session —
|
|
1437
|
+
// already blocked from /cancel — could abort another user's running turn
|
|
1438
|
+
// just by sending any normal message, defeating the /cancel restriction.
|
|
1439
|
+
// If not authorized, break out of the steer case: the message is NOT
|
|
1440
|
+
// dropped — it falls through to normal queuing (chains onto the session
|
|
1441
|
+
// queue tail and runs AFTER the active turn) without cancelling it.
|
|
1442
|
+
// isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
|
|
1443
|
+
// and for authorized members, so their steer-cancel is unchanged. Audit
|
|
1444
|
+
// the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
|
|
1445
|
+
// gates surface theirs) so an operator can see WHY a member's messages
|
|
1446
|
+
// queue instead of steering. Operator-level only — a normal message from an
|
|
1447
|
+
// unauthorized member shouldn't get a per-message user-facing rejection.
|
|
1448
|
+
// senderId is a stable platform id, not user-controlled display text.
|
|
1449
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
1450
|
+
process.stderr.write(`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`);
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
// Best-effort cancel the running turn so it winds down sooner, then fall
|
|
1454
|
+
// through to CHAIN this new turn onto the session queue tail (see `prev`
|
|
1455
|
+
// below). The new turn therefore runs ONLY AFTER the old turn's finally
|
|
1456
|
+
// has actually run — onChunk detached, activePrompts cleared, indicator
|
|
1457
|
+
// released — so it never executes concurrently with the turn it
|
|
1458
|
+
// supersedes.
|
|
1459
|
+
//
|
|
1460
|
+
// We deliberately do NOT race a bounded wait and then proceed with a
|
|
1461
|
+
// replacement bridge.prompt() while the old turn is still active: both
|
|
1462
|
+
// bridges key active-prompt tracking AND streamed chunks by sessionId
|
|
1463
|
+
// alone, so a concurrent replacement on one session is bridge-unsafe —
|
|
1464
|
+
// DaemonChannelBridge.prompt() rejects while the prior prompt is still
|
|
1465
|
+
// active (the replacement is silently dropped), and the abandoned turn's
|
|
1466
|
+
// late chunks mix into the replacement's stream (duplicated/stale
|
|
1467
|
+
// output). So a genuinely wedged turn makes its successor WAIT rather
|
|
1468
|
+
// than be force-interrupted. Turn-scoped cancellation/routing (a new
|
|
1469
|
+
// turn that runs without waiting for a wedged predecessor) is the
|
|
1470
|
+
// deferred fix — it needs an API change across every adapter and is out
|
|
1471
|
+
// of scope for this phase (wenshao option (b)).
|
|
295
1472
|
active.cancelled = true;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
1473
|
+
process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
|
|
1474
|
+
this.stopActiveStreaming(active, sessionId, 'steer');
|
|
1475
|
+
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
|
|
1476
|
+
// best-effort cancel that fails isn't silently invisible to operators.
|
|
1477
|
+
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
1478
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
|
|
1479
|
+
});
|
|
1480
|
+
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
1481
|
+
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
1482
|
+
// bridge.prompt() — surface it (the chained `.then()` clears it once the
|
|
1483
|
+
// predecessor settles). This only LOGS; it does not start a replacement or
|
|
1484
|
+
// change concurrency. /clear is the recovery path. unref so a pending timer
|
|
1485
|
+
// never keeps the process alive.
|
|
1486
|
+
steerWatchdog = setTimeout(() => {
|
|
1487
|
+
if (this.activePrompts.get(sessionId) === active) {
|
|
1488
|
+
process.stderr.write(`[${this.name}] steer queued behind active turn for session ${sessionId}: still waiting after ${CLEAR_CANCEL_TIMEOUT_MS}ms (use /clear to recover)\n`);
|
|
1489
|
+
}
|
|
1490
|
+
}, CLEAR_CANCEL_TIMEOUT_MS);
|
|
1491
|
+
steerWatchdog.unref?.();
|
|
1492
|
+
// Prepend a cancellation note so the agent understands context.
|
|
300
1493
|
promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
|
|
301
1494
|
break;
|
|
302
1495
|
}
|
|
@@ -311,10 +1504,70 @@ export class ChannelBase {
|
|
|
311
1504
|
}
|
|
312
1505
|
}
|
|
313
1506
|
}
|
|
314
|
-
|
|
1507
|
+
let shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
|
|
1508
|
+
if (shouldPrependSessionContext) {
|
|
1509
|
+
this.instructedSessions.add(sessionId);
|
|
1510
|
+
}
|
|
1511
|
+
// Run the prompt with per-session serialization. followup AND steer both chain
|
|
1512
|
+
// onto the existing queue tail; steer additionally best-effort cancelled the
|
|
1513
|
+
// running turn above so the tail resolves sooner. Chaining (rather than seeding
|
|
1514
|
+
// a fresh Promise.resolve()) is what guarantees this turn never runs while the
|
|
1515
|
+
// turn it supersedes is still active — see the steer branch above.
|
|
315
1516
|
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
1517
|
+
// Snapshot the session generation at enqueue time to guard against a /clear
|
|
1518
|
+
// racing this turn. There is no await between reading `active` above and this
|
|
1519
|
+
// snapshot, so the capture is atomic with the enqueue; if /clear bumps the
|
|
1520
|
+
// generation before this turn dequeues, the session we captured is gone — bail
|
|
1521
|
+
// (at the dequeue guard below) rather than resurrect it.
|
|
1522
|
+
const generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
316
1523
|
const useBlockStreaming = this.config.blockStreaming === 'on';
|
|
317
1524
|
const current = prev.then(async () => {
|
|
1525
|
+
// Disarm the steer watchdog: the predecessor's tail has resolved, so this
|
|
1526
|
+
// chained turn is no longer wedged behind it. No-op when unarmed (the timer is
|
|
1527
|
+
// only set on the steer path).
|
|
1528
|
+
clearTimeout(steerWatchdog);
|
|
1529
|
+
// A /clear (or reset/new) while we were queued bumps the generation; the
|
|
1530
|
+
// captured session is cleared, so don't run the prompt against it.
|
|
1531
|
+
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (!shouldPrependSessionContext &&
|
|
1535
|
+
!this.instructedSessions.has(sessionId)) {
|
|
1536
|
+
shouldPrependSessionContext = true;
|
|
1537
|
+
this.instructedSessions.add(sessionId);
|
|
1538
|
+
}
|
|
1539
|
+
const sessionContext = [];
|
|
1540
|
+
if (shouldPrependSessionContext) {
|
|
1541
|
+
let memoryText;
|
|
1542
|
+
if (this.channelMemory &&
|
|
1543
|
+
this.isAuthorizedForChannelMemory(envelope) &&
|
|
1544
|
+
(!this.isSharedSession(envelope) ||
|
|
1545
|
+
this.config.senderPolicy === 'allowlist')) {
|
|
1546
|
+
try {
|
|
1547
|
+
memoryText = (await this.channelMemory.readChannelMemory(this.channelMemoryTarget(envelope)))?.trim();
|
|
1548
|
+
}
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
this.logChannelMemoryError('read', envelope, this.channelMemoryErrorMessage(error));
|
|
1551
|
+
this.instructedSessions.delete(sessionId);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (memoryText) {
|
|
1555
|
+
sessionContext.push(`Channel memory for this chat:\n${sanitizePromptText(memoryText)}`);
|
|
1556
|
+
}
|
|
1557
|
+
if (this.config.instructions) {
|
|
1558
|
+
sessionContext.push(this.config.instructions);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const groupHistoryEntries = recognizedSlashCommand
|
|
1565
|
+
? []
|
|
1566
|
+
: this.drainPendingGroupHistory(envelope);
|
|
1567
|
+
let promptToSend = this.prependGroupHistoryContext(promptText, groupHistoryEntries);
|
|
1568
|
+
if (sessionContext.length > 0) {
|
|
1569
|
+
promptToSend = `${sessionContext.join('\n\n')}\n\n${promptToSend}`;
|
|
1570
|
+
}
|
|
318
1571
|
// Register this prompt as active
|
|
319
1572
|
let doneResolve = () => { };
|
|
320
1573
|
const done = new Promise((r) => {
|
|
@@ -324,7 +1577,12 @@ export class ChannelBase {
|
|
|
324
1577
|
cancelled: false,
|
|
325
1578
|
done,
|
|
326
1579
|
resolve: doneResolve,
|
|
1580
|
+
chatId: envelope.chatId,
|
|
1581
|
+
messageId: envelope.messageId,
|
|
327
1582
|
};
|
|
1583
|
+
// This turn is now the single owner of the session's active-prompt slot.
|
|
1584
|
+
// (Steer no longer hands a still-active session to a replacement; only
|
|
1585
|
+
// /clear evicts, and it gives the next turn a fresh session.)
|
|
328
1586
|
this.activePrompts.set(sessionId, promptState);
|
|
329
1587
|
this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
|
|
330
1588
|
const streamer = useBlockStreaming
|
|
@@ -342,14 +1600,18 @@ export class ChannelBase {
|
|
|
342
1600
|
streamer?.push(chunk);
|
|
343
1601
|
}
|
|
344
1602
|
};
|
|
345
|
-
this.bridge
|
|
1603
|
+
const promptBridge = this.bridge;
|
|
1604
|
+
promptBridge.on('textChunk', onChunk);
|
|
346
1605
|
try {
|
|
347
|
-
const response = await
|
|
1606
|
+
const response = await promptBridge.prompt(sessionId, promptToSend, {
|
|
348
1607
|
imageBase64,
|
|
349
1608
|
imageMimeType,
|
|
350
1609
|
});
|
|
351
1610
|
if (promptState.cancelRequested && !promptState.cancelled) {
|
|
352
|
-
|
|
1611
|
+
const cancelled = await promptState.cancelRequested;
|
|
1612
|
+
if (cancelled) {
|
|
1613
|
+
promptState.cancelled = true;
|
|
1614
|
+
}
|
|
353
1615
|
}
|
|
354
1616
|
// If cancelled, skip sending the response
|
|
355
1617
|
if (!promptState.cancelled && response) {
|
|
@@ -362,30 +1624,82 @@ export class ChannelBase {
|
|
|
362
1624
|
}
|
|
363
1625
|
}
|
|
364
1626
|
finally {
|
|
365
|
-
|
|
1627
|
+
promptBridge.off('textChunk', onChunk);
|
|
366
1628
|
streamer?.stop();
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
1629
|
+
// Identity guard: a turn that wedged past /clear's bounded wait gets
|
|
1630
|
+
// EVICTED — /clear gives up on active.done, deletes activePrompts, and a
|
|
1631
|
+
// turn the user starts AFTER the clear can re-seed activePrompts (and own
|
|
1632
|
+
// the collect buffer) for this session. When the wedged bridge.prompt
|
|
1633
|
+
// finally settles and runs this finally, touching session-visible state
|
|
1634
|
+
// would clobber that live later turn — ending the working indicator it
|
|
1635
|
+
// re-seeded or draining a buffer it owns. So only touch session-scoped
|
|
1636
|
+
// state when the entry is still ours. (Steer no longer evicts: it cancels
|
|
1637
|
+
// and waits, so a steered turn is always stillCurrent when it completes.)
|
|
1638
|
+
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
1639
|
+
// onPromptEnd runs platform cleanup (clear the typing interval, recall the
|
|
1640
|
+
// working reaction, finalize the card). Run it UNLESS this turn was a
|
|
1641
|
+
// /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
|
|
1642
|
+
// at clear-time, and a turn the user started after the clear may now own the
|
|
1643
|
+
// chat-scoped indicator, so re-running cleanup here would clobber it.
|
|
1644
|
+
// Invariant: clearEvicted is set ONLY by /clear's eviction, which then
|
|
1645
|
+
// UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
|
|
1646
|
+
// clear-time onPromptEnd guarantees the purge runs even if that throws), and
|
|
1647
|
+
// no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
|
|
1648
|
+
// stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
|
|
1649
|
+
// `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
|
|
1650
|
+
// Steer no longer evicts (it chains and waits), so a steered turn is always
|
|
1651
|
+
// stillCurrent on completion.
|
|
1652
|
+
if (!promptState.clearEvicted) {
|
|
1653
|
+
// onPromptEnd runs platform-adapter cleanup (clear the typing interval,
|
|
1654
|
+
// recall the working reaction, finalize the card) — network/IO that CAN
|
|
1655
|
+
// throw. Guard it like the /clear-eviction path above: an uncaught throw
|
|
1656
|
+
// here would skip activePrompts.delete (session leak), promptState.resolve
|
|
1657
|
+
// (active.done never settles → a later /clear falsely logs "abandoned a
|
|
1658
|
+
// wedged turn" for a turn that completed), and the collect-buffer drain
|
|
1659
|
+
// (lost messages) — and the rejected queue-chain promise, swallowed by the
|
|
1660
|
+
// tail .catch(() => {}), would silently drop every later turn this session.
|
|
1661
|
+
try {
|
|
1662
|
+
this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
|
|
1663
|
+
}
|
|
1664
|
+
catch (err) {
|
|
1665
|
+
process.stderr.write(`[${this.name}] onPromptEnd threw in finally for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
if (stillCurrent) {
|
|
1669
|
+
this.activePrompts.delete(sessionId);
|
|
1670
|
+
}
|
|
1671
|
+
// Signal any /clear waiter racing our done that we're done — even a
|
|
1672
|
+
// /clear-evicted wedged turn must release it (its bounded wait already
|
|
1673
|
+
// timed out). (Steer no longer waits on done; it chains on the queue tail.)
|
|
370
1674
|
promptState.resolve();
|
|
371
|
-
// Drain collect buffer if any messages accumulated
|
|
1675
|
+
// Drain collect buffer if any messages accumulated — but only while we're
|
|
1676
|
+
// still the active turn, so a /clear-evicted wedged turn whose bridge.prompt
|
|
1677
|
+
// settles late can't drain a buffer a later turn now owns. (Belt-and-
|
|
1678
|
+
// suspenders: /clear already deletes the buffer on eviction, so this guard
|
|
1679
|
+
// is defensive — but it keeps the invariant "only the current turn drains".)
|
|
372
1680
|
const buffer = this.collectBuffers.get(sessionId);
|
|
373
|
-
if (buffer && buffer.length > 0) {
|
|
1681
|
+
if (stillCurrent && buffer && buffer.length > 0) {
|
|
374
1682
|
this.collectBuffers.delete(sessionId);
|
|
1683
|
+
const lost = buffer.length;
|
|
375
1684
|
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
376
1685
|
const lastEnvelope = buffer[buffer.length - 1].envelope;
|
|
377
1686
|
// Re-enter handleInbound with the coalesced message
|
|
378
1687
|
const syntheticEnvelope = {
|
|
379
1688
|
...lastEnvelope,
|
|
380
1689
|
text: coalesced,
|
|
1690
|
+
// Coalesced text already carries each message's [sender] prefix.
|
|
1691
|
+
alreadyPrefixed: true,
|
|
381
1692
|
// Clear attachments/references — already resolved in original text
|
|
382
1693
|
referencedText: undefined,
|
|
383
1694
|
attachments: undefined,
|
|
384
1695
|
imageBase64: undefined,
|
|
385
1696
|
imageMimeType: undefined,
|
|
386
1697
|
};
|
|
387
|
-
// Queue the coalesced prompt (don't await to avoid deadlock on the queue)
|
|
388
|
-
|
|
1698
|
+
// Queue the coalesced prompt (don't await to avoid deadlock on the queue).
|
|
1699
|
+
// Surface a drain failure instead of silently losing buffered turns.
|
|
1700
|
+
this.handleInbound(syntheticEnvelope).catch((err) => {
|
|
1701
|
+
process.stderr.write(`[${this.name}] dropped ${lost} buffered message(s) on collect re-entry for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1702
|
+
});
|
|
389
1703
|
}
|
|
390
1704
|
}
|
|
391
1705
|
});
|
|
@@ -401,4 +1715,11 @@ export class ChannelBase {
|
|
|
401
1715
|
}
|
|
402
1716
|
}
|
|
403
1717
|
}
|
|
1718
|
+
function truncateGroupHistoryField(value) {
|
|
1719
|
+
return value.slice(0, GROUP_HISTORY_ENTRY_METADATA_LIMIT);
|
|
1720
|
+
}
|
|
1721
|
+
function truncateLoopLabel(prompt) {
|
|
1722
|
+
const chars = Array.from(prompt);
|
|
1723
|
+
return chars.length > 60 ? `${chars.slice(0, 57).join('')}...` : prompt;
|
|
1724
|
+
}
|
|
404
1725
|
//# sourceMappingURL=ChannelBase.js.map
|