@qwen-code/channel-base 0.19.2 → 0.19.3-nightly.20260701.a974594d7
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 +100 -35
- package/dist/AcpBridge.d.ts +10 -16
- package/dist/AcpBridge.js +30 -1
- package/dist/AcpBridge.js.map +1 -1
- package/dist/BlockStreamer.d.ts +2 -0
- package/dist/BlockStreamer.js +5 -0
- package/dist/BlockStreamer.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +47 -0
- package/dist/ChannelAgentBridge.js +2 -0
- package/dist/ChannelAgentBridge.js.map +1 -0
- package/dist/ChannelBase.d.ts +106 -5
- package/dist/ChannelBase.js +690 -48
- package/dist/ChannelBase.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +2 -2
- package/dist/DaemonChannelBridge.js +16 -2
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +15 -13
- package/dist/SessionRouter.js +187 -44
- package/dist/SessionRouter.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/sanitize.d.ts +60 -0
- package/dist/sanitize.js +115 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/types.d.ts +13 -3
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
1
2
|
import { BlockStreamer } from './BlockStreamer.js';
|
|
2
3
|
import { GroupGate } from './GroupGate.js';
|
|
3
4
|
import { SenderGate } from './SenderGate.js';
|
|
4
5
|
import { PairingStore } from './PairingStore.js';
|
|
5
6
|
import { SessionRouter } from './SessionRouter.js';
|
|
7
|
+
import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, } from './sanitize.js';
|
|
8
|
+
/**
|
|
9
|
+
* Max time /clear waits for a cancelled in-flight turn to wind down before
|
|
10
|
+
* purging anyway. A wedged ACP child (stuck tool call, not reading stdin, or
|
|
11
|
+
* crashed without closing) can leave active.done unresolved forever; without
|
|
12
|
+
* this bound /clear — and the whole channel — would hang. Safe because the
|
|
13
|
+
* purge runs regardless and the generation is bumped, so a turn that settles
|
|
14
|
+
* later is already invalidated.
|
|
15
|
+
*/
|
|
16
|
+
export const CLEAR_CANCEL_TIMEOUT_MS = 3000;
|
|
17
|
+
/**
|
|
18
|
+
* Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
|
|
19
|
+
* plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
|
|
20
|
+
* `/compress-fast`, `/git:commit`) parse as commands. Shared by parseCommand and
|
|
21
|
+
* isSlashCommand below so the two classifiers can't drift apart.
|
|
22
|
+
*/
|
|
23
|
+
const COMMAND_TOKEN_CHARS = 'a-zA-Z0-9_:-';
|
|
24
|
+
/** parseCommand: capture the leading `/command` token (+ optional `@botname`) and the rest as args. */
|
|
25
|
+
const PARSE_COMMAND_RE = new RegExp(`^\\/([${COMMAND_TOKEN_CHARS}]+)(?:@\\S+)?\\s*(.*)`, 's');
|
|
26
|
+
/** isSlashCommand: the first whitespace-delimited token alone must be a pure command token. */
|
|
27
|
+
const COMMAND_TOKEN_RE = new RegExp(`^[${COMMAND_TOKEN_CHARS}]+(?:@\\S+)?$`);
|
|
6
28
|
export class ChannelBase {
|
|
7
29
|
config;
|
|
8
30
|
bridge;
|
|
@@ -16,10 +38,26 @@ export class ChannelBase {
|
|
|
16
38
|
commands = new Map();
|
|
17
39
|
/** Per-session promise chain to serialize prompt + send (followup mode). */
|
|
18
40
|
sessionQueues = new Map();
|
|
41
|
+
registerBridgeEvents;
|
|
42
|
+
/**
|
|
43
|
+
* Per-session generation, bumped by /clear. A queued followup turn captures the
|
|
44
|
+
* generation when it enqueues and bails if /clear bumped it before the turn ran,
|
|
45
|
+
* so a cleared session can't be resurrected by an already-queued prompt.
|
|
46
|
+
*/
|
|
47
|
+
sessionGenerations = new Map();
|
|
19
48
|
/** Per-session active prompt tracking for dispatch modes. */
|
|
20
49
|
activePrompts = new Map();
|
|
21
50
|
/** Per-session message buffer for collect mode. */
|
|
22
51
|
collectBuffers = new Map();
|
|
52
|
+
bridgeToolCallListener = (event) => {
|
|
53
|
+
const target = this.router.getTarget(event.sessionId);
|
|
54
|
+
if (target) {
|
|
55
|
+
this.onToolCall(target.chatId, event);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
bridgeSessionDiedListener = (event) => {
|
|
59
|
+
this.onSessionDied(event.sessionId);
|
|
60
|
+
};
|
|
23
61
|
constructor(name, config, bridge, options) {
|
|
24
62
|
this.name = name;
|
|
25
63
|
this.config = config;
|
|
@@ -32,22 +70,38 @@ export class ChannelBase {
|
|
|
32
70
|
options?.router ||
|
|
33
71
|
new SessionRouter(bridge, config.cwd, config.sessionScope);
|
|
34
72
|
this.registerSharedCommands();
|
|
35
|
-
// When running standalone
|
|
73
|
+
// When running standalone, register bridge listeners directly.
|
|
36
74
|
// In gateway mode, the ChannelManager dispatches events instead.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
this.onToolCall(target.chatId, event);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
75
|
+
this.registerBridgeEvents =
|
|
76
|
+
options?.registerBridgeEvents ?? !options?.router;
|
|
77
|
+
if (this.registerBridgeEvents) {
|
|
78
|
+
this.attachBridgeEvents(bridge);
|
|
44
79
|
}
|
|
45
80
|
}
|
|
46
81
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
47
82
|
setBridge(bridge) {
|
|
83
|
+
if (this.registerBridgeEvents) {
|
|
84
|
+
this.detachBridgeEvents(this.bridge);
|
|
85
|
+
}
|
|
86
|
+
this.router.setBridge(bridge);
|
|
48
87
|
this.bridge = bridge;
|
|
88
|
+
if (this.registerBridgeEvents) {
|
|
89
|
+
this.attachBridgeEvents(bridge);
|
|
90
|
+
}
|
|
49
91
|
}
|
|
50
92
|
onToolCall(_chatId, _event) { }
|
|
93
|
+
onSessionDied(sessionId) {
|
|
94
|
+
this.router.removeSessionId(sessionId);
|
|
95
|
+
this.instructedSessions.delete(sessionId);
|
|
96
|
+
}
|
|
97
|
+
attachBridgeEvents(bridge) {
|
|
98
|
+
bridge.on('toolCall', this.bridgeToolCallListener);
|
|
99
|
+
bridge.on('sessionDied', this.bridgeSessionDiedListener);
|
|
100
|
+
}
|
|
101
|
+
detachBridgeEvents(bridge) {
|
|
102
|
+
bridge.off('toolCall', this.bridgeToolCallListener);
|
|
103
|
+
bridge.off('sessionDied', this.bridgeSessionDiedListener);
|
|
104
|
+
}
|
|
51
105
|
/**
|
|
52
106
|
* Called when a prompt actually begins processing (inside the session queue).
|
|
53
107
|
* Override to show a platform-specific working indicator (e.g., typing, reaction).
|
|
@@ -81,40 +135,220 @@ export class ChannelBase {
|
|
|
81
135
|
registerCommand(name, handler) {
|
|
82
136
|
this.commands.set(name.toLowerCase(), handler);
|
|
83
137
|
}
|
|
138
|
+
registerCancelCommand(name = 'cancel') {
|
|
139
|
+
this.registerCommand(name, async (envelope) => {
|
|
140
|
+
// /cancel aborts an in-flight turn — destructive in a shared session, where
|
|
141
|
+
// it would otherwise let any member kill another user's running turn. Gate it
|
|
142
|
+
// to authorized senders like /clear (auth gate only — no confirm step). A
|
|
143
|
+
// non-shared (1:1) session is always authorized, so behavior is unchanged.
|
|
144
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
145
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can cancel requests in this shared session.');
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const activeSessionId = this.findActiveSessionId(envelope);
|
|
149
|
+
if (!activeSessionId) {
|
|
150
|
+
await this.sendMessage(envelope.chatId, 'No request is currently running.');
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
const active = this.activePrompts.get(activeSessionId);
|
|
154
|
+
if (!active) {
|
|
155
|
+
await this.sendMessage(envelope.chatId, 'No request is currently running.');
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const cancelRequested = active.cancelRequested ??
|
|
159
|
+
this.bridge.cancelSession(activeSessionId).then(() => true, (err) => {
|
|
160
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${activeSessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
161
|
+
active.cancelRequested = undefined;
|
|
162
|
+
return false;
|
|
163
|
+
});
|
|
164
|
+
active.cancelRequested = cancelRequested;
|
|
165
|
+
const cancelSucceeded = await cancelRequested;
|
|
166
|
+
if (!cancelSucceeded) {
|
|
167
|
+
await this.sendMessage(envelope.chatId, 'Failed to cancel current request.');
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
active.cancelled = true;
|
|
171
|
+
active.stopStreaming?.();
|
|
172
|
+
this.collectBuffers.delete(activeSessionId);
|
|
173
|
+
await this.sendMessage(envelope.chatId, 'Cancelled current request.');
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
84
177
|
/** Register shared slash commands. Called from constructor. */
|
|
85
178
|
registerSharedCommands() {
|
|
86
|
-
const
|
|
87
|
-
const removedIds = this.router.removeSession(this.name, envelope.senderId, envelope.chatId);
|
|
179
|
+
const doClear = async (envelope) => {
|
|
180
|
+
const removedIds = this.router.removeSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
88
181
|
if (removedIds.length > 0) {
|
|
89
182
|
for (const id of removedIds) {
|
|
183
|
+
// Audit: clearing a SHARED session wipes the conversation for every
|
|
184
|
+
// participant, so record who triggered it (sanitized display name +
|
|
185
|
+
// stable senderId) and which session — mirrors the file's stderr audit
|
|
186
|
+
// style. A 1:1 DM clear only touches the caller, so it isn't logged.
|
|
187
|
+
if (this.isSharedSession(envelope)) {
|
|
188
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
189
|
+
process.stderr.write(`[${this.name}] shared session ${id} cleared by ${who} (sender ${envelope.senderId})\n`);
|
|
190
|
+
}
|
|
191
|
+
// Bump the generation up-front (before any await) so a followup turn
|
|
192
|
+
// already queued onto this session sees a stale generation and bails
|
|
193
|
+
// instead of running bridge.prompt() against the cleared session.
|
|
194
|
+
this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
|
|
195
|
+
// Cancel an in-flight turn (and drop its buffered follow-ups) before
|
|
196
|
+
// purging, so a running prompt can't deliver a stale response into —
|
|
197
|
+
// or resurrect via collect-drain — the just-cleared session.
|
|
198
|
+
const active = this.activePrompts.get(id);
|
|
199
|
+
this.collectBuffers.delete(id);
|
|
200
|
+
if (active) {
|
|
201
|
+
// Bounded cancel + wind-down wait; purge regardless of the result.
|
|
202
|
+
const settled = await this.cancelAndAwaitActive(active, id);
|
|
203
|
+
if (!settled) {
|
|
204
|
+
// Wedged: the turn never wound down within the bound. Surface it —
|
|
205
|
+
// otherwise a zombie bridge.prompt() lingers in the child with zero
|
|
206
|
+
// observability ("/clear worked" but a turn is still pinned).
|
|
207
|
+
// Include the originating chat/message (sanitized — platform IDs can
|
|
208
|
+
// be attacker-influenced) so oncall can correlate the wedged turn. Both
|
|
209
|
+
// are read defensively (fallback / omitted) so a partial entry can't
|
|
210
|
+
// crash /clear, the recovery path.
|
|
211
|
+
const wedgedChat = active.chatId
|
|
212
|
+
? sanitizeLogText(active.chatId, 64)
|
|
213
|
+
: 'unknown';
|
|
214
|
+
const wedgedMessage = active.messageId
|
|
215
|
+
? `, message ${sanitizeLogText(active.messageId, 64)}`
|
|
216
|
+
: '';
|
|
217
|
+
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`);
|
|
218
|
+
// The wedged turn's finally may run much later (or never), so clean
|
|
219
|
+
// up its OWN platform indicator now, while no replacement exists yet.
|
|
220
|
+
// Mark it clearEvicted FIRST so the late finally skips onPromptEnd — a
|
|
221
|
+
// turn the user starts after this /clear owns the chat indicator by
|
|
222
|
+
// then, and re-running cleanup would clobber it.
|
|
223
|
+
active.clearEvicted = true;
|
|
224
|
+
// onPromptEnd runs adapter cleanup (platform API calls that can throw).
|
|
225
|
+
// Swallow + audit any throw: an uncaught one would abort the purge
|
|
226
|
+
// below, leaving this turn in activePrompts so its late finally sees it
|
|
227
|
+
// as still-current (`stillCurrent || !clearEvicted`) and re-runs
|
|
228
|
+
// onPromptEnd anyway. Letting the purge proceed makes the turn
|
|
229
|
+
// non-current, so the clearEvicted guard then skips correctly.
|
|
230
|
+
try {
|
|
231
|
+
this.onPromptEnd(active.chatId, id, active.messageId);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
process.stderr.write(`[${this.name}] onPromptEnd threw during /clear eviction for session ${id}: ${err instanceof Error ? err.message : err}\n`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Purge every per-session map (all keyed by sessionId) so a
|
|
239
|
+
// long-running gateway doesn't leak dead entries after /clear.
|
|
90
240
|
this.instructedSessions.delete(id);
|
|
241
|
+
// The queue's tail resolves only after every turn queued before this
|
|
242
|
+
// /clear has dequeued and bailed on the bumped generation. Capture it
|
|
243
|
+
// before deletion so we can reclaim sessionGenerations[id] once it
|
|
244
|
+
// drains — otherwise the bumped entry leaks for the gateway's lifetime.
|
|
245
|
+
const drained = this.sessionQueues.get(id);
|
|
246
|
+
const bumpedGeneration = this.sessionGenerations.get(id);
|
|
247
|
+
this.sessionQueues.delete(id);
|
|
248
|
+
this.activePrompts.delete(id);
|
|
249
|
+
if (drained) {
|
|
250
|
+
// Deferred, never awaited: a wedged turn that never drains must not
|
|
251
|
+
// block /clear (the entry just lingers, as before). The guards skip
|
|
252
|
+
// reclamation if a newer turn re-queued onto this id or another
|
|
253
|
+
// /clear re-bumped it, so an entry a queued turn still needs is never
|
|
254
|
+
// deleted out from under it.
|
|
255
|
+
void drained.then(() => {
|
|
256
|
+
if (!this.sessionQueues.has(id) &&
|
|
257
|
+
this.sessionGenerations.get(id) === bumpedGeneration) {
|
|
258
|
+
this.sessionGenerations.delete(id);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// Nothing was ever queued for this session, so no turn can read the
|
|
264
|
+
// bumped value — reclaim it immediately.
|
|
265
|
+
this.sessionGenerations.delete(id);
|
|
266
|
+
}
|
|
91
267
|
}
|
|
92
|
-
await this.sendMessage(envelope.chatId, 'Session cleared.
|
|
268
|
+
await this.sendMessage(envelope.chatId, 'Session cleared. The next message starts a fresh conversation.');
|
|
93
269
|
}
|
|
94
270
|
else {
|
|
95
271
|
await this.sendMessage(envelope.chatId, 'No active session to clear.');
|
|
96
272
|
}
|
|
273
|
+
};
|
|
274
|
+
// For a shared session, clearing it affects everyone who shares it: restrict
|
|
275
|
+
// it to authorized senders (config.allowedUsers, when set) and require an
|
|
276
|
+
// explicit "confirm". DMs on per-user/thread scope and per-user groups clear
|
|
277
|
+
// directly — there /clear only touches the caller's own session.
|
|
278
|
+
const clearHandler = async (envelope, args) => {
|
|
279
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
280
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can clear this shared session.');
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (this.isSharedSession(envelope) && args.toLowerCase() !== 'confirm') {
|
|
284
|
+
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.');
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
await doClear(envelope);
|
|
97
288
|
return true;
|
|
98
289
|
};
|
|
99
290
|
this.registerCommand('clear', clearHandler);
|
|
100
291
|
this.registerCommand('reset', clearHandler);
|
|
101
292
|
this.registerCommand('new', clearHandler);
|
|
293
|
+
// Read-only: report the current (possibly group-shared) session and workspace.
|
|
294
|
+
// For a shared session, gate it to authorized senders like /clear — /who
|
|
295
|
+
// leaks the workspace basename, so non-members shouldn't see it either.
|
|
296
|
+
this.registerCommand('who', async (envelope) => {
|
|
297
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
298
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
const active = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
302
|
+
// `single` collapses EVERY DM and group to one `__single__` session, so it
|
|
303
|
+
// is shared channel-wide regardless of where the /who came from — report
|
|
304
|
+
// that explicitly (a group `single` session understates its blast radius as
|
|
305
|
+
// "shared by this group"). Other scopes keep their existing wording.
|
|
306
|
+
const scopeNote = this.config.sessionScope === 'single'
|
|
307
|
+
? ' (shared channel-wide)'
|
|
308
|
+
: this.isSharedSession(envelope)
|
|
309
|
+
? envelope.isGroup
|
|
310
|
+
? ' (shared by this group)'
|
|
311
|
+
: ''
|
|
312
|
+
: envelope.isGroup
|
|
313
|
+
? ' (private to you)'
|
|
314
|
+
: '';
|
|
315
|
+
await this.sendMessage(envelope.chatId, [
|
|
316
|
+
`Channel: ${this.name}`,
|
|
317
|
+
// Only the basename — don't leak the absolute cwd to group members.
|
|
318
|
+
`Workspace: ${basename(this.config.cwd)}`,
|
|
319
|
+
`Session: ${active ? 'active' : 'none'}${scopeNote}`,
|
|
320
|
+
].join('\n'));
|
|
321
|
+
return true;
|
|
322
|
+
});
|
|
102
323
|
this.registerCommand('help', async (envelope) => {
|
|
103
324
|
const lines = [
|
|
104
325
|
'Commands:',
|
|
105
326
|
'/help — Show this help',
|
|
106
|
-
|
|
327
|
+
this.isSharedSession(envelope)
|
|
328
|
+
? '/clear confirm — Clear the shared session (aliases: /reset, /new)'
|
|
329
|
+
: '/clear — Clear your session (aliases: /reset, /new)',
|
|
330
|
+
'/who — Show current session & workspace',
|
|
107
331
|
'/status — Show session info',
|
|
108
332
|
];
|
|
109
333
|
// Platform-specific commands (registered by adapters, not shared ones)
|
|
110
|
-
const sharedCmds = new Set([
|
|
334
|
+
const sharedCmds = new Set([
|
|
335
|
+
'help',
|
|
336
|
+
'clear',
|
|
337
|
+
'reset',
|
|
338
|
+
'new',
|
|
339
|
+
'who',
|
|
340
|
+
'status',
|
|
341
|
+
]);
|
|
111
342
|
const platformCmds = [...this.commands.keys()].filter((c) => !sharedCmds.has(c));
|
|
112
343
|
if (platformCmds.length > 0) {
|
|
113
344
|
for (const cmd of platformCmds) {
|
|
114
345
|
lines.push(`/${cmd}`);
|
|
115
346
|
}
|
|
116
347
|
}
|
|
117
|
-
const
|
|
348
|
+
const sessionId = this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
349
|
+
const agentCommands = sessionId
|
|
350
|
+
? this.getAgentCommandsForSession(sessionId)
|
|
351
|
+
: this.bridge.availableCommands;
|
|
118
352
|
if (agentCommands.length > 0) {
|
|
119
353
|
lines.push('', 'Agent commands (forwarded to Qwen Code):');
|
|
120
354
|
for (const cmd of agentCommands) {
|
|
@@ -126,7 +360,13 @@ export class ChannelBase {
|
|
|
126
360
|
return true;
|
|
127
361
|
});
|
|
128
362
|
this.registerCommand('status', async (envelope) => {
|
|
129
|
-
|
|
363
|
+
// For a shared session, gate it to authorized senders like /who — /status
|
|
364
|
+
// reports session & access state, so non-members shouldn't read it either.
|
|
365
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
366
|
+
await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
const hasSession = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
130
370
|
const policy = this.config.senderPolicy;
|
|
131
371
|
const lines = [
|
|
132
372
|
`Session: ${hasSession ? 'active' : 'none'}`,
|
|
@@ -142,18 +382,190 @@ export class ChannelBase {
|
|
|
142
382
|
const parsed = this.parseCommand(text);
|
|
143
383
|
return parsed !== null && this.commands.has(parsed.command);
|
|
144
384
|
}
|
|
385
|
+
findActiveSessionId(envelope) {
|
|
386
|
+
const sessionId = this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
387
|
+
return sessionId && this.activePrompts.has(sessionId)
|
|
388
|
+
? sessionId
|
|
389
|
+
: undefined;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Whether the resolved session is SHARED across senders. `single` collapses
|
|
393
|
+
* the whole channel to one `__single__` session for EVERY sender — group OR
|
|
394
|
+
* DM — so it is ALWAYS shared (even a DM maps to `__single__`). `thread` is
|
|
395
|
+
* shared only in a group (a DM maps to the lone caller's own chat). `user` is
|
|
396
|
+
* per-sender, never shared. Drives both the destructive-/clear confirm gate
|
|
397
|
+
* and the host-shell (`!`) gate.
|
|
398
|
+
*/
|
|
399
|
+
isSharedSession(envelope) {
|
|
400
|
+
return (this.config.sessionScope === 'single' ||
|
|
401
|
+
(envelope.isGroup && this.config.sessionScope === 'thread'));
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Whether `envelope.senderId` may act on the resolved session's destructive or
|
|
405
|
+
* workspace-leaking commands (/clear, /who). A SHARED session with a non-empty
|
|
406
|
+
* allowedUsers list is restricted to those members; a per-user session, or one
|
|
407
|
+
* with no allowlist, is unrestricted. Shared verbatim by /clear and /who so the
|
|
408
|
+
* gate can't drift; each caller sends its own rejection wording.
|
|
409
|
+
*/
|
|
410
|
+
isAuthorizedForSharedSession(envelope) {
|
|
411
|
+
if (!this.isSharedSession(envelope))
|
|
412
|
+
return true;
|
|
413
|
+
const authorized = this.config.allowedUsers;
|
|
414
|
+
return authorized.length === 0 || authorized.includes(envelope.senderId);
|
|
415
|
+
}
|
|
416
|
+
stopActiveStreaming(active, sessionId, reason) {
|
|
417
|
+
try {
|
|
418
|
+
active.stopStreaming?.();
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
process.stderr.write(`[${this.name}] stopStreaming threw during ${reason} for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Cancel the active turn and wait (bounded) for it to wind down. Stops the
|
|
426
|
+
* BlockStreamer so buffered text can't leak via the idle timer, then fires a
|
|
427
|
+
* best-effort cancelSession (NOT awaited — a wedged child/daemon can leave the
|
|
428
|
+
* request pending forever). Returns true if active.done settled first, false
|
|
429
|
+
* if the CLEAR_CANCEL_TIMEOUT_MS bound won (the turn never wound down). Used by
|
|
430
|
+
* /clear, which genuinely EVICTS the session and so must proceed even when the
|
|
431
|
+
* turn is wedged. Steer no longer uses this: it best-effort cancels then chains
|
|
432
|
+
* the new turn behind the old one (see handleInbound), so it never needs to
|
|
433
|
+
* proceed past a still-active turn.
|
|
434
|
+
*/
|
|
435
|
+
async cancelAndAwaitActive(active, sessionId) {
|
|
436
|
+
active.cancelled = true;
|
|
437
|
+
this.stopActiveStreaming(active, sessionId, 'cancel');
|
|
438
|
+
// Fire-and-forget, but LOG the IPC failure: a swallowed reason leaves a
|
|
439
|
+
// wedged turn undiagnosable (operator sees only the wind-down timeout below
|
|
440
|
+
// with no cause).
|
|
441
|
+
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
442
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (clear/await): ${err instanceof Error ? err.message : err}\n`);
|
|
443
|
+
});
|
|
444
|
+
let timer;
|
|
445
|
+
const settled = await Promise.race([
|
|
446
|
+
active.done.then(() => true),
|
|
447
|
+
new Promise((resolve) => {
|
|
448
|
+
timer = setTimeout(() => resolve(false), CLEAR_CANCEL_TIMEOUT_MS);
|
|
449
|
+
}),
|
|
450
|
+
]);
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
return settled;
|
|
453
|
+
}
|
|
145
454
|
/**
|
|
146
455
|
* Parse a slash command from message text.
|
|
147
|
-
* Returns { command, args } or null if not a slash command.
|
|
456
|
+
* Returns { command, raw, args } or null if not a slash command. `command` is
|
|
457
|
+
* lowercased for case-insensitive LOCAL dispatch (registerCommand lowercases the
|
|
458
|
+
* names it stores); `raw` keeps the typed case so agent-command matching can be
|
|
459
|
+
* CASE-SENSITIVE, mirroring the CLI's parseSlashCommand (`cmd.name === part`).
|
|
148
460
|
*/
|
|
149
461
|
parseCommand(text) {
|
|
150
|
-
|
|
462
|
+
// Trim first so a leading-whitespace slash command (common from IME /
|
|
463
|
+
// copy-paste, e.g. " /help") parses, and so this agrees with isSlashCommand
|
|
464
|
+
// (which already trims). Otherwise isSlashCommand suppresses the [sender] tag
|
|
465
|
+
// while parseCommand returns null, leaking the command to the agent unattributed.
|
|
466
|
+
const trimmed = text.trim();
|
|
467
|
+
if (!trimmed.startsWith('/'))
|
|
151
468
|
return null;
|
|
152
|
-
// Handle /command@botname format (Telegram groups)
|
|
153
|
-
|
|
469
|
+
// Handle /command@botname format (Telegram groups). The token allows `-` and
|
|
470
|
+
// `:` so hyphenated and namespaced agent commands (e.g. /compress-fast,
|
|
471
|
+
// /git:commit) still parse as commands rather than being treated as text
|
|
472
|
+
// (charset shared with isSlashCommand via PARSE_COMMAND_RE).
|
|
473
|
+
const match = trimmed.match(PARSE_COMMAND_RE);
|
|
154
474
|
if (!match)
|
|
155
475
|
return null;
|
|
156
|
-
return {
|
|
476
|
+
return {
|
|
477
|
+
command: match[1].toLowerCase(),
|
|
478
|
+
raw: match[1],
|
|
479
|
+
args: match[2].trim(),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Whether `text` is a real slash command rather than prose that merely starts
|
|
484
|
+
* with `/`. A command's first whitespace-delimited token must match
|
|
485
|
+
* parseCommand()'s charset — `[a-zA-Z0-9_:-]+`, plus an optional `@botname`
|
|
486
|
+
* suffix — and not be a `//` line comment or `/*` block comment. Slash-prefixed
|
|
487
|
+
* paths (`/tmp/foo`), comments, and a bare `/` are prose and keep their
|
|
488
|
+
* `[sender]` tag.
|
|
489
|
+
*
|
|
490
|
+
* Intentionally stricter than the CLI's looser classifier (cli
|
|
491
|
+
* `ui/utils/commandUtils.ts`), which forwards any non-comment, non-path
|
|
492
|
+
* `/<token>` (e.g. `/café`, a zero-width-laden token). Such inputs aren't
|
|
493
|
+
* runnable commands, and in a SHARED group session forwarding them unattributed
|
|
494
|
+
* is worse than a redundant tag — so anything off the command charset is
|
|
495
|
+
* treated as prose and keeps its `[sender]` tag. Purely lexical — never
|
|
496
|
+
* consults the async command list, so it can't race a fresh session.
|
|
497
|
+
*/
|
|
498
|
+
isSlashCommand(text) {
|
|
499
|
+
const trimmed = text.trim();
|
|
500
|
+
if (!trimmed.startsWith('/') ||
|
|
501
|
+
trimmed.startsWith('//') ||
|
|
502
|
+
trimmed.startsWith('/*')) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
// No trimStart: the token must immediately follow `/`. A space after the
|
|
506
|
+
// slash (`/ foo`) makes split()[0] empty, so this returns false — matching
|
|
507
|
+
// parseCommand, whose regex also requires the token right after `/`. If they
|
|
508
|
+
// diverged, `/ foo` in a shared group session would suppress the [sender] tag
|
|
509
|
+
// (isSlashCommand true) yet run no command (parseCommand null), reaching the
|
|
510
|
+
// agent unattributed.
|
|
511
|
+
const firstToken = trimmed.slice(1).split(/\s+/u)[0] ?? '';
|
|
512
|
+
return COMMAND_TOKEN_RE.test(firstToken);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Whether `text` names a command this channel can actually run: a locally
|
|
516
|
+
* registered command (`this.commands`, e.g. /clear, /who) OR an agent command
|
|
517
|
+
* THIS session exposes — by canonical name OR alias (e.g. `/summarize` for
|
|
518
|
+
* `/compress`). Paired with isSlashCommand so the `[sender]` attribution tag is
|
|
519
|
+
* suppressed ONLY for RECOGNIZED commands; command-SHAPED-but-unrecognized text
|
|
520
|
+
* (e.g. `/x\n[SYSTEM]: …`) keeps its tag rather than reaching a shared group
|
|
521
|
+
* unattributed, where an injected second line is more likely read as a system
|
|
522
|
+
* directive. Purely synchronous, like isSlashCommand: it reads the session's
|
|
523
|
+
* availableCommands snapshot WITHOUT awaiting, so it never races a fresh session
|
|
524
|
+
* (a genuine agent command sent before the snapshot loads is treated as
|
|
525
|
+
* unrecognized and KEEPS its tag — the safe default).
|
|
526
|
+
*/
|
|
527
|
+
isRecognizedCommand(text, sessionId) {
|
|
528
|
+
const parsed = this.parseCommand(text);
|
|
529
|
+
if (!parsed)
|
|
530
|
+
return false;
|
|
531
|
+
// LOCAL commands dispatch CASE-INSENSITIVELY: registerCommand lowercases the
|
|
532
|
+
// stored name and handleInbound looks it up by the lowercased token, so mirror
|
|
533
|
+
// that here with the lowercased `command`.
|
|
534
|
+
if (this.commands.has(parsed.command))
|
|
535
|
+
return true;
|
|
536
|
+
// AGENT commands: mirror the CLI's parseSlashCommand EXACTLY so the channel and
|
|
537
|
+
// the agent AGREE on what is a command. The CLI takes the FIRST whitespace token
|
|
538
|
+
// after the leading `/`, CASE-SENSITIVELY, and does NOT strip an `@suffix`
|
|
539
|
+
// (`cmd.name === part`, `cmd.altNames?.includes(part)`). So recognize the SAME
|
|
540
|
+
// token here — NOT parseCommand's `@`-stripped, lowercased `raw` (PARSE_COMMAND_RE
|
|
541
|
+
// drops `(?:@\S+)?`, which is the very divergence this closes). A wrong-case
|
|
542
|
+
// (`/Compress`), `@`-suffixed (`/compress@bot` — possibly aimed at ANOTHER bot, so
|
|
543
|
+
// we must NOT run it here), or injection-shaped (`/COMPRESS\n[SYSTEM]: …`) token
|
|
544
|
+
// then does NOT match → stays UNRECOGNIZED → keeps its `[sender]` tag (attributed),
|
|
545
|
+
// exactly as the agent treats it (it runs no command; the text reaches the model
|
|
546
|
+
// as prose). Array.isArray guards a malformed wire `altNames` (a non-array would
|
|
547
|
+
// throw at `.includes`).
|
|
548
|
+
const token = text.trim().slice(1).split(/\s+/u)[0] ?? '';
|
|
549
|
+
return this.getAgentCommandsForSession(sessionId).some((cmd) => cmd.name === token ||
|
|
550
|
+
(Array.isArray(cmd.altNames) && cmd.altNames.includes(token)));
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The agent-command snapshot for THIS session. DaemonChannelBridge keys
|
|
554
|
+
* commands per session, so its global `availableCommands` getter can return
|
|
555
|
+
* ANOTHER session's list — prefer its getAvailableCommands(sessionId) when
|
|
556
|
+
* present. AcpBridge runs a single agent and exposes only the global getter
|
|
557
|
+
* (inherently session-correct), so fall back to it. Synchronous, matching
|
|
558
|
+
* isRecognizedCommand's no-await contract.
|
|
559
|
+
*/
|
|
560
|
+
getAgentCommandsForSession(sessionId) {
|
|
561
|
+
// Structural (typed) access via AgentCommandsProvider rather than a blind
|
|
562
|
+
// `as unknown` cast: both members are optional, so AcpBridge (no per-session
|
|
563
|
+
// getter) is assignable while a rename/return-type change is still type-checked.
|
|
564
|
+
const bridge = this.bridge;
|
|
565
|
+
if (typeof bridge.getAvailableCommands === 'function') {
|
|
566
|
+
return bridge.getAvailableCommands(sessionId) ?? [];
|
|
567
|
+
}
|
|
568
|
+
return bridge.availableCommands ?? [];
|
|
157
569
|
}
|
|
158
570
|
async handleInbound(envelope) {
|
|
159
571
|
// 1. Group gate: policy + allowlist + mention gating
|
|
@@ -180,14 +592,41 @@ export class ChannelBase {
|
|
|
180
592
|
}
|
|
181
593
|
// Unrecognized commands fall through to the agent
|
|
182
594
|
}
|
|
595
|
+
// 3.5. Bang (!) shell command — refuse outside a private 1:1 chat BEFORE
|
|
596
|
+
// resolving a session, so a refused command never creates or persists one.
|
|
597
|
+
// Phase 0 has no per-sender trust model (the [sender] marker is NOT a trust
|
|
598
|
+
// boundary). Any group is multi-operator — even a user-scope group, which is
|
|
599
|
+
// NOT a "shared session" — so an allowed member could `!rm -rf /` the host.
|
|
600
|
+
const bangText = envelope.text.trimStart();
|
|
601
|
+
if (bangText.startsWith('!')) {
|
|
602
|
+
if (envelope.isGroup || this.isSharedSession(envelope)) {
|
|
603
|
+
// Audit a blocked host-shell attempt — a group/shared member trying `!`
|
|
604
|
+
// is security-relevant, so surface it to operators. Sanitize the display
|
|
605
|
+
// name (attacker-controlled) and do NOT echo the command payload.
|
|
606
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
607
|
+
process.stderr.write(`[${this.name}] blocked ! shell command from ${who} (sender ${envelope.senderId}) in chat ${sanitizeLogText(envelope.chatId, 64)}\n`);
|
|
608
|
+
}
|
|
609
|
+
if (envelope.isGroup) {
|
|
610
|
+
await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in group chats.');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// A single-scope DM collapses every DM to one channel-wide session, so it
|
|
614
|
+
// is multi-operator too despite not being a group.
|
|
615
|
+
if (this.isSharedSession(envelope)) {
|
|
616
|
+
await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in shared sessions.');
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
183
620
|
const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd);
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
621
|
+
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
622
|
+
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
623
|
+
// above, before the session was resolved.
|
|
624
|
+
if (bangText.startsWith('!')) {
|
|
625
|
+
const cmd = bangText.slice(1).trim();
|
|
626
|
+
const bridgeShellCommand = this.bridge.shellCommand;
|
|
627
|
+
if (cmd && bridgeShellCommand) {
|
|
189
628
|
try {
|
|
190
|
-
const result =
|
|
629
|
+
const result = await this.bridge.shellCommand(sessionId, cmd);
|
|
191
630
|
const longestRun = Math.max(0, ...Array.from((result.output || '').matchAll(/`+/g), (m) => m[0].length));
|
|
192
631
|
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
193
632
|
const output = result.output
|
|
@@ -206,8 +645,42 @@ export class ChannelBase {
|
|
|
206
645
|
}
|
|
207
646
|
// Prepend referenced (quoted) message text for reply context
|
|
208
647
|
let promptText = envelope.text;
|
|
648
|
+
// Multiplayer attribution: when a session can carry multiple humans, tag each
|
|
649
|
+
// turn with the speaker so the agent can tell members apart. That is any group
|
|
650
|
+
// AND any single-scope DM — `single` collapses every sender's DM into one
|
|
651
|
+
// __single__ session (the same multi-operator case the !-gate, /clear confirm
|
|
652
|
+
// and /who already treat as shared), so without a tag it would merge different
|
|
653
|
+
// people into one unattributed conversation. NOT gated on isSharedSession:
|
|
654
|
+
// that is false for a user-scope GROUP, which still needs attribution. Sanitize
|
|
655
|
+
// the name so a crafted nick can't break out of the [..] tag or inject
|
|
656
|
+
// newlines. Skipped for a per-user 1:1 chat and for already-prefixed re-entries
|
|
657
|
+
// (collect-mode coalescing). The tag is also suppressed for a real slash
|
|
658
|
+
// command — a [sender] prefix would stop it from parsing — but ONLY when it is
|
|
659
|
+
// BOTH a genuine command SHAPE (isSlashCommand) AND a RECOGNIZED command
|
|
660
|
+
// (isRecognizedCommand: a locally registered or agent-exposed command, by
|
|
661
|
+
// canonical name OR alias, for THIS session — matched EXACTLY as the agent's
|
|
662
|
+
// parseSlashCommand does, so the two never diverge). Command-shaped-but-
|
|
663
|
+
// unrecognized text like `/x\n[SYSTEM]: …` (token matches the charset but no such
|
|
664
|
+
// command exists) KEEPS its tag, so its injected second line can't reach a shared
|
|
665
|
+
// group unattributed and pose as a system directive. Slash-prefixed paths
|
|
666
|
+
// (/tmp/foo) and comments (//…, /*…*/) are prose, so they stay attributed too.
|
|
667
|
+
// Both checks are synchronous (no await), so this never races the async command
|
|
668
|
+
// list — see isRecognizedCommand for the no-await tradeoff.
|
|
669
|
+
if ((envelope.isGroup || this.config.sessionScope === 'single') &&
|
|
670
|
+
!envelope.alreadyPrefixed &&
|
|
671
|
+
!(this.isSlashCommand(envelope.text) &&
|
|
672
|
+
this.isRecognizedCommand(envelope.text, sessionId))) {
|
|
673
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
674
|
+
promptText = `[${who}] ${sanitizePromptText(promptText)}`;
|
|
675
|
+
}
|
|
209
676
|
if (envelope.referencedText) {
|
|
210
|
-
|
|
677
|
+
// Quoted text is attacker-controlled. sanitizeQuotedText strips C0/DEL
|
|
678
|
+
// controls, Unicode line/paragraph separators (U+2028/U+2029) and bidi
|
|
679
|
+
// overrides, and the wrapper's own `"[]` delimiters, then caps length -
|
|
680
|
+
// so a crafted quote can't inject newlines/instructions, close the
|
|
681
|
+
// [Replying to: "..."] wrapper, flip text direction, or balloon the prompt.
|
|
682
|
+
const quoted = sanitizeQuotedText(envelope.referencedText, 500);
|
|
683
|
+
promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
|
|
211
684
|
}
|
|
212
685
|
// Resolve attachments: extract image for bridge, append file paths to text
|
|
213
686
|
let imageBase64 = envelope.imageBase64;
|
|
@@ -221,8 +694,21 @@ export class ChannelBase {
|
|
|
221
694
|
}
|
|
222
695
|
else if (att.filePath) {
|
|
223
696
|
const label = att.type === 'file' ? 'file' : att.type;
|
|
224
|
-
|
|
225
|
-
|
|
697
|
+
// The filename is attacker-supplied (e.g. DingTalk), so neutralize both
|
|
698
|
+
// the human-readable label and the on-disk path as they enter the
|
|
699
|
+
// prompt. They need DIFFERENT rules: the quoted fileName label is just
|
|
700
|
+
// prose, so sanitizeQuotedText (which also strips `"[]`) is fine — but
|
|
701
|
+
// the rendered filePath must stay byte-resolvable. Brackets, quotes and
|
|
702
|
+
// spaces are VALID, common path chars (e.g. `app/[slug]/page.tsx`), so
|
|
703
|
+
// stripping them would advertise a path that doesn't exist on disk and
|
|
704
|
+
// break the agent's read-file tool. sanitizePromptPath preserves them
|
|
705
|
+
// and removes ONLY what could break/reorder the `saved to:` line
|
|
706
|
+
// (CR/LF, C0/DEL, Unicode line/para separators, bidi overrides).
|
|
707
|
+
const name = att.fileName
|
|
708
|
+
? ` "${sanitizeQuotedText(att.fileName, 128)}"`
|
|
709
|
+
: '';
|
|
710
|
+
const renderedPath = sanitizePromptPath(att.filePath);
|
|
711
|
+
filePaths.push(`User sent a ${label}${name}. It has been saved to: ${renderedPath}`);
|
|
226
712
|
}
|
|
227
713
|
}
|
|
228
714
|
if (filePaths.length > 0) {
|
|
@@ -240,6 +726,13 @@ export class ChannelBase {
|
|
|
240
726
|
: undefined;
|
|
241
727
|
const mode = groupCfg?.dispatchMode || this.config.dispatchMode || 'steer';
|
|
242
728
|
const active = this.activePrompts.get(sessionId);
|
|
729
|
+
// Diagnostic watchdog for a steered turn that chains behind a wedged
|
|
730
|
+
// predecessor. Chain-and-wait (option a) means a hung predecessor bridge.prompt()
|
|
731
|
+
// silently deadlocks this session with no log; this surfaces that. Armed only in
|
|
732
|
+
// the steer branch, disarmed as the first statement of the chained `.then()` once
|
|
733
|
+
// the predecessor's tail resolves. Diagnostic-only — it does NOT touch the
|
|
734
|
+
// chain-and-wait concurrency invariant.
|
|
735
|
+
let steerWatchdog;
|
|
243
736
|
if (active) {
|
|
244
737
|
// A prompt is already running for this session
|
|
245
738
|
switch (mode) {
|
|
@@ -254,12 +747,64 @@ export class ChannelBase {
|
|
|
254
747
|
return;
|
|
255
748
|
}
|
|
256
749
|
case 'steer': {
|
|
257
|
-
//
|
|
750
|
+
// Authorization gate (mirrors /cancel): steer = cancel-running +
|
|
751
|
+
// send-new, so without this an UNAUTHORIZED member of a shared session —
|
|
752
|
+
// already blocked from /cancel — could abort another user's running turn
|
|
753
|
+
// just by sending any normal message, defeating the /cancel restriction.
|
|
754
|
+
// If not authorized, break out of the steer case: the message is NOT
|
|
755
|
+
// dropped — it falls through to normal queuing (chains onto the session
|
|
756
|
+
// queue tail and runs AFTER the active turn) without cancelling it.
|
|
757
|
+
// isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
|
|
758
|
+
// and for authorized members, so their steer-cancel is unchanged. Audit
|
|
759
|
+
// the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
|
|
760
|
+
// gates surface theirs) so an operator can see WHY a member's messages
|
|
761
|
+
// queue instead of steering. Operator-level only — a normal message from an
|
|
762
|
+
// unauthorized member shouldn't get a per-message user-facing rejection.
|
|
763
|
+
// senderId is a stable platform id, not user-controlled display text.
|
|
764
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
765
|
+
process.stderr.write(`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`);
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
// Best-effort cancel the running turn so it winds down sooner, then fall
|
|
769
|
+
// through to CHAIN this new turn onto the session queue tail (see `prev`
|
|
770
|
+
// below). The new turn therefore runs ONLY AFTER the old turn's finally
|
|
771
|
+
// has actually run — onChunk detached, activePrompts cleared, indicator
|
|
772
|
+
// released — so it never executes concurrently with the turn it
|
|
773
|
+
// supersedes.
|
|
774
|
+
//
|
|
775
|
+
// We deliberately do NOT race a bounded wait and then proceed with a
|
|
776
|
+
// replacement bridge.prompt() while the old turn is still active: both
|
|
777
|
+
// bridges key active-prompt tracking AND streamed chunks by sessionId
|
|
778
|
+
// alone, so a concurrent replacement on one session is bridge-unsafe —
|
|
779
|
+
// DaemonChannelBridge.prompt() rejects while the prior prompt is still
|
|
780
|
+
// active (the replacement is silently dropped), and the abandoned turn's
|
|
781
|
+
// late chunks mix into the replacement's stream (duplicated/stale
|
|
782
|
+
// output). So a genuinely wedged turn makes its successor WAIT rather
|
|
783
|
+
// than be force-interrupted. Turn-scoped cancellation/routing (a new
|
|
784
|
+
// turn that runs without waiting for a wedged predecessor) is the
|
|
785
|
+
// deferred fix — it needs an API change across every adapter and is out
|
|
786
|
+
// of scope for this phase (wenshao option (b)).
|
|
258
787
|
active.cancelled = true;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
//
|
|
788
|
+
process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
|
|
789
|
+
this.stopActiveStreaming(active, sessionId, 'steer');
|
|
790
|
+
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
|
|
791
|
+
// best-effort cancel that fails isn't silently invisible to operators.
|
|
792
|
+
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
793
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
|
|
794
|
+
});
|
|
795
|
+
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
796
|
+
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
797
|
+
// bridge.prompt() — surface it (the chained `.then()` clears it once the
|
|
798
|
+
// predecessor settles). This only LOGS; it does not start a replacement or
|
|
799
|
+
// change concurrency. /clear is the recovery path. unref so a pending timer
|
|
800
|
+
// never keeps the process alive.
|
|
801
|
+
steerWatchdog = setTimeout(() => {
|
|
802
|
+
if (this.activePrompts.get(sessionId) === active) {
|
|
803
|
+
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`);
|
|
804
|
+
}
|
|
805
|
+
}, CLEAR_CANCEL_TIMEOUT_MS);
|
|
806
|
+
steerWatchdog.unref?.();
|
|
807
|
+
// Prepend a cancellation note so the agent understands context.
|
|
263
808
|
promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
|
|
264
809
|
break;
|
|
265
810
|
}
|
|
@@ -274,16 +819,55 @@ export class ChannelBase {
|
|
|
274
819
|
}
|
|
275
820
|
}
|
|
276
821
|
}
|
|
277
|
-
// Run the prompt
|
|
822
|
+
// Run the prompt with per-session serialization. followup AND steer both chain
|
|
823
|
+
// onto the existing queue tail; steer additionally best-effort cancelled the
|
|
824
|
+
// running turn above so the tail resolves sooner. Chaining (rather than seeding
|
|
825
|
+
// a fresh Promise.resolve()) is what guarantees this turn never runs while the
|
|
826
|
+
// turn it supersedes is still active — see the steer branch above.
|
|
278
827
|
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
828
|
+
// Snapshot the session generation at enqueue time to guard against a /clear
|
|
829
|
+
// racing this turn. There is no await between reading `active` above and this
|
|
830
|
+
// snapshot, so the capture is atomic with the enqueue; if /clear bumps the
|
|
831
|
+
// generation before this turn dequeues, the session we captured is gone — bail
|
|
832
|
+
// (at the dequeue guard below) rather than resurrect it.
|
|
833
|
+
const generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
279
834
|
const useBlockStreaming = this.config.blockStreaming === 'on';
|
|
280
835
|
const current = prev.then(async () => {
|
|
836
|
+
// Disarm the steer watchdog: the predecessor's tail has resolved, so this
|
|
837
|
+
// chained turn is no longer wedged behind it. No-op when unarmed (the timer is
|
|
838
|
+
// only set on the steer path).
|
|
839
|
+
clearTimeout(steerWatchdog);
|
|
840
|
+
// A /clear (or reset/new) while we were queued bumps the generation; the
|
|
841
|
+
// captured session is cleared, so don't run the prompt against it.
|
|
842
|
+
if ((this.sessionGenerations.get(sessionId) ?? 0) !== generation) {
|
|
843
|
+
// Surface the drop — otherwise an unanswered queued message vanishes
|
|
844
|
+
// silently, making "my message was never answered" undiagnosable.
|
|
845
|
+
// envelope.text is attacker-controlled, so neutralize it with the shared
|
|
846
|
+
// log sanitizer: it renders newlines visibly and strips the C0/DEL controls
|
|
847
|
+
// PLUS PROMPT_UNSAFE_INVISIBLES — the C1 block (notably NEL U+0085, a line
|
|
848
|
+
// break that could forge an extra [channel] log line), the Unicode line/
|
|
849
|
+
// paragraph separators U+2028/U+2029, and the bidi overrides — any of which
|
|
850
|
+
// would otherwise inject, overwrite, or reorder an operator's audit line.
|
|
851
|
+
// Same helper as the QQ audit log, so the defense can't drift between sites.
|
|
852
|
+
const loggedText = sanitizeLogText(envelope.text, 80);
|
|
853
|
+
process.stderr.write(`[${this.name}] dropped queued turn from ${envelope.senderId} for session ${sessionId}: session was cleared before it ran (text: ${loggedText})\n`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
281
856
|
// Register this prompt as active
|
|
282
857
|
let doneResolve = () => { };
|
|
283
858
|
const done = new Promise((r) => {
|
|
284
859
|
doneResolve = r;
|
|
285
860
|
});
|
|
286
|
-
const promptState = {
|
|
861
|
+
const promptState = {
|
|
862
|
+
cancelled: false,
|
|
863
|
+
done,
|
|
864
|
+
resolve: doneResolve,
|
|
865
|
+
chatId: envelope.chatId,
|
|
866
|
+
messageId: envelope.messageId,
|
|
867
|
+
};
|
|
868
|
+
// This turn is now the single owner of the session's active-prompt slot.
|
|
869
|
+
// (Steer no longer hands a still-active session to a replacement; only
|
|
870
|
+
// /clear evicts, and it gives the next turn a fresh session.)
|
|
287
871
|
this.activePrompts.set(sessionId, promptState);
|
|
288
872
|
this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
|
|
289
873
|
const streamer = useBlockStreaming
|
|
@@ -294,19 +878,24 @@ export class ChannelBase {
|
|
|
294
878
|
send: (text) => this.sendMessage(envelope.chatId, text),
|
|
295
879
|
})
|
|
296
880
|
: null;
|
|
881
|
+
promptState.stopStreaming = () => streamer?.stop();
|
|
297
882
|
const onChunk = (sid, chunk) => {
|
|
298
|
-
if (sid === sessionId) {
|
|
883
|
+
if (sid === sessionId && !promptState.cancelled) {
|
|
299
884
|
this.onResponseChunk(envelope.chatId, chunk, sessionId);
|
|
300
885
|
streamer?.push(chunk);
|
|
301
886
|
}
|
|
302
887
|
};
|
|
303
|
-
this.bridge
|
|
888
|
+
const promptBridge = this.bridge;
|
|
889
|
+
promptBridge.on('textChunk', onChunk);
|
|
304
890
|
try {
|
|
305
|
-
const response = await
|
|
891
|
+
const response = await promptBridge.prompt(sessionId, promptText, {
|
|
306
892
|
imageBase64,
|
|
307
893
|
imageMimeType,
|
|
308
894
|
});
|
|
309
|
-
|
|
895
|
+
if (promptState.cancelRequested && !promptState.cancelled) {
|
|
896
|
+
promptState.cancelled = await promptState.cancelRequested;
|
|
897
|
+
}
|
|
898
|
+
// If cancelled, skip sending the response
|
|
310
899
|
if (!promptState.cancelled && response) {
|
|
311
900
|
if (streamer) {
|
|
312
901
|
await streamer.flush();
|
|
@@ -317,29 +906,82 @@ export class ChannelBase {
|
|
|
317
906
|
}
|
|
318
907
|
}
|
|
319
908
|
finally {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
909
|
+
promptBridge.off('textChunk', onChunk);
|
|
910
|
+
streamer?.stop();
|
|
911
|
+
// Identity guard: a turn that wedged past /clear's bounded wait gets
|
|
912
|
+
// EVICTED — /clear gives up on active.done, deletes activePrompts, and a
|
|
913
|
+
// turn the user starts AFTER the clear can re-seed activePrompts (and own
|
|
914
|
+
// the collect buffer) for this session. When the wedged bridge.prompt
|
|
915
|
+
// finally settles and runs this finally, touching session-visible state
|
|
916
|
+
// would clobber that live later turn — ending the working indicator it
|
|
917
|
+
// re-seeded or draining a buffer it owns. So only touch session-scoped
|
|
918
|
+
// state when the entry is still ours. (Steer no longer evicts: it cancels
|
|
919
|
+
// and waits, so a steered turn is always stillCurrent when it completes.)
|
|
920
|
+
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
921
|
+
// onPromptEnd runs platform cleanup (clear the typing interval, recall the
|
|
922
|
+
// working reaction, finalize the card). Run it UNLESS this turn was a
|
|
923
|
+
// /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
|
|
924
|
+
// at clear-time, and a turn the user started after the clear may now own the
|
|
925
|
+
// chat-scoped indicator, so re-running cleanup here would clobber it.
|
|
926
|
+
// Invariant: clearEvicted is set ONLY by /clear's eviction, which then
|
|
927
|
+
// UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
|
|
928
|
+
// clear-time onPromptEnd guarantees the purge runs even if that throws), and
|
|
929
|
+
// no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
|
|
930
|
+
// stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
|
|
931
|
+
// `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
|
|
932
|
+
// Steer no longer evicts (it chains and waits), so a steered turn is always
|
|
933
|
+
// stillCurrent on completion.
|
|
934
|
+
if (!promptState.clearEvicted) {
|
|
935
|
+
// onPromptEnd runs platform-adapter cleanup (clear the typing interval,
|
|
936
|
+
// recall the working reaction, finalize the card) — network/IO that CAN
|
|
937
|
+
// throw. Guard it like the /clear-eviction path above: an uncaught throw
|
|
938
|
+
// here would skip activePrompts.delete (session leak), promptState.resolve
|
|
939
|
+
// (active.done never settles → a later /clear falsely logs "abandoned a
|
|
940
|
+
// wedged turn" for a turn that completed), and the collect-buffer drain
|
|
941
|
+
// (lost messages) — and the rejected queue-chain promise, swallowed by the
|
|
942
|
+
// tail .catch(() => {}), would silently drop every later turn this session.
|
|
943
|
+
try {
|
|
944
|
+
this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
process.stderr.write(`[${this.name}] onPromptEnd threw in finally for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (stillCurrent) {
|
|
951
|
+
this.activePrompts.delete(sessionId);
|
|
952
|
+
}
|
|
953
|
+
// Signal any /clear waiter racing our done that we're done — even a
|
|
954
|
+
// /clear-evicted wedged turn must release it (its bounded wait already
|
|
955
|
+
// timed out). (Steer no longer waits on done; it chains on the queue tail.)
|
|
324
956
|
promptState.resolve();
|
|
325
|
-
// Drain collect buffer if any messages accumulated
|
|
957
|
+
// Drain collect buffer if any messages accumulated — but only while we're
|
|
958
|
+
// still the active turn, so a /clear-evicted wedged turn whose bridge.prompt
|
|
959
|
+
// settles late can't drain a buffer a later turn now owns. (Belt-and-
|
|
960
|
+
// suspenders: /clear already deletes the buffer on eviction, so this guard
|
|
961
|
+
// is defensive — but it keeps the invariant "only the current turn drains".)
|
|
326
962
|
const buffer = this.collectBuffers.get(sessionId);
|
|
327
|
-
if (buffer && buffer.length > 0) {
|
|
963
|
+
if (stillCurrent && buffer && buffer.length > 0) {
|
|
328
964
|
this.collectBuffers.delete(sessionId);
|
|
965
|
+
const lost = buffer.length;
|
|
329
966
|
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
330
967
|
const lastEnvelope = buffer[buffer.length - 1].envelope;
|
|
331
968
|
// Re-enter handleInbound with the coalesced message
|
|
332
969
|
const syntheticEnvelope = {
|
|
333
970
|
...lastEnvelope,
|
|
334
971
|
text: coalesced,
|
|
972
|
+
// Coalesced text already carries each message's [sender] prefix.
|
|
973
|
+
alreadyPrefixed: true,
|
|
335
974
|
// Clear attachments/references — already resolved in original text
|
|
336
975
|
referencedText: undefined,
|
|
337
976
|
attachments: undefined,
|
|
338
977
|
imageBase64: undefined,
|
|
339
978
|
imageMimeType: undefined,
|
|
340
979
|
};
|
|
341
|
-
// Queue the coalesced prompt (don't await to avoid deadlock on the queue)
|
|
342
|
-
|
|
980
|
+
// Queue the coalesced prompt (don't await to avoid deadlock on the queue).
|
|
981
|
+
// Surface a drain failure instead of silently losing buffered turns.
|
|
982
|
+
this.handleInbound(syntheticEnvelope).catch((err) => {
|
|
983
|
+
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`);
|
|
984
|
+
});
|
|
343
985
|
}
|
|
344
986
|
}
|
|
345
987
|
});
|