@0xmaxma/claude-gateway 1.8.2 → 1.8.3

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.
Files changed (77) hide show
  1. package/README.md +7 -2
  2. package/config.template.json +4 -0
  3. package/dist/agent/dreaming/config.d.ts +6 -0
  4. package/dist/agent/dreaming/config.d.ts.map +1 -1
  5. package/dist/agent/dreaming/config.js +17 -24
  6. package/dist/agent/dreaming/config.js.map +1 -1
  7. package/dist/agent/knowledge/config.d.ts.map +1 -1
  8. package/dist/agent/knowledge/config.js +7 -13
  9. package/dist/agent/knowledge/config.js.map +1 -1
  10. package/dist/agent/runner.d.ts +87 -13
  11. package/dist/agent/runner.d.ts.map +1 -1
  12. package/dist/agent/runner.js +342 -117
  13. package/dist/agent/runner.js.map +1 -1
  14. package/dist/agent/turn-stream.d.ts +202 -0
  15. package/dist/agent/turn-stream.d.ts.map +1 -0
  16. package/dist/agent/turn-stream.js +322 -0
  17. package/dist/agent/turn-stream.js.map +1 -0
  18. package/dist/api/apps-router.d.ts.map +1 -1
  19. package/dist/api/apps-router.js +14 -2
  20. package/dist/api/apps-router.js.map +1 -1
  21. package/dist/api/gateway-router.d.ts.map +1 -1
  22. package/dist/api/gateway-router.js +2 -3
  23. package/dist/api/gateway-router.js.map +1 -1
  24. package/dist/api/line-webhook-router.d.ts +43 -1
  25. package/dist/api/line-webhook-router.d.ts.map +1 -1
  26. package/dist/api/line-webhook-router.js +286 -38
  27. package/dist/api/line-webhook-router.js.map +1 -1
  28. package/dist/api/router.d.ts +7 -1
  29. package/dist/api/router.d.ts.map +1 -1
  30. package/dist/api/router.js +328 -114
  31. package/dist/api/router.js.map +1 -1
  32. package/dist/apps/agent-manager.d.ts.map +1 -1
  33. package/dist/apps/agent-manager.js +4 -1
  34. package/dist/apps/agent-manager.js.map +1 -1
  35. package/dist/apps/installer.d.ts +75 -2
  36. package/dist/apps/installer.d.ts.map +1 -1
  37. package/dist/apps/installer.js +198 -13
  38. package/dist/apps/installer.js.map +1 -1
  39. package/dist/config/agent-env.d.ts +41 -0
  40. package/dist/config/agent-env.d.ts.map +1 -0
  41. package/dist/config/agent-env.js +154 -0
  42. package/dist/config/agent-env.js.map +1 -0
  43. package/dist/config/loader.d.ts +33 -2
  44. package/dist/config/loader.d.ts.map +1 -1
  45. package/dist/config/loader.js +33 -9
  46. package/dist/config/loader.js.map +1 -1
  47. package/dist/config/watcher.d.ts +1 -0
  48. package/dist/config/watcher.d.ts.map +1 -1
  49. package/dist/config/watcher.js +17 -1
  50. package/dist/config/watcher.js.map +1 -1
  51. package/dist/index.js +24 -51
  52. package/dist/index.js.map +1 -1
  53. package/dist/load-dotenv.d.ts +17 -0
  54. package/dist/load-dotenv.d.ts.map +1 -1
  55. package/dist/load-dotenv.js +32 -9
  56. package/dist/load-dotenv.js.map +1 -1
  57. package/dist/session/store.d.ts +9 -0
  58. package/dist/session/store.d.ts.map +1 -1
  59. package/dist/session/store.js +12 -0
  60. package/dist/session/store.js.map +1 -1
  61. package/dist/shell/bypass-dialog.d.ts +142 -0
  62. package/dist/shell/bypass-dialog.d.ts.map +1 -0
  63. package/dist/shell/bypass-dialog.js +207 -0
  64. package/dist/shell/bypass-dialog.js.map +1 -0
  65. package/dist/shell/claude-pty-shell.js +42 -3
  66. package/dist/shell/claude-pty-shell.js.map +1 -1
  67. package/dist/shell/screen.d.ts +7 -0
  68. package/dist/shell/screen.d.ts.map +1 -1
  69. package/dist/shell/screen.js +10 -1
  70. package/dist/shell/screen.js.map +1 -1
  71. package/dist/types.d.ts +20 -0
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/utils/config-num.d.ts +22 -0
  74. package/dist/utils/config-num.d.ts.map +1 -0
  75. package/dist/utils/config-num.js +34 -0
  76. package/dist/utils/config-num.js.map +1 -0
  77. package/package.json +1 -1
@@ -35,10 +35,12 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AgentRunner = exports.CHANNEL_COALESCE_WINDOW_MS = exports.DEFAULT_MODELS = exports.MAX_IMAGE_SIZE_BYTES = void 0;
37
37
  exports.toRelMediaFiles = toRelMediaFiles;
38
+ const crypto_1 = require("crypto");
38
39
  const events_1 = require("events");
39
40
  const fs = __importStar(require("fs"));
40
41
  const fsPromises = __importStar(require("fs/promises"));
41
42
  const http = __importStar(require("http"));
43
+ const os = __importStar(require("os"));
42
44
  const path = __importStar(require("path"));
43
45
  const logger_1 = require("../logger");
44
46
  const process_1 = require("../session/process");
@@ -53,6 +55,7 @@ const skills_1 = require("../skills");
53
55
  const builtin_commands_1 = require("./builtin-commands");
54
56
  const model_catalog_1 = require("./model-catalog");
55
57
  const safe_mode_1 = require("./safe-mode");
58
+ const turn_stream_1 = require("./turn-stream");
56
59
  const recovery_executor_1 = require("./recovery-executor");
57
60
  const recovery_policy_1 = require("./recovery-policy");
58
61
  const incident_1 = require("./incident");
@@ -214,6 +217,11 @@ class AgentRunner extends events_1.EventEmitter {
214
217
  this.idleCleanerTimer = null;
215
218
  // Tracks session IDs with an in-flight API request (prevents concurrent turns)
216
219
  this.pendingApiSessions = new Set();
220
+ // Per-session buffer of the current turn's stream events, so a disconnected or
221
+ // timed-out client can re-attach and pick the turn back up (#421). Distinct
222
+ // from pendingApiSessions: that clears the moment the turn settles, this one
223
+ // survives it for the replay grace window.
224
+ this.turnStreams = new turn_stream_1.TurnStreamRegistry();
217
225
  // Serialises concurrent getOrSpawnSession calls for the same key to prevent double-spawn
218
226
  this.sessionSpawnLocks = new Map();
219
227
  // Consecutive request_too_large (32MB) recoveries per mapKey. Survives the
@@ -951,6 +959,32 @@ class AgentRunner extends events_1.EventEmitter {
951
959
  this.turnQueue.delete(chatId);
952
960
  }
953
961
  }
