@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41
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/dist/claude/executor.d.ts +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +103 -1
- package/dist/claude/native-bridge.js +445 -30
- package/dist/claude/native-hook-main.js +81 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +178 -26
- package/dist/claude/native-integration.js +1528 -170
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +158 -47
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +92 -3
- package/dist/codex-app-server/forwarder.js +532 -57
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +206 -36
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/protocol.d.ts +64 -7
- package/dist/codex-app-server/ws-channel.d.ts +7 -0
- package/dist/codex-app-server/ws-channel.js +104 -28
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +323 -18
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +103 -46
- package/dist/host.js +1988 -634
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +94 -6
- package/dist/runner/child.d.ts +97 -28
- package/dist/runner/child.js +1486 -100
- package/dist/runner/manager.d.ts +110 -29
- package/dist/runner/manager.js +1481 -246
- package/dist/runner/protocol.d.ts +212 -24
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/runner/transport.d.ts +18 -2
- package/dist/runner/transport.js +82 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +44 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +126 -74
- package/dist/terminal/tmux.js +807 -211
- package/package.json +4 -4
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* the Claude Code TUI feeds through its hooks + transcript:
|
|
6
6
|
*
|
|
7
7
|
* - `hooks.jsonl` (bridge) — `SessionStart` reveals the transcript path + claude
|
|
8
|
-
* session id
|
|
8
|
+
* session id; `StopFailure` supplies the failure edge and `Stop` carries the
|
|
9
|
+
* legacy fallback boundary.
|
|
9
10
|
* - the transcript JSONL — the turn OPENS on a `role:user` prompt record and its
|
|
10
|
-
* assistant/tool records become {@link AgentEvent}s.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* assistant/tool records become {@link AgentEvent}s.
|
|
12
|
+
* - Claude's per-process session file — while readable, it is the source of
|
|
13
|
+
* truth for running/idle and supersedes the hook/pane heuristics.
|
|
13
14
|
*
|
|
14
15
|
* The mapped events drive the SAME per-turn {@link SessionNormalizer} sink shape
|
|
15
16
|
* the codex forwarder uses (see the host), so mirroring is identical downstream.
|
|
@@ -17,15 +18,29 @@
|
|
|
17
18
|
* backstop — but only when no tool call is still open, so a long Bash never trips
|
|
18
19
|
* a false turn-end.
|
|
19
20
|
*/
|
|
20
|
-
import { statSync } from "node:fs";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
21
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { parseTerminalCommandFragments, parseTranscriptRecord, subagentTranscriptPath, transcriptHasForkedFrom, transcriptHasRecentLocalCommand, } from "./transcript.js";
|
|
24
|
+
import { HOOKS_FILE, jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeState, readClaudeStatus, readCompactionForwardState, readDeltaForwardState, readForwardState, readHookForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlEntriesFrom, readMessageDeltasFrom, readSubagentForwardStates, readSubagentParentResponses, resetForwardState, resetSubagentForwardStates, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, writeDeltaForwardState, writeCompactionForwardState, writeHookForwardState, writeSubagentForwardStates, } from "./native-bridge.js";
|
|
23
25
|
import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
|
|
26
|
+
import { ClaudeSessionStatusPoller, } from "./session-status.js";
|
|
27
|
+
function cloneTerminalCommand(command) {
|
|
28
|
+
return command
|
|
29
|
+
? { command: command.command, ...(command.turnId ? { turnId: command.turnId } : {}) }
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
24
32
|
const MAX_SETTLED_INTERACTIONS = 512;
|
|
25
33
|
const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
|
|
26
34
|
const MAX_SUBMISSION_OBSERVATIONS = 64;
|
|
27
35
|
const INTERACTION_ACK_TIMEOUT_MS = 5_000;
|
|
36
|
+
// Claude writes this synthetic user record after Escape (including a tool-use
|
|
37
|
+
// interruption). It is lifecycle, not a new prompt. Kept aligned with
|
|
38
|
+
// reference implementation's `_CLAUDE_INTERRUPT_RECORD_RE`.
|
|
39
|
+
const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
|
|
28
40
|
const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
|
|
41
|
+
const FORWARDER_RESTART_INITIAL_MS = 1_000;
|
|
42
|
+
const FORWARDER_RESTART_MAX_MS = 30_000;
|
|
43
|
+
const FORWARDER_HEALTHY_UPTIME_MS = 60_000;
|
|
29
44
|
function processIsAlive(pid) {
|
|
30
45
|
try {
|
|
31
46
|
process.kill(pid, 0);
|
|
@@ -47,6 +62,76 @@ function userStringContent(rec) {
|
|
|
47
62
|
return undefined; // tool_result records are arrays
|
|
48
63
|
return content.trim() || undefined;
|
|
49
64
|
}
|
|
65
|
+
function queuedCommandPrompt(rec) {
|
|
66
|
+
if (rec.type !== "attachment" || !isRecord(rec.attachment))
|
|
67
|
+
return undefined;
|
|
68
|
+
if (rec.attachment.type !== "queued_command" ||
|
|
69
|
+
rec.attachment.commandMode !== "prompt")
|
|
70
|
+
return undefined;
|
|
71
|
+
return typeof rec.attachment.prompt === "string" && rec.attachment.prompt
|
|
72
|
+
? rec.attachment.prompt
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
function topLevelLocalCommand(rec) {
|
|
76
|
+
if (rec.subtype !== "local_command" || typeof rec.content !== "string")
|
|
77
|
+
return undefined;
|
|
78
|
+
return parseTerminalCommandFragments(rec.content);
|
|
79
|
+
}
|
|
80
|
+
function transcriptLocalCommand(rec) {
|
|
81
|
+
const stringContent = userStringContent(rec);
|
|
82
|
+
return topLevelLocalCommand(rec) ??
|
|
83
|
+
(stringContent === undefined ? undefined : parseTerminalCommandFragments(stringContent));
|
|
84
|
+
}
|
|
85
|
+
function terminalCommandData(fragments, priorCommand) {
|
|
86
|
+
return {
|
|
87
|
+
command: fragments.command ?? priorCommand ?? "",
|
|
88
|
+
...(fragments.stdout !== undefined ? { stdout: fragments.stdout } : {}),
|
|
89
|
+
...(fragments.stderr !== undefined ? { stderr: fragments.stderr } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const CLI_SCAFFOLDING_MARKERS = [
|
|
93
|
+
"<command-message>",
|
|
94
|
+
"<command-args>",
|
|
95
|
+
"<local-command-caveat>",
|
|
96
|
+
"<local-command-stdout>",
|
|
97
|
+
"<local-command-stderr>",
|
|
98
|
+
];
|
|
99
|
+
function isTaskNotificationText(text) {
|
|
100
|
+
const stripped = text.trimStart();
|
|
101
|
+
return stripped.startsWith("<task-notification>") &&
|
|
102
|
+
stripped.includes("<task-id>") &&
|
|
103
|
+
stripped.includes("</task-notification>");
|
|
104
|
+
}
|
|
105
|
+
function isClaudeUserScaffolding(text) {
|
|
106
|
+
const stripped = text.trimStart();
|
|
107
|
+
return stripped.includes("<command-name>") ||
|
|
108
|
+
CLI_SCAFFOLDING_MARKERS.some((marker) => stripped.startsWith(marker));
|
|
109
|
+
}
|
|
110
|
+
function userListTextMessages(rec) {
|
|
111
|
+
if (rec.type !== "user" || rec.message?.role !== "user")
|
|
112
|
+
return [];
|
|
113
|
+
const content = rec.message.content;
|
|
114
|
+
if (!Array.isArray(content))
|
|
115
|
+
return [];
|
|
116
|
+
const userTexts = [];
|
|
117
|
+
const messages = [];
|
|
118
|
+
for (const block of content) {
|
|
119
|
+
if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string")
|
|
120
|
+
continue;
|
|
121
|
+
if (!block.text || isClaudeUserScaffolding(block.text))
|
|
122
|
+
continue;
|
|
123
|
+
if (isTaskNotificationText(block.text)) {
|
|
124
|
+
messages.push({ text: block.text, isMeta: true });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
userTexts.push(block.text);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (userTexts.length > 0) {
|
|
131
|
+
messages.unshift({ text: userTexts.join(""), isMeta: false });
|
|
132
|
+
}
|
|
133
|
+
return messages;
|
|
134
|
+
}
|
|
50
135
|
function submissionText(value) {
|
|
51
136
|
if (typeof value !== "string")
|
|
52
137
|
return undefined;
|
|
@@ -74,15 +159,18 @@ function isRecord(value) {
|
|
|
74
159
|
function readId(value) {
|
|
75
160
|
return isRecord(value) && typeof value.id === "string" && value.id ? value.id : undefined;
|
|
76
161
|
}
|
|
77
|
-
|
|
78
|
-
function fileSize(path) {
|
|
162
|
+
function fileByteSize(path) {
|
|
79
163
|
try {
|
|
80
164
|
return statSync(path).size;
|
|
81
165
|
}
|
|
82
166
|
catch {
|
|
83
|
-
return
|
|
167
|
+
return undefined;
|
|
84
168
|
}
|
|
85
169
|
}
|
|
170
|
+
/** Byte size of a file, or 0 if it can't be stat'd (used to start a fork tail at EOF). */
|
|
171
|
+
function fileSize(path) {
|
|
172
|
+
return fileByteSize(path) ?? 0;
|
|
173
|
+
}
|
|
86
174
|
function readStr(value) {
|
|
87
175
|
if (typeof value === "string" && value)
|
|
88
176
|
return value;
|
|
@@ -120,17 +208,33 @@ function readTaskUpdates(rec) {
|
|
|
120
208
|
function normalizeTodoStatus(status) {
|
|
121
209
|
return status === "pending" || status === "in_progress" || status === "completed" ? status : undefined;
|
|
122
210
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
211
|
+
function readNativeSubagentMeta(path) {
|
|
212
|
+
let value;
|
|
213
|
+
try {
|
|
214
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
127
217
|
return undefined;
|
|
128
|
-
for (const block of content) {
|
|
129
|
-
if (isRecord(block) && block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
130
|
-
return block.tool_use_id;
|
|
131
|
-
}
|
|
132
218
|
}
|
|
133
|
-
|
|
219
|
+
if (!isRecord(value))
|
|
220
|
+
return undefined;
|
|
221
|
+
const agentType = typeof value.agentType === "string" && value.agentType
|
|
222
|
+
? value.agentType
|
|
223
|
+
: undefined;
|
|
224
|
+
const description = typeof value.description === "string" ? value.description : undefined;
|
|
225
|
+
const toolUseId = typeof value.toolUseId === "string" && value.toolUseId
|
|
226
|
+
? value.toolUseId
|
|
227
|
+
: undefined;
|
|
228
|
+
return agentType && description !== undefined && toolUseId
|
|
229
|
+
? { agentType, description, toolUseId }
|
|
230
|
+
: undefined;
|
|
231
|
+
}
|
|
232
|
+
function isSubagentTranscriptPath(path) {
|
|
233
|
+
return Boolean(path && /(?:^|[\\/])subagents(?:[\\/]|$)/u.test(path));
|
|
234
|
+
}
|
|
235
|
+
function transcriptSourceBase(rec, byteOffset, lineNumber) {
|
|
236
|
+
const stableId = rec.uuid || rec.requestId;
|
|
237
|
+
return stableId || `byte-${byteOffset}` || `line-${lineNumber}`;
|
|
134
238
|
}
|
|
135
239
|
export class ClaudeLiveSession {
|
|
136
240
|
bridgeDir;
|
|
@@ -143,10 +247,28 @@ export class ClaudeLiveSession {
|
|
|
143
247
|
leaseUpdatedAt;
|
|
144
248
|
started = false;
|
|
145
249
|
stopped = false;
|
|
250
|
+
supervisorTask;
|
|
251
|
+
releaseSleep;
|
|
146
252
|
hooksOffset = 0;
|
|
253
|
+
hookEventCursor = 0;
|
|
254
|
+
/** Rotation publication is a checkpoint barrier. No source is polled under
|
|
255
|
+
* either logical Session until the daemon confirms the new binding. */
|
|
256
|
+
rotationPending = false;
|
|
257
|
+
/** A non-rotation async hook (currently compaction completion) holds only the
|
|
258
|
+
* hook cursor and parent transcript. Native sub-agents, deltas, interactions,
|
|
259
|
+
* and live status continue to converge while its delivery retries. */
|
|
260
|
+
compactionHookPending = false;
|
|
261
|
+
pendingCompactionHook;
|
|
147
262
|
interactionsOffset = 0;
|
|
148
263
|
interactionAcksOffset = 0;
|
|
149
264
|
transcriptOffset = 0;
|
|
265
|
+
committedTranscriptOffset = 0;
|
|
266
|
+
transcriptLineCursor = 0;
|
|
267
|
+
committedTranscriptLineCursor = 0;
|
|
268
|
+
transcriptDeliveryPending = false;
|
|
269
|
+
/** A transcript isCompactSummary boundary is currently in its durable lane.
|
|
270
|
+
* The completion-hook fallback must wait so the real summary remains primary. */
|
|
271
|
+
transcriptCompactionPending = false;
|
|
150
272
|
/** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
|
|
151
273
|
* (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
|
|
152
274
|
* forwarder state; bounded there. */
|
|
@@ -158,17 +280,40 @@ export class ClaudeLiveSession {
|
|
|
158
280
|
/** Persisted native id requested through `claude --resume`. */
|
|
159
281
|
expectedClaudeSessionId;
|
|
160
282
|
resumeAtEndOnDiscovery;
|
|
283
|
+
initialTranscriptOffsetOnDiscovery;
|
|
161
284
|
/** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
|
|
162
285
|
* marker signals a `/fork` (vs. resuming a known branch). */
|
|
163
286
|
seenClaudeSessionIds = new Set();
|
|
164
287
|
turnOpen = false;
|
|
165
288
|
currentTurnId;
|
|
289
|
+
/** Input half of a split Claude `!cmd`, plus the turn id its later output
|
|
290
|
+
* must reuse. Kept across a terminal Stop edge like the current terminal
|
|
291
|
+
* response id. */
|
|
292
|
+
activeTerminalCommand;
|
|
293
|
+
/** Split-command state at the latest individually handled source boundary.
|
|
294
|
+
* Parsing may run ahead through later records, so durable state must not use
|
|
295
|
+
* the mutable live value until those later items are ACKed too. */
|
|
296
|
+
committedTerminalCommand;
|
|
297
|
+
/** The open turn received an explicit Escape/Stop and must close cancelled. */
|
|
298
|
+
turnInterrupted = false;
|
|
166
299
|
/** The Turn opened from a pre-transcript interaction. Its unique provisional
|
|
167
300
|
* id keeps responses distinct until the real prompt uuid can be adopted. */
|
|
168
301
|
syntheticTurn = false;
|
|
169
302
|
lastActivityAt = 0;
|
|
170
303
|
/** When the Stop hook fired (null = not yet) — drives the grace-period close. */
|
|
171
304
|
stopPendingAt = null;
|
|
305
|
+
/** Authoritative live background-shell count from the pending Stop hook. */
|
|
306
|
+
stopBackgroundTaskCount;
|
|
307
|
+
/** Whether the pending Stop count already rode an immediate idle edge. */
|
|
308
|
+
stopBackgroundTaskCountDelivered = false;
|
|
309
|
+
/** An idle status from Claude's session metadata. The short delay keeps a
|
|
310
|
+
* final transcript record in the response without letting stale tool state
|
|
311
|
+
* override the provider's terminal status. */
|
|
312
|
+
providerIdleAt = null;
|
|
313
|
+
statusPoller;
|
|
314
|
+
/** A failed hook is authoritative until the next running edge. Claude writes
|
|
315
|
+
* idle after failures too, so that trailing file update must not erase it. */
|
|
316
|
+
providerFailureSticky = false;
|
|
172
317
|
/** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
|
|
173
318
|
openToolIds = new Set();
|
|
174
319
|
/** Transcript-backed acknowledgements for web→TUI input. A direct idle
|
|
@@ -197,8 +342,17 @@ export class ClaudeLiveSession {
|
|
|
197
342
|
* append its request immediately before the failure hook lands; closing here
|
|
198
343
|
* would let the later interaction poll reopen an already-failed Turn. */
|
|
199
344
|
turnFailurePending = null;
|
|
200
|
-
/**
|
|
201
|
-
|
|
345
|
+
/** Stop/StopFailure owns the hook cursor until its terminal status reaches a
|
|
346
|
+
* handled delivery outcome. This preserves replay on runner restart. */
|
|
347
|
+
pendingTerminalHook;
|
|
348
|
+
terminalHookDelivery;
|
|
349
|
+
pendingTranscriptCompactions = [];
|
|
350
|
+
/** Claude Code native Task/Agent sidechains. Each child owns an independent
|
|
351
|
+
* cursor/retry lane so one unavailable delivery cannot block its siblings or
|
|
352
|
+
* the parent transcript. */
|
|
353
|
+
nativeSubagents = new Map();
|
|
354
|
+
nativeSubagentParentResponses = new Map();
|
|
355
|
+
subagentStateParentPath;
|
|
202
356
|
/** The agent's task list, keyed by task id in creation order (claude
|
|
203
357
|
* `TaskCreate`/`TaskUpdate`). Emitted whole as a snapshot on any change. */
|
|
204
358
|
todos = new Map();
|
|
@@ -206,6 +360,7 @@ export class ClaudeLiveSession {
|
|
|
206
360
|
* a turn's usage on close. undefined until the statusLine hook first fires. */
|
|
207
361
|
latestStatus;
|
|
208
362
|
deltasOffset = 0;
|
|
363
|
+
seenDeltaKeys = new Set();
|
|
209
364
|
/** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
|
|
210
365
|
* transcript's assistant-text records so a streamed message and its final
|
|
211
366
|
* item share an itemId (message_id is absent from the transcript). */
|
|
@@ -228,7 +383,20 @@ export class ClaudeLiveSession {
|
|
|
228
383
|
((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
|
|
229
384
|
this.expectedClaudeSessionId = opts.claudeSessionId;
|
|
230
385
|
this.resumeAtEndOnDiscovery = opts.resumeAtEndOnDiscovery ?? false;
|
|
386
|
+
this.initialTranscriptOffsetOnDiscovery =
|
|
387
|
+
opts.initialTranscriptOffsetOnDiscovery === undefined
|
|
388
|
+
? undefined
|
|
389
|
+
: Math.max(0, Math.trunc(opts.initialTranscriptOffsetOnDiscovery));
|
|
231
390
|
this.transcriptPath = opts.transcriptPath;
|
|
391
|
+
for (const sessionId of readClaudeState(this.bridgeDir).seenClaudeSessionIds ?? []) {
|
|
392
|
+
this.seenClaudeSessionIds.add(sessionId);
|
|
393
|
+
}
|
|
394
|
+
this.deltasOffset = readDeltaForwardState(this.bridgeDir).byteOffset;
|
|
395
|
+
const hookState = readHookForwardState(this.bridgeDir);
|
|
396
|
+
if (hookState) {
|
|
397
|
+
this.hooksOffset = hookState.byteOffset;
|
|
398
|
+
this.hookEventCursor = hookState.eventCursor;
|
|
399
|
+
}
|
|
232
400
|
// Resume path: bind the session id + restore the persisted forwarder cursor so
|
|
233
401
|
// we continue from where a prior forwarder left off (no re-mirror on relaunch).
|
|
234
402
|
if (opts.transcriptPath) {
|
|
@@ -237,6 +405,7 @@ export class ClaudeLiveSession {
|
|
|
237
405
|
this.seenClaudeSessionIds.add(opts.claudeSessionId);
|
|
238
406
|
}
|
|
239
407
|
this.restoreForwardState(opts.transcriptPath);
|
|
408
|
+
this.restoreSubagentForwardStates(opts.transcriptPath);
|
|
240
409
|
}
|
|
241
410
|
}
|
|
242
411
|
/**
|
|
@@ -253,23 +422,59 @@ export class ClaudeLiveSession {
|
|
|
253
422
|
return false;
|
|
254
423
|
for (const id of state.seenSourceIds)
|
|
255
424
|
this.seenSourceIds.add(id);
|
|
425
|
+
const transcriptSize = fileByteSize(transcriptPath);
|
|
426
|
+
const transcriptMissing = transcriptSize === undefined;
|
|
256
427
|
const fingerprint = jsonlCursorFingerprint(transcriptPath, state.byteOffset);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
428
|
+
const cursorValid = fingerprint !== undefined && fingerprint === state.cursorFingerprint;
|
|
429
|
+
const adoptEmptyCursor = state.cursorFingerprint === undefined &&
|
|
430
|
+
state.byteOffset === 0 &&
|
|
431
|
+
(state.lineCursor ?? 0) === 0;
|
|
432
|
+
const restoreLogicalState = transcriptMissing || cursorValid || adoptEmptyCursor;
|
|
433
|
+
this.transcriptOffset = restoreLogicalState
|
|
434
|
+
? state.byteOffset
|
|
435
|
+
: transcriptSize ?? 0; // truncated/replaced → skip to EOF
|
|
436
|
+
this.committedTranscriptOffset = this.transcriptOffset;
|
|
437
|
+
this.transcriptLineCursor = restoreLogicalState ? state.lineCursor ?? 0 : 0;
|
|
438
|
+
this.committedTranscriptLineCursor = this.transcriptLineCursor;
|
|
439
|
+
if (restoreLogicalState && state.activeTerminalCommand) {
|
|
440
|
+
this.activeTerminalCommand = cloneTerminalCommand(state.activeTerminalCommand);
|
|
441
|
+
this.committedTerminalCommand = cloneTerminalCommand(state.activeTerminalCommand);
|
|
442
|
+
}
|
|
443
|
+
if (restoreLogicalState && state.turnOpen) {
|
|
444
|
+
this.turnOpen = true;
|
|
445
|
+
this.currentTurnId = state.currentTurnId;
|
|
446
|
+
this.sink.onTurnStart(this.currentTurnId);
|
|
447
|
+
}
|
|
448
|
+
// Seed an adopted empty cursor, or rewrite the replacement state
|
|
449
|
+
// (EOF + seen ids only), so another crash cannot resurrect stale logical
|
|
450
|
+
// response or split-command state from a replaced transcript.
|
|
451
|
+
if (!transcriptMissing && !cursorValid)
|
|
452
|
+
this.persistForwardState();
|
|
261
453
|
return true;
|
|
262
454
|
}
|
|
263
455
|
/** Persist the durable forwarder cursor (byte offset + seen ids + fingerprint). */
|
|
264
456
|
persistForwardState() {
|
|
265
457
|
if (!this.transcriptPath)
|
|
266
458
|
return;
|
|
267
|
-
const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.
|
|
459
|
+
const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.committedTranscriptOffset);
|
|
268
460
|
writeForwardState(this.bridgeDir, {
|
|
269
461
|
transcriptPath: this.transcriptPath,
|
|
270
|
-
byteOffset: this.
|
|
462
|
+
byteOffset: this.committedTranscriptOffset,
|
|
463
|
+
lineCursor: this.committedTranscriptLineCursor,
|
|
271
464
|
seenSourceIds: [...this.seenSourceIds],
|
|
272
465
|
...(fingerprint ? { cursorFingerprint: fingerprint } : {}),
|
|
466
|
+
...(this.currentTurnId ? { currentTurnId: this.currentTurnId } : {}),
|
|
467
|
+
...(this.committedTerminalCommand
|
|
468
|
+
? { activeTerminalCommand: this.committedTerminalCommand }
|
|
469
|
+
: {}),
|
|
470
|
+
...(this.turnOpen
|
|
471
|
+
? {
|
|
472
|
+
turnOpen: true,
|
|
473
|
+
currentResponseId: this.currentTurnId
|
|
474
|
+
? `resp_claude_${this.currentTurnId}`
|
|
475
|
+
: "resp_claude_native",
|
|
476
|
+
}
|
|
477
|
+
: {}),
|
|
273
478
|
});
|
|
274
479
|
}
|
|
275
480
|
/** True once SessionStart bound the transcript (or a resume path was given). */
|
|
@@ -322,12 +527,14 @@ export class ClaudeLiveSession {
|
|
|
322
527
|
if (this.started)
|
|
323
528
|
return;
|
|
324
529
|
this.started = true;
|
|
325
|
-
|
|
530
|
+
this.supervisorTask = this.supervise();
|
|
326
531
|
}
|
|
327
532
|
stop() {
|
|
328
533
|
if (this.stopped)
|
|
329
534
|
return;
|
|
330
535
|
this.stopped = true;
|
|
536
|
+
this.releaseSleep?.();
|
|
537
|
+
this.statusPoller?.retire();
|
|
331
538
|
// A hook may have appended its request just before shutdown but not yet
|
|
332
539
|
// reached a scheduled poll. Drain it so the subprocess receives cancellation
|
|
333
540
|
// instead of remaining blocked until Claude's one-day hook timeout.
|
|
@@ -339,6 +546,24 @@ export class ClaudeLiveSession {
|
|
|
339
546
|
this.closeTurn();
|
|
340
547
|
this.scheduleClaimedInteractionScrubs();
|
|
341
548
|
}
|
|
549
|
+
/** Wait for the supervised polling task to retire. Terminal teardown starts
|
|
550
|
+
* synchronously in {@link stop}; callers use this bounded join before their
|
|
551
|
+
* runner process exits. */
|
|
552
|
+
async waitForStop(timeoutMs = 5_000) {
|
|
553
|
+
const task = this.supervisorTask;
|
|
554
|
+
if (!task)
|
|
555
|
+
return;
|
|
556
|
+
let timer;
|
|
557
|
+
await Promise.race([
|
|
558
|
+
task,
|
|
559
|
+
new Promise((resolve) => {
|
|
560
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
561
|
+
timer.unref?.();
|
|
562
|
+
}),
|
|
563
|
+
]);
|
|
564
|
+
if (timer)
|
|
565
|
+
clearTimeout(timer);
|
|
566
|
+
}
|
|
342
567
|
/** Phase two of runner shutdown. The caller must stop the Claude terminal
|
|
343
568
|
* first so no hook can still be opening an atomically claimed answer. This is
|
|
344
569
|
* synchronous because the runner process exits immediately afterwards. */
|
|
@@ -350,24 +575,80 @@ export class ClaudeLiveSession {
|
|
|
350
575
|
this.claimedInteractions.clear();
|
|
351
576
|
this.scheduledClaimScrubs.clear();
|
|
352
577
|
}
|
|
353
|
-
/** One poll cycle (
|
|
578
|
+
/** One poll cycle (rotation/PreCompact prescan → transcript → hooks → live
|
|
579
|
+
* lanes → idle backstop). Exposed for tests to
|
|
354
580
|
* drive deterministically; the async {@link loop} just calls it on an interval. */
|
|
355
581
|
tick() {
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
582
|
+
// Preserve the required phase order: rotation and PreCompact are pre-scanned,
|
|
583
|
+
// transcript items (including the real compact summary) run next, then the
|
|
584
|
+
// durable hook cursor catches up. This keeps completion hooks secondary
|
|
585
|
+
// without letting an older poison hook block clear/fork or token minting.
|
|
586
|
+
const hadTranscript = this.transcriptPath !== undefined;
|
|
587
|
+
this.prescanSessionRotation();
|
|
588
|
+
if (this.rotationPending)
|
|
589
|
+
return;
|
|
590
|
+
this.prescanPrecompactEdges();
|
|
591
|
+
// The hook and transcript compaction paths share one pending token. Do not
|
|
592
|
+
// let them race the same boundary, but do keep every independent live lane
|
|
593
|
+
// moving while the hook path waits for its ACK.
|
|
594
|
+
if (!this.compactionHookPending)
|
|
595
|
+
this.pollTranscript();
|
|
360
596
|
this.pollHooks();
|
|
361
|
-
this.
|
|
597
|
+
if (this.rotationPending)
|
|
598
|
+
return;
|
|
599
|
+
// Initial SessionStart is the only time the transcript path is unavailable
|
|
600
|
+
// during the pre-items phase. Once the hook discovers it, tail it in this
|
|
601
|
+
// same tick before Stop/StopFailure is flushed below.
|
|
602
|
+
if (!hadTranscript && this.transcriptPath && !this.compactionHookPending) {
|
|
603
|
+
this.pollTranscript();
|
|
604
|
+
}
|
|
605
|
+
this.pollNativeSubagents();
|
|
362
606
|
this.pollDeltas();
|
|
363
607
|
this.pollInteractions();
|
|
364
608
|
this.drainInteractionCommits();
|
|
365
609
|
this.pollAbandonedInteractions();
|
|
366
610
|
this.flushTurnFailure();
|
|
367
611
|
this.pollStatus();
|
|
612
|
+
this.pollSessionStatus();
|
|
368
613
|
this.flushStopSignal();
|
|
369
614
|
this.maybeIdleClose();
|
|
370
615
|
}
|
|
616
|
+
/** Bind Claude's per-process session metadata after the terminal exists. */
|
|
617
|
+
attachStatusSource({ panePid, configDir, }) {
|
|
618
|
+
this.statusPoller?.retire();
|
|
619
|
+
this.statusPoller = new ClaudeSessionStatusPoller({
|
|
620
|
+
panePid,
|
|
621
|
+
sessionId: () => this.currentClaudeSessionId,
|
|
622
|
+
onStatus: (status) => this.handleSessionStatus(status),
|
|
623
|
+
...(configDir ? { configDir } : {}),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
async supervise() {
|
|
627
|
+
let backoffMs = FORWARDER_RESTART_INITIAL_MS;
|
|
628
|
+
while (!this.stopped) {
|
|
629
|
+
const startedAt = this.now();
|
|
630
|
+
let crashed;
|
|
631
|
+
try {
|
|
632
|
+
await this.loop();
|
|
633
|
+
if (!this.stopped) {
|
|
634
|
+
console.warn("[claude-forwarder] polling loop returned unexpectedly; restarting");
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
crashed = error;
|
|
639
|
+
}
|
|
640
|
+
if (this.stopped)
|
|
641
|
+
break;
|
|
642
|
+
if (this.now() - startedAt >= FORWARDER_HEALTHY_UPTIME_MS) {
|
|
643
|
+
backoffMs = FORWARDER_RESTART_INITIAL_MS;
|
|
644
|
+
}
|
|
645
|
+
if (crashed !== undefined) {
|
|
646
|
+
console.error(`[claude-forwarder] polling loop crashed; restarting in ${backoffMs}ms:`, crashed);
|
|
647
|
+
}
|
|
648
|
+
await this.sleepUntilWake(backoffMs);
|
|
649
|
+
backoffMs = Math.min(backoffMs * 2, FORWARDER_RESTART_MAX_MS);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
371
652
|
async loop() {
|
|
372
653
|
while (!this.stopped) {
|
|
373
654
|
try {
|
|
@@ -379,7 +660,7 @@ export class ClaudeLiveSession {
|
|
|
379
660
|
// per-iteration `except Exception` in the transcript forwarder loop.
|
|
380
661
|
console.error("[claude-forwarder] tick failed; continuing:", err);
|
|
381
662
|
}
|
|
382
|
-
await
|
|
663
|
+
await this.sleepUntilWake(this.pollMs);
|
|
383
664
|
}
|
|
384
665
|
// Catch a request appended while the loop was waking up to observe stopped.
|
|
385
666
|
this.pollInteractions();
|
|
@@ -388,34 +669,355 @@ export class ClaudeLiveSession {
|
|
|
388
669
|
this.closeTurn();
|
|
389
670
|
this.scheduleClaimedInteractionScrubs();
|
|
390
671
|
}
|
|
672
|
+
sleepUntilWake(delayMs) {
|
|
673
|
+
if (this.stopped)
|
|
674
|
+
return Promise.resolve();
|
|
675
|
+
return new Promise((resolve) => {
|
|
676
|
+
const timer = setTimeout(done, delayMs);
|
|
677
|
+
timer.unref?.();
|
|
678
|
+
const self = this;
|
|
679
|
+
function done() {
|
|
680
|
+
if (self.releaseSleep === done)
|
|
681
|
+
self.releaseSleep = undefined;
|
|
682
|
+
clearTimeout(timer);
|
|
683
|
+
resolve();
|
|
684
|
+
}
|
|
685
|
+
this.releaseSleep = done;
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** Rotation is a control edge, not an ordinary hook-stream item. Scan past a
|
|
689
|
+
* held Stop/compaction cursor so an old retry can never strand the physical
|
|
690
|
+
* Claude process on a new Session while Rynx still targets the old one. */
|
|
691
|
+
prescanSessionRotation() {
|
|
692
|
+
if (this.rotationPending)
|
|
693
|
+
return;
|
|
694
|
+
const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
|
|
695
|
+
const rotation = events.find((event) => this.isSessionRotationHook(event));
|
|
696
|
+
if (!rotation)
|
|
697
|
+
return;
|
|
698
|
+
// Abandon older hook deliveries. Their runner generation is cancelled by
|
|
699
|
+
// retargetMirror; the identity checks below prevent a late completion from
|
|
700
|
+
// moving the durable hook cursor backwards after the rotation commits.
|
|
701
|
+
this.pendingTerminalHook = undefined;
|
|
702
|
+
this.terminalHookDelivery = undefined;
|
|
703
|
+
this.stopSignalPending = false;
|
|
704
|
+
this.turnFailurePending = null;
|
|
705
|
+
this.pendingCompactionHook = undefined;
|
|
706
|
+
this.compactionHookPending = false;
|
|
707
|
+
const publication = this.handleSessionStart(rotation);
|
|
708
|
+
if (!publication || typeof publication.then !== "function") {
|
|
709
|
+
this.persistHookCursor(rotation);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
this.rotationPending = true;
|
|
713
|
+
void publication.then(() => this.persistHookCursor(rotation), (error) => {
|
|
714
|
+
console.error("[claude-forwarder] Session rotation publication failed; retaining hook cursor:", error);
|
|
715
|
+
}).finally(() => {
|
|
716
|
+
this.rotationPending = false;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
/** Mint compaction tokens before transcript parsing without advancing the
|
|
720
|
+
* hook cursor. The ordinary hook pass later observes the same cursor-keyed
|
|
721
|
+
* edge and converges on the already-created token. */
|
|
722
|
+
prescanPrecompactEdges() {
|
|
723
|
+
const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
|
|
724
|
+
for (const event of events) {
|
|
725
|
+
if (event.eventName === "PreCompact")
|
|
726
|
+
this.notePrecompact(event);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
391
729
|
pollHooks() {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
730
|
+
if (this.rotationPending || this.compactionHookPending || this.pendingTerminalHook)
|
|
731
|
+
return;
|
|
732
|
+
const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
|
|
733
|
+
for (const ev of events) {
|
|
734
|
+
if (this.transcriptCompactionPending &&
|
|
735
|
+
ev.eventName === "SessionStart" &&
|
|
736
|
+
ev.source === "compact") {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const terminalHook = (ev.eventName === "Stop" || ev.eventName === "StopFailure") &&
|
|
740
|
+
!isSubagentTranscriptPath(ev.transcriptPath);
|
|
741
|
+
if (terminalHook) {
|
|
742
|
+
this.pendingTerminalHook = ev;
|
|
743
|
+
this.handleHook(ev);
|
|
744
|
+
// Interactions from the same filesystem window are discovered later in
|
|
745
|
+
// this tick. flushStopSignal/flushTurnFailure owns the cursor commit.
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const handled = this.handleHook(ev);
|
|
749
|
+
if (handled && typeof handled.then === "function") {
|
|
750
|
+
const rotation = this.isSessionRotationHook(ev);
|
|
751
|
+
if (rotation)
|
|
752
|
+
this.rotationPending = true;
|
|
753
|
+
else {
|
|
754
|
+
this.compactionHookPending = true;
|
|
755
|
+
this.pendingCompactionHook = ev;
|
|
756
|
+
}
|
|
757
|
+
void handled.then(() => {
|
|
758
|
+
if (rotation || this.pendingCompactionHook === ev)
|
|
759
|
+
this.persistHookCursor(ev);
|
|
760
|
+
}, (error) => {
|
|
761
|
+
console.error("[claude-forwarder] asynchronous hook publication failed; retaining hook cursor:", error);
|
|
762
|
+
}).finally(() => {
|
|
763
|
+
if (rotation)
|
|
764
|
+
this.rotationPending = false;
|
|
765
|
+
else if (this.pendingCompactionHook === ev) {
|
|
766
|
+
this.pendingCompactionHook = undefined;
|
|
767
|
+
this.compactionHookPending = false;
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
this.persistHookCursor(ev);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
isSessionRotationHook(ev) {
|
|
776
|
+
if (ev.eventName !== "SessionStart" || !this.transcriptPath || !ev.transcriptPath) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
|
|
780
|
+
return false;
|
|
781
|
+
if (ev.source === "clear")
|
|
782
|
+
return true;
|
|
783
|
+
return this.isForkHookRecord(ev);
|
|
784
|
+
}
|
|
785
|
+
isForkHookRecord(ev) {
|
|
786
|
+
if (ev.payload.rynx_fork_detected === true &&
|
|
787
|
+
ev.eventName === "SessionStart" &&
|
|
788
|
+
ev.source === "resume")
|
|
789
|
+
return true;
|
|
790
|
+
if (ev.eventName !== "SessionStart" ||
|
|
791
|
+
ev.source !== "resume" ||
|
|
792
|
+
!ev.transcriptPath ||
|
|
793
|
+
!ev.sessionId)
|
|
794
|
+
return false;
|
|
795
|
+
const sourceSessionId = typeof ev.payload.rynx_previous_claude_session_id === "string"
|
|
796
|
+
? ev.payload.rynx_previous_claude_session_id
|
|
797
|
+
: this.currentClaudeSessionId;
|
|
798
|
+
if (!sourceSessionId || sourceSessionId === ev.sessionId)
|
|
799
|
+
return false;
|
|
800
|
+
const wasSeen = typeof ev.payload.rynx_claude_session_was_seen === "boolean"
|
|
801
|
+
? ev.payload.rynx_claude_session_was_seen
|
|
802
|
+
: this.seenClaudeSessionIds.has(ev.sessionId);
|
|
803
|
+
if (wasSeen)
|
|
804
|
+
return false;
|
|
805
|
+
return transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId, sourceSessionId) || (ev.recordedAt !== undefined &&
|
|
806
|
+
transcriptHasRecentLocalCommand(ev.transcriptPath, ev.sessionId, ev.recordedAt));
|
|
807
|
+
}
|
|
808
|
+
persistHookCursor(ev) {
|
|
809
|
+
this.hooksOffset = ev.byteOffset;
|
|
810
|
+
this.hookEventCursor = ev.eventCursor;
|
|
811
|
+
const fingerprint = jsonlCursorFingerprint(`${this.bridgeDir}/${HOOKS_FILE}`, ev.byteOffset);
|
|
812
|
+
writeHookForwardState(this.bridgeDir, {
|
|
813
|
+
eventCursor: ev.eventCursor,
|
|
814
|
+
byteOffset: ev.byteOffset,
|
|
815
|
+
...(fingerprint ? { cursorFingerprint: fingerprint } : {}),
|
|
816
|
+
});
|
|
396
817
|
}
|
|
397
818
|
handleHook(ev) {
|
|
398
819
|
if (ev.eventName === "SessionStart") {
|
|
399
|
-
this.handleSessionStart(ev);
|
|
820
|
+
return this.handleSessionStart(ev);
|
|
821
|
+
}
|
|
822
|
+
if (ev.eventName === "PreCompact") {
|
|
823
|
+
this.sink.onCompactionStatus?.("in_progress");
|
|
824
|
+
this.notePrecompact(ev);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (ev.eventName === "TaskCreated" ||
|
|
828
|
+
ev.eventName === "TaskCompleted" ||
|
|
829
|
+
ev.eventName === "PostToolUse") {
|
|
830
|
+
this.handleTodoHook(ev);
|
|
400
831
|
return;
|
|
401
832
|
}
|
|
402
833
|
if (ev.eventName === "Stop" || ev.eventName === "StopFailure") {
|
|
403
834
|
// Subagent stops (transcript_path under `subagents/`) don't end the parent turn.
|
|
404
|
-
if (ev.transcriptPath
|
|
835
|
+
if (isSubagentTranscriptPath(ev.transcriptPath))
|
|
405
836
|
return;
|
|
406
837
|
// Stop only signals idle — it does NOT close the turn (see onIdle): closing
|
|
407
838
|
// here would split a late-flushing assistant record into its own turn.
|
|
408
839
|
if (ev.eventName === "StopFailure") {
|
|
409
840
|
this.stopSignalPending = false;
|
|
410
841
|
this.stopPendingAt = null;
|
|
842
|
+
this.stopBackgroundTaskCount = undefined;
|
|
843
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
411
844
|
this.turnFailurePending = new Error("claude turn failed");
|
|
412
845
|
}
|
|
413
846
|
else {
|
|
414
847
|
this.stopSignalPending = true;
|
|
415
848
|
this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
|
|
849
|
+
this.stopBackgroundTaskCount = ev.backgroundTaskCount ?? 0;
|
|
850
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
416
851
|
}
|
|
417
852
|
}
|
|
418
853
|
}
|
|
854
|
+
handleTodoHook(ev) {
|
|
855
|
+
const payload = ev.payload;
|
|
856
|
+
if (ev.eventName === "TaskCreated") {
|
|
857
|
+
const id = readStr(payload.task_id);
|
|
858
|
+
if (!id)
|
|
859
|
+
return;
|
|
860
|
+
this.todos.set(id, {
|
|
861
|
+
id,
|
|
862
|
+
subject: readStr(payload.task_subject) ?? id,
|
|
863
|
+
status: "pending",
|
|
864
|
+
});
|
|
865
|
+
this.sink.onTodos([...this.todos.values()]);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (ev.eventName === "TaskCompleted") {
|
|
869
|
+
const id = readStr(payload.task_id);
|
|
870
|
+
const existing = id ? this.todos.get(id) : undefined;
|
|
871
|
+
if (!id || !existing)
|
|
872
|
+
return;
|
|
873
|
+
this.todos.set(id, {
|
|
874
|
+
id,
|
|
875
|
+
subject: existing.subject,
|
|
876
|
+
status: "completed",
|
|
877
|
+
});
|
|
878
|
+
this.sink.onTodos([...this.todos.values()]);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (ev.eventName !== "PostToolUse" || !isRecord(payload.tool_input))
|
|
882
|
+
return;
|
|
883
|
+
const input = payload.tool_input;
|
|
884
|
+
if (payload.tool_name === "TodoWrite" && Array.isArray(input.todos)) {
|
|
885
|
+
const snapshot = [];
|
|
886
|
+
for (const [index, raw] of input.todos.entries()) {
|
|
887
|
+
if (!isRecord(raw))
|
|
888
|
+
continue;
|
|
889
|
+
const subject = readStr(raw.content) ?? readStr(raw.subject);
|
|
890
|
+
if (!subject)
|
|
891
|
+
continue;
|
|
892
|
+
snapshot.push({
|
|
893
|
+
id: readStr(raw.id) ?? `todo-${index}`,
|
|
894
|
+
subject,
|
|
895
|
+
status: normalizeTodoStatus(readStr(raw.status)) ?? "pending",
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
this.todos.clear();
|
|
899
|
+
for (const todo of snapshot)
|
|
900
|
+
this.todos.set(todo.id, todo);
|
|
901
|
+
this.sink.onTodos(snapshot);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (payload.tool_name !== "TaskUpdate")
|
|
905
|
+
return;
|
|
906
|
+
const id = readStr(input.taskId);
|
|
907
|
+
if (!id)
|
|
908
|
+
return;
|
|
909
|
+
const existing = this.todos.get(id);
|
|
910
|
+
if (!existing)
|
|
911
|
+
return;
|
|
912
|
+
if (input.status === "deleted") {
|
|
913
|
+
if (this.todos.delete(id))
|
|
914
|
+
this.sink.onTodos([...this.todos.values()]);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
this.todos.set(id, {
|
|
918
|
+
id,
|
|
919
|
+
subject: readStr(input.subject) ?? existing.subject,
|
|
920
|
+
status: normalizeTodoStatus(readStr(input.status)) ?? existing.status,
|
|
921
|
+
});
|
|
922
|
+
this.sink.onTodos([...this.todos.values()]);
|
|
923
|
+
}
|
|
924
|
+
compactionGeneration() {
|
|
925
|
+
return this.currentClaudeSessionId ?? this.transcriptPath ?? "unbound";
|
|
926
|
+
}
|
|
927
|
+
readCompactionState() {
|
|
928
|
+
return readCompactionForwardState(this.bridgeDir, this.compactionGeneration());
|
|
929
|
+
}
|
|
930
|
+
notePrecompact(ev) {
|
|
931
|
+
const state = this.readCompactionState();
|
|
932
|
+
if (ev.eventCursor <= state.lastPrecompactCursor)
|
|
933
|
+
return;
|
|
934
|
+
const sequence = state.lastSequence + 1;
|
|
935
|
+
writeCompactionForwardState(this.bridgeDir, {
|
|
936
|
+
generation: state.generation,
|
|
937
|
+
lastSequence: sequence,
|
|
938
|
+
persistedSequences: state.persistedSequences,
|
|
939
|
+
lastPrecompactCursor: ev.eventCursor,
|
|
940
|
+
pending: {
|
|
941
|
+
sequence,
|
|
942
|
+
...(ev.sessionId ? { claudeSessionId: ev.sessionId } : {}),
|
|
943
|
+
...(ev.transcriptPath ? { transcriptPath: ev.transcriptPath } : {}),
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
compactionPendingMatches(state, claudeSessionId, transcriptPath) {
|
|
948
|
+
const pending = state.pending;
|
|
949
|
+
if (!pending)
|
|
950
|
+
return false;
|
|
951
|
+
if (pending.claudeSessionId && claudeSessionId &&
|
|
952
|
+
pending.claudeSessionId !== claudeSessionId)
|
|
953
|
+
return false;
|
|
954
|
+
if (pending.transcriptPath && transcriptPath &&
|
|
955
|
+
pending.transcriptPath !== transcriptPath)
|
|
956
|
+
return false;
|
|
957
|
+
return !state.persistedSequences.includes(pending.sequence);
|
|
958
|
+
}
|
|
959
|
+
markCompactionPersisted(sequence, expectCompletionAck, generation = this.compactionGeneration()) {
|
|
960
|
+
if (this.compactionGeneration() !== generation)
|
|
961
|
+
return;
|
|
962
|
+
const state = this.readCompactionState();
|
|
963
|
+
if (state.generation !== generation)
|
|
964
|
+
return;
|
|
965
|
+
const persistedSequences = state.persistedSequences.includes(sequence)
|
|
966
|
+
? state.persistedSequences
|
|
967
|
+
: [...state.persistedSequences, sequence].slice(-16);
|
|
968
|
+
writeCompactionForwardState(this.bridgeDir, {
|
|
969
|
+
generation: state.generation,
|
|
970
|
+
lastSequence: Math.max(state.lastSequence, sequence),
|
|
971
|
+
persistedSequences,
|
|
972
|
+
lastPrecompactCursor: state.lastPrecompactCursor,
|
|
973
|
+
...(state.pending?.sequence !== sequence && state.pending
|
|
974
|
+
? { pending: state.pending }
|
|
975
|
+
: {}),
|
|
976
|
+
...(expectCompletionAck ? { expectCompletionAckSequence: sequence } : {}),
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
claimHookCompaction(ev) {
|
|
980
|
+
const state = this.readCompactionState();
|
|
981
|
+
if (this.compactionPendingMatches(state, ev.sessionId, ev.transcriptPath)) {
|
|
982
|
+
return state.pending.sequence;
|
|
983
|
+
}
|
|
984
|
+
if (state.expectCompletionAckSequence &&
|
|
985
|
+
state.persistedSequences.includes(state.expectCompletionAckSequence)) {
|
|
986
|
+
writeCompactionForwardState(this.bridgeDir, {
|
|
987
|
+
...state,
|
|
988
|
+
expectCompletionAckSequence: undefined,
|
|
989
|
+
});
|
|
990
|
+
return undefined;
|
|
991
|
+
}
|
|
992
|
+
const sequence = state.lastSequence + 1;
|
|
993
|
+
writeCompactionForwardState(this.bridgeDir, {
|
|
994
|
+
generation: state.generation,
|
|
995
|
+
lastSequence: sequence,
|
|
996
|
+
persistedSequences: state.persistedSequences,
|
|
997
|
+
lastPrecompactCursor: state.lastPrecompactCursor,
|
|
998
|
+
pending: {
|
|
999
|
+
sequence,
|
|
1000
|
+
...(ev.sessionId ? { claudeSessionId: ev.sessionId } : {}),
|
|
1001
|
+
...(ev.transcriptPath ? { transcriptPath: ev.transcriptPath } : {}),
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
return sequence;
|
|
1005
|
+
}
|
|
1006
|
+
handleCompactionCompletionHook(ev) {
|
|
1007
|
+
this.sink.onCompactionStatus?.("completed");
|
|
1008
|
+
const generation = this.compactionGeneration();
|
|
1009
|
+
const sequence = this.claimHookCompaction(ev);
|
|
1010
|
+
if (sequence === undefined || !this.sink.onCompactionBoundary)
|
|
1011
|
+
return;
|
|
1012
|
+
const result = this.sink.onCompactionBoundary("[Claude Code compaction — context was compacted in the terminal]", sequence, "hook");
|
|
1013
|
+
const finish = (handled) => {
|
|
1014
|
+
// A bounded permanent hook-path rejection advances the hook but leaves
|
|
1015
|
+
// the token for the primary transcript summary path.
|
|
1016
|
+
if (handled)
|
|
1017
|
+
this.markCompactionPersisted(sequence, false, generation);
|
|
1018
|
+
};
|
|
1019
|
+
return Promise.resolve(result).then(finish);
|
|
1020
|
+
}
|
|
419
1021
|
/** Process a provider failure only after this tick has discovered every
|
|
420
1022
|
* blocking request already appended by its hook subprocess. */
|
|
421
1023
|
flushTurnFailure() {
|
|
@@ -423,16 +1025,67 @@ export class ClaudeLiveSession {
|
|
|
423
1025
|
if (!error)
|
|
424
1026
|
return;
|
|
425
1027
|
this.turnFailurePending = null;
|
|
426
|
-
this.
|
|
1028
|
+
this.providerFailureSticky = true;
|
|
1029
|
+
const sourceId = this.pendingTerminalHook
|
|
1030
|
+
? `hook:${this.pendingTerminalHook.eventCursor}:${this.pendingTerminalHook.byteOffset}:failed`
|
|
1031
|
+
: undefined;
|
|
1032
|
+
const delivery = this.turnOpen
|
|
1033
|
+
? this.closeTurnError(error, sourceId)
|
|
1034
|
+
: this.sink.onTurnError(error, sourceId);
|
|
1035
|
+
if (this.pendingTerminalHook)
|
|
1036
|
+
this.finishTerminalHookDelivery(delivery);
|
|
427
1037
|
}
|
|
428
1038
|
/** Emit the Stop idle signal only after this tick has discovered native
|
|
429
1039
|
* interactions. A pending question/permission is active execution, not idle. */
|
|
430
1040
|
flushStopSignal() {
|
|
431
|
-
if (!this.stopSignalPending)
|
|
1041
|
+
if (!this.stopSignalPending) {
|
|
1042
|
+
if (this.pendingTerminalHook?.eventName === "Stop" && this.pendingInteractions.size > 0) {
|
|
1043
|
+
this.persistHookCursor(this.pendingTerminalHook);
|
|
1044
|
+
this.pendingTerminalHook = undefined;
|
|
1045
|
+
}
|
|
432
1046
|
return;
|
|
1047
|
+
}
|
|
433
1048
|
this.stopSignalPending = false;
|
|
434
|
-
if (this.pendingInteractions.size === 0)
|
|
435
|
-
this.
|
|
1049
|
+
if (this.pendingInteractions.size === 0) {
|
|
1050
|
+
const sourceId = this.pendingTerminalHook
|
|
1051
|
+
? `hook:${this.pendingTerminalHook.eventCursor}:${this.pendingTerminalHook.byteOffset}:idle`
|
|
1052
|
+
: undefined;
|
|
1053
|
+
const delivery = this.sink.onIdle(this.stopBackgroundTaskCount, sourceId);
|
|
1054
|
+
this.stopBackgroundTaskCountDelivered = true;
|
|
1055
|
+
if (this.pendingTerminalHook)
|
|
1056
|
+
this.finishTerminalHookDelivery(delivery);
|
|
1057
|
+
}
|
|
1058
|
+
else if (this.pendingTerminalHook) {
|
|
1059
|
+
// A pending native interaction means Stop is not a real idle edge, but
|
|
1060
|
+
// the hook record itself is handled and may advance.
|
|
1061
|
+
this.persistHookCursor(this.pendingTerminalHook);
|
|
1062
|
+
this.pendingTerminalHook = undefined;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
finishTerminalHookDelivery(delivery) {
|
|
1066
|
+
const hook = this.pendingTerminalHook;
|
|
1067
|
+
if (!hook || this.terminalHookDelivery)
|
|
1068
|
+
return;
|
|
1069
|
+
this.terminalHookDelivery = hook;
|
|
1070
|
+
void Promise.resolve(delivery).then(() => {
|
|
1071
|
+
if (this.pendingTerminalHook !== hook)
|
|
1072
|
+
return;
|
|
1073
|
+
this.persistHookCursor(hook);
|
|
1074
|
+
this.pendingTerminalHook = undefined;
|
|
1075
|
+
}, (error) => {
|
|
1076
|
+
if (this.pendingTerminalHook !== hook)
|
|
1077
|
+
return;
|
|
1078
|
+
console.error("[claude-forwarder] terminal hook delivery failed; retaining cursor:", error);
|
|
1079
|
+
if (hook.eventName === "StopFailure") {
|
|
1080
|
+
this.turnFailurePending = new Error("claude turn failed");
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
this.stopSignalPending = true;
|
|
1084
|
+
}
|
|
1085
|
+
}).finally(() => {
|
|
1086
|
+
if (this.terminalHookDelivery === hook)
|
|
1087
|
+
this.terminalHookDelivery = undefined;
|
|
1088
|
+
});
|
|
436
1089
|
}
|
|
437
1090
|
/** SessionStart drives discovery (first) and rotation (a later one with a NEW
|
|
438
1091
|
* session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
|
|
@@ -450,52 +1103,113 @@ export class ClaudeLiveSession {
|
|
|
450
1103
|
}
|
|
451
1104
|
this.transcriptPath = ev.transcriptPath;
|
|
452
1105
|
this.transcriptOffset = 0;
|
|
1106
|
+
this.committedTranscriptOffset = 0;
|
|
1107
|
+
this.transcriptLineCursor = 0;
|
|
1108
|
+
this.committedTranscriptLineCursor = 0;
|
|
453
1109
|
this.currentClaudeSessionId = ev.sessionId;
|
|
454
1110
|
if (ev.sessionId)
|
|
455
1111
|
this.seenClaudeSessionIds.add(ev.sessionId);
|
|
456
1112
|
// Resume from the persisted cursor so a relaunched forwarder doesn't re-mirror
|
|
457
1113
|
// already-logged turns.
|
|
458
1114
|
const restored = this.restoreForwardState(ev.transcriptPath);
|
|
459
|
-
|
|
1115
|
+
this.restoreSubagentForwardStates(ev.transcriptPath);
|
|
1116
|
+
if (!restored && this.initialTranscriptOffsetOnDiscovery !== undefined) {
|
|
1117
|
+
this.transcriptOffset = Math.min(this.initialTranscriptOffsetOnDiscovery, fileSize(ev.transcriptPath));
|
|
1118
|
+
this.committedTranscriptOffset = this.transcriptOffset;
|
|
1119
|
+
// Seed the exact rotation boundary even when the transcript is empty or
|
|
1120
|
+
// no bytes are consumed this tick. This closes a second-crash window
|
|
1121
|
+
// between SessionStart discovery and the first target record.
|
|
1122
|
+
this.persistForwardState();
|
|
1123
|
+
}
|
|
1124
|
+
else if (!restored && this.resumeAtEndOnDiscovery) {
|
|
460
1125
|
this.transcriptOffset = fileSize(ev.transcriptPath);
|
|
1126
|
+
this.committedTranscriptOffset = this.transcriptOffset;
|
|
1127
|
+
// Persist the fallback baseline just like an exact prepared prefix.
|
|
1128
|
+
// Otherwise a crash with the first new record still awaiting ACK can
|
|
1129
|
+
// make the next launch seed at the newer EOF and lose that record.
|
|
1130
|
+
this.persistForwardState();
|
|
461
1131
|
}
|
|
462
1132
|
if (!this.discovered && ev.sessionId) {
|
|
463
1133
|
this.discovered = true;
|
|
464
1134
|
this.sink.onSessionDiscovered?.(ev.sessionId, ev.transcriptPath);
|
|
465
1135
|
}
|
|
466
|
-
return;
|
|
1136
|
+
return ev.source === "compact" ? this.handleCompactionCompletionHook(ev) : undefined;
|
|
467
1137
|
}
|
|
468
1138
|
// Drain the old transcript before a SessionStart switches paths so a slash
|
|
469
1139
|
// command recorded immediately before /clear or /fork can still
|
|
470
1140
|
// acknowledge the web submission.
|
|
471
1141
|
if (ev.transcriptPath !== this.transcriptPath)
|
|
472
1142
|
this.pollTranscript();
|
|
1143
|
+
if (ev.source === "compact") {
|
|
1144
|
+
const follow = () => {
|
|
1145
|
+
if (ev.transcriptPath !== this.transcriptPath) {
|
|
1146
|
+
this.transcriptPath = ev.transcriptPath;
|
|
1147
|
+
this.transcriptOffset = 0;
|
|
1148
|
+
this.committedTranscriptOffset = 0;
|
|
1149
|
+
this.transcriptLineCursor = 0;
|
|
1150
|
+
this.committedTranscriptLineCursor = 0;
|
|
1151
|
+
this.seenSourceIds.clear();
|
|
1152
|
+
resetForwardState(this.bridgeDir);
|
|
1153
|
+
this.persistForwardState();
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
const completion = this.handleCompactionCompletionHook(ev);
|
|
1157
|
+
if (completion && typeof completion.then === "function")
|
|
1158
|
+
return completion.then(follow);
|
|
1159
|
+
follow();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
473
1162
|
// Same session id (a plain re-announce) → nothing to do.
|
|
474
1163
|
if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
|
|
475
1164
|
return;
|
|
476
|
-
const wasSeen = this.seenClaudeSessionIds.has(ev.sessionId);
|
|
477
|
-
this.seenClaudeSessionIds.add(ev.sessionId);
|
|
478
1165
|
let kind;
|
|
479
1166
|
if (ev.source === "clear")
|
|
480
1167
|
kind = "clear";
|
|
481
|
-
else if (
|
|
1168
|
+
else if (this.isForkHookRecord(ev))
|
|
482
1169
|
kind = "fork";
|
|
1170
|
+
// Capture the target boundary at SessionStart, before target publication
|
|
1171
|
+
// can wait or retry. Fork has copied source history up to this byte; clear
|
|
1172
|
+
// has no inherited prefix. A later EOF would also include new target work.
|
|
1173
|
+
const initialTranscriptOffset = kind === "fork" ? fileSize(ev.transcriptPath) : 0;
|
|
1174
|
+
const commitRepoint = () => {
|
|
1175
|
+
// A fork copies the source history into the new transcript; start at its
|
|
1176
|
+
// END so those records aren't re-mirrored. A clear starts fresh at byte 0.
|
|
1177
|
+
this.repointTranscript(ev.transcriptPath, initialTranscriptOffset);
|
|
1178
|
+
this.currentClaudeSessionId = ev.sessionId;
|
|
1179
|
+
this.seenClaudeSessionIds.add(ev.sessionId);
|
|
1180
|
+
};
|
|
1181
|
+
if (!kind) {
|
|
1182
|
+
commitRepoint();
|
|
1183
|
+
return;
|
|
483
1184
|
}
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
this.
|
|
488
|
-
|
|
489
|
-
|
|
1185
|
+
// Close the old Response before the host publishes session.rotated and
|
|
1186
|
+
// retargets the IPC generation. The host serializes this settlement on the
|
|
1187
|
+
// control lane so it cannot be cancelled into the new Session.
|
|
1188
|
+
this.closeTurn("rotation");
|
|
1189
|
+
// Do not switch source state or consume the hook until the host has
|
|
1190
|
+
// persisted and published the logical Session rotation.
|
|
1191
|
+
const publication = this.sink.onSessionRotated?.(kind, ev.sessionId, ev.transcriptPath, initialTranscriptOffset);
|
|
1192
|
+
if (publication && typeof publication.then === "function") {
|
|
1193
|
+
return publication.then(commitRepoint);
|
|
1194
|
+
}
|
|
1195
|
+
commitRepoint();
|
|
490
1196
|
}
|
|
491
1197
|
/** Switch the tailed transcript and reset per-session accumulators (the bridge
|
|
492
1198
|
* files — hooks/deltas/status — are shared by the same claude process across a
|
|
493
1199
|
* rotation, so their cursors are NOT reset). */
|
|
494
|
-
repointTranscript(newPath,
|
|
1200
|
+
repointTranscript(newPath, initialOffset) {
|
|
495
1201
|
this.closeTurn(); // finalize the prior session's last turn before switching
|
|
1202
|
+
this.activeTerminalCommand = undefined;
|
|
1203
|
+
this.committedTerminalCommand = undefined;
|
|
496
1204
|
this.transcriptPath = newPath;
|
|
497
|
-
this.transcriptOffset =
|
|
498
|
-
this.
|
|
1205
|
+
this.transcriptOffset = Math.min(Math.max(0, initialOffset), fileSize(newPath));
|
|
1206
|
+
this.committedTranscriptOffset = this.transcriptOffset;
|
|
1207
|
+
this.transcriptLineCursor = 0;
|
|
1208
|
+
this.committedTranscriptLineCursor = 0;
|
|
1209
|
+
this.nativeSubagents.clear();
|
|
1210
|
+
this.nativeSubagentParentResponses.clear();
|
|
1211
|
+
this.subagentStateParentPath = newPath;
|
|
1212
|
+
resetSubagentForwardStates(this.bridgeDir);
|
|
499
1213
|
this.todos.clear();
|
|
500
1214
|
this.pendingQueuedSubmissions.length = 0;
|
|
501
1215
|
this.resetMessageCorrelation();
|
|
@@ -506,16 +1220,366 @@ export class ClaudeLiveSession {
|
|
|
506
1220
|
resetForwardState(this.bridgeDir);
|
|
507
1221
|
this.persistForwardState();
|
|
508
1222
|
}
|
|
1223
|
+
/** Revalidate the committed cursor every poll, not only after process restart.
|
|
1224
|
+
* Claude may replace/truncate a transcript in place. Treat a stale
|
|
1225
|
+
* nonzero fingerprint as a new file and skips to its current EOF while
|
|
1226
|
+
* preserving the bounded seen set. A temporarily missing file keeps the
|
|
1227
|
+
* cursor unchanged. */
|
|
1228
|
+
validateLiveTranscriptCursor() {
|
|
1229
|
+
const transcriptPath = this.transcriptPath;
|
|
1230
|
+
if (!transcriptPath)
|
|
1231
|
+
return;
|
|
1232
|
+
const state = readForwardState(this.bridgeDir);
|
|
1233
|
+
if (!state ||
|
|
1234
|
+
state.transcriptPath !== transcriptPath ||
|
|
1235
|
+
state.byteOffset !== this.committedTranscriptOffset)
|
|
1236
|
+
return;
|
|
1237
|
+
const currentFingerprint = jsonlCursorFingerprint(transcriptPath, this.committedTranscriptOffset);
|
|
1238
|
+
if (currentFingerprint === undefined) {
|
|
1239
|
+
if (fileByteSize(transcriptPath) === undefined)
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
else if (state.cursorFingerprint === currentFingerprint) {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
else if (state.cursorFingerprint === undefined &&
|
|
1246
|
+
this.committedTranscriptOffset === 0 &&
|
|
1247
|
+
this.committedTranscriptLineCursor === 0) {
|
|
1248
|
+
this.persistForwardState();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
console.warn("[claude-forwarder] transcript cursor fingerprint changed; skipping to current EOF");
|
|
1252
|
+
this.closeTurn();
|
|
1253
|
+
this.activeTerminalCommand = undefined;
|
|
1254
|
+
this.committedTerminalCommand = undefined;
|
|
1255
|
+
const endOffset = fileSize(transcriptPath);
|
|
1256
|
+
this.transcriptOffset = endOffset;
|
|
1257
|
+
this.committedTranscriptOffset = endOffset;
|
|
1258
|
+
this.transcriptLineCursor = 0;
|
|
1259
|
+
this.committedTranscriptLineCursor = 0;
|
|
1260
|
+
this.persistForwardState();
|
|
1261
|
+
}
|
|
509
1262
|
pollTranscript() {
|
|
510
|
-
if (!this.transcriptPath)
|
|
1263
|
+
if (!this.transcriptPath || this.transcriptDeliveryPending)
|
|
511
1264
|
return;
|
|
512
|
-
|
|
1265
|
+
this.validateLiveTranscriptCursor();
|
|
1266
|
+
const transcriptPath = this.transcriptPath;
|
|
1267
|
+
const { entries, nextOffset, nextLineCursor } = readJsonlEntriesFrom(transcriptPath, this.transcriptOffset, this.transcriptLineCursor);
|
|
513
1268
|
this.transcriptOffset = nextOffset;
|
|
514
|
-
|
|
1269
|
+
this.transcriptLineCursor = nextLineCursor;
|
|
1270
|
+
this.pendingTranscriptCompactions = [];
|
|
1271
|
+
const deliveries = [];
|
|
1272
|
+
const fallbackSourceIds = [];
|
|
1273
|
+
for (const entry of entries) {
|
|
1274
|
+
const rec = entry.record;
|
|
1275
|
+
const sourceBase = transcriptSourceBase(rec, entry.byteOffset, entry.lineNumber);
|
|
1276
|
+
// Compatibility with the old record-level seen set. New writes persist
|
|
1277
|
+
// per-item keys returned by onTranscriptRecordEnd.
|
|
1278
|
+
if (this.seenSourceIds.has(sourceBase))
|
|
1279
|
+
continue;
|
|
1280
|
+
const terminalCheckpoint = this.terminalCommandCheckpointForRecord(rec);
|
|
1281
|
+
this.sink.onTranscriptRecordStart?.(sourceBase, (sourceId) => this.seenSourceIds.has(sourceId), {
|
|
1282
|
+
onHandled: (sourceId) => {
|
|
1283
|
+
this.rememberSourceId(sourceId);
|
|
1284
|
+
if (terminalCheckpoint) {
|
|
1285
|
+
this.committedTerminalCommand = cloneTerminalCommand(terminalCheckpoint.next);
|
|
1286
|
+
}
|
|
1287
|
+
this.persistForwardState();
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
515
1290
|
this.handleRecord(rec);
|
|
516
|
-
|
|
517
|
-
|
|
1291
|
+
const delivery = this.sink.onTranscriptRecordEnd?.();
|
|
1292
|
+
if (delivery)
|
|
1293
|
+
deliveries.push(delivery);
|
|
1294
|
+
else
|
|
1295
|
+
fallbackSourceIds.push(sourceBase);
|
|
1296
|
+
}
|
|
1297
|
+
const commit = (sourceGroups) => {
|
|
1298
|
+
if (this.transcriptPath !== transcriptPath)
|
|
1299
|
+
return;
|
|
1300
|
+
for (const sourceId of [...fallbackSourceIds, ...sourceGroups.flat()]) {
|
|
1301
|
+
this.rememberSourceId(sourceId);
|
|
1302
|
+
}
|
|
1303
|
+
this.committedTerminalCommand = cloneTerminalCommand(this.activeTerminalCommand);
|
|
1304
|
+
this.committedTranscriptOffset = nextOffset;
|
|
1305
|
+
this.committedTranscriptLineCursor = nextLineCursor;
|
|
518
1306
|
this.persistForwardState();
|
|
1307
|
+
};
|
|
1308
|
+
if (deliveries.length === 0 && this.pendingTranscriptCompactions.length === 0) {
|
|
1309
|
+
if (nextOffset !== this.committedTranscriptOffset)
|
|
1310
|
+
commit([]);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const compactions = this.pendingTranscriptCompactions;
|
|
1314
|
+
this.pendingTranscriptCompactions = [];
|
|
1315
|
+
this.transcriptDeliveryPending = true;
|
|
1316
|
+
this.transcriptCompactionPending = compactions.length > 0;
|
|
1317
|
+
void Promise.all([
|
|
1318
|
+
Promise.all(deliveries),
|
|
1319
|
+
Promise.all(compactions.map(async ({ sequence, generation, delivery }) => {
|
|
1320
|
+
if (!await delivery)
|
|
1321
|
+
throw new Error(`compaction ${sequence} delivery was not handled`);
|
|
1322
|
+
this.markCompactionPersisted(sequence, true, generation);
|
|
1323
|
+
})),
|
|
1324
|
+
]).then(([sourceGroups]) => commit(sourceGroups), (error) => {
|
|
1325
|
+
if (this.transcriptPath !== transcriptPath)
|
|
1326
|
+
return;
|
|
1327
|
+
this.transcriptOffset = this.committedTranscriptOffset;
|
|
1328
|
+
this.transcriptLineCursor = this.committedTranscriptLineCursor;
|
|
1329
|
+
this.activeTerminalCommand = cloneTerminalCommand(this.committedTerminalCommand);
|
|
1330
|
+
console.error("[claude-forwarder] transcript delivery failed; retrying window:", error);
|
|
1331
|
+
}).finally(() => {
|
|
1332
|
+
this.transcriptDeliveryPending = false;
|
|
1333
|
+
this.transcriptCompactionPending = false;
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
rememberSourceId(sourceId) {
|
|
1337
|
+
if (this.seenSourceIds.has(sourceId))
|
|
1338
|
+
return;
|
|
1339
|
+
this.seenSourceIds.add(sourceId);
|
|
1340
|
+
while (this.seenSourceIds.size > 2_000) {
|
|
1341
|
+
const oldest = this.seenSourceIds.values().next().value;
|
|
1342
|
+
if (!oldest)
|
|
1343
|
+
break;
|
|
1344
|
+
this.seenSourceIds.delete(oldest);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
restoreSubagentForwardStates(parentTranscriptPath) {
|
|
1348
|
+
if (this.subagentStateParentPath === parentTranscriptPath)
|
|
1349
|
+
return;
|
|
1350
|
+
this.nativeSubagents.clear();
|
|
1351
|
+
this.nativeSubagentParentResponses.clear();
|
|
1352
|
+
this.subagentStateParentPath = parentTranscriptPath;
|
|
1353
|
+
for (const [parentToolCallId, responseId] of readSubagentParentResponses(this.bridgeDir, parentTranscriptPath)) {
|
|
1354
|
+
this.nativeSubagentParentResponses.set(parentToolCallId, responseId);
|
|
1355
|
+
this.sink.onSubagentParentResponse?.(parentToolCallId, responseId);
|
|
1356
|
+
}
|
|
1357
|
+
for (const state of readSubagentForwardStates(this.bridgeDir, parentTranscriptPath)) {
|
|
1358
|
+
this.nativeSubagents.set(state.agentId, {
|
|
1359
|
+
agentId: state.agentId,
|
|
1360
|
+
parentToolCallId: state.parentToolCallId,
|
|
1361
|
+
...(state.parentResponseId ? { parentResponseId: state.parentResponseId } : {}),
|
|
1362
|
+
transcriptPath: state.transcriptPath,
|
|
1363
|
+
readOffset: state.byteOffset,
|
|
1364
|
+
committedOffset: state.byteOffset,
|
|
1365
|
+
lineCursor: state.lineCursor ?? 0,
|
|
1366
|
+
committedLineCursor: state.lineCursor ?? 0,
|
|
1367
|
+
seenSourceIds: new Set(state.seenSourceIds),
|
|
1368
|
+
deliveryPending: false,
|
|
1369
|
+
...(state.terminalCommand
|
|
1370
|
+
? {
|
|
1371
|
+
terminalCommand: state.terminalCommand,
|
|
1372
|
+
committedTerminalCommand: state.terminalCommand,
|
|
1373
|
+
}
|
|
1374
|
+
: {}),
|
|
1375
|
+
});
|
|
1376
|
+
if (state.parentResponseId) {
|
|
1377
|
+
this.sink.onSubagentParentResponse?.(state.parentToolCallId, state.parentResponseId);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
persistSubagentForwardStates() {
|
|
1382
|
+
const parentTranscriptPath = this.transcriptPath;
|
|
1383
|
+
if (!parentTranscriptPath)
|
|
1384
|
+
return;
|
|
1385
|
+
const states = [...this.nativeSubagents.values()]
|
|
1386
|
+
.sort((a, b) => a.agentId.localeCompare(b.agentId))
|
|
1387
|
+
.map((state) => ({
|
|
1388
|
+
agentId: state.agentId,
|
|
1389
|
+
parentToolCallId: state.parentToolCallId,
|
|
1390
|
+
...(state.parentResponseId ? { parentResponseId: state.parentResponseId } : {}),
|
|
1391
|
+
transcriptPath: state.transcriptPath,
|
|
1392
|
+
byteOffset: state.committedOffset,
|
|
1393
|
+
lineCursor: state.committedLineCursor,
|
|
1394
|
+
seenSourceIds: [...state.seenSourceIds],
|
|
1395
|
+
...(state.committedTerminalCommand
|
|
1396
|
+
? { terminalCommand: state.committedTerminalCommand }
|
|
1397
|
+
: {}),
|
|
1398
|
+
}));
|
|
1399
|
+
writeSubagentForwardStates(this.bridgeDir, parentTranscriptPath, states, this.nativeSubagentParentResponses);
|
|
1400
|
+
}
|
|
1401
|
+
pollNativeSubagents() {
|
|
1402
|
+
const parentTranscriptPath = this.transcriptPath;
|
|
1403
|
+
if (!parentTranscriptPath)
|
|
1404
|
+
return;
|
|
1405
|
+
this.restoreSubagentForwardStates(parentTranscriptPath);
|
|
1406
|
+
const directory = join(parentTranscriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
1407
|
+
let metaFiles;
|
|
1408
|
+
try {
|
|
1409
|
+
metaFiles = readdirSync(directory)
|
|
1410
|
+
.filter((name) => /^agent-.+\.meta\.json$/.test(name))
|
|
1411
|
+
.sort();
|
|
1412
|
+
}
|
|
1413
|
+
catch {
|
|
1414
|
+
metaFiles = [];
|
|
1415
|
+
}
|
|
1416
|
+
for (const metaFile of metaFiles) {
|
|
1417
|
+
const agentId = metaFile.slice("agent-".length, -".meta.json".length);
|
|
1418
|
+
if (!agentId)
|
|
1419
|
+
continue;
|
|
1420
|
+
const meta = readNativeSubagentMeta(join(directory, metaFile));
|
|
1421
|
+
if (!meta)
|
|
1422
|
+
continue; // partial/malformed meta is retried next tick
|
|
1423
|
+
const transcriptPath = subagentTranscriptPath(parentTranscriptPath, agentId);
|
|
1424
|
+
let state = this.nativeSubagents.get(agentId);
|
|
1425
|
+
if (!state) {
|
|
1426
|
+
const parentResponseId = this.nativeSubagentParentResponses.get(meta.toolUseId) ??
|
|
1427
|
+
this.sink.resolveSubagentParentResponseId?.(meta.toolUseId);
|
|
1428
|
+
state = {
|
|
1429
|
+
agentId,
|
|
1430
|
+
parentToolCallId: meta.toolUseId,
|
|
1431
|
+
...(parentResponseId ? { parentResponseId } : {}),
|
|
1432
|
+
transcriptPath,
|
|
1433
|
+
readOffset: 0,
|
|
1434
|
+
committedOffset: 0,
|
|
1435
|
+
lineCursor: 0,
|
|
1436
|
+
committedLineCursor: 0,
|
|
1437
|
+
seenSourceIds: new Set(),
|
|
1438
|
+
deliveryPending: false,
|
|
1439
|
+
};
|
|
1440
|
+
this.nativeSubagents.set(agentId, state);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// Once registered, a child lane is owned by its persisted cursor. Claude
|
|
1444
|
+
// may remove or temporarily rewrite the discovery meta while the child
|
|
1445
|
+
// transcript is still growing; do not make continued delivery depend on
|
|
1446
|
+
// rediscovering that meta on every poll.
|
|
1447
|
+
for (const state of this.nativeSubagents.values()) {
|
|
1448
|
+
if (!state.parentResponseId) {
|
|
1449
|
+
const parentResponseId = this.nativeSubagentParentResponses.get(state.parentToolCallId) ?? this.sink.resolveSubagentParentResponseId?.(state.parentToolCallId);
|
|
1450
|
+
if (parentResponseId) {
|
|
1451
|
+
state.parentResponseId = parentResponseId;
|
|
1452
|
+
this.nativeSubagentParentResponses.set(state.parentToolCallId, parentResponseId);
|
|
1453
|
+
this.sink.onSubagentParentResponse?.(state.parentToolCallId, parentResponseId);
|
|
1454
|
+
this.persistSubagentForwardStates();
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
else {
|
|
1458
|
+
this.sink.onSubagentParentResponse?.(state.parentToolCallId, state.parentResponseId);
|
|
1459
|
+
}
|
|
1460
|
+
this.pollNativeSubagent(state);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
pollNativeSubagent(state) {
|
|
1464
|
+
if (state.deliveryPending)
|
|
1465
|
+
return;
|
|
1466
|
+
const size = fileByteSize(state.transcriptPath);
|
|
1467
|
+
if (size === undefined)
|
|
1468
|
+
return;
|
|
1469
|
+
if (size < state.readOffset) {
|
|
1470
|
+
// Sub-agent state has no transcript fingerprint. A truncation rewinds to zero;
|
|
1471
|
+
// the bounded per-item seen set suppresses recent duplicates.
|
|
1472
|
+
state.readOffset = 0;
|
|
1473
|
+
state.committedOffset = 0;
|
|
1474
|
+
state.lineCursor = 0;
|
|
1475
|
+
state.committedLineCursor = 0;
|
|
1476
|
+
state.terminalCommand = undefined;
|
|
1477
|
+
state.committedTerminalCommand = undefined;
|
|
1478
|
+
}
|
|
1479
|
+
const { entries, nextOffset, nextLineCursor } = readJsonlEntriesFrom(state.transcriptPath, state.readOffset, state.lineCursor);
|
|
1480
|
+
state.readOffset = nextOffset;
|
|
1481
|
+
state.lineCursor = nextLineCursor;
|
|
1482
|
+
const deliveries = [];
|
|
1483
|
+
const fallbackSourceIds = [];
|
|
1484
|
+
for (const entry of entries) {
|
|
1485
|
+
const sourceBase = transcriptSourceBase(entry.record, entry.byteOffset, entry.lineNumber);
|
|
1486
|
+
if (state.seenSourceIds.has(sourceBase))
|
|
1487
|
+
continue;
|
|
1488
|
+
const queuedPrompt = queuedCommandPrompt(entry.record);
|
|
1489
|
+
const prompt = userStringContent(entry.record);
|
|
1490
|
+
const localCommand = transcriptLocalCommand(entry.record);
|
|
1491
|
+
const terminalCheckpoint = localCommand?.hasOutput
|
|
1492
|
+
? { next: undefined }
|
|
1493
|
+
: localCommand?.command !== undefined
|
|
1494
|
+
? { next: localCommand.command }
|
|
1495
|
+
: undefined;
|
|
1496
|
+
this.sink.onTranscriptRecordStart?.(sourceBase, (sourceId) => state.seenSourceIds.has(sourceId), {
|
|
1497
|
+
lane: `claude-subagent:${state.agentId}`,
|
|
1498
|
+
deadLetterPath: join(this.bridgeDir, "dead_letter.jsonl"),
|
|
1499
|
+
onHandled: (sourceId) => {
|
|
1500
|
+
if (!state.seenSourceIds.has(sourceId)) {
|
|
1501
|
+
state.seenSourceIds.add(sourceId);
|
|
1502
|
+
while (state.seenSourceIds.size > 2_000) {
|
|
1503
|
+
const oldest = state.seenSourceIds.values().next().value;
|
|
1504
|
+
if (!oldest)
|
|
1505
|
+
break;
|
|
1506
|
+
state.seenSourceIds.delete(oldest);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (terminalCheckpoint) {
|
|
1510
|
+
state.committedTerminalCommand = terminalCheckpoint.next;
|
|
1511
|
+
}
|
|
1512
|
+
this.persistSubagentForwardStates();
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
if (queuedPrompt !== undefined) {
|
|
1516
|
+
this.sink.onSubagentUserMessage?.(queuedPrompt, state.parentToolCallId);
|
|
1517
|
+
}
|
|
1518
|
+
if (localCommand) {
|
|
1519
|
+
this.sink.onSubagentTerminalCommand?.(terminalCommandData(localCommand, state.terminalCommand), state.parentToolCallId);
|
|
1520
|
+
if (localCommand.command !== undefined)
|
|
1521
|
+
state.terminalCommand = localCommand.command;
|
|
1522
|
+
if (localCommand.hasOutput)
|
|
1523
|
+
state.terminalCommand = undefined;
|
|
1524
|
+
}
|
|
1525
|
+
else if (prompt !== undefined && !isClaudeUserScaffolding(prompt)) {
|
|
1526
|
+
if (isTaskNotificationText(prompt)) {
|
|
1527
|
+
this.sink.onMetaUserMessage?.(prompt, sourceBase, state.parentToolCallId);
|
|
1528
|
+
}
|
|
1529
|
+
else {
|
|
1530
|
+
this.sink.onSubagentUserMessage?.(prompt, state.parentToolCallId);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
for (const message of userListTextMessages(entry.record)) {
|
|
1534
|
+
if (message.isMeta) {
|
|
1535
|
+
this.sink.onMetaUserMessage?.(message.text, sourceBase, state.parentToolCallId);
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
this.sink.onSubagentUserMessage?.(message.text, state.parentToolCallId);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
for (const event of parseTranscriptRecord(entry.record, {
|
|
1542
|
+
parentToolUseId: state.parentToolCallId,
|
|
1543
|
+
})) {
|
|
1544
|
+
this.sink.onEvent(event);
|
|
1545
|
+
}
|
|
1546
|
+
const delivery = this.sink.onTranscriptRecordEnd?.();
|
|
1547
|
+
if (delivery)
|
|
1548
|
+
deliveries.push(delivery);
|
|
1549
|
+
else
|
|
1550
|
+
fallbackSourceIds.push(sourceBase);
|
|
1551
|
+
}
|
|
1552
|
+
const commit = (sourceGroups) => {
|
|
1553
|
+
for (const sourceId of [...fallbackSourceIds, ...sourceGroups.flat()]) {
|
|
1554
|
+
if (state.seenSourceIds.has(sourceId))
|
|
1555
|
+
continue;
|
|
1556
|
+
state.seenSourceIds.add(sourceId);
|
|
1557
|
+
while (state.seenSourceIds.size > 2_000) {
|
|
1558
|
+
const oldest = state.seenSourceIds.values().next().value;
|
|
1559
|
+
if (!oldest)
|
|
1560
|
+
break;
|
|
1561
|
+
state.seenSourceIds.delete(oldest);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
state.committedTerminalCommand = state.terminalCommand;
|
|
1565
|
+
state.committedOffset = nextOffset;
|
|
1566
|
+
state.committedLineCursor = nextLineCursor;
|
|
1567
|
+
this.persistSubagentForwardStates();
|
|
1568
|
+
};
|
|
1569
|
+
if (deliveries.length === 0) {
|
|
1570
|
+
if (nextOffset !== state.committedOffset)
|
|
1571
|
+
commit([]);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
state.deliveryPending = true;
|
|
1575
|
+
void Promise.all(deliveries).then((sourceGroups) => commit(sourceGroups), (error) => {
|
|
1576
|
+
state.readOffset = state.committedOffset;
|
|
1577
|
+
state.lineCursor = state.committedLineCursor;
|
|
1578
|
+
state.terminalCommand = state.committedTerminalCommand;
|
|
1579
|
+
console.error(`[claude-forwarder] sub-agent ${state.agentId} delivery failed; retrying window:`, error);
|
|
1580
|
+
}).finally(() => {
|
|
1581
|
+
state.deliveryPending = false;
|
|
1582
|
+
});
|
|
519
1583
|
}
|
|
520
1584
|
/** Tail blocking hook requests after transcript records, so an interaction
|
|
521
1585
|
* emitted in the same poll attaches to the user Turn that caused it. */
|
|
@@ -787,11 +1851,57 @@ export class ClaudeLiveSession {
|
|
|
787
1851
|
if (oldest)
|
|
788
1852
|
this.settledInteractions.delete(oldest);
|
|
789
1853
|
}
|
|
1854
|
+
/** State change represented by this record once its durable terminal item is
|
|
1855
|
+
* handled. Parsing can run through multiple records before their deliveries
|
|
1856
|
+
* settle, so this checkpoint is intentionally separate from the live state. */
|
|
1857
|
+
terminalCommandCheckpointForRecord(rec) {
|
|
1858
|
+
if (rec.isSidechain === true || rec.isCompactSummary === true || rec.isMeta === true) {
|
|
1859
|
+
return undefined;
|
|
1860
|
+
}
|
|
1861
|
+
const localCommand = transcriptLocalCommand(rec);
|
|
1862
|
+
if (localCommand) {
|
|
1863
|
+
if (localCommand.hasOutput)
|
|
1864
|
+
return { next: undefined };
|
|
1865
|
+
if (localCommand.command !== undefined) {
|
|
1866
|
+
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
1867
|
+
return {
|
|
1868
|
+
next: {
|
|
1869
|
+
command: localCommand.command,
|
|
1870
|
+
...(turnId ? { turnId } : {}),
|
|
1871
|
+
},
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
return undefined;
|
|
1875
|
+
}
|
|
1876
|
+
return this.activeTerminalCommand ? { next: undefined } : undefined;
|
|
1877
|
+
}
|
|
790
1878
|
handleRecord(rec) {
|
|
791
1879
|
if (rec.isSidechain === true)
|
|
792
1880
|
return; // sub-agent turns (Phase 9 forwards them)
|
|
1881
|
+
if (rec.isCompactSummary === true) {
|
|
1882
|
+
this.handleCompactSummary(rec);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
793
1885
|
if (rec.isMeta === true)
|
|
794
1886
|
return; // Claude-generated UI metadata, not a human message
|
|
1887
|
+
const stringContent = userStringContent(rec);
|
|
1888
|
+
const localCommand = transcriptLocalCommand(rec);
|
|
1889
|
+
if (localCommand) {
|
|
1890
|
+
this.emitTerminalCommand(rec, localCommand);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (this.activeTerminalCommand) {
|
|
1894
|
+
this.closeTurn();
|
|
1895
|
+
this.activeTerminalCommand = undefined;
|
|
1896
|
+
}
|
|
1897
|
+
const queuedPrompt = queuedCommandPrompt(rec);
|
|
1898
|
+
if (queuedPrompt !== undefined) {
|
|
1899
|
+
for (const candidate of submissionCandidates(queuedPrompt)) {
|
|
1900
|
+
this.rememberSubmission(candidate);
|
|
1901
|
+
}
|
|
1902
|
+
this.emitUserMessageRecord(rec, queuedPrompt, false);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
795
1905
|
// Type-ahead is acknowledged at enqueue time. Its later
|
|
796
1906
|
// `promptSource:"queued"` user record must not count again: another
|
|
797
1907
|
// identical web message may already be awaiting its own acknowledgement.
|
|
@@ -809,18 +1919,14 @@ export class ClaudeLiveSession {
|
|
|
809
1919
|
}
|
|
810
1920
|
return;
|
|
811
1921
|
}
|
|
812
|
-
|
|
813
|
-
// re-read after a fingerprint reset). rynx emits a record's items atomically,
|
|
814
|
-
// so the record uuid is the source key (vs reference implementation's per-block key for its
|
|
815
|
-
// per-item POST). Records without a uuid fall through undeduped (rare).
|
|
816
|
-
const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
|
|
817
|
-
if (sourceId) {
|
|
818
|
-
if (this.seenSourceIds.has(sourceId))
|
|
819
|
-
return;
|
|
820
|
-
this.seenSourceIds.add(sourceId);
|
|
821
|
-
}
|
|
822
|
-
const content = userStringContent(rec);
|
|
1922
|
+
const content = stringContent;
|
|
823
1923
|
if (content !== undefined) {
|
|
1924
|
+
const firstLine = content.trim().split("\n", 1)[0] ?? "";
|
|
1925
|
+
if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
|
|
1926
|
+
this.noteInterrupted();
|
|
1927
|
+
this.lastActivityAt = this.now();
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
824
1930
|
const candidates = submissionCandidates(content);
|
|
825
1931
|
const queuedPromotion = rec.promptSource !== "typed" &&
|
|
826
1932
|
candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
|
|
@@ -834,38 +1940,32 @@ export class ClaudeLiveSession {
|
|
|
834
1940
|
for (const candidate of candidates)
|
|
835
1941
|
this.rememberSubmission(candidate);
|
|
836
1942
|
}
|
|
837
|
-
|
|
838
|
-
// it as a self-contained terminal_command turn (before the prompt check, as
|
|
839
|
-
// it too is `<`-prefixed).
|
|
840
|
-
const term = parseTerminalCommand(content);
|
|
841
|
-
if (term) {
|
|
842
|
-
this.emitTerminalCommand(rec, term);
|
|
1943
|
+
if (isClaudeUserScaffolding(content))
|
|
843
1944
|
return;
|
|
1945
|
+
if (isTaskNotificationText(content)) {
|
|
1946
|
+
this.sink.onMetaUserMessage?.(content, rec.uuid ?? rec.requestId);
|
|
844
1947
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
848
|
-
if (this.turnOpen && this.syntheticTurn) {
|
|
849
|
-
// A hook can flush just before the transcript's user record. The
|
|
850
|
-
// interaction already opened a unique provisional response; adopt the
|
|
851
|
-
// real turn id internally without closing/cancelling that same Turn.
|
|
852
|
-
this.currentTurnId = turnId;
|
|
853
|
-
this.syntheticTurn = false;
|
|
854
|
-
}
|
|
855
|
-
else {
|
|
856
|
-
this.closeTurn();
|
|
857
|
-
this.currentTurnId = turnId;
|
|
858
|
-
this.turnOpen = true;
|
|
859
|
-
this.syntheticTurn = false;
|
|
860
|
-
this.openToolIds.clear();
|
|
861
|
-
this.stopPendingAt = null;
|
|
862
|
-
this.sink.onTurnStart(this.currentTurnId);
|
|
863
|
-
}
|
|
864
|
-
this.sink.onUserMessage(content);
|
|
865
|
-
this.lastActivityAt = this.now();
|
|
1948
|
+
else {
|
|
1949
|
+
this.emitUserMessageRecord(rec, content, queuedPromotion);
|
|
866
1950
|
}
|
|
867
1951
|
return; // string-content user record: prompt, terminal_command, or skipped marker
|
|
868
1952
|
}
|
|
1953
|
+
for (const message of userListTextMessages(rec)) {
|
|
1954
|
+
if (message.isMeta) {
|
|
1955
|
+
this.sink.onMetaUserMessage?.(message.text, rec.uuid ?? rec.requestId);
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
const candidates = submissionCandidates(message.text);
|
|
1959
|
+
const queuedPromotion = rec.promptSource !== "typed" &&
|
|
1960
|
+
candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
|
|
1961
|
+
if (!queuedPromotion &&
|
|
1962
|
+
rec.promptSource !== "queued" &&
|
|
1963
|
+
rec.promptSource !== "sdk") {
|
|
1964
|
+
for (const candidate of candidates)
|
|
1965
|
+
this.rememberSubmission(candidate);
|
|
1966
|
+
}
|
|
1967
|
+
this.emitUserMessageRecord(rec, message.text, queuedPromotion);
|
|
1968
|
+
}
|
|
869
1969
|
const events = parseTranscriptRecord(rec);
|
|
870
1970
|
if (events.length === 0)
|
|
871
1971
|
return;
|
|
@@ -873,15 +1973,75 @@ export class ClaudeLiveSession {
|
|
|
873
1973
|
for (const event of events) {
|
|
874
1974
|
const mapped = this.remapMessageItem(event);
|
|
875
1975
|
this.trackTool(mapped);
|
|
1976
|
+
this.rememberNativeSubagentParentResponse(mapped);
|
|
876
1977
|
this.sink.onEvent(mapped);
|
|
877
1978
|
}
|
|
878
|
-
// A Task tool_result carries `toolUseResult.agentId` — replay the (now
|
|
879
|
-
// complete) sub-agent transcript nested under this parent Task call.
|
|
880
|
-
this.maybeForwardSubagent(rec);
|
|
881
1979
|
// TaskCreate/TaskUpdate records also refresh the task-list snapshot.
|
|
882
1980
|
this.maybeUpdateTodos(rec);
|
|
883
1981
|
this.lastActivityAt = this.now();
|
|
884
1982
|
}
|
|
1983
|
+
emitUserMessageRecord(rec, content, queuedPromotion) {
|
|
1984
|
+
const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
|
|
1985
|
+
if (queuedIntoOpenTurn) {
|
|
1986
|
+
// Claude type-ahead is one native busy interval: enqueue now, promote
|
|
1987
|
+
// later, and emit one final Stop. Do not close/re-open the canonical
|
|
1988
|
+
// response when the promoted user record arrives mid-turn.
|
|
1989
|
+
this.sink.onUserMessage(content);
|
|
1990
|
+
this.lastActivityAt = this.now();
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
1994
|
+
if (this.turnOpen && this.syntheticTurn) {
|
|
1995
|
+
// A hook can flush just before the transcript's user record. The
|
|
1996
|
+
// interaction already opened a unique provisional response; adopt the
|
|
1997
|
+
// real turn id internally without closing/cancelling that same Turn.
|
|
1998
|
+
this.currentTurnId = turnId;
|
|
1999
|
+
this.syntheticTurn = false;
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
this.closeTurn();
|
|
2003
|
+
this.currentTurnId = turnId;
|
|
2004
|
+
this.turnOpen = true;
|
|
2005
|
+
this.turnInterrupted = false;
|
|
2006
|
+
this.syntheticTurn = false;
|
|
2007
|
+
this.providerIdleAt = null;
|
|
2008
|
+
this.openToolIds.clear();
|
|
2009
|
+
this.stopPendingAt = null;
|
|
2010
|
+
this.stopBackgroundTaskCount = undefined;
|
|
2011
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
2012
|
+
this.sink.onTurnStart(this.currentTurnId);
|
|
2013
|
+
}
|
|
2014
|
+
this.sink.onUserMessage(content);
|
|
2015
|
+
this.lastActivityAt = this.now();
|
|
2016
|
+
}
|
|
2017
|
+
handleCompactSummary(rec) {
|
|
2018
|
+
const state = this.readCompactionState();
|
|
2019
|
+
if (!this.compactionPendingMatches(state)) {
|
|
2020
|
+
if (state.persistedSequences.length === 0 && !state.expectCompletionAckSequence) {
|
|
2021
|
+
console.warn("[claude-forwarder] skipping isCompactSummary without a pending PreCompact token");
|
|
2022
|
+
}
|
|
2023
|
+
if (state.expectCompletionAckSequence) {
|
|
2024
|
+
const { expectCompletionAckSequence: _omitted, ...withoutAck } = state;
|
|
2025
|
+
writeCompactionForwardState(this.bridgeDir, withoutAck);
|
|
2026
|
+
}
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const sequence = state.pending.sequence;
|
|
2030
|
+
const content = rec.message?.content;
|
|
2031
|
+
const summary = typeof content === "string"
|
|
2032
|
+
? content
|
|
2033
|
+
: Array.isArray(content)
|
|
2034
|
+
? content.flatMap((block) => isRecord(block) && typeof block.text === "string" ? [block.text] : []).join("\n")
|
|
2035
|
+
: "";
|
|
2036
|
+
const delivery = this.sink.onCompactionBoundary?.(summary || "[Claude Code compaction — context was compacted in the terminal]", sequence, "transcript");
|
|
2037
|
+
if (delivery === undefined)
|
|
2038
|
+
return;
|
|
2039
|
+
this.pendingTranscriptCompactions.push({
|
|
2040
|
+
sequence,
|
|
2041
|
+
generation: state.generation,
|
|
2042
|
+
delivery: Promise.resolve(delivery),
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
885
2045
|
/** Fold a `TaskCreate` (new pending task) or `TaskUpdate` (status/subject/delete)
|
|
886
2046
|
* record into the task list; on any change emit the whole list as a snapshot. */
|
|
887
2047
|
maybeUpdateTodos(rec) {
|
|
@@ -908,28 +2068,6 @@ export class ClaudeLiveSession {
|
|
|
908
2068
|
if (changed)
|
|
909
2069
|
this.sink.onTodos([...this.todos.values()]);
|
|
910
2070
|
}
|
|
911
|
-
/** On a `Task` tool_result, replay the sub-agent's own transcript
|
|
912
|
-
* (`subagents/agent-<agentId>.jsonl`) as events tagged with the parent Task
|
|
913
|
-
* tool-use id, so the canonical layer nests them under that call. Fired once
|
|
914
|
-
* per sub-agent (at tool_result time the file is complete — no live race). */
|
|
915
|
-
maybeForwardSubagent(rec) {
|
|
916
|
-
if (!this.transcriptPath)
|
|
917
|
-
return;
|
|
918
|
-
const result = rec.toolUseResult;
|
|
919
|
-
const agentId = isRecord(result) && typeof result.agentId === "string" ? result.agentId : undefined;
|
|
920
|
-
if (!agentId || this.seenSubagents.has(agentId))
|
|
921
|
-
return;
|
|
922
|
-
const parentToolUseId = toolResultCallId(rec);
|
|
923
|
-
if (!parentToolUseId)
|
|
924
|
-
return;
|
|
925
|
-
this.seenSubagents.add(agentId);
|
|
926
|
-
const file = subagentTranscriptPath(this.transcriptPath, agentId);
|
|
927
|
-
// Emit raw (no remap/track): sub-agent messages have their own itemIds and
|
|
928
|
-
// must not consume the parent's MessageDisplay FIFO, and their tool pairs are
|
|
929
|
-
// already complete so they need no open-tool tracking.
|
|
930
|
-
for (const event of readSubagentEvents(file, parentToolUseId))
|
|
931
|
-
this.sink.onEvent(event);
|
|
932
|
-
}
|
|
933
2071
|
/** Tail streamed assistant-text chunks (MessageDisplay) into live `token`
|
|
934
2072
|
* events. Guarded on an open turn — deltas belong to the turn the user record
|
|
935
2073
|
* opened; the offset advances only once processed. */
|
|
@@ -938,9 +2076,21 @@ export class ClaudeLiveSession {
|
|
|
938
2076
|
this.deltasOffset = nextOffset;
|
|
939
2077
|
// MessageDisplay has no Turn id. Chunks observed while idle belong to the
|
|
940
2078
|
// Turn that just closed and must never be replayed into a later Turn.
|
|
941
|
-
if (!this.turnOpen)
|
|
2079
|
+
if (!this.turnOpen) {
|
|
2080
|
+
writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
|
|
942
2081
|
return;
|
|
2082
|
+
}
|
|
943
2083
|
for (const d of deltas) {
|
|
2084
|
+
const key = `${d.messageId}:${d.index}`;
|
|
2085
|
+
if (this.seenDeltaKeys.has(key))
|
|
2086
|
+
continue;
|
|
2087
|
+
this.seenDeltaKeys.add(key);
|
|
2088
|
+
while (this.seenDeltaKeys.size > 5_000) {
|
|
2089
|
+
const oldest = this.seenDeltaKeys.values().next().value;
|
|
2090
|
+
if (!oldest)
|
|
2091
|
+
break;
|
|
2092
|
+
this.seenDeltaKeys.delete(oldest);
|
|
2093
|
+
}
|
|
944
2094
|
this.streamedMessageText.set(d.messageId, (this.streamedMessageText.get(d.messageId) ?? "") + d.delta);
|
|
945
2095
|
this.sink.onEvent({
|
|
946
2096
|
type: "token",
|
|
@@ -956,6 +2106,9 @@ export class ClaudeLiveSession {
|
|
|
956
2106
|
this.finalizeMessageDisplay(d.messageId);
|
|
957
2107
|
}
|
|
958
2108
|
}
|
|
2109
|
+
if (nextOffset > 0 || deltas.length > 0) {
|
|
2110
|
+
writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
|
|
2111
|
+
}
|
|
959
2112
|
if (deltas.length)
|
|
960
2113
|
this.lastActivityAt = this.now();
|
|
961
2114
|
}
|
|
@@ -978,6 +2131,34 @@ export class ClaudeLiveSession {
|
|
|
978
2131
|
if (status)
|
|
979
2132
|
this.latestStatus = status;
|
|
980
2133
|
}
|
|
2134
|
+
pollSessionStatus() {
|
|
2135
|
+
const poller = this.statusPoller;
|
|
2136
|
+
if (!poller)
|
|
2137
|
+
return;
|
|
2138
|
+
const wasActive = poller.active;
|
|
2139
|
+
poller.tick();
|
|
2140
|
+
if (wasActive && !poller.active)
|
|
2141
|
+
this.providerIdleAt = null;
|
|
2142
|
+
}
|
|
2143
|
+
handleSessionStatus(status) {
|
|
2144
|
+
if (this.pendingTerminalHook?.eventName !== "Stop") {
|
|
2145
|
+
this.stopSignalPending = false;
|
|
2146
|
+
this.stopPendingAt = null;
|
|
2147
|
+
}
|
|
2148
|
+
if (status.runnerStatus === "running") {
|
|
2149
|
+
this.providerFailureSticky = false;
|
|
2150
|
+
// A new native busy interval supersedes the prior Stop snapshot. The
|
|
2151
|
+
// Session running edge clears its sticky tally; do not re-emit that old
|
|
2152
|
+
// count if the previous transcript Turn closes a moment later.
|
|
2153
|
+
this.stopBackgroundTaskCount = undefined;
|
|
2154
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
2155
|
+
}
|
|
2156
|
+
else if (this.providerFailureSticky) {
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
this.sink.onStatus?.(status.runnerStatus, status.blockedOn);
|
|
2160
|
+
this.providerIdleAt = status.runnerStatus === "idle" ? this.now() : null;
|
|
2161
|
+
}
|
|
981
2162
|
/** A usage record from the latest statusLine snapshot (snake_case, the keys the
|
|
982
2163
|
* normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
|
|
983
2164
|
* if the statusLine hook has not fired yet. */
|
|
@@ -1035,6 +2216,25 @@ export class ClaudeLiveSession {
|
|
|
1035
2216
|
this.openToolIds.delete(id);
|
|
1036
2217
|
}
|
|
1037
2218
|
}
|
|
2219
|
+
rememberNativeSubagentParentResponse(event) {
|
|
2220
|
+
if (event.type !== "tool" || event.event !== "on_tool_start")
|
|
2221
|
+
return;
|
|
2222
|
+
const parentToolCallId = readId(event.input) ?? readId(event.data);
|
|
2223
|
+
if (!parentToolCallId || this.nativeSubagentParentResponses.has(parentToolCallId))
|
|
2224
|
+
return;
|
|
2225
|
+
const responseId = this.currentTurnId
|
|
2226
|
+
? `resp_claude_${this.currentTurnId}`
|
|
2227
|
+
: "resp_claude_native";
|
|
2228
|
+
this.nativeSubagentParentResponses.set(parentToolCallId, responseId);
|
|
2229
|
+
while (this.nativeSubagentParentResponses.size > 2_000) {
|
|
2230
|
+
const oldest = this.nativeSubagentParentResponses.keys().next().value;
|
|
2231
|
+
if (!oldest)
|
|
2232
|
+
break;
|
|
2233
|
+
this.nativeSubagentParentResponses.delete(oldest);
|
|
2234
|
+
}
|
|
2235
|
+
this.sink.onSubagentParentResponse?.(parentToolCallId, responseId);
|
|
2236
|
+
this.persistSubagentForwardStates();
|
|
2237
|
+
}
|
|
1038
2238
|
ensureTurn(provisionalTurnId) {
|
|
1039
2239
|
if (this.turnOpen)
|
|
1040
2240
|
return;
|
|
@@ -1042,18 +2242,37 @@ export class ClaudeLiveSession {
|
|
|
1042
2242
|
this.currentTurnId = provisionalTurnId;
|
|
1043
2243
|
this.syntheticTurn = true;
|
|
1044
2244
|
}
|
|
2245
|
+
this.providerIdleAt = null;
|
|
1045
2246
|
this.turnOpen = true;
|
|
2247
|
+
this.turnInterrupted = false;
|
|
2248
|
+
this.stopBackgroundTaskCount = undefined;
|
|
2249
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
1046
2250
|
this.sink.onTurnStart(this.currentTurnId);
|
|
1047
2251
|
}
|
|
1048
|
-
/** Mirror a
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
2252
|
+
/** Mirror a Claude `!cmd` as one logical mini-turn even when input and output
|
|
2253
|
+
* are separate transcript records. The output repeats the remembered command
|
|
2254
|
+
* in Rynx's combined terminal item shape; the transcript projection merges
|
|
2255
|
+
* that completion into the preceding command row. */
|
|
2256
|
+
emitTerminalCommand(rec, fragments) {
|
|
2257
|
+
if (fragments.command !== undefined) {
|
|
2258
|
+
this.closeTurn();
|
|
2259
|
+
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
2260
|
+
this.ensureTurn(turnId);
|
|
2261
|
+
this.syntheticTurn = false;
|
|
2262
|
+
this.activeTerminalCommand = { command: fragments.command, ...(turnId ? { turnId } : {}) };
|
|
2263
|
+
}
|
|
2264
|
+
else if (!this.turnOpen) {
|
|
2265
|
+
const turnId = this.activeTerminalCommand?.turnId ??
|
|
2266
|
+
(typeof rec.uuid === "string" ? rec.uuid : undefined);
|
|
2267
|
+
this.ensureTurn(turnId);
|
|
2268
|
+
this.syntheticTurn = false;
|
|
2269
|
+
}
|
|
2270
|
+
this.sink.onTerminalCommand(terminalCommandData(fragments, this.activeTerminalCommand?.command));
|
|
2271
|
+
this.lastActivityAt = this.now();
|
|
2272
|
+
if (fragments.hasOutput) {
|
|
2273
|
+
this.closeTurn();
|
|
2274
|
+
this.activeTerminalCommand = undefined;
|
|
2275
|
+
}
|
|
1057
2276
|
}
|
|
1058
2277
|
/** The web Stop button interrupted this session (host sent Escape). claude
|
|
1059
2278
|
* records the interrupt in its own transcript but may not fire a Stop hook when
|
|
@@ -1063,10 +2282,14 @@ export class ClaudeLiveSession {
|
|
|
1063
2282
|
* turn's close via the same short grace. The Escape has stopped claude, so this
|
|
1064
2283
|
* does not race a still-running response. */
|
|
1065
2284
|
noteInterrupted() {
|
|
1066
|
-
if (!this.turnOpen)
|
|
2285
|
+
if (!this.turnOpen || this.turnInterrupted)
|
|
1067
2286
|
return;
|
|
2287
|
+
this.turnInterrupted = true;
|
|
2288
|
+
this.sink.onTurnInterruptRequested?.();
|
|
1068
2289
|
this.cancelPendingInteractions("turn_interrupted");
|
|
1069
2290
|
this.stopSignalPending = false;
|
|
2291
|
+
if (this.statusPoller?.active)
|
|
2292
|
+
return;
|
|
1070
2293
|
this.sink.onIdle();
|
|
1071
2294
|
this.stopPendingAt = this.now();
|
|
1072
2295
|
}
|
|
@@ -1077,7 +2300,22 @@ export class ClaudeLiveSession {
|
|
|
1077
2300
|
isTurnOpen() {
|
|
1078
2301
|
return this.turnOpen;
|
|
1079
2302
|
}
|
|
1080
|
-
|
|
2303
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
2304
|
+
failOpenTurn(error) {
|
|
2305
|
+
if (!this.turnOpen)
|
|
2306
|
+
return false;
|
|
2307
|
+
this.closeTurnError(error);
|
|
2308
|
+
return true;
|
|
2309
|
+
}
|
|
2310
|
+
/** Retire process-scoped metadata before classifying a terminal exit. */
|
|
2311
|
+
noteTerminalExit(error) {
|
|
2312
|
+
this.statusPoller?.retire();
|
|
2313
|
+
const failedOpenTurn = this.failOpenTurn(error);
|
|
2314
|
+
if (!failedOpenTurn)
|
|
2315
|
+
this.sink.onTurnError(error);
|
|
2316
|
+
return failedOpenTurn;
|
|
2317
|
+
}
|
|
2318
|
+
closeTurn(reason) {
|
|
1081
2319
|
if (!this.turnOpen)
|
|
1082
2320
|
return;
|
|
1083
2321
|
this.drainInteractionCommits();
|
|
@@ -1089,9 +2327,22 @@ export class ClaudeLiveSession {
|
|
|
1089
2327
|
this.stopSignalPending = false;
|
|
1090
2328
|
this.stopPendingAt = null;
|
|
1091
2329
|
this.resetMessageCorrelation();
|
|
1092
|
-
this.
|
|
2330
|
+
const interrupted = this.turnInterrupted;
|
|
2331
|
+
this.turnInterrupted = false;
|
|
2332
|
+
const usage = this.statusUsage();
|
|
2333
|
+
const backgroundTaskCount = this.stopBackgroundTaskCountDelivered
|
|
2334
|
+
? undefined
|
|
2335
|
+
: this.stopBackgroundTaskCount;
|
|
2336
|
+
this.stopBackgroundTaskCount = undefined;
|
|
2337
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
2338
|
+
if (interrupted && this.sink.onTurnInterrupted)
|
|
2339
|
+
this.sink.onTurnInterrupted(usage);
|
|
2340
|
+
else
|
|
2341
|
+
this.sink.onTurnEnd(usage, backgroundTaskCount, reason);
|
|
2342
|
+
if (!this.transcriptDeliveryPending)
|
|
2343
|
+
this.persistForwardState();
|
|
1093
2344
|
}
|
|
1094
|
-
closeTurnError(error) {
|
|
2345
|
+
closeTurnError(error, sourceId) {
|
|
1095
2346
|
if (!this.turnOpen)
|
|
1096
2347
|
return;
|
|
1097
2348
|
this.drainInteractionCommits();
|
|
@@ -1100,10 +2351,16 @@ export class ClaudeLiveSession {
|
|
|
1100
2351
|
this.currentTurnId = undefined;
|
|
1101
2352
|
this.syntheticTurn = false;
|
|
1102
2353
|
this.openToolIds.clear();
|
|
2354
|
+
this.turnInterrupted = false;
|
|
1103
2355
|
this.stopSignalPending = false;
|
|
1104
2356
|
this.stopPendingAt = null;
|
|
2357
|
+
this.stopBackgroundTaskCount = undefined;
|
|
2358
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
1105
2359
|
this.resetMessageCorrelation();
|
|
1106
|
-
this.sink.onTurnError(error);
|
|
2360
|
+
const delivery = this.sink.onTurnError(error, sourceId);
|
|
2361
|
+
if (!this.transcriptDeliveryPending)
|
|
2362
|
+
this.persistForwardState();
|
|
2363
|
+
return delivery;
|
|
1107
2364
|
}
|
|
1108
2365
|
resetMessageCorrelation() {
|
|
1109
2366
|
this.finalizedMessageIds.clear();
|
|
@@ -1112,6 +2369,14 @@ export class ClaudeLiveSession {
|
|
|
1112
2369
|
this.streamedMessageText.clear();
|
|
1113
2370
|
}
|
|
1114
2371
|
maybeIdleClose() {
|
|
2372
|
+
if (this.statusPoller?.active) {
|
|
2373
|
+
if (this.turnOpen &&
|
|
2374
|
+
this.providerIdleAt !== null &&
|
|
2375
|
+
this.now() - this.providerIdleAt >= this.stopGraceMs) {
|
|
2376
|
+
this.closeTurn();
|
|
2377
|
+
}
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
1115
2380
|
if (!this.turnOpen ||
|
|
1116
2381
|
this.openToolIds.size > 0 ||
|
|
1117
2382
|
this.pendingInteractions.size > 0)
|
|
@@ -1132,14 +2397,79 @@ export class ClaudeLiveSession {
|
|
|
1132
2397
|
/** Claude Code renders this glyph once the input box is mounted (ready-gate). */
|
|
1133
2398
|
const CLAUDE_PROMPT_GLYPH = "❯";
|
|
1134
2399
|
const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
|
|
2400
|
+
const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
|
|
2401
|
+
const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
|
|
2402
|
+
const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
|
|
2403
|
+
/** Claude-native readiness window: tmux may exist well before the
|
|
2404
|
+
* first interactive composer mounts on a cold start. */
|
|
2405
|
+
const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
|
|
2406
|
+
const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
|
|
2407
|
+
const CLAUDE_PASTE_SETTLE_MS = 100;
|
|
2408
|
+
const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
|
|
2409
|
+
const CLAUDE_SUBMIT_RETRY_MS = 1_000;
|
|
2410
|
+
function selectedMenuRow(line, glyph) {
|
|
2411
|
+
const index = line.indexOf(glyph);
|
|
2412
|
+
if (index < 0)
|
|
2413
|
+
return false;
|
|
2414
|
+
return /^\d+\.\s/.test(line.slice(index + glyph.length).trimStart());
|
|
2415
|
+
}
|
|
2416
|
+
function boxRule(line) {
|
|
2417
|
+
const text = line.trim();
|
|
2418
|
+
return text.length >= 3 && [...text].every((character) => CLAUDE_BOX_RULE_CHARS.has(character));
|
|
2419
|
+
}
|
|
1135
2420
|
/** The live composer sits at the bottom of the pane. Restricting readiness to
|
|
1136
2421
|
* its trailing non-empty lines prevents an old prompt glyph in scrollback from
|
|
1137
2422
|
* accepting input while Claude is still booting or showing another screen. */
|
|
1138
2423
|
function claudePromptRendered(pane, glyph) {
|
|
1139
2424
|
const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
2425
|
+
const tailStart = Math.max(0, nonEmpty.length - CLAUDE_PROMPT_SCAN_TAIL_LINES);
|
|
2426
|
+
for (let index = tailStart; index < nonEmpty.length; index += 1) {
|
|
2427
|
+
const line = nonEmpty[index] ?? "";
|
|
2428
|
+
if (!line.includes(glyph))
|
|
2429
|
+
continue;
|
|
2430
|
+
const framed = nonEmpty.slice(index + 1).some(boxRule);
|
|
2431
|
+
if (framed)
|
|
2432
|
+
return true;
|
|
2433
|
+
if (!selectedMenuRow(line, glyph))
|
|
2434
|
+
return true;
|
|
2435
|
+
}
|
|
2436
|
+
// A fan-out of subagents can make the running footer arbitrarily tall. Above
|
|
2437
|
+
// the fixed tail, only a prompt framed by the composer's closing rule counts.
|
|
2438
|
+
for (let index = 0; index < tailStart; index += 1) {
|
|
2439
|
+
const line = nonEmpty[index] ?? "";
|
|
2440
|
+
if (line.includes(glyph) && nonEmpty.slice(index + 1).some(boxRule))
|
|
2441
|
+
return true;
|
|
2442
|
+
}
|
|
2443
|
+
return false;
|
|
2444
|
+
}
|
|
2445
|
+
function draftNeedle(content) {
|
|
2446
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
2447
|
+
for (const rawLine of normalized.split("\n")) {
|
|
2448
|
+
const control = rawLine.search(/[\x00-\x1f]/);
|
|
2449
|
+
const line = (control >= 0 ? rawLine.slice(0, control) : rawLine).trim();
|
|
2450
|
+
if (line)
|
|
2451
|
+
return line.slice(0, CLAUDE_DRAFT_NEEDLE_MAX_CHARS);
|
|
2452
|
+
}
|
|
2453
|
+
return "";
|
|
2454
|
+
}
|
|
2455
|
+
function draftInInputBox(pane, glyph, needle) {
|
|
2456
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.includes(glyph));
|
|
2457
|
+
const line = lines.at(-1);
|
|
2458
|
+
if (!line)
|
|
2459
|
+
return false;
|
|
2460
|
+
const tail = line.slice(line.lastIndexOf(glyph) + glyph.length);
|
|
2461
|
+
if (tail.includes(CLAUDE_PASTED_PLACEHOLDER_PREFIX))
|
|
2462
|
+
return true;
|
|
2463
|
+
return Boolean(needle) && tail.includes(needle);
|
|
2464
|
+
}
|
|
2465
|
+
function terminalFailureTail(pane) {
|
|
2466
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.trim()).slice(-12);
|
|
2467
|
+
if (lines.length === 0)
|
|
2468
|
+
return "";
|
|
2469
|
+
let tail = lines.join("\n");
|
|
2470
|
+
if (tail.length > 800)
|
|
2471
|
+
tail = `…${tail.slice(-800)}`;
|
|
2472
|
+
return `\nlast terminal output:\n${tail}`;
|
|
1143
2473
|
}
|
|
1144
2474
|
async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
1145
2475
|
const deadline = now() + timeoutMs;
|
|
@@ -1159,9 +2489,9 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
|
1159
2489
|
*
|
|
1160
2490
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
1161
2491
|
* `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
|
|
1162
|
-
* caller reports, NOT a signal to fall through to a second output path.
|
|
1163
|
-
*
|
|
1164
|
-
* neither a direct user prompt nor a type-ahead enqueue.
|
|
2492
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
2493
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
2494
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
1165
2495
|
*/
|
|
1166
2496
|
export async function injectViaTerminal(injector, text, opts = {}) {
|
|
1167
2497
|
const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
|
|
@@ -1180,12 +2510,23 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1180
2510
|
};
|
|
1181
2511
|
// 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
|
|
1182
2512
|
// not-ready pane is a hard error, never a fall-through-to-run signal.
|
|
1183
|
-
|
|
2513
|
+
let promptPolls = 0;
|
|
2514
|
+
let emptyPromptPolls = 0;
|
|
2515
|
+
let lastPromptPane = "";
|
|
2516
|
+
const ready = await pollUntil(() => {
|
|
2517
|
+
const pane = injector.capturePane();
|
|
2518
|
+
promptPolls += 1;
|
|
2519
|
+
if (pane.trim())
|
|
2520
|
+
lastPromptPane = pane;
|
|
2521
|
+
else
|
|
2522
|
+
emptyPromptPolls += 1;
|
|
2523
|
+
return claudePromptRendered(pane, glyph);
|
|
2524
|
+
}, opts.promptTimeoutMs ?? CLAUDE_PROMPT_READY_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1184
2525
|
if (cancelled())
|
|
1185
2526
|
return false;
|
|
1186
2527
|
if (!ready) {
|
|
1187
|
-
|
|
1188
|
-
|
|
2528
|
+
throw new Error(`claude prompt not ready (no input composer within timeout; ${promptPolls} polls, ` +
|
|
2529
|
+
`${emptyPromptPolls} empty captures)${terminalFailureTail(lastPromptPane)}`);
|
|
1189
2530
|
}
|
|
1190
2531
|
// 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
|
|
1191
2532
|
// documented soft-newline escape: a bare submit Enter would consume it and
|
|
@@ -1193,30 +2534,47 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1193
2534
|
// the bracketed paste; normal messages must not gain an empty input row.
|
|
1194
2535
|
injector.clearInputLine();
|
|
1195
2536
|
injector.paste(text.endsWith("\\") ? `${text}\n` : text);
|
|
1196
|
-
// 3. `paste-buffer`
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
await
|
|
2537
|
+
// 3. tmux accepting `paste-buffer` does not mean Claude consumed it. Wait for
|
|
2538
|
+
// the draft (or its large-paste placeholder) to appear in the live composer;
|
|
2539
|
+
// otherwise Enter can be coalesced into the paste as a newline.
|
|
2540
|
+
const needle = draftNeedle(text);
|
|
2541
|
+
const draftSeen = await pollUntil(() => draftInInputBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? CLAUDE_PASTE_COMMIT_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
2542
|
+
await sleep(opts.settleMs ?? CLAUDE_PASTE_SETTLE_MS);
|
|
1201
2543
|
if (cancelled())
|
|
1202
2544
|
return false; // Stop pressed mid-paste → don't submit
|
|
1203
|
-
// 4. Submit.
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
// queue enqueue ends the loop immediately.
|
|
2545
|
+
// 4. Submit and verify. Retry only while this exact draft is still visible;
|
|
2546
|
+
// once it leaves the composer, another Enter could hit a permission dialog or
|
|
2547
|
+
// an empty prompt. Transcript acknowledgement is the strongest success proof.
|
|
1207
2548
|
const observed = opts.submissionObserved;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
2549
|
+
let attempts = 1;
|
|
2550
|
+
const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
|
|
2551
|
+
injector.sendEnter();
|
|
2552
|
+
// Preserve the old blind-submit fallback when capture-pane cannot
|
|
2553
|
+
// identify the draft; its absence would otherwise "prove" success trivially.
|
|
2554
|
+
if (!draftSeen)
|
|
2555
|
+
return true;
|
|
2556
|
+
const deadline = now() + (opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS);
|
|
2557
|
+
const retryMs = opts.submitRetryMs ?? CLAUDE_SUBMIT_RETRY_MS;
|
|
2558
|
+
let lastEnterAt = now();
|
|
2559
|
+
while (now() < deadline) {
|
|
2560
|
+
if (cancelled())
|
|
2561
|
+
return false;
|
|
2562
|
+
if (observed?.())
|
|
1214
2563
|
return true;
|
|
1215
|
-
if (
|
|
2564
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1216
2565
|
return true;
|
|
2566
|
+
if (now() - lastEnterAt >= retryMs && attempts < maxAttempts) {
|
|
2567
|
+
injector.sendEnter();
|
|
2568
|
+
attempts += 1;
|
|
2569
|
+
lastEnterAt = now();
|
|
1217
2570
|
}
|
|
1218
|
-
|
|
1219
|
-
return false;
|
|
2571
|
+
await sleep(pollMs);
|
|
1220
2572
|
}
|
|
1221
|
-
|
|
2573
|
+
if (observed?.())
|
|
2574
|
+
return true;
|
|
2575
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
2576
|
+
return true;
|
|
2577
|
+
throw new Error(`Claude Code did not accept the submitted message within ` +
|
|
2578
|
+
`${opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS}ms ` +
|
|
2579
|
+
"(the draft is still in the input box). The message was not delivered.");
|
|
1222
2580
|
}
|