@qwen-code/channel-base 0.19.4 → 0.19.5

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.
@@ -22,6 +22,8 @@ const CURRENT_MESSAGE_MARKER = '[Current message - respond to this]';
22
22
  const GROUP_HISTORY_ENTRY_TEXT_LIMIT = 1000;
23
23
  const GROUP_HISTORY_ENTRY_METADATA_LIMIT = 256;
24
24
  const LOOP_CANCEL_GRACE_MS = 5000;
25
+ /** Sentinel message for the loop-prompt timeout rejection; matched by identity below. */
26
+ const LOOP_TIMED_OUT_MESSAGE = 'loop timed out';
25
27
  /**
26
28
  * Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
27
29
  * plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
@@ -51,6 +53,9 @@ export class ChannelBase {
51
53
  gate;
52
54
  router;
53
55
  name;
56
+ /** Resolved (defaulted + frozen) identity/scope — adapters should read these, not raw config. */
57
+ identity;
58
+ memoryScope;
54
59
  /** Resolved proxy URL, available to subclasses for adapter-specific clients. */
55
60
  proxy;
56
61
  channelMemory;
@@ -72,19 +77,43 @@ export class ChannelBase {
72
77
  /** Per-session message buffer for collect mode. */
73
78
  collectBuffers = new Map();
74
79
  bridgeToolCallListener = (event) => {
75
- const target = this.router.getTarget(event.sessionId);
76
- if (target) {
77
- this.onToolCall(target.chatId, event);
78
- }
80
+ this.dispatchToolCall(event);
79
81
  };
80
82
  bridgeSessionDiedListener = (event) => {
81
83
  this.onSessionDied(event.sessionId);
82
84
  };
85
+ dispatchToolCall(event) {
86
+ const target = this.router.getTarget(event.sessionId);
87
+ const active = this.activePrompts.get(event.sessionId);
88
+ const chatId = active?.chatId ?? target?.chatId;
89
+ if (!chatId) {
90
+ return;
91
+ }
92
+ if (active && !active.cancelled && !active.cancelPending) {
93
+ // `?? ''`: dispatchToolCall is a public entry point — a third-party bridge
94
+ // omitting a field must not throw out of its emit('toolCall').
95
+ const safeToolCall = {
96
+ sessionId: event.sessionId,
97
+ toolCallId: event.toolCallId,
98
+ kind: sanitizeLogText(event.kind ?? '', 20),
99
+ title: sanitizeLogText(event.title ?? '', 80),
100
+ status: sanitizeLogText(event.status ?? '', 20),
101
+ };
102
+ this.emitTaskLifecycle({
103
+ ...this.lifecycleBase(chatId, event.sessionId, active.messageId),
104
+ type: 'tool_call',
105
+ toolCall: safeToolCall,
106
+ });
107
+ }
108
+ this.onToolCall(chatId, event);
109
+ }
83
110
  constructor(name, config, bridge, options) {
84
111
  this.name = name;
85
112
  this.config = config;
86
113
  this.bridge = bridge;
87
114
  this.proxy = options?.proxy;
115
+ this.identity = Object.freeze(this.resolveIdentity(name, config));
116
+ this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
88
117
  this.channelMemory = options?.channelMemory;
89
118
  this.groupHistory = new GroupHistoryStore(options?.groupHistoryPath ??
90
119
  join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
@@ -104,6 +133,101 @@ export class ChannelBase {
104
133
  this.attachBridgeEvents(bridge);
105
134
  }
106
135
  }
136
+ /**
137
+ * Adapter hook for task lifecycle events — the canonical way to track task
138
+ * state (onPromptStart/onPromptEnd are retained for back-compat). The prompt
139
+ * flow never awaits this hook; an async override's rejection is caught and
140
+ * logged, nothing more.
141
+ */
142
+ onTaskLifecycle(_event) { }
143
+ emitTaskLifecycle(event) {
144
+ try {
145
+ const result = this.onTaskLifecycle(event);
146
+ if (result && typeof result.catch === 'function') {
147
+ result.catch((err) => {
148
+ this.logTaskLifecycleError(event, err);
149
+ });
150
+ }
151
+ }
152
+ catch (err) {
153
+ this.logTaskLifecycleError(event, err);
154
+ }
155
+ }
156
+ logTaskLifecycleError(event, err) {
157
+ const channel = sanitizeLogText(this.name, 64);
158
+ const sessionId = sanitizeLogText(event.sessionId, 64);
159
+ const stack = err instanceof Error && err.stack
160
+ ? ` | ${sanitizeLogText(err.stack, 500)}`
161
+ : '';
162
+ process.stderr.write(`[${channel}] onTaskLifecycle threw for ${event.type} session ${sessionId}: ${this.lifecycleError(err)}${stack}\n`);
163
+ }
164
+ lifecycleError(err) {
165
+ return sanitizeLogText(err instanceof Error ? err.message : String(err), 200);
166
+ }
167
+ emitTaskCancellation(active, sessionId, reason) {
168
+ if (active.cancellationEmitted) {
169
+ return;
170
+ }
171
+ active.cancellationEmitted = true;
172
+ this.emitTaskLifecycle({
173
+ ...this.lifecycleBase(active.chatId, sessionId, active.messageId),
174
+ type: 'cancelled',
175
+ reason,
176
+ });
177
+ }
178
+ resolveIdentity(name, config) {
179
+ return {
180
+ id: config.identity?.id || `channel:${name}`,
181
+ displayName: config.identity?.displayName || name,
182
+ ...(config.identity?.description
183
+ ? { description: config.identity.description }
184
+ : {}),
185
+ };
186
+ }
187
+ resolveMemoryScope(name, config) {
188
+ return {
189
+ namespace: config.memoryScope?.namespace || `channel:${name}`,
190
+ mode: config.memoryScope?.mode ?? 'metadata-only',
191
+ };
192
+ }
193
+ /** Built once — identity/memoryScope are frozen at construction. */
194
+ boundaryPrompt;
195
+ channelBoundaryPrompt() {
196
+ if (this.boundaryPrompt !== undefined) {
197
+ return this.boundaryPrompt;
198
+ }
199
+ const identityLines = [
200
+ 'Channel identity:',
201
+ `- id: ${sanitizeQuotedText(this.identity.id, 128)}`,
202
+ `- display name: ${sanitizeQuotedText(this.identity.displayName, 128)}`,
203
+ ...(this.identity.description
204
+ ? [
205
+ `- description: ${sanitizeQuotedText(this.identity.description, 256)}`,
206
+ ]
207
+ : []),
208
+ ];
209
+ const memoryLines = [
210
+ 'Memory scope:',
211
+ `- namespace: ${sanitizeQuotedText(this.memoryScope.namespace, 128)}`,
212
+ `- mode: ${this.memoryScope.mode}`,
213
+ '- data from other channels must not be shared.',
214
+ ];
215
+ this.boundaryPrompt = [...identityLines, '', ...memoryLines].join('\n');
216
+ return this.boundaryPrompt;
217
+ }
218
+ shouldPrependChannelBoundaryPrompt() {
219
+ return Boolean(this.config.identity || this.config.memoryScope);
220
+ }
221
+ lifecycleBase(chatId, sessionId, messageId) {
222
+ return {
223
+ channelName: this.name,
224
+ chatId,
225
+ sessionId,
226
+ ...(messageId ? { messageId } : {}),
227
+ identity: this.identity,
228
+ memoryScope: this.memoryScope,
229
+ };
230
+ }
107
231
  supportsProactiveSend() {
108
232
  return false;
109
233
  }
@@ -148,7 +272,9 @@ export class ChannelBase {
148
272
  const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd);
149
273
  const label = sanitizeQuotedText(job.label || job.id, 80);
150
274
  const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
151
- let promptText = `[Loop "${label}" created by ${createdBy}]\n\n${sanitizePromptText(job.prompt)}`;
275
+ // Without the delivery-contract sentence the model treats "post X" prompts
276
+ // as an action it must perform itself and goes hunting for send credentials.
277
+ let promptText = `[Loop "${label}" created by ${createdBy}] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\n${sanitizePromptText(job.prompt)}`;
152
278
  const shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
153
279
  const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
154
280
  const generation = this.sessionGenerations.get(sessionId) ?? 0;
@@ -187,6 +313,11 @@ export class ChannelBase {
187
313
  if (this.config.instructions) {
188
314
  context.push(this.config.instructions);
189
315
  }
316
+ // Boundary block goes last: recency bias means later instructions win,
317
+ // and the isolation boundary must not be overridable by operator text.
318
+ if (this.shouldPrependChannelBoundaryPrompt()) {
319
+ context.push(this.channelBoundaryPrompt());
320
+ }
190
321
  if (context.length > 0) {
191
322
  promptText = `${context.join('\n\n')}\n\n${promptText}`;
192
323
  }
@@ -211,35 +342,116 @@ export class ChannelBase {
211
342
  resolve: doneResolve,
212
343
  chatId: job.target.chatId,
213
344
  messageId: job.id,
345
+ loopPrompt: true,
214
346
  };
215
347
  this.activePrompts.set(sessionId, promptState);
216
- this.onPromptStart(job.target.chatId, sessionId);
348
+ this.emitTaskLifecycle({
349
+ ...this.lifecycleBase(job.target.chatId, sessionId, job.id),
350
+ type: 'started',
351
+ });
352
+ // Guarded: an adapter indicator failure must not orphan the started
353
+ // event (no terminal) or leak the activePrompts entry.
354
+ // No messageId: the hook contract passes INBOUND platform message ids,
355
+ // and adapters act on them (cards, reactions) — a loop job id would
356
+ // collide. Lifecycle events still carry job.id for correlation.
357
+ try {
358
+ this.onPromptStart(job.target.chatId, sessionId);
359
+ }
360
+ catch (err) {
361
+ process.stderr.write(`[${this.name}] onPromptStart threw in loop ${job.id} for session ${sessionId}: ${this.lifecycleError(err)}\n`);
362
+ }
363
+ // Same hold-and-replay contract as handleInbound's onChunk: visible
364
+ // sinks stay out of the transcript while a cancel is pending.
365
+ const heldChunks = [];
366
+ const releaseHeldChunks = () => {
367
+ for (const held of heldChunks.splice(0)) {
368
+ this.emitTaskLifecycle({
369
+ ...this.lifecycleBase(job.target.chatId, sessionId, job.id),
370
+ type: 'text_chunk',
371
+ chunk: held,
372
+ });
373
+ this.onResponseChunk(job.target.chatId, held, sessionId);
374
+ }
375
+ };
217
376
  const onChunk = (sid, chunk) => {
218
- if (sid === sessionId && !promptState.cancelled) {
219
- this.onResponseChunk(job.target.chatId, chunk, sessionId);
377
+ if (sid !== sessionId || promptState.cancelled) {
378
+ return;
379
+ }
380
+ heldChunks.push(chunk);
381
+ if (!promptState.cancelPending) {
382
+ releaseHeldChunks();
220
383
  }
221
384
  };
222
385
  const promptBridge = this.bridge;
223
386
  promptBridge.on('textChunk', onChunk);
224
387
  try {
225
388
  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
- }
389
+ await this.settleCancelRequested(promptState);
232
390
  if (promptState.cancelled) {
233
- throw new ChannelLoopSkippedError('loop cancelled before delivery');
391
+ throw new ChannelLoopSkippedError('loop cancelled before delivery', 'cancel_command');
234
392
  }
393
+ releaseHeldChunks();
235
394
  if (options.shouldContinue && !(await options.shouldContinue())) {
236
395
  throw new ChannelLoopSkippedError('loop dropped before delivery');
237
396
  }
397
+ if (promptState.cancelled) {
398
+ throw new ChannelLoopSkippedError('loop cancelled before delivery', 'cancel_command');
399
+ }
238
400
  if (response) {
401
+ promptState.deliveryStarted = true;
239
402
  await this.pushProactive(job.target, response);
240
403
  }
404
+ // Once delivery started the run counts as completed — a cancel settling
405
+ // during/after the send must not convert a delivered run into a skip
406
+ // (a one-shot loop would stay enabled and deliver twice).
407
+ if (!promptState.deliveryStarted) {
408
+ await this.settleCancelRequested(promptState);
409
+ if (promptState.cancelled) {
410
+ throw new ChannelLoopSkippedError('loop cancelled before delivery', 'cancel_command');
411
+ }
412
+ }
413
+ // /clear can evict mid-delivery and emit its own terminal event; never
414
+ // follow a cancelled event with completed for the same prompt.
415
+ if (!promptState.cancellationEmitted) {
416
+ this.emitTaskLifecycle({
417
+ ...this.lifecycleBase(job.target.chatId, sessionId, job.id),
418
+ type: 'completed',
419
+ });
420
+ }
241
421
  return response;
242
422
  }
423
+ catch (err) {
424
+ // Once delivery started, a late-settling cancel must not flip
425
+ // `cancelled` here — it would suppress the failed emit while the
426
+ // /cancel handler (seeing deliveryStarted) declines to emit its own
427
+ // terminal, leaving the task with no terminal event at all.
428
+ if (!promptState.deliveryStarted) {
429
+ await this.settleCancelRequested(promptState);
430
+ }
431
+ if (err instanceof ChannelLoopSkippedError && !promptState.cancelled) {
432
+ this.emitTaskCancellation(promptState, sessionId, err.reason);
433
+ promptState.cancelled = true;
434
+ }
435
+ if (!promptState.cancelled &&
436
+ !(err instanceof ChannelLoopSkippedError)) {
437
+ releaseHeldChunks();
438
+ this.emitTaskLifecycle({
439
+ ...this.lifecycleBase(job.target.chatId, sessionId, job.id),
440
+ type: 'failed',
441
+ error: this.lifecycleError(err),
442
+ phase: promptState.deliveryStarted ? 'delivery' : 'agent',
443
+ });
444
+ }
445
+ else if (promptState.cancelled &&
446
+ !(err instanceof ChannelLoopSkippedError) &&
447
+ !(err instanceof Error && err.message === LOOP_TIMED_OUT_MESSAGE)) {
448
+ const channel = sanitizeLogText(this.name, 64);
449
+ const safeJobId = sanitizeLogText(job.id, 64);
450
+ const safeSessionId = sanitizeLogText(sessionId, 64);
451
+ process.stderr.write(`[${channel}] loop ${safeJobId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
452
+ }
453
+ throw err;
454
+ }
243
455
  finally {
244
456
  promptBridge.off('textChunk', onChunk);
245
457
  const stillCurrent = this.activePrompts.get(sessionId) === promptState;
@@ -291,16 +503,17 @@ export class ChannelBase {
291
503
  prompt,
292
504
  new Promise((_, reject) => {
293
505
  timer = setTimeout(() => {
294
- reject(new Error('loop timed out'));
506
+ reject(new Error(LOOP_TIMED_OUT_MESSAGE));
295
507
  }, timeoutMs);
296
508
  timer.unref?.();
297
509
  }),
298
510
  ]);
299
511
  }
300
512
  catch (err) {
301
- if (err instanceof Error && err.message === 'loop timed out') {
513
+ if (err instanceof Error && err.message === LOOP_TIMED_OUT_MESSAGE) {
302
514
  promptState.cancelled = true;
303
515
  await this.cancelTimedOutLoopPrompt(promptBridge, sessionId, jobId);
516
+ this.emitTaskCancellation(promptState, sessionId, 'timeout');
304
517
  }
305
518
  throw err;
306
519
  }
@@ -331,6 +544,72 @@ export class ChannelBase {
331
544
  clearTimeout(graceTimer);
332
545
  }
333
546
  }
547
+ requestActivePromptCancellation(sessionId, reason = 'cancel_command') {
548
+ const active = this.activePrompts.get(sessionId);
549
+ if (!active) {
550
+ return this.bridge.cancelSession(sessionId).then(() => true, (err) => {
551
+ this.logCancelSessionFailure(sessionId, err);
552
+ return false;
553
+ });
554
+ }
555
+ if (active.deliveryStarted) {
556
+ return Promise.resolve(false);
557
+ }
558
+ const cancelRequested = active.cancelRequested ??
559
+ this.bridge.cancelSession(sessionId).then(() => true, (err) => {
560
+ this.logCancelSessionFailure(sessionId, err);
561
+ active.cancelRequested = undefined;
562
+ return false;
563
+ });
564
+ active.cancelRequested = cancelRequested;
565
+ active.cancelPending = true;
566
+ return cancelRequested
567
+ .finally(() => {
568
+ active.cancelPending = false;
569
+ })
570
+ .then((cancelSucceeded) => {
571
+ // Re-check after the await: while the cancel RPC was in flight the
572
+ // turn may have started delivery, or ended on its own (uncancelled) —
573
+ // claiming success then would emit a spurious cancelled event for a
574
+ // response the user received. A turn that ended already-cancelled
575
+ // (the abort landed) still counts as a successful cancel.
576
+ const turnEnded = this.activePrompts.get(sessionId) !== active;
577
+ if (!cancelSucceeded ||
578
+ active.deliveryStarted ||
579
+ (turnEnded && !active.cancelled)) {
580
+ return false;
581
+ }
582
+ active.cancelled = true;
583
+ this.stopActiveStreaming(active, sessionId, reason);
584
+ this.collectBuffers.delete(sessionId);
585
+ this.emitTaskCancellation(active, sessionId, reason);
586
+ return true;
587
+ });
588
+ }
589
+ logCancelSessionFailure(sessionId, err) {
590
+ process.stderr.write(`[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
591
+ }
592
+ async settleCancelRequested(active) {
593
+ if (!active.cancelRequested || active.cancelled) {
594
+ return;
595
+ }
596
+ let timer;
597
+ try {
598
+ const cancelled = await Promise.race([
599
+ active.cancelRequested,
600
+ new Promise((resolve) => {
601
+ timer = setTimeout(() => resolve(false), CLEAR_CANCEL_TIMEOUT_MS);
602
+ timer.unref?.();
603
+ }),
604
+ ]);
605
+ if (cancelled) {
606
+ active.cancelled = true;
607
+ }
608
+ }
609
+ finally {
610
+ clearTimeout(timer);
611
+ }
612
+ }
334
613
  onToolCall(_chatId, _event) { }
335
614
  onSessionDied(sessionId) {
336
615
  this.router.removeSessionId(sessionId);
@@ -397,22 +676,12 @@ export class ChannelBase {
397
676
  await this.sendMessage(envelope.chatId, 'No request is currently running.');
398
677
  return true;
399
678
  }
400
- const cancelRequested = active.cancelRequested ??
401
- this.bridge.cancelSession(activeSessionId).then(() => true, (err) => {
402
- process.stderr.write(`[${this.name}] cancelSession failed for session=${activeSessionId}: ${err instanceof Error ? err.message : err}\n`);
403
- active.cancelRequested = undefined;
404
- return false;
405
- });
406
- active.cancelRequested = cancelRequested;
407
- const cancelSucceeded = await cancelRequested;
408
- if (!cancelSucceeded) {
409
- await this.sendMessage(envelope.chatId, 'Failed to cancel current request.');
410
- return true;
411
- }
412
- active.cancelled = true;
413
- active.stopStreaming?.();
414
- this.collectBuffers.delete(activeSessionId);
415
- await this.sendMessage(envelope.chatId, 'Cancelled current request.');
679
+ // Single cancel state machine: adapter stop buttons and /cancel share
680
+ // requestActivePromptCancellation so the two paths cannot drift.
681
+ const cancelSucceeded = await this.requestActivePromptCancellation(activeSessionId, 'cancel_command');
682
+ await this.sendMessage(envelope.chatId, cancelSucceeded
683
+ ? 'Cancelled current request.'
684
+ : 'Failed to cancel current request.');
416
685
  return true;
417
686
  });
418
687
  }
@@ -471,7 +740,7 @@ export class ChannelBase {
471
740
  // onPromptEnd anyway. Letting the purge proceed makes the turn
472
741
  // non-current, so the clearEvicted guard then skips correctly.
473
742
  try {
474
- this.onPromptEnd(active.chatId, id, active.messageId);
743
+ this.onPromptEnd(active.chatId, id, active.loopPrompt ? undefined : active.messageId);
475
744
  }
476
745
  catch (err) {
477
746
  process.stderr.write(`[${this.name}] onPromptEnd threw during /clear eviction for session ${id}: ${err instanceof Error ? err.message : err}\n`);
@@ -557,6 +826,14 @@ export class ChannelBase {
557
826
  : '';
558
827
  await this.sendMessage(envelope.chatId, [
559
828
  `Channel: ${this.name}`,
829
+ // Identity/memory lines only for channels that opted in — keep
830
+ // unconfigured channels' output unchanged.
831
+ ...(this.shouldPrependChannelBoundaryPrompt()
832
+ ? [
833
+ `Identity: ${sanitizeQuotedText(this.identity.displayName, 128)}`,
834
+ `Memory: ${sanitizeQuotedText(this.memoryScope.namespace, 128)}`,
835
+ ]
836
+ : []),
560
837
  // Only the basename — don't leak the absolute cwd to group members.
561
838
  `Workspace: ${basename(this.config.cwd)}`,
562
839
  `Session: ${active ? 'active' : 'none'}${scopeNote}`,
@@ -705,6 +982,12 @@ export class ChannelBase {
705
982
  `Session: ${hasSession ? 'active' : 'none'}`,
706
983
  `Access: ${policy}`,
707
984
  `Channel: ${this.name}`,
985
+ ...(this.shouldPrependChannelBoundaryPrompt()
986
+ ? [
987
+ `Identity: ${sanitizeQuotedText(this.identity.id, 128)}`,
988
+ `Memory: ${this.memoryScope.mode}`,
989
+ ]
990
+ : []),
708
991
  ];
709
992
  await this.sendMessage(envelope.chatId, lines.join('\n'));
710
993
  return true;
@@ -1036,6 +1319,7 @@ export class ChannelBase {
1036
1319
  void this.bridge.cancelSession(sessionId).catch((err) => {
1037
1320
  process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (clear/await): ${err instanceof Error ? err.message : err}\n`);
1038
1321
  });
1322
+ this.emitTaskCancellation(active, sessionId, 'clear');
1039
1323
  let timer;
1040
1324
  const settled = await Promise.race([
1041
1325
  active.done.then(() => true),
@@ -1469,14 +1753,21 @@ export class ChannelBase {
1469
1753
  // turn that runs without waiting for a wedged predecessor) is the
1470
1754
  // deferred fix — it needs an API change across every adapter and is out
1471
1755
  // of scope for this phase (wenshao option (b)).
1756
+ const firstCancellation = !active.cancelled;
1472
1757
  active.cancelled = true;
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
- });
1758
+ if (firstCancellation) {
1759
+ process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
1760
+ this.stopActiveStreaming(active, sessionId, 'steer');
1761
+ // Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
1762
+ // best-effort cancel that fails isn't silently invisible to operators.
1763
+ void this.bridge.cancelSession(sessionId).catch((err) => {
1764
+ process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
1765
+ });
1766
+ // Emitted before the bridge cancel settles: steer supersedes the
1767
+ // turn at the channel level (cancelled is already set above), so
1768
+ // the event reflects that intent, not the bridge RPC outcome.
1769
+ this.emitTaskCancellation(active, sessionId, 'steer');
1770
+ }
1480
1771
  // Diagnostic watchdog: if the predecessor turn is STILL the active prompt
1481
1772
  // after the wind-down bound, this steered turn is wedged behind a hung
1482
1773
  // bridge.prompt() — surface it (the chained `.then()` clears it once the
@@ -1557,6 +1848,11 @@ export class ChannelBase {
1557
1848
  if (this.config.instructions) {
1558
1849
  sessionContext.push(this.config.instructions);
1559
1850
  }
1851
+ // Boundary block goes last: recency bias means later instructions win,
1852
+ // and the isolation boundary must not be overridable by operator text.
1853
+ if (this.shouldPrependChannelBoundaryPrompt()) {
1854
+ sessionContext.push(this.channelBoundaryPrompt());
1855
+ }
1560
1856
  }
1561
1857
  if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
1562
1858
  return;
@@ -1584,7 +1880,18 @@ export class ChannelBase {
1584
1880
  // (Steer no longer hands a still-active session to a replacement; only
1585
1881
  // /clear evicts, and it gives the next turn a fresh session.)
1586
1882
  this.activePrompts.set(sessionId, promptState);
1587
- this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
1883
+ this.emitTaskLifecycle({
1884
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
1885
+ type: 'started',
1886
+ });
1887
+ // Guarded: an adapter indicator failure must not orphan the started
1888
+ // event (no terminal) or leak the activePrompts entry.
1889
+ try {
1890
+ this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
1891
+ }
1892
+ catch (err) {
1893
+ process.stderr.write(`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`);
1894
+ }
1588
1895
  const streamer = useBlockStreaming
1589
1896
  ? new BlockStreamer({
1590
1897
  minChars: this.config.blockStreamingChunk?.minChars ?? 400,
@@ -1594,10 +1901,28 @@ export class ChannelBase {
1594
1901
  })
1595
1902
  : null;
1596
1903
  promptState.stopStreaming = () => streamer?.stop();
1904
+ // Chunks arriving while a cancel is PENDING are held here: pushing them
1905
+ // to any visible sink could send output the cancel can't recall. On a
1906
+ // failed cancel they're replayed; on success, discarded.
1907
+ const heldChunks = [];
1908
+ const releaseHeldChunks = () => {
1909
+ for (const held of heldChunks.splice(0)) {
1910
+ this.emitTaskLifecycle({
1911
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
1912
+ type: 'text_chunk',
1913
+ chunk: held,
1914
+ });
1915
+ this.onResponseChunk(envelope.chatId, held, sessionId);
1916
+ streamer?.push(held);
1917
+ }
1918
+ };
1597
1919
  const onChunk = (sid, chunk) => {
1598
- if (sid === sessionId && !promptState.cancelled) {
1599
- this.onResponseChunk(envelope.chatId, chunk, sessionId);
1600
- streamer?.push(chunk);
1920
+ if (sid !== sessionId || promptState.cancelled) {
1921
+ return;
1922
+ }
1923
+ heldChunks.push(chunk);
1924
+ if (!promptState.cancelPending) {
1925
+ releaseHeldChunks();
1601
1926
  }
1602
1927
  };
1603
1928
  const promptBridge = this.bridge;
@@ -1607,14 +1932,13 @@ export class ChannelBase {
1607
1932
  imageBase64,
1608
1933
  imageMimeType,
1609
1934
  });
1610
- if (promptState.cancelRequested && !promptState.cancelled) {
1611
- const cancelled = await promptState.cancelRequested;
1612
- if (cancelled) {
1613
- promptState.cancelled = true;
1614
- }
1935
+ await this.settleCancelRequested(promptState);
1936
+ if (!promptState.cancelled) {
1937
+ releaseHeldChunks();
1615
1938
  }
1616
1939
  // If cancelled, skip sending the response
1617
1940
  if (!promptState.cancelled && response) {
1941
+ promptState.deliveryStarted = true;
1618
1942
  if (streamer) {
1619
1943
  await streamer.flush();
1620
1944
  }
@@ -1622,6 +1946,41 @@ export class ChannelBase {
1622
1946
  await this.onResponseComplete(envelope.chatId, response, sessionId);
1623
1947
  }
1624
1948
  }
1949
+ // Once delivery started the turn's outcome is fixed — don't let a
1950
+ // cancel settling during the send rewrite completed into cancelled.
1951
+ if (!promptState.deliveryStarted) {
1952
+ await this.settleCancelRequested(promptState);
1953
+ }
1954
+ if (!promptState.cancelled && !promptState.cancellationEmitted) {
1955
+ this.emitTaskLifecycle({
1956
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
1957
+ type: 'completed',
1958
+ });
1959
+ }
1960
+ }
1961
+ catch (err) {
1962
+ // Mirror the try path: once delivery started, a late-settling cancel
1963
+ // must not suppress the failed emit (the /cancel handler declines to
1964
+ // emit its own terminal once deliveryStarted is set).
1965
+ if (!promptState.deliveryStarted) {
1966
+ await this.settleCancelRequested(promptState);
1967
+ }
1968
+ if (!promptState.cancelled) {
1969
+ releaseHeldChunks();
1970
+ this.emitTaskLifecycle({
1971
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
1972
+ type: 'failed',
1973
+ error: this.lifecycleError(err),
1974
+ phase: promptState.deliveryStarted ? 'delivery' : 'agent',
1975
+ });
1976
+ }
1977
+ else {
1978
+ const channel = sanitizeLogText(this.name, 64);
1979
+ const safeSessionId = sanitizeLogText(sessionId, 64);
1980
+ const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
1981
+ process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
1982
+ }
1983
+ throw err;
1625
1984
  }
1626
1985
  finally {
1627
1986
  promptBridge.off('textChunk', onChunk);