962
+ /**
963
+ * Delete a channel's throwaway staging copy of an inbound attachment, once
964
+ * MediaStore holds the permanent one.
965
+ *
966
+ * Opt-in via `meta.media_ephemeral === '1'` — only a channel that created the
967
+ * file under the system temp dir sets it, and only for a path it built itself.
968
+ * Meta arrives over the local /channel intake, so the flag alone is not
969
+ * authority to unlink: the path is resolved (symlinks included) and must sit
970
+ * INSIDE the real temp dir, which bounds a forged flag to deleting something
971
+ * already in a world-writable scratch directory. Every failure is swallowed —
972
+ * a leftover temp file is a wart, and a throw here would abort recording the
973
+ * turn that was already delivered.
974
+ */
975
+ static discardEphemeralStaging(stagedPath) {
976
+ try {
977
+ const tmpRoot = fs.realpathSync(os.tmpdir());
978
+ const real = fs.realpathSync(stagedPath);
979
+ const rel = path.relative(tmpRoot, real);
980
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
981
+ return;
982
+ fs.rmSync(real, { force: true });
983
+ }
984
+ catch {
985
+ // Already gone, unreadable, or outside the temp dir — leave it alone.
986
+ }
987
+ }
954
988
  /**
955
989
  * Inject one coalesced turn into the session immediately. The caller must have
956
990
  * already marked the chat active in `turnActive`. Records each buffered message
@@ -976,6 +1010,7 @@ class AgentRunner extends events_1.EventEmitter {
976
1010
  // Persist to permanent history DB (separate from session context)
977
1011
  const mediaFiles = [];
978
1012
  if (meta['image_path']) {
1013
+ const stagedPath = meta['image_path'];
979
1014
  try {
980
1015
  const rel = media_store_1.MediaStore.copyToMedia(this.agentsBaseDir, this.agentConfig.id, `${channelSource}-${chatId}`, meta['image_path']);
981
1016
  mediaFiles.push(rel);
@@ -986,6 +1021,11 @@ class AgentRunner extends events_1.EventEmitter {
986
1021
  // Mutating meta['image_path'] here (same object as entry.meta) makes the
987
1022
  // channel XML and image-size tracker below both use the readable path.
988
1023
  meta['image_path'] = media_store_1.MediaStore.resolvePath(this.agentsBaseDir, this.agentConfig.id, rel);
1024
+ // The staging copy has served its purpose — drop it (see
1025
+ // discardEphemeralStaging). Only after a SUCCESSFUL copy: the catch
1026
+ // below keeps the original path as the agent's only route to the bytes.
1027
+ if (meta['media_ephemeral'] === '1')
1028
+ AgentRunner.discardEphemeralStaging(stagedPath);
989
1029
  }
990
1030
  catch {
991
1031
  // Non-fatal — leave the original path so host agents still read it
@@ -1045,7 +1085,15 @@ class AgentRunner extends events_1.EventEmitter {
1045
1085
  }
1046
1086
  blocks.push(channelXml);
1047
1087
  const imagePath = entry.meta?.['image_path'];
1048
- if (imagePath) {
1088
+ // Non-image attachments (LINE files) ride the same image_path channel so
1089
+ // the runner stages them into MediaStore, but they must NOT count toward
1090
+ // the image-size restart budget below: that budget exists because images
1091
+ // are pulled into context as pixels, whereas a document the agent may
1092
+ // never open would push the chat into a summary+restart it never needed.
1093
+ // media_type is set by the LINE router alone; everything else (Telegram,
1094
+ // Slack, web upload) omits it and keeps counting as an image.
1095
+ const isImageMedia = (entry.meta?.['media_type'] ?? 'image') === 'image';
1096
+ if (imagePath && isImageMedia) {
1049
1097
  const queue = this.pendingImagePaths.get(chatId) ?? [];
1050
1098
  queue.push(imagePath);
1051
1099
  this.pendingImagePaths.set(chatId, queue);
@@ -1424,6 +1472,17 @@ class AgentRunner extends events_1.EventEmitter {
1424
1472
  // Auto-forward result text to channel if agent didn't call reply tool.
1425
1473
  let replyCalled = false;
1426
1474
  let replyToolUseId = null; // track id to detect failed tool calls
1475
+ // Reply tool_use blocks re-appear across cumulative `assistant` snapshots
1476
+ // exactly like line_image ones, so a repeat is skipped by block id rather
1477
+ // than by `replyCalled`. Gating on the flag also froze `replyToolUseId` on
1478
+ // the turn's FIRST reply call, so a LATER call's failure never matched the
1479
+ // unblock below and the turn's final message was dropped silently (#422).
1480
+ const seenReplyToolIds = new Set();
1481
+ // Set when a reply tool call failed this turn. The fallback forward must
1482
+ // then reach the channel even though an EARLIER successful reply in the
1483
+ // same turn already wrote a `.replied` marker carrying this turn's id —
1484
+ // see writeAutoForward()'s forceDeliver argument.
1485
+ let replySendFailed = false;
1427
1486
  // line_image tool_use blocks re-appear across cumulative `assistant` stream
1428
1487
  // snapshots (--include-partial-messages), so dedupe history inserts by the
1429
1488
  // block id — otherwise one sent image lands in the transcript N times and
@@ -1438,6 +1497,14 @@ class AgentRunner extends events_1.EventEmitter {
1438
1497
  // so the result's plain-text auto-forward is skipped (no duplicate message).
1439
1498
  let menuSentThisTurn = false;
1440
1499
  let menuPromptTextThisTurn = '';
1500
+ // Text of the most recent assistant message this turn. Used only as a
1501
+ // fallback when the turn's `result` is an empty string — some backends
1502
+ // (OpenRouter) stream the answer as assistant text and then close the turn
1503
+ // with `result: ""`, which would otherwise leave the history DB with no row
1504
+ // for the reply. Replaced, never appended: assistant messages arrive as
1505
+ // cumulative snapshots, so the last one is the complete text (same
1506
+ // semantics as `result`).
1507
+ let lastAssistantTextThisTurn = '';
1441
1508
  let typingDoneTimer = null;
1442
1509
  const TYPING_DONE_DELAY_MS = 3000;
1443
1510
  const replyToolName = source === 'discord'
@@ -1459,6 +1526,12 @@ class AgentRunner extends events_1.EventEmitter {
1459
1526
  if (obj['type'] === 'assistant') {
1460
1527
  const msg = obj['message'];
1461
1528
  if (Array.isArray(msg?.content)) {
1529
+ const textThisMessage = msg.content
1530
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
1531
+ .map((b) => b.text)
1532
+ .join('');
1533
+ if (textThisMessage.trim())
1534
+ lastAssistantTextThisTurn = textThisMessage;
1462
1535
  for (const block of msg.content) {
1463
1536
  // Skill-learning: count every tool_use this turn (deduped by block
1464
1537
  // id inside the manager, since cumulative assistant snapshots re-emit
@@ -1492,9 +1565,22 @@ class AgentRunner extends events_1.EventEmitter {
1492
1565
  });
1493
1566
  }
1494
1567
  }
1495
- if (block.type === 'tool_use' && block.name === replyToolName && !replyCalled) {
1568
+ if (block.type === 'tool_use' && block.name === replyToolName) {
1569
+ // The block comes off the CLI's stdout — validate the id rather
1570
+ // than casting, so a malformed one degrades to the flag check
1571
+ // instead of poisoning the Set with a non-string key.
1572
+ const rawBlockId = block['id'];
1573
+ const replyBlockId = typeof rawBlockId === 'string' && rawBlockId ? rawBlockId : null;
1574
+ // Skip a re-emitted snapshot of a block already handled. Blocks
1575
+ // with no id fall back to the old flag check, which is the best
1576
+ // dedup available without an id.
1577
+ const alreadyHandled = replyBlockId !== null ? seenReplyToolIds.has(replyBlockId) : replyCalled;
1578
+ if (alreadyHandled)
1579
+ continue;
1580
+ if (replyBlockId !== null)
1581
+ seenReplyToolIds.add(replyBlockId);
1496
1582
  replyCalled = true;
1497
- replyToolUseId = block['id'] ?? null;
1583
+ replyToolUseId = replyBlockId;
1498
1584
  // Persist the reply text to history so it appears in chat history API
1499
1585
  const replyText = typeof block.input?.['text'] === 'string' ? block.input['text'].trim() : '';
1500
1586
  // Capture any images the reply attached (reply tool's `files`)
@@ -1535,6 +1621,7 @@ class AgentRunner extends events_1.EventEmitter {
1535
1621
  if (block.type === 'tool_result' && block.tool_use_id === replyToolUseId && block.is_error) {
1536
1622
  replyCalled = false;
1537
1623
  replyToolUseId = null;
1624
+ replySendFailed = true;
1538
1625
  }
1539
1626
  }
1540
1627
  }
@@ -1652,10 +1739,12 @@ class AgentRunner extends events_1.EventEmitter {
1652
1739
  // Detect Anthropic API socket drop — Claude CLI emits this as is_error:false
1653
1740
  // with "API Error: The socket connection was closed unexpectedly". The error
1654
1741
  // bypasses the replyCalled gate so the user always gets notified, even when
1655
- // the agent already called the reply tool earlier in the same turn.
1742
+ // the agent already called the reply tool earlier in the same turn — hence
1743
+ // forceDeliver, or that earlier reply's `.replied` marker would dedup this
1744
+ // notice away and defeat the bypass (#422).
1656
1745
  const isSocketError = !proc.queryMode && resultText.includes(ANTHROPIC_SOCKET_ERROR);
1657
1746
  if (isSocketError) {
1658
- this.writeAutoForward(mapKey, '⚡ Connection to Anthropic API dropped. Please resend your message.');
1747
+ this.writeAutoForward(mapKey, '⚡ Connection to Anthropic API dropped. Please resend your message.', 'text', true);
1659
1748
  }
1660
1749
  // Bug B: headless (claude --print) reports a too-large request as a
1661
1750
  // synthetic `result` (is_error + "Request too large (max"), NOT as the
@@ -1697,8 +1786,26 @@ class AgentRunner extends events_1.EventEmitter {
1697
1786
  forwardableText = '';
1698
1787
  }
1699
1788
  }
1700
- if (!isSocketError && !isRequestTooLarge && forwardableText && !proc.queryMode && !replyCalled && !isThinkingCorruption) {
1701
- const text = forwardableText;
1789
+ // A backend that closes the turn with `result: ""` after streaming the
1790
+ // answer as assistant text (OpenRouter) leaves nothing to store here.
1791
+ // SessionProcess still appends that text to the session JSON, so the
1792
+ // history DB would be the only layer missing the reply — and the
1793
+ // cross-channel producer used to hide that by writing a second copy of
1794
+ // every reply (see sendMessageToSession's done()). Persist the fallback
1795
+ // instead, but only for the shape it was written for: an *empty* result
1796
+ // ending a whole turn on the headless backend.
1797
+ // • A menu turn is excluded because `forwardableText` above may have
1798
+ // been blanked on purpose — falling back there would resurrect the
1799
+ // option list the strip removed and duplicate the prompt row
1800
+ // already inserted when the menu was rendered.
1801
+ // • pty-shell is excluded because its `result` fires per sub-turn, so
1802
+ // every tool-call boundary with an empty result would add a phantom
1803
+ // row of that sub-turn's narration.
1804
+ // Forwarding deliberately keeps using forwardableText alone: recovering
1805
+ // a missing history row is not a reason to post a new channel message.
1806
+ const canFallBack = !menuSentThisTurn && proc.backend !== 'pty-shell';
1807
+ const persistableText = forwardableText || (canFallBack ? lastAssistantTextThisTurn.trim() : '');
1808
+ if (!isSocketError && !isRequestTooLarge && persistableText && !proc.queryMode && !replyCalled && !isThinkingCorruption) {
1702
1809
  const channelSrcForResult = this.channelSourceMap.get(mapKey) ?? 'telegram';
1703
1810
  // Persist assistant reply to permanent history DB
1704
1811
  this.historyDb.insertMessage({
@@ -1706,39 +1813,44 @@ class AgentRunner extends events_1.EventEmitter {
1706
1813
  sessionId: actualSessionId,
1707
1814
  source: channelSrcForResult,
1708
1815
  role: 'assistant',
1709
- content: text,
1816
+ content: persistableText,
1710
1817
  ts: Date.now(),
1711
1818
  });
1712
- // Forward to channel. LINE has no .forward consumer — the gateway
1713
- // delivers via LineReplyManager (free reply, or cache + postback
1714
- // button when slow) instead of writeAutoForward.
1715
- const channelText = (0, markdown_1.normalizeTelegramLineBreaks)(text);
1716
- if (channelSrcForResult === 'line' && this.lineReply) {
1717
- void this.lineReply.onAnswer(mapKey, channelText);
1718
- }
1719
- else if (channelSrcForResult !== 'discord' &&
1720
- channelSrcForResult !== 'slack' &&
1721
- ((0, markdown_1.hasMarkdown)(channelText) || (0, markdown_1.containsTelegramHtml)(channelText))) {
1722
- // Telegram HTML entities Slack has its own mrkdwn format and
1723
- // would display these tags literally, so Slack skips this and
1724
- // falls through to the plain-text branch below.
1725
- this.writeAutoForward(mapKey, (0, markdown_1.toTelegramHtml)(channelText), 'html');
1726
- }
1727
- else {
1728
- this.writeAutoForward(mapKey, channelText);
1819
+ if (forwardableText) {
1820
+ // Forward to channel. LINE has no .forward consumer — the gateway
1821
+ // delivers via LineReplyManager (free reply, or cache + postback
1822
+ // button when slow) instead of writeAutoForward.
1823
+ const channelText = (0, markdown_1.normalizeTelegramLineBreaks)(forwardableText);
1824
+ if (channelSrcForResult === 'line' && this.lineReply) {
1825
+ void this.lineReply.onAnswer(mapKey, channelText);
1826
+ }
1827
+ else if (channelSrcForResult !== 'discord' &&
1828
+ channelSrcForResult !== 'slack' &&
1829
+ ((0, markdown_1.hasMarkdown)(channelText) || (0, markdown_1.containsTelegramHtml)(channelText))) {
1830
+ // Telegram HTML entities Slack has its own mrkdwn format and
1831
+ // would display these tags literally, so Slack skips this and
1832
+ // falls through to the plain-text branch below.
1833
+ this.writeAutoForward(mapKey, (0, markdown_1.toTelegramHtml)(channelText), 'html', replySendFailed);
1834
+ }
1835
+ else {
1836
+ this.writeAutoForward(mapKey, channelText, 'text', replySendFailed);
1837
+ }
1838
+ // Assistant output reached the channel — mark the turn delivered so
1839
+ // a later recovery does not resend a message that was answered (C1).
1840
+ const lt = this.lastTurn.get(mapKey);
1841
+ if (lt)
1842
+ lt.delivered = true;
1729
1843
  }
1730
- // Assistant output reached the channel — mark the turn delivered so
1731
- // a later recovery does not resend a message that was answered (C1).
1732
- const lt = this.lastTurn.get(mapKey);
1733
- if (lt)
1734
- lt.delivered = true;
1735
1844
  }
1736
1845
  replyCalled = false; // reset for next turn
1737
1846
  replyToolUseId = null;
1847
+ replySendFailed = false;
1848
+ seenReplyToolIds.clear();
1738
1849
  seenLineImageIds.clear();
1739
1850
  pendingLineImageMedia.clear();
1740
1851
  menuSentThisTurn = false;
1741
1852
  menuPromptTextThisTurn = '';
1853
+ lastAssistantTextThisTurn = '';
1742
1854
  // In pty-shell mode, a `result` fires after every Claude API sub-turn
1743
1855
  // (there can be many per user message, separated by tool-call gaps of
1744
1856
  // arbitrary length). Starting the typing-done timer here would stop
@@ -2462,7 +2574,7 @@ class AgentRunner extends events_1.EventEmitter {
2462
2574
  }
2463
2575
  this.pendingRestarts.add(chatId);
2464
2576
  }
2465
- writeAutoForward(chatId, text, format = 'text') {
2577
+ writeAutoForward(chatId, text, format = 'text', forceDeliver = false) {
2466
2578
  // LINE has no .forward consumer — route through LineReplyManager's push path.
2467
2579
  if (this.channelFor(chatId) === 'line') {
2468
2580
  if (this.lineReply)
@@ -2498,6 +2610,11 @@ class AgentRunner extends events_1.EventEmitter {
2498
2610
  // state) must both reach the user, not have the second clobber the
2499
2611
  // first. turnId is the live typing-signal timestamp — the receiver uses
2500
2612
  // it to scope the `.replied` dedup marker to the turn that wrote it.
2613
+ // forceDeliver writes a null turnId instead: `isEntryAlreadyReplied()`
2614
+ // never matches a null on either side, so the entry survives a `.replied`
2615
+ // marker left by an EARLIER successful reply in this same turn. Used for
2616
+ // text that is NOT the one that marker covers — a fallback forward after a
2617
+ // reply call failed, and the socket-drop notice (#422).
2501
2618
  let entries = [];
2502
2619
  try {
2503
2620
  const raw = fs.readFileSync(forwardPath, 'utf8').trim();
@@ -2516,7 +2633,7 @@ class AgentRunner extends events_1.EventEmitter {
2516
2633
  catch {
2517
2634
  // No existing file — fresh queue.
2518
2635
  }
2519
- entries.push({ text, format, turnId: this.readCurrentTurnId(chatId) });
2636
+ entries.push({ text, format, turnId: forceDeliver ? null : this.readCurrentTurnId(chatId) });
2520
2637
  // Atomic write (tmp + rename): the receiver polls this directory on
2521
2638
  // every typing tick, so a non-atomic write could be read mid-flush.
2522
2639
  const tmpPath = `${forwardPath}.tmp`;
@@ -2697,6 +2814,8 @@ class AgentRunner extends events_1.EventEmitter {
2697
2814
  clearTimeout(buf.timer);
2698
2815
  }
2699
2816
  this.channelCoalesce.clear();
2817
+ // Drops every turn record and its replay-grace timer (#421).
2818
+ this.turnStreams.clear();
2700
2819
  // Receiver teardown now resolves only once the child has exited, so it must
2701
2820
  // be awaited or shutdown() can exit the gateway out from under a receiver
2702
2821
  // that is still shutting down (issue #405). Run it concurrently with session
@@ -2921,14 +3040,14 @@ class AgentRunner extends events_1.EventEmitter {
2921
3040
  }
2922
3041
  resolve({ text: finalText, attachments });
2923
3042
  };
2924
- const fail = (err) => {
3043
+ const fail = (err, partialText) => {
2925
3044
  if (settled)
2926
3045
  return;
2927
3046
  settled = true;
2928
3047
  // Terminal close for a turn that produced no result (#75): record it so
2929
3048
  // history does not end on a dangling user message, and drain the
2930
3049
  // attachment buffer so nothing leaks into the next turn.
2931
- this.persistFailedApiTurn(chatId, sessionId, err, opts.skipUserMessage);
3050
+ this.persistFailedApiTurn(chatId, sessionId, err, opts.skipUserMessage, partialText);
2932
3051
  cleanup();
2933
3052
  reject(err); // no-op when the soft timeout already rejected
2934
3053
  };
@@ -2950,7 +3069,10 @@ class AgentRunner extends events_1.EventEmitter {
2950
3069
  done(buffer.join(''), true);
2951
3070
  return;
2952
3071
  }
2953
- fail(Object.assign(new Error('Session process exited unexpectedly before responding.'), { code: 'PROCESS_EXITED' }));
3072
+ // Whatever streamed before the crash is real work the caller may already
3073
+ // have read; persist it alongside the notice rather than replacing it,
3074
+ // exactly as the hard cap below does.
3075
+ fail(Object.assign(new Error('Session process exited unexpectedly before responding.'), { code: 'PROCESS_EXITED' }), buffer.join(''));
2954
3076
  };
2955
3077
  const cleanup = () => {
2956
3078
  clearTimeout(globalTimer);
@@ -3019,10 +3141,17 @@ class AgentRunner extends events_1.EventEmitter {
3019
3141
  // bounds a genuinely hung turn.
3020
3142
  let hardCapTimer;
3021
3143
  const globalTimer = setTimeout(() => {
3022
- reject(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT' }));
3144
+ // TIMEOUT_SOFT: the caller's budget elapsed, the turn has not. The hard
3145
+ // cap below rejects nobody — the promise is already settled — but it
3146
+ // does stop the turn, so only IT gets the terminal TIMEOUT.
3147
+ reject(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT_SOFT' }));
3023
3148
  hardCapTimer = setTimeout(() => {
3149
+ // Interrupt before clearing the processing flag — interrupt() no-ops
3150
+ // once `_processing` is false. See the streaming path for why the cap
3151
+ // stops the turn instead of only detaching from it.
3152
+ session.interrupt();
3024
3153
  session.setProcessing(false);
3025
- fail(Object.assign(new Error('Agent response timed out.'), { code: 'TIMEOUT' }));
3154
+ fail(Object.assign(new Error('Agent response timed out.'), { code: 'TIMEOUT' }), buffer.join(''));
3026
3155
  }, API_TIMEOUT_HARD_CAP_EXTRA_MS);
3027
3156
  }, opts.timeoutMs);
3028
3157
  session.on('output', onOutput);
@@ -3038,7 +3167,9 @@ class AgentRunner extends events_1.EventEmitter {
3038
3167
  * Send a message to an API session and stream back events via callbacks.
3039
3168
  *
3040
3169
  * Returns a disconnect handler for the caller to wire to the SSE close event.
3041
- * On client disconnect the stream continues server-side until the result is saved to DB.
3170
+ * It detaches this caller's sink; the turn keeps running, keeps buffering, and
3171
+ * stays re-attachable via attachTurnStream() until the replay grace window
3172
+ * expires (#421).
3042
3173
  */
