@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.
- package/README.md +7 -2
- package/config.template.json +4 -0
- package/dist/agent/dreaming/config.d.ts +6 -0
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +17 -24
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +7 -13
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/runner.d.ts +87 -13
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +342 -117
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/turn-stream.d.ts +202 -0
- package/dist/agent/turn-stream.d.ts.map +1 -0
- package/dist/agent/turn-stream.js +322 -0
- package/dist/agent/turn-stream.js.map +1 -0
- package/dist/api/apps-router.d.ts.map +1 -1
- package/dist/api/apps-router.js +14 -2
- package/dist/api/apps-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +2 -3
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/line-webhook-router.d.ts +43 -1
- package/dist/api/line-webhook-router.d.ts.map +1 -1
- package/dist/api/line-webhook-router.js +286 -38
- package/dist/api/line-webhook-router.js.map +1 -1
- package/dist/api/router.d.ts +7 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +328 -114
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +4 -1
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/apps/installer.d.ts +75 -2
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +198 -13
- package/dist/apps/installer.js.map +1 -1
- package/dist/config/agent-env.d.ts +41 -0
- package/dist/config/agent-env.d.ts.map +1 -0
- package/dist/config/agent-env.js +154 -0
- package/dist/config/agent-env.js.map +1 -0
- package/dist/config/loader.d.ts +33 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +33 -9
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +17 -1
- package/dist/config/watcher.js.map +1 -1
- package/dist/index.js +24 -51
- package/dist/index.js.map +1 -1
- package/dist/load-dotenv.d.ts +17 -0
- package/dist/load-dotenv.d.ts.map +1 -1
- package/dist/load-dotenv.js +32 -9
- package/dist/load-dotenv.js.map +1 -1
- package/dist/session/store.d.ts +9 -0
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +12 -0
- package/dist/session/store.js.map +1 -1
- package/dist/shell/bypass-dialog.d.ts +142 -0
- package/dist/shell/bypass-dialog.d.ts.map +1 -0
- package/dist/shell/bypass-dialog.js +207 -0
- package/dist/shell/bypass-dialog.js.map +1 -0
- package/dist/shell/claude-pty-shell.js +42 -3
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/screen.d.ts +7 -0
- package/dist/shell/screen.d.ts.map +1 -1
- package/dist/shell/screen.js +10 -1
- package/dist/shell/screen.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/config-num.d.ts +22 -0
- package/dist/utils/config-num.d.ts.map +1 -0
- package/dist/utils/config-num.js +34 -0
- package/dist/utils/config-num.js.map +1 -0
- package/package.json +1 -1
package/dist/agent/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1701
|
-
|
|
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:
|
|
1816
|
+
content: persistableText,
|
|
1710
1817
|
ts: Date.now(),
|
|
1711
1818
|
});
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
channelSrcForResult !== '
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
3096
|
-
//
|
|
3097
|
-
//
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3194
|
-
//
|
|
3195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3276
|
-
//
|
|
3277
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
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
|
-
|
|
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 = () => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)}" ` +
|