@qwen-code/channel-base 0.24.6 → 0.24.7

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.
@@ -904,6 +904,9 @@ export class ChannelBase {
904
904
  this.router =
905
905
  options?.router ||
906
906
  new SessionRouter(bridge, config.cwd, config.sessionScope);
907
+ if (config.multiSession && config.sessionRotation) {
908
+ throw new Error('sessionRotation cannot be combined with multiSession.');
909
+ }
907
910
  if (config.multiSession) {
908
911
  if (config.sessionScope !== 'user') {
909
912
  throw new Error(`Channel "${name}" requires sessionScope "user" when multiSession is enabled.`);
@@ -920,6 +923,28 @@ export class ChannelBase {
920
923
  onSessionRetiring: (sessionId) => this.onSessionRetiring(sessionId),
921
924
  });
922
925
  }
926
+ this.router.setChannelRotation(this.name, config.sessionRotation);
927
+ if (config.sessionRotation) {
928
+ this.router.setRotationActivityChecker(this.name, (sessionId) => this.isNamedSessionBusy(sessionId));
929
+ this.router.setRotationListener(this.name, (sessionId, target) => {
930
+ try {
931
+ this.onSessionRetiring(sessionId);
932
+ }
933
+ catch (error) {
934
+ process.stderr.write(`[${this.name}] Session retirement failed: ${this.lifecycleError(error)}\n`);
935
+ }
936
+ this.cancelBtw(sessionId);
937
+ this.dropCollectBuffer(sessionId);
938
+ this.instructedSessions.delete(sessionId);
939
+ this.unattendedMemorySessions.delete(sessionId);
940
+ this.removePendingPermissionsForSession(sessionId);
941
+ this.sessionQueues.delete(sessionId);
942
+ this.sessionGenerations.delete(sessionId);
943
+ void this.sendThreadMessage(target.chatId, target.threadId, 'This conversation reached its configured limit. Starting a fresh session.').catch((error) => {
944
+ process.stderr.write(`[${this.name}] Failed to announce session rotation: ${this.lifecycleError(error)}\n`);
945
+ });
946
+ });
947
+ }
923
948
  this.registerSharedCommands();
924
949
  if (this.loopController) {
925
950
  bridge.registerChannelLoopToolHandler?.(this.channelLoopToolHandler);
@@ -1395,7 +1420,9 @@ export class ChannelBase {
1395
1420
  throw new Error(`Loop ${job.id} target is no longer authorized.`);
1396
1421
  }
1397
1422
  await this.waitForBridgeRecovery();
1398
- const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup, { routeKey: job.target.messageRoute });
1423
+ const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup, { routeKey: job.target.messageRoute, holdForTurn: true });
1424
+ this.reserveQueuedTurn(sessionId);
1425
+ this.router.releaseRoutingLease(sessionId);
1399
1426
  const label = sanitizeQuotedText(job.label || job.id, 80);
1400
1427
  const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
1401
1428
  // Without the delivery-contract sentence the model treats "post X" prompts
@@ -1606,8 +1633,9 @@ export class ChannelBase {
1606
1633
  this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, `loop ${job.id}`);
1607
1634
  }
1608
1635
  });
1609
- this.sessionQueues.set(sessionId, current.then(() => undefined).catch(() => { }));
1610
- return current;
1636
+ const tracked = current.finally(() => this.releaseQueuedTurn(sessionId));
1637
+ this.sessionQueues.set(sessionId, tracked.then(() => undefined).catch(() => { }));
1638
+ return tracked;
1611
1639
  }
1612
1640
  validateWebhookTask(task) {
1613
1641
  this.resolveWebhookTaskTarget(task);
@@ -1639,7 +1667,10 @@ export class ChannelBase {
1639
1667
  await this.waitForBridgeRecovery();
1640
1668
  const sessionId = await this.router.resolve(this.name, target.senderId, target.chatId, target.threadId, this.config.cwd, target.isGroup, {
1641
1669
  routingThreadId: this.webhookRoutingThreadId(task, target),
1670
+ holdForTurn: true,
1642
1671
  });
1672
+ this.reserveQueuedTurn(sessionId);
1673
+ this.router.releaseRoutingLease(sessionId);
1643
1674
  const promptText = buildChannelWebhookPrompt(task, target);
1644
1675
  const taskId = `webhook:${task.source}:${task.eventType}`;
1645
1676
  const safeTaskId = sanitizeLogText(taskId, 64);
@@ -1811,8 +1842,9 @@ export class ChannelBase {
1811
1842
  this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, `webhook ${safeTaskId}`);
1812
1843
  }
1813
1844
  });
1814
- this.sessionQueues.set(sessionId, current.then(() => undefined).catch(() => undefined));
1815
- return await current;
1845
+ const tracked = current.finally(() => this.releaseQueuedTurn(sessionId));
1846
+ this.sessionQueues.set(sessionId, tracked.then(() => undefined).catch(() => undefined));
1847
+ return await tracked;
1816
1848
  }
1817
1849
  webhookRoutingThreadId(task, target) {
1818
1850
  return `webhook:${task.source}:${target.threadId ?? target.chatId}`;
@@ -4746,588 +4778,595 @@ export class ChannelBase {
4746
4778
  }
4747
4779
  }
4748
4780
  else {
4749
- sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup, { routeKey: envelope.messageRoute });
4750
- }
4751
- const sourceLabel = this.namedSessions
4752
- ? this.sourceLabelForTurn(sessionId, envelope)
4753
- : undefined;
4754
- if (this.namedSessions && !sourceLabel) {
4755
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Could not identify the selected task. Use /sessions, select it again, and retry.`);
4756
- return;
4757
- }
4758
- if (btwQuestion !== undefined) {
4759
- await this.handleBtw(envelope, sessionId, btwQuestion, sourceLabel);
4760
- return;
4781
+ sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup, { routeKey: envelope.messageRoute, holdForTurn: true });
4761
4782
  }
4762
- // Bang (!) execution — a private 1:1 session has a single operator, so
4763
- // direct shell execution stays allowed. Group/shared contexts were refused
4764
- // above, before the session was resolved.
4765
- if (bangText.startsWith('!')) {
4766
- const cmd = bangText.slice(1).trim();
4767
- const bridgeShellCommand = this.bridge.shellCommand;
4768
- if (cmd && bridgeShellCommand) {
4769
- try {
4770
- const result = await bridgeShellCommand(sessionId, cmd);
4771
- const longestRun = Math.max(0, ...Array.from((result.output || '').matchAll(/`+/g), (m) => m[0].length));
4772
- const fence = '`'.repeat(Math.max(3, longestRun + 1));
4773
- const output = result.output
4774
- ? `${fence}\n${result.output}\n${fence}`
4775
- : '(no output)';
4776
- const exitLine = result.exitCode !== null && result.exitCode !== 0
4777
- ? `\nExit code: ${result.exitCode}`
4778
- : '';
4779
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`, sourceLabel);
4780
- }
4781
- catch (error) {
4782
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, sourceLabel);
4783
- }
4783
+ try {
4784
+ const sourceLabel = this.namedSessions
4785
+ ? this.sourceLabelForTurn(sessionId, envelope)
4786
+ : undefined;
4787
+ if (this.namedSessions && !sourceLabel) {
4788
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Could not identify the selected task. Use /sessions, select it again, and retry.`);
4784
4789
  return;
4785
4790
  }