3043
3174
  async sendApiMessageStream(sessionId, chatId, message, callbacks, opts) {
3044
3175
  if (this.pendingApiSessions.has(sessionId)) {
@@ -3092,17 +3223,12 @@ class AgentRunner extends events_1.EventEmitter {
3092
3223
  session.touch();
3093
3224
  const buffer = [];
3094
3225
  let settled = false;
3095
- let clientGone = false;
3096
- // onError must fire at most once per turn. The soft timeout notifies the
3097
- // client directly (below) WITHOUT setting `settled`, so the later hard-cap
3098
- // fail() would otherwise call onError a second time.
3099
- let errorNotified = false;
3100
- const notifyError = (err) => {
3101
- if (errorNotified)
3102
- return;
3103
- errorNotified = true;
3104
- callbacks.onError(err);
3105
- };
3226
+ // The turn's event buffer. Every emission below records into it whether or
3227
+ // not anyone is listening; the sink decides only what reaches a socket, so a
3228
+ // disconnect no longer costs the client the rest of the turn (#421).
3229
+ const turn = this.turnStreams.start((0, turn_stream_1.turnStreamKey)('api', sessionId), opts.requestId ?? (0, crypto_1.randomUUID)());
3230
+ const initialSink = (0, turn_stream_1.callbackSink)(callbacks);
3231
+ turn.attach(initialSink, 0);
3106
3232
  // Track partial message text for delta computation (--include-partial-messages)
3107
3233
  let lastPartialText = '';
3108
3234
  // Accumulate tool_use blocks from stream_event (content_block_start → delta → stop)
@@ -3148,9 +3274,9 @@ class AgentRunner extends events_1.EventEmitter {
3148
3274
  ts: streamAssistantTs,
3149
3275
  });
3150
3276
  }
3151
- callbacks.onDone(finalText, attachments);
3277
+ this.turnStreams.complete(turn, (0, turn_stream_1.resultEvent)(finalText, attachments));
3152
3278
  };
