@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.
@@ -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 (no gateway), register toolCall listener directly.
73
+ // When running standalone, register bridge listeners directly.
36
74
  // In gateway mode, the ChannelManager dispatches events instead.
37
- if (!options?.router) {
38
- bridge.on('toolCall', (event) => {
39
- const target = this.router.getTarget(event.sessionId);
40
- if (target) {
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 clearHandler = async (envelope) => {
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. Your next message will start a fresh conversation.');
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
- '/clear — Clear your session (aliases: /reset, /new)',
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(['help', 'clear', 'reset', 'new', 'status']);
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 agentCommands = this.bridge.availableCommands;
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
- const hasSession = this.router.hasSession(this.name, envelope.senderId, envelope.chatId);
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
- if (!text.startsWith('/'))
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
- const match = text.match(/^\/([a-zA-Z0-9_]+)(?:@\S+)?\s*(.*)/s);
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 { command: match[1].toLowerCase(), args: match[2].trim() };
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
- // 3.5. Bang (!) shell command — direct execution, no LLM
185
- if (envelope.text.startsWith('!')) {
186
- const cmd = envelope.text.slice(1).trim();
187
- const bridgeShellCommand = this.bridge['shellCommand'];
188
- if (cmd && typeof bridgeShellCommand === 'function') {
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 = (await bridgeShellCommand(sessionId, cmd));
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
- promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`;
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
- const name = att.fileName ? ` "${att.fileName}"` : '';
225
- filePaths.push(`User sent a ${label}${name}. It has been saved to: ${att.filePath}`);
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
- // Cancel the running prompt, then fall through to send a new one
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
- await this.bridge.cancelSession(sessionId).catch(() => { });
260
- // Wait for the active prompt to finish winding down
261
- await active.done;
262
- // Prepend a cancellation note so the agent understands context
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 (with followup-mode serialization for safety)
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 = { cancelled: false, done, resolve: doneResolve };
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.on('textChunk', onChunk);
888
+ const promptBridge = this.bridge;
889
+ promptBridge.on('textChunk', onChunk);
304
890
  try {
305
- const response = await this.bridge.prompt(sessionId, promptText, {
891
+ const response = await promptBridge.prompt(sessionId, promptText, {
306
892
  imageBase64,
307
893
  imageMimeType,
308
894
  });
309
- // If cancelled (steer mode), skip sending the response
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
- this.bridge.off('textChunk', onChunk);
321
- this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
322
- this.activePrompts.delete(sessionId);
323
- // Signal any steer waiter that we're done
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
- this.handleInbound(syntheticEnvelope).catch(() => { });
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
  });