4786
- }
4787
- const recognizedSlashCommand = this.isSlashCommand(envelope.text) &&
4788
- this.isRecognizedCommand(envelope.text, sessionId);
4789
- // Prepend referenced (quoted) message text for reply context
4790
- let promptText = envelope.text;
4791
- // Multiplayer attribution: when a session can carry multiple humans, tag each
4792
- // turn with the speaker so the agent can tell members apart. That is any group
4793
- // AND any single-scope DM — `single` collapses every sender's DM into one
4794
- // __single__ session (the same multi-operator case the !-gate, /clear confirm
4795
- // and /who already treat as shared), so without a tag it would merge different
4796
- // people into one unattributed conversation. NOT gated on isSharedSession:
4797
- // that is false for a user-scope GROUP, which still needs attribution. Sanitize
4798
- // the name so a crafted nick can't break out of the [..] tag or inject
4799
- // newlines. Skipped for a per-user 1:1 chat and for already-prefixed re-entries
4800
- // (collect-mode coalescing). The tag is also suppressed for a real slash
4801
- // command — a [sender] prefix would stop it from parsing — but ONLY when it is
4802
- // BOTH a genuine command SHAPE (isSlashCommand) AND a RECOGNIZED command
4803
- // (isRecognizedCommand: a locally registered or agent-exposed command, by
4804
- // canonical name OR alias, for THIS session — matched EXACTLY as the agent's
4805
- // parseSlashCommand does, so the two never diverge). Command-shaped-but-
4806
- // unrecognized text like `/x\n[SYSTEM]: …` (token matches the charset but no such
4807
- // command exists) KEEPS its tag, so its injected second line can't reach a shared
4808
- // group unattributed and pose as a system directive. Slash-prefixed paths
4809
- // (/tmp/foo) and comments (//…, /*…*/) are prose, so they stay attributed too.
4810
- // Both checks are synchronous (no await), so this never races the async command
4811
- // list — see isRecognizedCommand for the no-await tradeoff.
4812
- if ((envelope.isGroup || this.config.sessionScope === 'single') &&
4813
- !envelope.alreadyPrefixed &&
4814
- !recognizedSlashCommand) {
4815
- const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
4816
- promptText = `[${who}] ${sanitizePromptText(promptText)}`;
4817
- // Render the non-bot mention marker AFTER sanitization (like the
4818
- // [Replying to:] wrapper below). Inside `text` it would pass through
4819
- // sanitizePromptText, which strips brackets only on content <=64 chars
4820
- // and folds its newline — so with IDs included, the delivered format
4821
- // would depend on the ID list's length. IDs are platform-controlled, so
4822
- // neutralize them like quoted text before they bypass the sanitizer here.
4823
- if (envelope.mentionedMemberIds?.length) {
4824
- const ids = envelope.mentionedMemberIds
4825
- .map((id) => sanitizeQuotedText(id, 64).trim())
4826
- // A junk-only ID over the cap truncates to a bare '…' (not
4827
- // whitespace), which would advertise a phantom member — drop it
4828
- // like an empty ID.
4829
- .filter((id) => id.length > 0 && id !== '…');
4830
- if (ids.length > 0) {
4831
- const memberLabel = ids.length === 1 ? 'member' : 'members';
4832
- promptText = `[Mentioned ${ids.length} other group ${memberLabel}: ${ids.join(', ')}]\n\n${promptText}`;
4791
+ if (btwQuestion !== undefined) {
4792
+ await this.handleBtw(envelope, sessionId, btwQuestion, sourceLabel);
4793
+ return;
4794
+ }
4795
+ // Bang (!) execution — a private 1:1 session has a single operator, so
4796
+ // direct shell execution stays allowed. Group/shared contexts were refused
4797
+ // above, before the session was resolved.
4798
+ if (bangText.startsWith('!')) {
4799
+ const cmd = bangText.slice(1).trim();
4800
+ const bridgeShellCommand = this.bridge.shellCommand;
4801
+ if (cmd && bridgeShellCommand) {
4802
+ try {
4803
+ const result = await bridgeShellCommand(sessionId, cmd);
4804
+ const longestRun = Math.max(0, ...Array.from((result.output || '').matchAll(/`+/g), (m) => m[0].length));
4805
+ const fence = '`'.repeat(Math.max(3, longestRun + 1));
4806
+ const output = result.output
4807
+ ? `${fence}\n${result.output}\n${fence}`
4808
+ : '(no output)';
4809
+ const exitLine = result.exitCode !== null && result.exitCode !== 0
4810
+ ? `\nExit code: ${result.exitCode}`
4811
+ : '';
4812
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`, sourceLabel);
4813
+ }
4814
+ catch (error) {
4815
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, sourceLabel);
4816
+ }
4817
+ return;
4833
4818
  }
4834
4819
  }
4835
- }
4836
- if (envelope.referencedText) {
4837
- // Quoted text is attacker-controlled. sanitizeQuotedText strips C0/DEL
4838
- // controls, Unicode line/paragraph separators (U+2028/U+2029) and bidi
4839
- // overrides, and the wrapper's own `"[]` delimiters, then caps length -
4840
- // so a crafted quote can't inject newlines/instructions, close the
4841
- // [Replying to: "..."] wrapper, flip text direction, or balloon the prompt.
4842
- const quoted = sanitizeQuotedText(envelope.referencedText, 500);
4843
- promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
4844
- }
4845
- // Resolve attachments: extract images for bridge, append file paths to text
4846
- let imageBase64 = envelope.imageBase64;
4847
- let imageMimeType = envelope.imageMimeType;
4848
- const images = [];
4849
- if (imageBase64 && imageMimeType) {
4850
- images.push({ data: imageBase64, mimeType: imageMimeType });
4851
- }
4852
- if (envelope.attachments?.length) {
4853
- const filePaths = [];
4854
- for (const att of envelope.attachments) {
4855
- if (att.type === 'image' && att.data) {
4856
- images.push({ data: att.data, mimeType: att.mimeType });
4857
- if (!imageBase64) {
4858
- imageBase64 = att.data;
4859
- imageMimeType = att.mimeType;
4820
+ const recognizedSlashCommand = this.isSlashCommand(envelope.text) &&
4821
+ this.isRecognizedCommand(envelope.text, sessionId);
4822
+ // Prepend referenced (quoted) message text for reply context
4823
+ let promptText = envelope.text;
4824
+ // Multiplayer attribution: when a session can carry multiple humans, tag each
4825
+ // turn with the speaker so the agent can tell members apart. That is any group
4826
+ // AND any single-scope DM — `single` collapses every sender's DM into one
4827
+ // __single__ session (the same multi-operator case the !-gate, /clear confirm
4828
+ // and /who already treat as shared), so without a tag it would merge different
4829
+ // people into one unattributed conversation. NOT gated on isSharedSession:
4830
+ // that is false for a user-scope GROUP, which still needs attribution. Sanitize
4831
+ // the name so a crafted nick can't break out of the [..] tag or inject
4832
+ // newlines. Skipped for a per-user 1:1 chat and for already-prefixed re-entries
4833
+ // (collect-mode coalescing). The tag is also suppressed for a real slash
4834
+ // command — a [sender] prefix would stop it from parsing — but ONLY when it is
4835
+ // BOTH a genuine command SHAPE (isSlashCommand) AND a RECOGNIZED command
4836
+ // (isRecognizedCommand: a locally registered or agent-exposed command, by
4837
+ // canonical name OR alias, for THIS session — matched EXACTLY as the agent's
4838
+ // parseSlashCommand does, so the two never diverge). Command-shaped-but-
4839
+ // unrecognized text like `/x\n[SYSTEM]: …` (token matches the charset but no such
4840
+ // command exists) KEEPS its tag, so its injected second line can't reach a shared
4841
+ // group unattributed and pose as a system directive. Slash-prefixed paths
4842
+ // (/tmp/foo) and comments (//…, /*…*/) are prose, so they stay attributed too.
4843
+ // Both checks are synchronous (no await), so this never races the async command
4844
+ // list — see isRecognizedCommand for the no-await tradeoff.
4845
+ if ((envelope.isGroup || this.config.sessionScope === 'single') &&
4846
+ !envelope.alreadyPrefixed &&
4847
+ !recognizedSlashCommand) {
4848
+ const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
4849
+ promptText = `[${who}] ${sanitizePromptText(promptText)}`;
4850
+ // Render the non-bot mention marker AFTER sanitization (like the
4851
+ // [Replying to:] wrapper below). Inside `text` it would pass through
4852
+ // sanitizePromptText, which strips brackets only on content <=64 chars
4853
+ // and folds its newline — so with IDs included, the delivered format
4854
+ // would depend on the ID list's length. IDs are platform-controlled, so
4855
+ // neutralize them like quoted text before they bypass the sanitizer here.
4856
+ if (envelope.mentionedMemberIds?.length) {
4857
+ const ids = envelope.mentionedMemberIds
4858
+ .map((id) => sanitizeQuotedText(id, 64).trim())
4859
+ // A junk-only ID over the cap truncates to a bare '…' (not
4860
+ // whitespace), which would advertise a phantom member — drop it
4861
+ // like an empty ID.
4862
+ .filter((id) => id.length > 0 && id !== '…');
4863
+ if (ids.length > 0) {
4864
+ const memberLabel = ids.length === 1 ? 'member' : 'members';
4865
+ promptText = `[Mentioned ${ids.length} other group ${memberLabel}: ${ids.join(', ')}]\n\n${promptText}`;
4860
4866
  }
4861
4867
  }
4862
- else if (att.filePath) {
4863
- const label = att.type === 'file' ? 'file' : att.type;
4864
- // The filename is attacker-supplied (e.g. DingTalk), so neutralize both
4865
- // the human-readable label and the on-disk path as they enter the
4866
- // prompt. They need DIFFERENT rules: the quoted fileName label is just
4867
- // prose, so sanitizeQuotedText (which also strips `"[]`) is fine — but
4868
- // the rendered filePath must stay byte-resolvable. Brackets, quotes and
4869
- // spaces are VALID, common path chars (e.g. `app/[slug]/page.tsx`), so
4870
- // stripping them would advertise a path that doesn't exist on disk and
4871
- // break the agent's read-file tool. sanitizePromptPath preserves them
4872
- // and removes ONLY what could break/reorder the `saved to:` line
4873
- // (CR/LF, C0/DEL, Unicode line/para separators, bidi overrides).
4874
- const name = att.fileName
4875
- ? ` "${sanitizeQuotedText(att.fileName, 128)}"`
4876
- : '';
4877
- const renderedPath = sanitizePromptPath(att.filePath);
4878
- filePaths.push(`User sent a ${label}${name}. It has been saved to: ${renderedPath}`);
4868
+ }
4869
+ if (envelope.referencedText) {
4870
+ // Quoted text is attacker-controlled. sanitizeQuotedText strips C0/DEL
4871
+ // controls, Unicode line/paragraph separators (U+2028/U+2029) and bidi
4872
+ // overrides, and the wrapper's own `"[]` delimiters, then caps length -
4873
+ // so a crafted quote can't inject newlines/instructions, close the
4874
+ // [Replying to: "..."] wrapper, flip text direction, or balloon the prompt.
4875
+ const quoted = sanitizeQuotedText(envelope.referencedText, 500);
4876
+ promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
4877
+ }
4878
+ // Resolve attachments: extract images for bridge, append file paths to text
4879
+ let imageBase64 = envelope.imageBase64;
4880
+ let imageMimeType = envelope.imageMimeType;
4881
+ const images = [];
4882
+ if (imageBase64 && imageMimeType) {
4883
+ images.push({ data: imageBase64, mimeType: imageMimeType });
4884
+ }
4885
+ if (envelope.attachments?.length) {
4886
+ const filePaths = [];
4887
+ for (const att of envelope.attachments) {
4888
+ if (att.type === 'image' && att.data) {
4889
+ images.push({ data: att.data, mimeType: att.mimeType });
4890
+ if (!imageBase64) {
4891
+ imageBase64 = att.data;
4892
+ imageMimeType = att.mimeType;
4893
+ }
4894
+ }
4895
+ else if (att.filePath) {
4896
+ const label = att.type === 'file' ? 'file' : att.type;
4897
+ // The filename is attacker-supplied (e.g. DingTalk), so neutralize both
4898
+ // the human-readable label and the on-disk path as they enter the
4899
+ // prompt. They need DIFFERENT rules: the quoted fileName label is just
4900
+ // prose, so sanitizeQuotedText (which also strips `"[]`) is fine — but
4901
+ // the rendered filePath must stay byte-resolvable. Brackets, quotes and
4902
+ // spaces are VALID, common path chars (e.g. `app/[slug]/page.tsx`), so
4903
+ // stripping them would advertise a path that doesn't exist on disk and
4904
+ // break the agent's read-file tool. sanitizePromptPath preserves them
4905
+ // and removes ONLY what could break/reorder the `saved to:` line
4906
+ // (CR/LF, C0/DEL, Unicode line/para separators, bidi overrides).
4907
+ const name = att.fileName
4908
+ ? ` "${sanitizeQuotedText(att.fileName, 128)}"`
4909
+ : '';
4910
+ const renderedPath = sanitizePromptPath(att.filePath);
4911
+ filePaths.push(`User sent a ${label}${name}. It has been saved to: ${renderedPath}`);
4912
+ }
4913
+ }
4914
+ if (filePaths.length > 0) {
4915
+ promptText = promptText + '\n\n' + filePaths.join('\n');
4879
4916
  }
4880
4917
  }
4881
- if (filePaths.length > 0) {
4882
- promptText = promptText + '\n\n' + filePaths.join('\n');
4918
+ if (envelope.metadata) {
4919
+ promptText =
4920
+ promptText + '\n\n' + sanitizePromptText(envelope.metadata);
4883
4921
  }
4884
- }
4885
- if (envelope.metadata) {
4886
- promptText = promptText + '\n\n' + sanitizePromptText(envelope.metadata);
4887
- }
4888
- // Resolve dispatch mode: per-group override → channel config → default
4889
- const groupMode = envelope.isGroup
4890
- ? (this.groupConfigFor(envelope.chatId)?.dispatchMode ??
4891
- this.groupConfigFor('*')?.dispatchMode)
4892
- : undefined;
4893
- const mode = groupMode ?? this.config.dispatchMode ?? 'steer';
4894
- const active = this.activePrompts.get(sessionId);
4895
- // Diagnostic watchdog for a steered turn that chains behind a wedged
4896
- // predecessor. Chain-and-wait (option a) means a hung predecessor bridge.prompt()
4897
- // silently deadlocks this session with no log; this surfaces that. Armed only in
4898
- // the steer branch, disarmed as the first statement of the chained `.then()` once
4899
- // the predecessor's tail resolves. Diagnostic-only — it does NOT touch the
4900
- // chain-and-wait concurrency invariant.
4901
- let steerWatchdog;
4902
- if (active) {
4903
- // A prompt is already running for this session
4904
- switch (mode) {
4905
- case 'collect': {
4906
- // Buffer the message; it will be coalesced when the active prompt finishes
4907
- let buffer = this.collectBuffers.get(sessionId);
4908
- if (!buffer) {
4909
- buffer = [];
4910
- this.collectBuffers.set(sessionId, buffer);
4911
- }
4912
- buffer.push({ text: promptText, envelope });
4913
- try {
4914
- this.onPromptBuffered(envelope.chatId, sessionId, envelope.messageId);
4922
+ // Resolve dispatch mode: per-group override → channel config → default
4923
+ const groupMode = envelope.isGroup
4924
+ ? (this.groupConfigFor(envelope.chatId)?.dispatchMode ??
4925
+ this.groupConfigFor('*')?.dispatchMode)
4926
+ : undefined;
4927
+ const mode = groupMode ?? this.config.dispatchMode ?? 'steer';
4928
+ const active = this.activePrompts.get(sessionId);
4929
+ // Diagnostic watchdog for a steered turn that chains behind a wedged
4930
+ // predecessor. Chain-and-wait (option a) means a hung predecessor bridge.prompt()
4931
+ // silently deadlocks this session with no log; this surfaces that. Armed only in
4932
+ // the steer branch, disarmed as the first statement of the chained `.then()` once
4933
+ // the predecessor's tail resolves. Diagnostic-only — it does NOT touch the
4934
+ // chain-and-wait concurrency invariant.
4935
+ let steerWatchdog;
4936
+ if (active) {
4937
+ // A prompt is already running for this session
4938
+ switch (mode) {
4939
+ case 'collect': {
4940
+ // Buffer the message; it will be coalesced when the active prompt finishes
4941
+ let buffer = this.collectBuffers.get(sessionId);
4942
+ if (!buffer) {
4943
+ buffer = [];
4944
+ this.collectBuffers.set(sessionId, buffer);
4945
+ }
4946
+ buffer.push({ text: promptText, envelope });
4947
+ try {
4948
+ this.onPromptBuffered(envelope.chatId, sessionId, envelope.messageId);
4949
+ }
4950
+ catch (err) {
4951
+ process.stderr.write(`[${this.name}] onPromptBuffered threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
4952
+ }
4953
+ return;
4915
4954
  }
4916
- catch (err) {
4917
- process.stderr.write(`[${this.name}] onPromptBuffered threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
4955
+ case 'steer': {
4956
+ // Authorization gate (mirrors /cancel): steer = cancel-running +
4957
+ // send-new, so without this an UNAUTHORIZED member of a shared session —
4958
+ // already blocked from /cancel — could abort another user's running turn
4959
+ // just by sending any normal message, defeating the /cancel restriction.
4960
+ // If not authorized, break out of the steer case: the message is NOT
4961
+ // dropped — it falls through to normal queuing (chains onto the session
4962
+ // queue tail and runs AFTER the active turn) without cancelling it.
4963
+ // isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
4964
+ // and for authorized members, so their steer-cancel is unchanged. Audit
4965
+ // the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
4966
+ // gates surface theirs) so an operator can see WHY a member's messages
4967
+ // queue instead of steering. Operator-level only — a normal message from an
4968
+ // unauthorized member shouldn't get a per-message user-facing rejection.
4969
+ // senderId is a stable platform id, not user-controlled display text.
4970
+ if (!this.isAuthorizedForSharedSession(envelope)) {
4971
+ process.stderr.write(`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`);
4972
+ break;
4973
+ }
4974
+ // Best-effort cancel the running turn so it winds down sooner, then fall
4975
+ // through to CHAIN this new turn onto the session queue tail (see `prev`
4976
+ // below). The new turn therefore runs ONLY AFTER the old turn's finally
4977
+ // has actually run — onChunk detached, activePrompts cleared, indicator
4978
+ // released — so it never executes concurrently with the turn it
4979
+ // supersedes.
4980
+ //
4981
+ // We deliberately do NOT race a bounded wait and then proceed with a
4982
+ // replacement bridge.prompt() while the old turn is still active: both
4983
+ // bridges key active-prompt tracking AND streamed chunks by sessionId
4984
+ // alone, so a concurrent replacement on one session is bridge-unsafe —
4985
+ // DaemonChannelBridge.prompt() rejects while the prior prompt is still
4986
+ // active (the replacement is silently dropped), and the abandoned turn's
4987
+ // late chunks mix into the replacement's stream (duplicated/stale
4988
+ // output). So a genuinely wedged turn makes its successor WAIT rather
4989
+ // than be force-interrupted. Turn-scoped cancellation/routing (a new
4990
+ // turn that runs without waiting for a wedged predecessor) is the
4991
+ // deferred fix — it needs an API change across every adapter and is out
4992
+ // of scope for this phase (wenshao option (b)).
4993
+ const firstCancellation = !active.cancelled;
4994
+ active.cancelled = true;
4995
+ if (firstCancellation) {
4996
+ process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
4997
+ // Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
4998
+ // best-effort cancel that fails isn't silently invisible to operators.
4999
+ void this.bridge.cancelSession(sessionId).catch((err) => {
5000
+ process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
5001
+ });
5002
+ // Emitted before the bridge cancel settles: steer supersedes the
5003
+ // turn at the channel level (cancelled is already set above), so
5004
+ // the event reflects that intent, not the bridge RPC outcome.
5005
+ this.emitTaskCancellation(active, sessionId, 'steer');
5006
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
5007
+ }
5008
+ // Diagnostic watchdog: if the predecessor turn is STILL the active prompt
5009
+ // after the wind-down bound, this steered turn is wedged behind a hung
5010
+ // bridge.prompt() — surface it (the chained `.then()` clears it once the
5011
+ // predecessor settles). This only LOGS; it does not start a replacement or
5012
+ // change concurrency. /clear is the recovery path. unref so a pending timer
5013
+ // never keeps the process alive.
5014
+ steerWatchdog = setTimeout(() => {
5015
+ if (this.activePrompts.get(sessionId) === active) {
5016
+ 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`);
5017
+ }
5018
+ }, CLEAR_CANCEL_TIMEOUT_MS);
5019
+ steerWatchdog.unref?.();
5020
+ // Prepend a cancellation note so the agent understands context.
5021
+ promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
5022
+ break;
4918
5023
  }
4919
- return;
4920
- }
4921
- case 'steer': {
4922
- // Authorization gate (mirrors /cancel): steer = cancel-running +
4923
- // send-new, so without this an UNAUTHORIZED member of a shared session —
4924
- // already blocked from /cancel — could abort another user's running turn
4925
- // just by sending any normal message, defeating the /cancel restriction.
4926
- // If not authorized, break out of the steer case: the message is NOT
4927
- // dropped — it falls through to normal queuing (chains onto the session
4928
- // queue tail and runs AFTER the active turn) without cancelling it.
4929
- // isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
4930
- // and for authorized members, so their steer-cancel is unchanged. Audit
4931
- // the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
4932
- // gates surface theirs) so an operator can see WHY a member's messages
4933
- // queue instead of steering. Operator-level only — a normal message from an
4934
- // unauthorized member shouldn't get a per-message user-facing rejection.
4935
- // senderId is a stable platform id, not user-controlled display text.
4936
- if (!this.isAuthorizedForSharedSession(envelope)) {
4937
- process.stderr.write(`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`);
5024
+ case 'followup': {
5025
+ // Chain onto the session queue (existing sequential behavior)
4938
5026
  break;
4939
5027
  }
4940
- // Best-effort cancel the running turn so it winds down sooner, then fall
4941
- // through to CHAIN this new turn onto the session queue tail (see `prev`
4942
- // below). The new turn therefore runs ONLY AFTER the old turn's finally
4943
- // has actually run — onChunk detached, activePrompts cleared, indicator
4944
- // released — so it never executes concurrently with the turn it
4945
- // supersedes.
4946
- //
4947
- // We deliberately do NOT race a bounded wait and then proceed with a
4948
- // replacement bridge.prompt() while the old turn is still active: both
4949
- // bridges key active-prompt tracking AND streamed chunks by sessionId
4950
- // alone, so a concurrent replacement on one session is bridge-unsafe —
4951
- // DaemonChannelBridge.prompt() rejects while the prior prompt is still
4952
- // active (the replacement is silently dropped), and the abandoned turn's
4953
- // late chunks mix into the replacement's stream (duplicated/stale
4954
- // output). So a genuinely wedged turn makes its successor WAIT rather
4955
- // than be force-interrupted. Turn-scoped cancellation/routing (a new
4956
- // turn that runs without waiting for a wedged predecessor) is the
4957
- // deferred fix — it needs an API change across every adapter and is out
4958
- // of scope for this phase (wenshao option (b)).
4959
- const firstCancellation = !active.cancelled;
4960
- active.cancelled = true;
4961
- if (firstCancellation) {
4962
- process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
4963
- // Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
4964
- // best-effort cancel that fails isn't silently invisible to operators.
4965
- void this.bridge.cancelSession(sessionId).catch((err) => {
4966
- process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
4967
- });
4968
- // Emitted before the bridge cancel settles: steer supersedes the
4969
- // turn at the channel level (cancelled is already set above), so
4970
- // the event reflects that intent, not the bridge RPC outcome.
4971
- this.emitTaskCancellation(active, sessionId, 'steer');
4972
- this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
5028
+ default: {
5029
+ // Exhaustive check — should never happen
5030
+ const _exhaustive = mode;
5031
+ throw new Error(`Unknown dispatch mode: ${_exhaustive}`);
4973
5032
  }
4974
- // Diagnostic watchdog: if the predecessor turn is STILL the active prompt
4975
- // after the wind-down bound, this steered turn is wedged behind a hung
4976
- // bridge.prompt() — surface it (the chained `.then()` clears it once the
4977
- // predecessor settles). This only LOGS; it does not start a replacement or
4978
- // change concurrency. /clear is the recovery path. unref so a pending timer
4979
- // never keeps the process alive.
4980
- steerWatchdog = setTimeout(() => {
4981
- if (this.activePrompts.get(sessionId) === active) {
4982
- 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`);
4983
- }
4984
- }, CLEAR_CANCEL_TIMEOUT_MS);
4985
- steerWatchdog.unref?.();
4986
- // Prepend a cancellation note so the agent understands context.
4987
- promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
4988
- break;
4989
- }
4990
- case 'followup': {
4991
- // Chain onto the session queue (existing sequential behavior)
4992
- break;
4993
- }
4994
- default: {
4995
- // Exhaustive check — should never happen
4996
- const _exhaustive = mode;
4997
- throw new Error(`Unknown dispatch mode: ${_exhaustive}`);
4998
5033
  }
4999
5034
  }
5000
- }
5001
- let shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
5002
- if (shouldPrependSessionContext) {
5003
- this.instructedSessions.add(sessionId);
5004
- }
5005
- // Run the prompt with per-session serialization. followup AND steer both chain
5006
- // onto the existing queue tail; steer additionally best-effort cancelled the
5007
- // running turn above so the tail resolves sooner. Chaining (rather than seeding
5008
- // a fresh Promise.resolve()) is what guarantees this turn never runs while the
5009
- // turn it supersedes is still active — see the steer branch above.
5010
- const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
5011
- // Fresh turns snapshot the generation at enqueue time. A collected re-entry
5012
- // keeps the generation captured when its buffer drained, so /clear cannot
5013
- // resurrect it while preprocessing runs before this queue.
5014
- const generation = namedTurn?.generation ?? this.sessionGenerations.get(sessionId) ?? 0;
5015
- if (namedTurn) {
5016
- namedTurn.claimed = true;
5017
- }
5018
- else {
5019
- this.reserveQueuedTurn(sessionId);
5020
- }
5021
- const current = prev.then(async () => {
5022
- // Disarm the steer watchdog: the predecessor's tail has resolved, so this
5023
- // chained turn is no longer wedged behind it. No-op when unarmed (the timer is
5024
- // only set on the steer path).
5025
- clearTimeout(steerWatchdog);
5026
- // A /clear (or reset/new) while we were queued bumps the generation; the
5027
- // captured session is cleared, so don't run the prompt against it.
5028
- if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
5029
- return;
5030
- }
5031
- if (!shouldPrependSessionContext &&
5032
- !this.instructedSessions.has(sessionId)) {
5033
- shouldPrependSessionContext = true;
5035
+ let shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
5036
+ if (shouldPrependSessionContext) {
5034
5037
  this.instructedSessions.add(sessionId);
5035
5038
  }
5036
- const sessionContext = [];
5037
- if (shouldPrependSessionContext) {
5038
- if (this.config.instructions) {
5039
- sessionContext.push(this.config.instructions);
5040
- }
5041
- const routeInstructions = envelope.messageRoute === undefined
5042
- ? undefined
5043
- : this.messageRoutes.get(envelope.messageRoute);
5044
- if (routeInstructions)
5045
- sessionContext.push(routeInstructions);
5046
- // Boundary block goes last: recency bias means later instructions win,
5047
- // and the isolation boundary must not be overridable by operator text.
5048
- if (this.shouldPrependChannelBoundaryPrompt()) {
5049
- sessionContext.push(this.channelBoundaryPrompt());
5050
- }
5039
+ // Run the prompt with per-session serialization. followup AND steer both chain
5040
+ // onto the existing queue tail; steer additionally best-effort cancelled the
5041
+ // running turn above so the tail resolves sooner. Chaining (rather than seeding
5042
+ // a fresh Promise.resolve()) is what guarantees this turn never runs while the
5043
+ // turn it supersedes is still active — see the steer branch above.
5044
+ const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
5045
+ // Fresh turns snapshot the generation at enqueue time. A collected re-entry
5046
+ // keeps the generation captured when its buffer drained, so /clear cannot
5047
+ // resurrect it while preprocessing runs before this queue.
5048
+ const generation = namedTurn?.generation ?? this.sessionGenerations.get(sessionId) ?? 0;
5049
+ if (namedTurn) {
5050
+ namedTurn.claimed = true;
5051
5051
  }
5052
- let recallContext;
5053
- let recallRead;
5054
- if (!recognizedSlashCommand &&
5055
- this.channelMemory &&
5056
- this.shouldInjectChannelMemory()) {
5057
- const memoryTarget = this.channelMemoryTarget(envelope);
5058
- recallRead = this.beginChannelMemoryRead(memoryTarget);
5059
- const recallStartedAt = performance.now();
5060
- try {
5061
- const selection = await this.selectRelevantChannelMemory(envelope, memoryTarget, recallRead);
5062
- const stale = recallRead.generation !== recallRead.state.generation;
5063
- const relevantEntries = stale ? [] : selection.entries;
5064
- this.observeChannelMemoryRecall(recallStartedAt, selection.cache, stale
5065
- ? 'stale'
5066
- : (selection.result ??
5067
- (relevantEntries.length > 0 ? 'selected' : 'empty')), relevantEntries.length);
5068
- if (relevantEntries.length > 0) {
5069
- recallContext =
5070
- this.formatRelevantChannelMemoryContext(relevantEntries);
5071
- }
5052
+ else {
5053
+ this.reserveQueuedTurn(sessionId);
5054
+ }
5055
+ const current = prev.then(async () => {
5056
+ // Disarm the steer watchdog: the predecessor's tail has resolved, so this
5057
+ // chained turn is no longer wedged behind it. No-op when unarmed (the timer is
5058
+ // only set on the steer path).
5059
+ clearTimeout(steerWatchdog);
5060
+ // A /clear (or reset/new) while we were queued bumps the generation; the
5061
+ // captured session is cleared, so don't run the prompt against it.
5062
+ if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
5063
+ return;
5072
5064
  }
5073
- catch {
5074
- this.observeChannelMemoryRecall(recallStartedAt, 'bypass', 'read_error', 0);
5075
- this.releaseChannelMemoryRead(recallRead);
5076
- recallRead = undefined;
5077
- this.logChannelMemoryError('read', envelope, 'entry listing failed');
5065
+ if (!shouldPrependSessionContext &&
5066
+ !this.instructedSessions.has(sessionId)) {
5067
+ shouldPrependSessionContext = true;
5068
+ this.instructedSessions.add(sessionId);
5078
5069
  }
5079
- }
5080
- if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
5081
- if (recallRead) {
5082
- this.releaseChannelMemoryRead(recallRead);
5070
+ const sessionContext = [];
5071
+ if (shouldPrependSessionContext) {
5072
+ if (this.config.instructions) {
5073
+ sessionContext.push(this.config.instructions);
5074
+ }
5075
+ const routeInstructions = envelope.messageRoute === undefined
5076
+ ? undefined
5077
+ : this.messageRoutes.get(envelope.messageRoute);
5078
+ if (routeInstructions)
5079
+ sessionContext.push(routeInstructions);
5080
+ // Boundary block goes last: recency bias means later instructions win,
5081
+ // and the isolation boundary must not be overridable by operator text.
5082
+ if (this.shouldPrependChannelBoundaryPrompt()) {
5083
+ sessionContext.push(this.channelBoundaryPrompt());
5084
+ }
5083
5085
  }
5084
- return;
5085
- }
5086
- const acceptedRecallContext = recallRead?.generation === recallRead?.state.generation
5087
- ? recallContext
5088
- : undefined;
5089
- if (recognizedSlashCommand) {
5090
- this.forgetPendingGroupHistory(envelope);
5091
- }
5092
- const groupHistoryEntries = recognizedSlashCommand
5093
- ? []
5094
- : this.drainPendingGroupHistory(envelope);
5095
- let promptToSend = this.prependGroupHistoryContext(envelope, promptText, groupHistoryEntries);
5096
- const hiddenContext = [
5097
- ...(acceptedRecallContext ? [acceptedRecallContext] : []),
5098
- ...sessionContext,
5099
- ];
5100
- if (hiddenContext.length > 0) {
5101
- promptToSend = `${hiddenContext.join('\n\n')}\n\n${promptToSend}`;
5102
- }
5103
- if (recallRead) {
5104
- this.releaseChannelMemoryRead(recallRead);
5105
- }
5106
- // Register this prompt as active
5107
- let doneResolve = () => { };
5108
- const done = new Promise((r) => {
5109
- doneResolve = r;
5110
- });
5111
- const promptState = {
5112
- runId: randomUUID(),
5113
- owner: {
5114
- kind: 'channel_user',
5115
- id: envelope.senderId,
5116
- },
5117
- cancelled: false,
5118
- done,
5119
- resolve: doneResolve,
5120
- chatId: envelope.chatId,
5121
- threadId: envelope.threadId,
5122
- isGroup: envelope.isGroup,
5123
- messageId: envelope.messageId,
5124
- senderId: envelope.senderId,
5125
- senderName: envelope.senderName,
5126
- metadata: envelope.metadata,
5127
- sourceLabel,
5128
- };
5129
- // This turn is now the single owner of the session's active-prompt slot.
5130
- // (Steer no longer hands a still-active session to a replacement; only
5131
- // /clear evicts, and it gives the next turn a fresh session.)
5132
- this.activePrompts.set(sessionId, promptState);
5133
- this.emitTaskLifecycle({
5134
- ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5135
- type: 'started',
5136
- });
5137
- // Guarded: an adapter indicator failure must not orphan the started
5138
- // event (no terminal) or leak the activePrompts entry.
5139
- try {
5140
- this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
5141
- }
5142
- catch (err) {
5143
- process.stderr.write(`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`);
5144
- }
5145
- // Chunks arriving while a cancel is PENDING are held here: pushing them
5146
- // to any visible sink could send output the cancel can't recall. On a
5147
- // failed cancel they're replayed; on success, discarded.
5148
- const heldChunks = [];
5149
- const releaseHeldChunks = () => {
5150
- for (const held of heldChunks.splice(0)) {
5151
- const segment = this.ensureOutputSegment(sessionId, promptState);
5152
- this.emitTaskLifecycle({
5153
- ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5154
- type: 'text_chunk',
5155
- chunk: held,
5156
- });
5157
- this.onResponseChunk(envelope.chatId, held, sessionId, segment);
5086
+ let recallContext;
5087
+ let recallRead;
5088
+ if (!recognizedSlashCommand &&
5089
+ this.channelMemory &&
5090
+ this.shouldInjectChannelMemory()) {
5091
+ const memoryTarget = this.channelMemoryTarget(envelope);
5092
+ recallRead = this.beginChannelMemoryRead(memoryTarget);
5093
+ const recallStartedAt = performance.now();
5094
+ try {
5095
+ const selection = await this.selectRelevantChannelMemory(envelope, memoryTarget, recallRead);
5096
+ const stale = recallRead.generation !== recallRead.state.generation;
5097
+ const relevantEntries = stale ? [] : selection.entries;
5098
+ this.observeChannelMemoryRecall(recallStartedAt, selection.cache, stale
5099
+ ? 'stale'
5100
+ : (selection.result ??
5101
+ (relevantEntries.length > 0 ? 'selected' : 'empty')), relevantEntries.length);
5102
+ if (relevantEntries.length > 0) {
5103
+ recallContext =
5104
+ this.formatRelevantChannelMemoryContext(relevantEntries);
5105
+ }
5106
+ }
5107
+ catch {
5108
+ this.observeChannelMemoryRecall(recallStartedAt, 'bypass', 'read_error', 0);
5109
+ this.releaseChannelMemoryRead(recallRead);
5110
+ recallRead = undefined;
5111
+ this.logChannelMemoryError('read', envelope, 'entry listing failed');
5112
+ }
5158
5113
  }
5159
- };
5160
- const onChunk = (sid, chunk) => {
5161
- if (sid !== sessionId || promptState.cancelled) {
5114
+ if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
5115
+ if (recallRead) {
5116
+ this.releaseChannelMemoryRead(recallRead);
5117
+ }
5162
5118
  return;
5163
5119
  }
5164
- heldChunks.push(chunk);
5165
- if (!promptState.cancelPending) {
5166
- releaseHeldChunks();
5120
+ const acceptedRecallContext = recallRead?.generation === recallRead?.state.generation
5121
+ ? recallContext
5122
+ : undefined;
5123
+ if (recognizedSlashCommand) {
5124
+ this.forgetPendingGroupHistory(envelope);
5167
5125
  }
5168
- };
5169
- const onResponseBoundary = (sid) => {
5170
- if (sid !== sessionId ||
5171
- promptState.cancelled ||
5172
- promptState.cancelPending) {
5173
- return;
5126
+ const groupHistoryEntries = recognizedSlashCommand
5127
+ ? []
5128
+ : this.drainPendingGroupHistory(envelope);
5129
+ let promptToSend = this.prependGroupHistoryContext(envelope, promptText, groupHistoryEntries);
5130
+ const hiddenContext = [
5131
+ ...(acceptedRecallContext ? [acceptedRecallContext] : []),
5132
+ ...sessionContext,
5133
+ ];
5134
+ if (hiddenContext.length > 0) {
5135
+ promptToSend = `${hiddenContext.join('\n\n')}\n\n${promptToSend}`;
5174
5136
  }
5175
- heldChunks.length = 0;
5176
- const segment = this.closeOutputSegment(sessionId, promptState);
5177
- void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
5178
- };
5179
- // Queue wait and memory recall can outlive a bridge crash. Capture the
5180
- // bridge only after the latest recovery has restored session routing.
5181
- await this.waitForBridgeRecovery();
5182
- const promptBridge = this.bridge;
5183
- promptBridge.on('textChunk', onChunk);
5184
- promptBridge.on('responseBoundary', onResponseBoundary);
5185
- let taskResultPartial = false;
5186
- try {
5187
- const response = await promptBridge.prompt(sessionId, promptToSend, {
5188
- ...(this.config.outputMode === 'per_task'
5189
- ? {
5190
- outputMode: 'per_task',
5191
- onTaskResult: ({ partial }) => {
5192
- taskResultPartial = partial;
5193
- },
5194
- }
5195
- : {}),
5196
- ...(images.length > 0 ? { images } : {}),
5197
- imageBase64,
5198
- imageMimeType,
5199
- // Session history shows exactly what the model receives. Only the
5200
- // controls that can reorder or hide rendered text are neutralized.
5201
- displayText: sanitizeDisplayText(promptToSend),
5202
- });
5203
- await this.settleCancelRequested(promptState);
5204
- if (!promptState.cancelled) {
5205
- releaseHeldChunks();
5137
+ if (recallRead) {
5138
+ this.releaseChannelMemoryRead(recallRead);
5206
5139
  }
5207
- // If cancelled, skip sending the response
5208
- if (!promptState.cancelled && response) {
5209
- promptState.deliveryStarted = true;
5210
- const segment = this.ensureOutputSegment(sessionId, promptState);
5211
- if (segment && taskResultPartial)
5212
- segment.partial = true;
5213
- await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
5214
- if (segment && promptState.activeSegmentId === segment.segmentId) {
5215
- promptState.activeSegmentId = undefined;
5216
- }
5140
+ // Register this prompt as active
5141
+ let doneResolve = () => { };
5142
+ const done = new Promise((r) => {
5143
+ doneResolve = r;
5144
+ });
5145
+ const promptState = {
5146
+ runId: randomUUID(),
5147
+ owner: {
5148
+ kind: 'channel_user',
5149
+ id: envelope.senderId,
5150
+ },
5151
+ cancelled: false,
5152
+ done,
5153
+ resolve: doneResolve,
5154
+ chatId: envelope.chatId,
5155
+ threadId: envelope.threadId,
5156
+ isGroup: envelope.isGroup,
5157
+ messageId: envelope.messageId,
5158
+ senderId: envelope.senderId,
5159
+ senderName: envelope.senderName,
5160
+ metadata: envelope.metadata,
5161
+ sourceLabel,
5162
+ };
5163
+ // This turn is now the single owner of the session's active-prompt slot.
5164
+ // (Steer no longer hands a still-active session to a replacement; only
5165
+ // /clear evicts, and it gives the next turn a fresh session.)
5166
+ this.activePrompts.set(sessionId, promptState);
5167
+ this.emitTaskLifecycle({
5168
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5169
+ type: 'started',
5170
+ });
5171
+ // Guarded: an adapter indicator failure must not orphan the started
5172
+ // event (no terminal) or leak the activePrompts entry.
5173
+ try {
5174
+ this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
5217
5175
  }
5218
- // Once delivery started the turn's outcome is fixed — don't let a
5219
- // cancel settling during the send rewrite completed into cancelled.
5220
- if (!promptState.deliveryStarted) {
5221
- await this.settleCancelRequested(promptState);
5176
+ catch (err) {
5177
+ process.stderr.write(`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`);
5222
5178
  }
5223
- if (!promptState.cancelled && !promptState.cancellationEmitted) {
5179
+ // Chunks arriving while a cancel is PENDING are held here: pushing them
5180
+ // to any visible sink could send output the cancel can't recall. On a
5181
+ // failed cancel they're replayed; on success, discarded.
5182
+ const heldChunks = [];
5183
+ const releaseHeldChunks = () => {
5184
+ for (const held of heldChunks.splice(0)) {
5185
+ const segment = this.ensureOutputSegment(sessionId, promptState);
5186
+ this.emitTaskLifecycle({
5187
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5188
+ type: 'text_chunk',
5189
+ chunk: held,
5190
+ });
5191
+ this.onResponseChunk(envelope.chatId, held, sessionId, segment);
5192
+ }
5193
+ };
5194
+ const onChunk = (sid, chunk) => {
5195
+ if (sid !== sessionId || promptState.cancelled) {
5196
+ return;
5197
+ }
5198
+ heldChunks.push(chunk);
5199
+ if (!promptState.cancelPending) {
5200
+ releaseHeldChunks();
5201
+ }
5202
+ };
5203
+ const onResponseBoundary = (sid) => {
5204
+ if (sid !== sessionId ||
5205
+ promptState.cancelled ||
5206
+ promptState.cancelPending) {
5207
+ return;
5208
+ }
5209
+ heldChunks.length = 0;
5224
5210
  const segment = this.closeOutputSegment(sessionId, promptState);
5225
- void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
5226
- this.emitTaskLifecycle({
5227
- ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5228
- type: 'completed',
5211
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
5212
+ };
5213
+ // Queue wait and memory recall can outlive a bridge crash. Capture the
5214
+ // bridge only after the latest recovery has restored session routing.
5215
+ await this.waitForBridgeRecovery();
5216
+ const promptBridge = this.bridge;
5217
+ promptBridge.on('textChunk', onChunk);
5218
+ promptBridge.on('responseBoundary', onResponseBoundary);
5219
+ let taskResultPartial = false;
5220
+ try {
5221
+ const response = await promptBridge.prompt(sessionId, promptToSend, {
5222
+ ...(this.config.outputMode === 'per_task'
5223
+ ? {
5224
+ outputMode: 'per_task',
5225
+ onTaskResult: ({ partial }) => {
5226
+ taskResultPartial = partial;
5227
+ },
5228
+ }
5229
+ : {}),
5230
+ ...(images.length > 0 ? { images } : {}),
5231
+ imageBase64,
5232
+ imageMimeType,
5233
+ // Session history shows exactly what the model receives. Only the
5234
+ // controls that can reorder or hide rendered text are neutralized.
5235
+ displayText: sanitizeDisplayText(promptToSend),
5229
5236
  });
5230
- }
5231
- }
5232
- catch (err) {
5233
- const runtimeCancelled = !promptState.deliveryStarted &&
5234
- err instanceof ChannelPromptCancelledError;
5235
- // Mirror the try path: once delivery started, a late-settling cancel
5236
- // must not suppress the failed emit (the /cancel handler declines to
5237
- // emit its own terminal once deliveryStarted is set).
5238
- if (!promptState.deliveryStarted) {
5239
5237
  await this.settleCancelRequested(promptState);
5240
- if (runtimeCancelled) {
5241
- this.emitTaskCancellation(promptState, sessionId, 'runtime_cancelled');
5238
+ if (!promptState.cancelled) {
5239
+ releaseHeldChunks();
5240
+ }
5241
+ // If cancelled, skip sending the response
5242
+ if (!promptState.cancelled && response) {
5243
+ promptState.deliveryStarted = true;
5244
+ const segment = this.ensureOutputSegment(sessionId, promptState);
5245
+ if (segment && taskResultPartial)
5246
+ segment.partial = true;
5247
+ await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
5248
+ if (segment && promptState.activeSegmentId === segment.segmentId) {
5249
+ promptState.activeSegmentId = undefined;
5250
+ }
5251
+ }
5252
+ // Once delivery started the turn's outcome is fixed — don't let a
5253
+ // cancel settling during the send rewrite completed into cancelled.
5254
+ if (!promptState.deliveryStarted) {
5255
+ await this.settleCancelRequested(promptState);
5256
+ }
5257
+ if (!promptState.cancelled && !promptState.cancellationEmitted) {
5258
+ const segment = this.closeOutputSegment(sessionId, promptState);
5259
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
5260
+ this.emitTaskLifecycle({
5261
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5262
+ type: 'completed',
5263
+ });
5242
5264
  }
5243
5265
  }
5244
- if (!promptState.cancelled && !runtimeCancelled) {
5245
- releaseHeldChunks();
5246
- const segment = this.closeOutputSegment(sessionId, promptState);
5247
- void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
5248
- this.emitTaskLifecycle({
5249
- ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5250
- type: 'failed',
5251
- error: this.lifecycleError(err),
5252
- phase: promptState.deliveryStarted ? 'delivery' : 'agent',
5253
- });
5254
- }
5255
- else {
5256
- const channel = sanitizeLogText(this.name, 64);
5257
- const safeSessionId = sanitizeLogText(sessionId, 64);
5258
- const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
5259
- process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
5260
- }
5261
- if (promptState.cancelled || runtimeCancelled) {
5262
- return;
5263
- }
5264
- if (sourceLabel) {
5265
- this.inboundErrorSourceLabels.set(envelope, sourceLabel);
5266
- }
5267
- throw err;
5268
- }
5269
- finally {
5270
- promptBridge.off('textChunk', onChunk);
5271
- promptBridge.off('responseBoundary', onResponseBoundary);
5272
- // Identity guard: a turn that wedged past /clear's bounded wait gets
5273
- // EVICTED — /clear gives up on active.done, deletes activePrompts, and a
5274
- // turn the user starts AFTER the clear can re-seed activePrompts (and own
5275
- // the collect buffer) for this session. When the wedged bridge.prompt
5276
- // finally settles and runs this finally, touching session-visible state
5277
- // would clobber that live later turn — ending the working indicator it
5278
- // re-seeded or draining a buffer it owns. So only touch session-scoped
5279
- // state when the entry is still ours. (Steer no longer evicts: it cancels
5280
- // and waits, so a steered turn is always stillCurrent when it completes.)
5281
- const stillCurrent = this.activePrompts.get(sessionId) === promptState;
5282
- // onPromptEnd runs platform cleanup (clear the typing interval, recall the
5283
- // working reaction, finalize the card). Run it UNLESS this turn was a
5284
- // /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
5285
- // at clear-time, and a turn the user started after the clear may now own the
5286
- // chat-scoped indicator, so re-running cleanup here would clobber it.
5287
- // Invariant: clearEvicted is set ONLY by /clear's eviction, which then
5288
- // UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
5289
- // clear-time onPromptEnd guarantees the purge runs even if that throws), and
5290
- // no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
5291
- // stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
5292
- // `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
5293
- // Steer no longer evicts (it chains and waits), so a steered turn is always
5294
- // stillCurrent on completion.
5295
- if (!promptState.clearEvicted) {
5296
- // onPromptEnd runs platform-adapter cleanup (clear the typing interval,
5297
- // recall the working reaction, finalize the card) — network/IO that CAN
5298
- // throw. Guard it like the /clear-eviction path above: an uncaught throw
5299
- // here would skip activePrompts.delete (session leak), promptState.resolve
5300
- // (active.done never settles → a later /clear falsely logs "abandoned a
5301
- // wedged turn" for a turn that completed), and the collect-buffer drain
5302
- // (lost messages) — and the rejected queue-chain promise, swallowed by the
5303
- // tail .catch(() => {}), would silently drop every later turn this session.
5304
- try {
5305
- this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
5266
+ catch (err) {
5267
+ const runtimeCancelled = !promptState.deliveryStarted &&
5268
+ err instanceof ChannelPromptCancelledError;
5269
+ // Mirror the try path: once delivery started, a late-settling cancel
5270
+ // must not suppress the failed emit (the /cancel handler declines to
5271
+ // emit its own terminal once deliveryStarted is set).
5272
+ if (!promptState.deliveryStarted) {
5273
+ await this.settleCancelRequested(promptState);
5274
+ if (runtimeCancelled) {
5275
+ this.emitTaskCancellation(promptState, sessionId, 'runtime_cancelled');
5276
+ }
5306
5277
  }
5307
- catch (err) {
5308
- process.stderr.write(`[${this.name}] onPromptEnd threw in finally for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
5278
+ if (!promptState.cancelled && !runtimeCancelled) {
5279
+ releaseHeldChunks();
5280
+ const segment = this.closeOutputSegment(sessionId, promptState);
5281
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
5282
+ this.emitTaskLifecycle({
5283
+ ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
5284
+ type: 'failed',
5285
+ error: this.lifecycleError(err),
5286
+ phase: promptState.deliveryStarted ? 'delivery' : 'agent',
5287
+ });
5288
+ }
5289
+ else {
5290
+ const channel = sanitizeLogText(this.name, 64);
5291
+ const safeSessionId = sanitizeLogText(sessionId, 64);
5292
+ const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
5293
+ process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
5309
5294
  }
5295
+ if (promptState.cancelled || runtimeCancelled) {
5296
+ return;
5297
+ }
5298
+ if (sourceLabel) {
5299
+ this.inboundErrorSourceLabels.set(envelope, sourceLabel);
5300
+ }
5301
+ throw err;
5310
5302
  }
5311
- if (stillCurrent) {
5312
- this.activePrompts.delete(sessionId);
5303
+ finally {
5304
+ promptBridge.off('textChunk', onChunk);
5305
+ promptBridge.off('responseBoundary', onResponseBoundary);
5306
+ // Identity guard: a turn that wedged past /clear's bounded wait gets
5307
+ // EVICTED — /clear gives up on active.done, deletes activePrompts, and a
5308
+ // turn the user starts AFTER the clear can re-seed activePrompts (and own
5309
+ // the collect buffer) for this session. When the wedged bridge.prompt
5310
+ // finally settles and runs this finally, touching session-visible state
5311
+ // would clobber that live later turn — ending the working indicator it
5312
+ // re-seeded or draining a buffer it owns. So only touch session-scoped
5313
+ // state when the entry is still ours. (Steer no longer evicts: it cancels
5314
+ // and waits, so a steered turn is always stillCurrent when it completes.)
5315
+ const stillCurrent = this.activePrompts.get(sessionId) === promptState;
5316
+ // onPromptEnd runs platform cleanup (clear the typing interval, recall the
5317
+ // working reaction, finalize the card). Run it UNLESS this turn was a
5318
+ // /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
5319
+ // at clear-time, and a turn the user started after the clear may now own the
5320
+ // chat-scoped indicator, so re-running cleanup here would clobber it.
5321
+ // Invariant: clearEvicted is set ONLY by /clear's eviction, which then
5322
+ // UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
5323
+ // clear-time onPromptEnd guarantees the purge runs even if that throws), and
5324
+ // no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
5325
+ // stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
5326
+ // `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
5327
+ // Steer no longer evicts (it chains and waits), so a steered turn is always
5328
+ // stillCurrent on completion.
5329
+ if (!promptState.clearEvicted) {
5330
+ // onPromptEnd runs platform-adapter cleanup (clear the typing interval,
5331
+ // recall the working reaction, finalize the card) — network/IO that CAN
5332
+ // throw. Guard it like the /clear-eviction path above: an uncaught throw
5333
+ // here would skip activePrompts.delete (session leak), promptState.resolve
5334
+ // (active.done never settles → a later /clear falsely logs "abandoned a
5335
+ // wedged turn" for a turn that completed), and the collect-buffer drain
5336
+ // (lost messages) — and the rejected queue-chain promise, swallowed by the
5337
+ // tail .catch(() => {}), would silently drop every later turn this session.
5338
+ try {
5339
+ this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
5340
+ }
5341
+ catch (err) {
5342
+ process.stderr.write(`[${this.name}] onPromptEnd threw in finally for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
5343
+ }
5344
+ }
5345
+ if (stillCurrent) {
5346
+ this.activePrompts.delete(sessionId);
5347
+ }
5348
+ // Signal any /clear waiter racing our done that we're done — even a
5349
+ // /clear-evicted wedged turn must release it (its bounded wait already
5350
+ // timed out). (Steer no longer waits on done; it chains on the queue tail.)
5351
+ promptState.resolve();
5352
+ // Drain collect buffer if any messages accumulated — but only while we're
5353
+ // still the active turn, so a /clear-evicted wedged turn whose bridge.prompt
5354
+ // settles late can't drain a buffer a later turn now owns. (Belt-and-
5355
+ // suspenders: /clear already deletes the buffer on eviction, so this guard
5356
+ // is defensive — but it keeps the invariant "only the current turn drains".)
5357
+ this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, 'prompt completion');
5313
5358
  }
5314
- // Signal any /clear waiter racing our done that we're done — even a
5315
- // /clear-evicted wedged turn must release it (its bounded wait already
5316
- // timed out). (Steer no longer waits on done; it chains on the queue tail.)
5317
- promptState.resolve();
5318
- // Drain collect buffer if any messages accumulated — but only while we're
5319
- // still the active turn, so a /clear-evicted wedged turn whose bridge.prompt
5320
- // settles late can't drain a buffer a later turn now owns. (Belt-and-
5321
- // suspenders: /clear already deletes the buffer on eviction, so this guard
5322
- // is defensive — but it keeps the invariant "only the current turn drains".)
5323
- this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, 'prompt completion');
5324
- }
5325
- });
5326
- const tracked = current.finally(() => {
5327
- this.releaseQueuedTurn(sessionId);
5328
- });
5329
- this.sessionQueues.set(sessionId, tracked.catch(() => { }));
5330
- await tracked;
5359
+ });
5360
+ const tracked = current.finally(() => {
5361
+ this.releaseQueuedTurn(sessionId);
5362
+ });
5363
+ this.sessionQueues.set(sessionId, tracked.catch(() => { }));
5364
+ await tracked;
5365
+ }
5366
+ finally {
5367
+ if (!this.namedSessions)
5368
+ this.router.releaseRoutingLease(sessionId);
5369
+ }
5331
5370
  }
5332
5371
  pairingRejectionMessage(rejected) {
5333
5372
  return rejected === 'sender_pending'