3153
- const fail = (err) => {
3279
+ const fail = (err, partialText) => {
3154
3280
  if (settled)
3155
3281
  return;
3156
3282
  settled = true;
@@ -3159,8 +3285,8 @@ class AgentRunner extends events_1.EventEmitter {
3159
3285
  // history does not end on a dangling user message (the web reads that
3160
3286
  // state as "still thinking" and spins forever), and drain the attachment
3161
3287
  // buffer so nothing leaks into the next turn.
3162
- this.persistFailedApiTurn(chatId, sessionId, err, opts.skipUserMessage);
3163
- notifyError(err);
3288
+ this.persistFailedApiTurn(chatId, sessionId, err, opts.skipUserMessage, partialText);
3289
+ this.turnStreams.complete(turn, (0, turn_stream_1.errorEvent)(err), err);
3164
3290
  };
3165
3291
  // The subprocess died without ever emitting a final `result` line (crash,
3166
3292
  // an unexpected signal — distinct from a graceful /stop, whose SIGINT
@@ -3180,7 +3306,11 @@ class AgentRunner extends events_1.EventEmitter {
3180
3306
  done(buffer.join(''), true);
3181
3307
  return;
3182
3308
  }
3183
- fail(Object.assign(new Error('Session process exited unexpectedly before responding.'), { code: 'PROCESS_EXITED' }));
3309
+ // Whatever streamed before the crash is real work the client may already
3310
+ // have rendered; persist it alongside the notice rather than replacing it,
3311
+ // exactly as the hard cap below does. A crash is the case partialText was
3312
+ // added for — the client saw the deltas and no `result` will ever land.
3313
+ fail(Object.assign(new Error('Session process exited unexpectedly before responding.'), { code: 'PROCESS_EXITED' }), buffer.join(''));
3184
3314
  };
3185
3315
  const cleanup = () => {
3186
3316
  clearTimeout(globalTimer);
@@ -3190,9 +3320,12 @@ class AgentRunner extends events_1.EventEmitter {
3190
3320
  session.off('exit', onExit);
3191
3321
  this.pendingApiSessions.delete(sessionId);
3192
3322
  };
3193
- // Called when the SSE client disconnects. Marks clientGone so SSE writes
3194
- // fail silently, but keeps onOutput listening so the result is still saved to DB.
3195
- const onClientDisconnect = () => { clientGone = true; };
3323
+ // Called when the SSE client disconnects. Detaches that connection's sink
3324
+ // the producer below keeps recording, so a re-attach can still deliver
3325
+ // everything that arrived while nobody was listening (#421). Passing the
3326
+ // sink means a stale close from a superseded connection cannot silence the
3327
+ // one that replaced it.
3328
+ const onClientDisconnect = () => { turn.detach(initialSink); };
3196
3329
  const onOutput = (line) => {
3197
3330
  try {
3198
3331
  const obj = JSON.parse(line);
@@ -3207,15 +3340,13 @@ class AgentRunner extends events_1.EventEmitter {
3207
3340
  fullText += block.text;
3208
3341
  // PTY mode (headless=false) emits tool_use blocks inside assistant messages
3209
3342
  if (block.type === 'tool_use' && block.name && block.id) {
3210
- if (!clientGone)
3211
- callbacks.onChunk({ type: 'tool_use', name: block.name, id: block.id, input: block.input });
3343
+ turn.emit({ type: 'tool_use', name: block.name, id: block.id, input: block.input });
3212
3344
  }
3213
3345
  }
3214
3346
  if (fullText.length > lastPartialText.length) {
3215
3347
  const delta = fullText.slice(lastPartialText.length);
3216
3348
  buffer.push(delta);
3217
- if (!clientGone)
3218
- callbacks.onChunk({ type: 'text_delta', text: delta });
3349
+ turn.emit({ type: 'text_delta', text: delta });
3219
3350
  }
3220
3351
  lastPartialText = fullText;
3221
3352
  }
@@ -3225,34 +3356,27 @@ class AgentRunner extends events_1.EventEmitter {
3225
3356
  const text = obj['text'] ?? '';
3226
3357
  if (text) {
3227
3358
  buffer.push(text);
3228
- if (!clientGone)
3229
- callbacks.onChunk({ type: 'text_delta', text });
3359
+ turn.emit({ type: 'text_delta', text });
3230
3360
  }
3231
3361
  }
3232
3362
  // stream_event from --output-format stream-json (tool_use + text_delta)
3233
- this._applyStreamEvent(obj, toolBlocks, clientGone ? () => { } : callbacks.onChunk, (text) => {
3363
+ this._applyStreamEvent(obj, toolBlocks, (event) => turn.emit(event), (text) => {
3234
3364
  buffer.push(text);
3235
3365
  // Update lastPartialText so the final 'assistant' message won't re-send the full text
3236
3366
  lastPartialText += text;
3237
- if (!clientGone)
3238
- callbacks.onChunk({ type: 'text_delta', text });
3367
+ turn.emit({ type: 'text_delta', text });
3239
3368
  });
3240
3369
  // Text from delta field (other formats)
3241
3370
  if (obj['type'] !== 'assistant' && obj['type'] !== 'text' && obj['type'] !== 'result' && obj['type'] !== 'stream_event') {
3242
3371
  const deltaText = obj['delta']?.['text'];
3243
3372
  if (deltaText) {
3244
3373
  buffer.push(deltaText);
3245
- if (!clientGone)
3246
- callbacks.onChunk({ type: 'text_delta', text: deltaText });
3374
+ turn.emit({ type: 'text_delta', text: deltaText });
3247
3375
  }
3248
3376
  }
3249
3377
  // Thinking
3250
3378
  if (obj['type'] === 'thinking') {
3251
- if (!clientGone)
3252
- callbacks.onChunk({
3253
- type: 'thinking',
3254
- text: obj['text'] ?? '',
3255
- });
3379
+ turn.emit({ type: 'thinking', text: obj['text'] ?? '' });
3256
3380
  }
3257
3381
  // Result = end of turn
3258
3382
  if (obj['type'] === 'result') {
@@ -3272,25 +3396,40 @@ class AgentRunner extends events_1.EventEmitter {
3272
3396
  /* non-JSON stdout line */
3273
3397
  }
3274
3398
  };
3275
- // Soft timeout (#75): tell the SSE client now, but KEEP the output listener
3276
- // attached exactly like onClientDisconnect so a turn that finishes a few
3277
- // seconds past the budget still lands in history with its attachments
3399
+ // Soft timeout (#75, reworked in #421): tell the client the budget elapsed,
3400
+ // but say so with a NON-terminal `timeout` event rather than an `error`. The
3401
+ // turn is not cancelled — the output listener stays attached so a turn that
3402
+ // finishes a few seconds late still lands in history with its attachments
3278
3403
  // (observed: 304s image turn vs 300s budget → image generated, reply lost,
3279
- // spinner stuck). The hard cap bounds a genuinely hung turn.
3404
+ // spinner stuck) and the buffer keeps recording, so whether the client
3405
+ // stays on this connection or reconnects to …/stream, it still gets the
3406
+ // reply. Reporting a still-running turn as `error` was the bug: history
3407
+ // gained a message no live client could ever see. The hard cap below bounds
3408
+ // a genuinely hung turn and IS a real error.
3280
3409
  let hardCapTimer;
3281
3410
  const globalTimer = setTimeout(() => {
3282
- clientGone = true;
3283
- try {
3284
- notifyError(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT' }));
3285
- }
3286
- catch { /* client already gone */ }
3411
+ turn.emit({ type: 'timeout', message: 'Agent response timeout', resumable: true });
3287
3412
  // The session stays in-flight (pendingApiSessions) until the hard cap so a
3288
3413
  // retry on the SAME session_id gets a 409 CONFLICT rather than interleaving
3289
3414
  // with the subprocess that is still processing this turn. This lock is
3290
- // intentionally held for the whole soft→hard window (see API.md §409).
3415
+ // intentionally held for the whole soft→hard window (see API.md §409)
3416
+ // resuming the turn goes through …/stream, which is never a conflict.
3291
3417
  hardCapTimer = setTimeout(() => {
3418
+ // Genuinely end the turn rather than just unsubscribing from it. The old
3419
+ // cleanup() only did `session.off('output')`, so past the cap the
3420
+ // subprocess kept running and kept burning tokens while its eventual
3421
+ // `result` line was parsed by nobody — the worst of both worlds, and the
3422
+ // reason the `⚠️` row this writes could contradict a turn that was still
3423
+ // alive. After the interrupt the row is simply true.
3424
+ //
3425
+ // ORDER MATTERS: interrupt() gates on `_processing` and no-ops once it
3426
+ // is false, so it must come BEFORE setProcessing(false).
3427
+ session.interrupt();
3292
3428
  session.setProcessing(false);
3293
- fail(Object.assign(new Error('Agent response timed out.'), { code: 'TIMEOUT' }));
3429
+ // Whatever the turn did stream before the cap is real work the client
3430
+ // may already have rendered; persist it alongside the notice instead of
3431
+ // replacing it, exactly as the /stop path does.
3432
+ fail(Object.assign(new Error('Agent response timed out.'), { code: 'TIMEOUT' }), buffer.join(''));
3294
3433
  }, API_TIMEOUT_HARD_CAP_EXTRA_MS);
3295
3434
  }, opts.timeoutMs);
3296
3435
  session.on('output', onOutput);
@@ -3342,6 +3481,53 @@ class AgentRunner extends events_1.EventEmitter {
3342
3481
  hasActiveApiSession(sessionId) {
3343
3482
  return this.pendingApiSessions.has(sessionId);
3344
3483
  }
3484
+ /**
3485
+ * Attach a sink to a session's current turn stream (#421) — the server half of
3486
+ * "resume a turn on a new connection".
3487
+ *
3488
+ * Replays every buffered event after `afterSeq` and then, if the turn is still
3489
+ * running, installs `sink` as the live one. Both happen in one synchronous
3490
+ * block inside TurnStream.attach, so nothing can slip through the seam.
3491
+ *
3492
+ * Returns a `detach` handler on success, or a reason the attach could not be
3493
+ * honoured:
3494
+ * - `gone` — no record: the turn never existed, or its replay grace
3495
+ * window has expired. The client should read history instead.
3496
+ * - `mismatch` — a record exists but for a different request_id, i.e. the
3497
+ * turn the client asked for is already over.
3498
+ * - `truncated` — a non-zero cursor sits inside a region the bounded buffer
3499
+ * has already evicted, so a gapless replay is impossible. A
3500
+ * cursor-less attach is never truncated: it has no seam to
3501
+ * keep, and takes whatever tail is still buffered.
3502
+ *
3503
+ * Looks only under the `api` namespace: this is the API resume endpoint, and a
3504
+ * channel turn on the same session id is a different turn (see turnStreamKey).
3505
+ */
3506
+ attachTurnStream(sessionId, sink, opts = {}) {
3507
+ const turn = this.turnStreams.get((0, turn_stream_1.turnStreamKey)('api', sessionId));
3508
+ if (!turn)
3509
+ return { ok: false, reason: 'gone' };
3510
+ if (opts.requestId && opts.requestId !== turn.requestId)
3511
+ return { ok: false, reason: 'mismatch' };
3512
+ const failure = turn.attach(sink, opts.afterSeq ?? 0);
3513
+ if (failure)
3514
+ return { ok: false, reason: failure };
3515
+ return { ok: true, requestId: turn.requestId, detach: () => turn.detach(sink) };
3516
+ }
3517
+ /**
3518
+ * Identity and wall-clock start of the session's current API turn, or undefined
3519
+ * when there is no resumable record.
3520
+ *
3521
+ * The resume endpoint needs both *before* it attaches, because attach() replays
3522
+ * synchronously into a sink built from them: `startedAt` so a replayed terminal
3523
+ * frame reports `duration_ms` for the turn rather than for the reconnect, and
3524
+ * `requestId` so a client that reconnected without one still learns the token it
3525
+ * needs to resume again from a cursor.
3526
+ */
3527
+ turnStreamInfo(sessionId) {
3528
+ const turn = this.turnStreams.get((0, turn_stream_1.turnStreamKey)('api', sessionId));
3529
+ return turn ? { requestId: turn.requestId, startedAt: turn.startedAt } : undefined;
3530
+ }
3345
3531
  /**
3346
3532
  * Register file paths as attachments for the current API session turn.
3347
3533
  * Called by the api_reply MCP tool via the attachments endpoint.
@@ -3362,13 +3548,19 @@ class AgentRunner extends events_1.EventEmitter {
3362
3548
  * assistant row so history never ends on a dangling user message.
3363
3549
  * System-initiated turns (skipUserMessage) have nothing dangling to close —
3364
3550
  * only the buffer is drained.
3551
+ *
3552
+ * `partialText` is whatever the turn already streamed before it died. It is
3553
+ * prepended rather than discarded, so the single row this writes carries both
3554
+ * the work the client saw and the notice — never a failure row that erases a
3555
+ * half-finished reply, and never a failure row *plus* a separate reply row
3556
+ * (the callers' `settled` guard makes done() and fail() mutually exclusive).
3365
3557
  */
3366
- persistFailedApiTurn(chatId, sessionId, err, skipUserMessage) {
3558
+ persistFailedApiTurn(chatId, sessionId, err, skipUserMessage, partialText) {
3367
3559
  const attachments = this.popApiAttachments(sessionId);
3368
3560
  if (skipUserMessage)
3369
3561
  return;
3370
3562
  const failTs = Date.now();
3371
- const content = `\u26a0\ufe0f ${err.message}`;
3563
+ const content = [partialText?.trim(), `\u26a0\ufe0f ${err.message}`].filter(Boolean).join('\n\n');
3372
3564
  this.sessionStore
3373
3565
  .appendMessage(this.agentConfig.id, sessionId, { role: 'assistant', content, ts: failTs })
3374
3566
  .catch(() => { });
@@ -3694,6 +3886,14 @@ class AgentRunner extends events_1.EventEmitter {
3694
3886
  async listApiSessions(chatId) {
3695
3887
  return this.sessionStore.listSessions(this.agentConfig.id, chatId, 'api');
3696
3888
  }
3889
+ /**
3890
+ * Does `sessionId` already exist under `chatId`? The API layer calls this before
3891
+ * honouring a client-supplied `session_id`, so an unknown id is rejected instead
3892
+ * of silently becoming a brand-new session. Read-only — it never registers.
3893
+ */
3894
+ async apiSessionExists(chatId, sessionId) {
3895
+ return this.sessionStore.apiSessionExists(this.agentConfig.id, chatId, sessionId);
3896
+ }
3697
3897
  async createApiSession(chatId, prompt, name) {
3698
3898
  const sessionName = name ?? (prompt
3699
3899
  ? (prompt.length > 60 ? `${prompt.slice(0, 60)}...` : prompt)
@@ -3763,7 +3963,10 @@ class AgentRunner extends events_1.EventEmitter {
3763
3963
  /**
3764
3964
  * Send a message into an existing channel session (cross-channel continuation from UI).
3765
3965
  * The session process receives full history context from the session JSON (Layer 1).
3766
- * The reply is streamed back via SSE callbacks and persisted to history DB.
3966
+ * The reply is streamed back via SSE callbacks; persistence is *not* this
3967
+ * method's job. A channel session already has two session-bound writers — the
3968
+ * long-lived output handler installed in spawnSession, and SessionProcess's own
3969
+ * parser — and writing here as well produced a second row per turn. See done().
3767
3970
  */
3768
3971
  async sendMessageToSession(rawChatId, channel, sessionId, message, senderName, callbacks, opts) {
3769
3972
  // Ensure the session process uses the correct channel source
@@ -3788,38 +3991,54 @@ class AgentRunner extends events_1.EventEmitter {
3788
3991
  });
3789
3992
  const buffer = [];
3790
3993
  let settled = false;
3791
- let clientGone = false;
3792
3994
  let lastPartialText = '';
3995
+ // Same event plumbing as the API path (#421) — one mechanism, not a
3996
+ // per-producer copy. The key is namespaced per producer (turnStreamKey), so
3997
+ // a channel turn and an API turn on one session id cannot displace each
3998
+ // other's record. Re-attaching is *not* part of the deal here: the resume
3999
+ // endpoint looks under `api` only, so this record exists to drive the live
4000
+ // sink and is released the moment the turn ends (see done()/fail()).
4001
+ const turn = this.turnStreams.start((0, turn_stream_1.turnStreamKey)(channel, sessionId), opts.requestId ?? (0, crypto_1.randomUUID)());
4002
+ const initialSink = (0, turn_stream_1.callbackSink)(callbacks);
4003
+ turn.attach(initialSink, 0);
3793
4004
  const toolBlocks = new Map();
3794
4005
  const done = (result) => {
3795
4006
  if (settled)
3796
4007
  return;
3797
4008
  settled = true;
3798
4009
  cleanup();
3799
- if (result.trim()) {
3800
- const uiAssistantTs = Date.now();
3801
- this.sessionStore.appendTelegramMessage(this.agentConfig.id, rawChatId, sessionId, {
3802
- role: 'assistant',
3803
- content: result.trim(),
3804
- ts: uiAssistantTs,
3805
- }, channel).catch(() => { });
3806
- this.historyDb.insertMessage({
3807
- chatId: `${channel}-${rawChatId}`,
3808
- sessionId,
3809
- source: channel,
3810
- role: 'assistant',
3811
- content: result.trim(),
3812
- ts: uiAssistantTs,
3813
- });
3814
- }
3815
- callbacks.onDone(result.trim());
4010
+ // Deliberately does NOT persist the reply. This turn targets a *channel*
4011
+ // session, and channel sessions already have two writers that are bound to
4012
+ // the session rather than to this turn:
4013
+ // • history DB — the long-lived proc.on('output') handler installed in
4014
+ // spawnSession, which writes either the reply-tool text or the result
4015
+ // text (and deliberately suppresses socket errors, request_too_large
4016
+ // and corrupted-thinking output).
4017
+ // • session JSON — SessionProcess's own parser, which appends for every
4018
+ // `source !== 'api'` session.
4019
+ // Writing here as well produced two rows per cross-channel turn, and after
4020
+ // a reply-tool turn the second row held the model's trailing `result`
4021
+ // narration — text the user never received on the channel. The API path
4022
+ // (sendApiMessage*) still persists its own reply: there neither writer
4023
+ // exists, because `source === 'api'` disables SessionProcess's append and
4024
+ // API sessions get no channel output handler.
4025
+ //
4026
+ // No-op once the soft timeout has answered: TurnStream.complete() keeps
4027
+ // its first terminal frame.
4028
+ //
4029
+ // Released rather than kept for the replay grace window: this turn is filed
4030
+ // under the channel namespace and attachTurnStream() only looks under `api`,
4031
+ // so nothing can re-attach to it. See API.md — the live view is documented
4032
+ // as non-resumable, and holding its buffer for two minutes would cost
4033
+ // memory no client can spend.
4034
+ this.turnStreams.completeAndRelease(turn, (0, turn_stream_1.resultEvent)(result.trim(), []));
3816
4035
  };
3817
4036
  const fail = (err) => {
3818
4037
  if (settled)
3819
4038
  return;
3820
4039
  settled = true;
3821
4040
  cleanup();
3822
- callbacks.onError(err);
4041
+ this.turnStreams.completeAndRelease(turn, (0, turn_stream_1.errorEvent)(err), err);
3823
4042
  };
3824
4043
  let globalTimer;
3825
4044
  const cleanup = () => {
@@ -3827,7 +4046,7 @@ class AgentRunner extends events_1.EventEmitter {
3827
4046
  clearTimeout(globalTimer);
3828
4047
  session.off('output', onOutput);
3829
4048
  };
3830
- const onClientDisconnect = () => { clientGone = true; };
4049
+ const onClientDisconnect = () => { turn.detach(initialSink); };
3831
4050
  const onOutput = (line) => {
3832
4051
  try {
3833
4052
  const obj = JSON.parse(line);
@@ -3840,15 +4059,13 @@ class AgentRunner extends events_1.EventEmitter {
3840
4059
  fullText += block.text;
3841
4060
  // PTY mode (headless=false) emits tool_use blocks inside assistant messages
3842
4061
  if (block.type === 'tool_use' && block.name && block.id) {
3843
- if (!clientGone)
3844
- callbacks.onChunk({ type: 'tool_use', name: block.name, id: block.id, input: block.input });
4062
+ turn.emit({ type: 'tool_use', name: block.name, id: block.id, input: block.input });
3845
4063
  }
3846
4064
  }
3847
4065
  if (fullText.length > lastPartialText.length) {
3848
4066
  const delta = fullText.slice(lastPartialText.length);
3849
4067
  buffer.push(delta);
3850
- if (!clientGone)
3851
- callbacks.onChunk({ type: 'text_delta', text: delta });
4068
+ turn.emit({ type: 'text_delta', text: delta });
3852
4069
  }
3853
4070
  lastPartialText = fullText;
3854
4071
  }
@@ -3857,17 +4074,15 @@ class AgentRunner extends events_1.EventEmitter {
3857
4074
  const text = obj['text'] ?? '';
3858
4075
  if (text) {
3859
4076
  buffer.push(text);
3860
- if (!clientGone)
3861
- callbacks.onChunk({ type: 'text_delta', text });
4077
+ turn.emit({ type: 'text_delta', text });
3862
4078
  }
3863
4079
  }
3864
4080
  // stream_event from --output-format stream-json (tool_use + text_delta)
3865
- this._applyStreamEvent(obj, toolBlocks, clientGone ? () => { } : callbacks.onChunk, (text) => {
4081
+ this._applyStreamEvent(obj, toolBlocks, (event) => turn.emit(event), (text) => {
3866
4082
  buffer.push(text);
3867
4083
  // Update lastPartialText so the final 'assistant' message won't re-send the full text
3868
4084
  lastPartialText += text;
3869
- if (!clientGone)
3870
- callbacks.onChunk({ type: 'text_delta', text });
4085
+ turn.emit({ type: 'text_delta', text });
3871
4086
  });
3872
4087
  if (obj['type'] === 'result') {
3873
4088
  session.setProcessing(false);
@@ -3880,7 +4095,17 @@ class AgentRunner extends events_1.EventEmitter {
3880
4095
  };
3881
4096
  globalTimer = setTimeout(() => {
3882
4097
  session.setProcessing(false);
3883
- fail(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT' }));
4098
+ // TIMEOUT_SOFT, not TIMEOUT: this path has no hard cap and no resume
4099
+ // endpoint, so the turn keeps running with nobody listening on the wire
4100
+ // and its result lands in history alone. The API hard cap interrupts the
4101
+ // turn and means the opposite — see the code vocabulary on StreamEvent's
4102
+ // `error`.
4103
+ //
4104
+ // Detaching this turn's listener here costs nothing: this producer no
4105
+ // longer persists anything (see done()). The session's own long-lived
4106
+ // output handler and SessionProcess own both storage layers and are not
4107
+ // bound to this turn, so the late answer still lands in history.
4108
+ fail(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT_SOFT' }));
3884
4109
  }, opts.timeoutMs);
3885
4110
  session.on('output', onOutput);
3886
4111
  const channelXml = `<channel source="ui" chat_id="${AgentRunner.escapeXmlAttr(rawChatId)}" session_id="${AgentRunner.escapeXmlAttr(sessionId)}" ` +