@qwen-code/channel-base 0.19.3 → 0.19.4

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