@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39
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/native-bridge.d.ts +85 -0
- package/dist/claude/native-bridge.js +335 -17
- package/dist/claude/native-hook-main.js +18 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +120 -18
- package/dist/claude/native-integration.js +1200 -161
- 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 +131 -30
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +31 -2
- package/dist/host.js +551 -72
- package/dist/runner/child.d.ts +29 -5
- package/dist/runner/child.js +635 -54
- package/dist/runner/manager.d.ts +25 -0
- package/dist/runner/manager.js +805 -115
- package/dist/runner/protocol.d.ts +76 -3
- package/dist/runner/transport.d.ts +9 -0
- package/dist/runner/transport.js +39 -12
- package/package.json +2 -2
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
* backstop — but only when no tool call is still open, so a long Bash never trips
|
|
19
19
|
* a false turn-end.
|
|
20
20
|
*/
|
|
21
|
-
import { statSync } from "node:fs";
|
|
22
|
-
import {
|
|
23
|
-
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";
|
|
24
25
|
import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
|
|
25
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
|
+
}
|
|
26
32
|
const MAX_SETTLED_INTERACTIONS = 512;
|
|
27
33
|
const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
|
|
28
34
|
const MAX_SUBMISSION_OBSERVATIONS = 64;
|
|
@@ -56,6 +62,76 @@ function userStringContent(rec) {
|
|
|
56
62
|
return undefined; // tool_result records are arrays
|
|
57
63
|
return content.trim() || undefined;
|
|
58
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
|
+
}
|
|
59
135
|
function submissionText(value) {
|
|
60
136
|
if (typeof value !== "string")
|
|
61
137
|
return undefined;
|
|
@@ -83,15 +159,18 @@ function isRecord(value) {
|
|
|
83
159
|
function readId(value) {
|
|
84
160
|
return isRecord(value) && typeof value.id === "string" && value.id ? value.id : undefined;
|
|
85
161
|
}
|
|
86
|
-
|
|
87
|
-
function fileSize(path) {
|
|
162
|
+
function fileByteSize(path) {
|
|
88
163
|
try {
|
|
89
164
|
return statSync(path).size;
|
|
90
165
|
}
|
|
91
166
|
catch {
|
|
92
|
-
return
|
|
167
|
+
return undefined;
|
|
93
168
|
}
|
|
94
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
|
+
}
|
|
95
174
|
function readStr(value) {
|
|
96
175
|
if (typeof value === "string" && value)
|
|
97
176
|
return value;
|
|
@@ -129,17 +208,33 @@ function readTaskUpdates(rec) {
|
|
|
129
208
|
function normalizeTodoStatus(status) {
|
|
130
209
|
return status === "pending" || status === "in_progress" || status === "completed" ? status : undefined;
|
|
131
210
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
211
|
+
function readNativeSubagentMeta(path) {
|
|
212
|
+
let value;
|
|
213
|
+
try {
|
|
214
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
136
217
|
return undefined;
|
|
137
|
-
for (const block of content) {
|
|
138
|
-
if (isRecord(block) && block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
139
|
-
return block.tool_use_id;
|
|
140
|
-
}
|
|
141
218
|
}
|
|
142
|
-
|
|
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}`;
|
|
143
238
|
}
|
|
144
239
|
export class ClaudeLiveSession {
|
|
145
240
|
bridgeDir;
|
|
@@ -156,9 +251,24 @@ export class ClaudeLiveSession {
|
|
|
156
251
|
releaseSleep;
|
|
157
252
|
hooksOffset = 0;
|
|
158
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;
|
|
159
262
|
interactionsOffset = 0;
|
|
160
263
|
interactionAcksOffset = 0;
|
|
161
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;
|
|
162
272
|
/** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
|
|
163
273
|
* (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
|
|
164
274
|
* forwarder state; bounded there. */
|
|
@@ -170,11 +280,20 @@ export class ClaudeLiveSession {
|
|
|
170
280
|
/** Persisted native id requested through `claude --resume`. */
|
|
171
281
|
expectedClaudeSessionId;
|
|
172
282
|
resumeAtEndOnDiscovery;
|
|
283
|
+
initialTranscriptOffsetOnDiscovery;
|
|
173
284
|
/** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
|
|
174
285
|
* marker signals a `/fork` (vs. resuming a known branch). */
|
|
175
286
|
seenClaudeSessionIds = new Set();
|
|
176
287
|
turnOpen = false;
|
|
177
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;
|
|
178
297
|
/** The open turn received an explicit Escape/Stop and must close cancelled. */
|
|
179
298
|
turnInterrupted = false;
|
|
180
299
|
/** The Turn opened from a pre-transcript interaction. Its unique provisional
|
|
@@ -223,8 +342,17 @@ export class ClaudeLiveSession {
|
|
|
223
342
|
* append its request immediately before the failure hook lands; closing here
|
|
224
343
|
* would let the later interaction poll reopen an already-failed Turn. */
|
|
225
344
|
turnFailurePending = null;
|
|
226
|
-
/**
|
|
227
|
-
|
|
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;
|
|
228
356
|
/** The agent's task list, keyed by task id in creation order (claude
|
|
229
357
|
* `TaskCreate`/`TaskUpdate`). Emitted whole as a snapshot on any change. */
|
|
230
358
|
todos = new Map();
|
|
@@ -232,6 +360,7 @@ export class ClaudeLiveSession {
|
|
|
232
360
|
* a turn's usage on close. undefined until the statusLine hook first fires. */
|
|
233
361
|
latestStatus;
|
|
234
362
|
deltasOffset = 0;
|
|
363
|
+
seenDeltaKeys = new Set();
|
|
235
364
|
/** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
|
|
236
365
|
* transcript's assistant-text records so a streamed message and its final
|
|
237
366
|
* item share an itemId (message_id is absent from the transcript). */
|
|
@@ -254,7 +383,15 @@ export class ClaudeLiveSession {
|
|
|
254
383
|
((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
|
|
255
384
|
this.expectedClaudeSessionId = opts.claudeSessionId;
|
|
256
385
|
this.resumeAtEndOnDiscovery = opts.resumeAtEndOnDiscovery ?? false;
|
|
386
|
+
this.initialTranscriptOffsetOnDiscovery =
|
|
387
|
+
opts.initialTranscriptOffsetOnDiscovery === undefined
|
|
388
|
+
? undefined
|
|
389
|
+
: Math.max(0, Math.trunc(opts.initialTranscriptOffsetOnDiscovery));
|
|
257
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;
|
|
258
395
|
const hookState = readHookForwardState(this.bridgeDir);
|
|
259
396
|
if (hookState) {
|
|
260
397
|
this.hooksOffset = hookState.byteOffset;
|
|
@@ -268,6 +405,7 @@ export class ClaudeLiveSession {
|
|
|
268
405
|
this.seenClaudeSessionIds.add(opts.claudeSessionId);
|
|
269
406
|
}
|
|
270
407
|
this.restoreForwardState(opts.transcriptPath);
|
|
408
|
+
this.restoreSubagentForwardStates(opts.transcriptPath);
|
|
271
409
|
}
|
|
272
410
|
}
|
|
273
411
|
/**
|
|
@@ -284,23 +422,59 @@ export class ClaudeLiveSession {
|
|
|
284
422
|
return false;
|
|
285
423
|
for (const id of state.seenSourceIds)
|
|
286
424
|
this.seenSourceIds.add(id);
|
|
425
|
+
const transcriptSize = fileByteSize(transcriptPath);
|
|
426
|
+
const transcriptMissing = transcriptSize === undefined;
|
|
287
427
|
const fingerprint = jsonlCursorFingerprint(transcriptPath, state.byteOffset);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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();
|
|
292
453
|
return true;
|
|
293
454
|
}
|
|
294
455
|
/** Persist the durable forwarder cursor (byte offset + seen ids + fingerprint). */
|
|
295
456
|
persistForwardState() {
|
|
296
457
|
if (!this.transcriptPath)
|
|
297
458
|
return;
|
|
298
|
-
const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.
|
|
459
|
+
const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.committedTranscriptOffset);
|
|
299
460
|
writeForwardState(this.bridgeDir, {
|
|
300
461
|
transcriptPath: this.transcriptPath,
|
|
301
|
-
byteOffset: this.
|
|
462
|
+
byteOffset: this.committedTranscriptOffset,
|
|
463
|
+
lineCursor: this.committedTranscriptLineCursor,
|
|
302
464
|
seenSourceIds: [...this.seenSourceIds],
|
|
303
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
|
+
: {}),
|
|
304
478
|
});
|
|
305
479
|
}
|
|
306
480
|
/** True once SessionStart bound the transcript (or a resume path was given). */
|
|
@@ -401,15 +575,34 @@ export class ClaudeLiveSession {
|
|
|
401
575
|
this.claimedInteractions.clear();
|
|
402
576
|
this.scheduledClaimScrubs.clear();
|
|
403
577
|
}
|
|
404
|
-
/** One poll cycle (
|
|
578
|
+
/** One poll cycle (rotation/PreCompact prescan → transcript → hooks → live
|
|
579
|
+
* lanes → idle backstop). Exposed for tests to
|
|
405
580
|
* drive deterministically; the async {@link loop} just calls it on an interval. */
|
|
406
581
|
tick() {
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
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();
|
|
411
596
|
this.pollHooks();
|
|
412
|
-
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();
|
|
413
606
|
this.pollDeltas();
|
|
414
607
|
this.pollInteractions();
|
|
415
608
|
this.drainInteractionCommits();
|
|
@@ -492,24 +685,126 @@ export class ClaudeLiveSession {
|
|
|
492
685
|
this.releaseSleep = done;
|
|
493
686
|
});
|
|
494
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
|
+
}
|
|
495
729
|
pollHooks() {
|
|
730
|
+
if (this.rotationPending || this.compactionHookPending || this.pendingTerminalHook)
|
|
731
|
+
return;
|
|
496
732
|
const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
|
|
497
733
|
for (const ev of events) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
this.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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);
|
|
511
773
|
}
|
|
512
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
|
+
}
|
|
513
808
|
persistHookCursor(ev) {
|
|
514
809
|
this.hooksOffset = ev.byteOffset;
|
|
515
810
|
this.hookEventCursor = ev.eventCursor;
|
|
@@ -522,12 +817,22 @@ export class ClaudeLiveSession {
|
|
|
522
817
|
}
|
|
523
818
|
handleHook(ev) {
|
|
524
819
|
if (ev.eventName === "SessionStart") {
|
|
525
|
-
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);
|
|
526
831
|
return;
|
|
527
832
|
}
|
|
528
833
|
if (ev.eventName === "Stop" || ev.eventName === "StopFailure") {
|
|
529
834
|
// Subagent stops (transcript_path under `subagents/`) don't end the parent turn.
|
|
530
|
-
if (ev.transcriptPath
|
|
835
|
+
if (isSubagentTranscriptPath(ev.transcriptPath))
|
|
531
836
|
return;
|
|
532
837
|
// Stop only signals idle — it does NOT close the turn (see onIdle): closing
|
|
533
838
|
// here would split a late-flushing assistant record into its own turn.
|
|
@@ -546,6 +851,173 @@ export class ClaudeLiveSession {
|
|
|
546
851
|
}
|
|
547
852
|
}
|
|
548
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
|
+
}
|
|
549
1021
|
/** Process a provider failure only after this tick has discovered every
|
|
550
1022
|
* blocking request already appended by its hook subprocess. */
|
|
551
1023
|
flushTurnFailure() {
|
|
@@ -554,22 +1026,67 @@ export class ClaudeLiveSession {
|
|
|
554
1026
|
return;
|
|
555
1027
|
this.turnFailurePending = null;
|
|
556
1028
|
this.providerFailureSticky = true;
|
|
557
|
-
|
|
558
|
-
this.
|
|
559
|
-
|
|
560
|
-
|
|
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);
|
|
561
1037
|
}
|
|
562
1038
|
/** Emit the Stop idle signal only after this tick has discovered native
|
|
563
1039
|
* interactions. A pending question/permission is active execution, not idle. */
|
|
564
1040
|
flushStopSignal() {
|
|
565
|
-
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
|
+
}
|
|
566
1046
|
return;
|
|
1047
|
+
}
|
|
567
1048
|
this.stopSignalPending = false;
|
|
568
1049
|
if (this.pendingInteractions.size === 0) {
|
|
569
|
-
this.
|
|
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);
|
|
570
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;
|
|
571
1063
|
}
|
|
572
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
|
+
});
|
|
1089
|
+
}
|
|
573
1090
|
/** SessionStart drives discovery (first) and rotation (a later one with a NEW
|
|
574
1091
|
* session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
|
|
575
1092
|
* into an unseen id with a forkedFrom marker. Any other new-transcript
|
|
@@ -586,52 +1103,113 @@ export class ClaudeLiveSession {
|
|
|
586
1103
|
}
|
|
587
1104
|
this.transcriptPath = ev.transcriptPath;
|
|
588
1105
|
this.transcriptOffset = 0;
|
|
1106
|
+
this.committedTranscriptOffset = 0;
|
|
1107
|
+
this.transcriptLineCursor = 0;
|
|
1108
|
+
this.committedTranscriptLineCursor = 0;
|
|
589
1109
|
this.currentClaudeSessionId = ev.sessionId;
|
|
590
1110
|
if (ev.sessionId)
|
|
591
1111
|
this.seenClaudeSessionIds.add(ev.sessionId);
|
|
592
1112
|
// Resume from the persisted cursor so a relaunched forwarder doesn't re-mirror
|
|
593
1113
|
// already-logged turns.
|
|
594
1114
|
const restored = this.restoreForwardState(ev.transcriptPath);
|
|
595
|
-
|
|
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) {
|
|
596
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();
|
|
597
1131
|
}
|
|
598
1132
|
if (!this.discovered && ev.sessionId) {
|
|
599
1133
|
this.discovered = true;
|
|
600
1134
|
this.sink.onSessionDiscovered?.(ev.sessionId, ev.transcriptPath);
|
|
601
1135
|
}
|
|
602
|
-
return;
|
|
1136
|
+
return ev.source === "compact" ? this.handleCompactionCompletionHook(ev) : undefined;
|
|
603
1137
|
}
|
|
604
1138
|
// Drain the old transcript before a SessionStart switches paths so a slash
|
|
605
1139
|
// command recorded immediately before /clear or /fork can still
|
|
606
1140
|
// acknowledge the web submission.
|
|
607
1141
|
if (ev.transcriptPath !== this.transcriptPath)
|
|
608
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
|
+
}
|
|
609
1162
|
// Same session id (a plain re-announce) → nothing to do.
|
|
610
1163
|
if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
|
|
611
1164
|
return;
|
|
612
|
-
const wasSeen = this.seenClaudeSessionIds.has(ev.sessionId);
|
|
613
|
-
this.seenClaudeSessionIds.add(ev.sessionId);
|
|
614
1165
|
let kind;
|
|
615
1166
|
if (ev.source === "clear")
|
|
616
1167
|
kind = "clear";
|
|
617
|
-
else if (
|
|
1168
|
+
else if (this.isForkHookRecord(ev))
|
|
618
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;
|
|
619
1184
|
}
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
this.
|
|
624
|
-
|
|
625
|
-
|
|
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();
|
|
626
1196
|
}
|
|
627
1197
|
/** Switch the tailed transcript and reset per-session accumulators (the bridge
|
|
628
1198
|
* files — hooks/deltas/status — are shared by the same claude process across a
|
|
629
1199
|
* rotation, so their cursors are NOT reset). */
|
|
630
|
-
repointTranscript(newPath,
|
|
1200
|
+
repointTranscript(newPath, initialOffset) {
|
|
631
1201
|
this.closeTurn(); // finalize the prior session's last turn before switching
|
|
1202
|
+
this.activeTerminalCommand = undefined;
|
|
1203
|
+
this.committedTerminalCommand = undefined;
|
|
632
1204
|
this.transcriptPath = newPath;
|
|
633
|
-
this.transcriptOffset =
|
|
634
|
-
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);
|
|
635
1213
|
this.todos.clear();
|
|
636
1214
|
this.pendingQueuedSubmissions.length = 0;
|
|
637
1215
|
this.resetMessageCorrelation();
|
|
@@ -642,16 +1220,366 @@ export class ClaudeLiveSession {
|
|
|
642
1220
|
resetForwardState(this.bridgeDir);
|
|
643
1221
|
this.persistForwardState();
|
|
644
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
|
+
}
|
|
645
1262
|
pollTranscript() {
|
|
646
|
-
if (!this.transcriptPath)
|
|
1263
|
+
if (!this.transcriptPath || this.transcriptDeliveryPending)
|
|
647
1264
|
return;
|
|
648
|
-
|
|
1265
|
+
this.validateLiveTranscriptCursor();
|
|
1266
|
+
const transcriptPath = this.transcriptPath;
|
|
1267
|
+
const { entries, nextOffset, nextLineCursor } = readJsonlEntriesFrom(transcriptPath, this.transcriptOffset, this.transcriptLineCursor);
|
|
649
1268
|
this.transcriptOffset = nextOffset;
|
|
650
|
-
|
|
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
|
+
});
|
|
651
1290
|
this.handleRecord(rec);
|
|
652
|
-
|
|
653
|
-
|
|
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;
|
|
654
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
|
+
});
|
|
655
1583
|
}
|
|
656
1584
|
/** Tail blocking hook requests after transcript records, so an interaction
|
|
657
1585
|
* emitted in the same poll attaches to the user Turn that caused it. */
|
|
@@ -923,11 +1851,57 @@ export class ClaudeLiveSession {
|
|
|
923
1851
|
if (oldest)
|
|
924
1852
|
this.settledInteractions.delete(oldest);
|
|
925
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
|
+
}
|
|
926
1878
|
handleRecord(rec) {
|
|
927
1879
|
if (rec.isSidechain === true)
|
|
928
1880
|
return; // sub-agent turns (Phase 9 forwards them)
|
|
1881
|
+
if (rec.isCompactSummary === true) {
|
|
1882
|
+
this.handleCompactSummary(rec);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
929
1885
|
if (rec.isMeta === true)
|
|
930
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
|
+
}
|
|
931
1905
|
// Type-ahead is acknowledged at enqueue time. Its later
|
|
932
1906
|
// `promptSource:"queued"` user record must not count again: another
|
|
933
1907
|
// identical web message may already be awaiting its own acknowledgement.
|
|
@@ -945,17 +1919,7 @@ export class ClaudeLiveSession {
|
|
|
945
1919
|
}
|
|
946
1920
|
return;
|
|
947
1921
|
}
|
|
948
|
-
|
|
949
|
-
// re-read after a fingerprint reset). rynx emits a record's items atomically,
|
|
950
|
-
// so the record uuid is the source key (vs reference implementation's per-block key for its
|
|
951
|
-
// per-item POST). Records without a uuid fall through undeduped (rare).
|
|
952
|
-
const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
|
|
953
|
-
if (sourceId) {
|
|
954
|
-
if (this.seenSourceIds.has(sourceId))
|
|
955
|
-
return;
|
|
956
|
-
this.seenSourceIds.add(sourceId);
|
|
957
|
-
}
|
|
958
|
-
const content = userStringContent(rec);
|
|
1922
|
+
const content = stringContent;
|
|
959
1923
|
if (content !== undefined) {
|
|
960
1924
|
const firstLine = content.trim().split("\n", 1)[0] ?? "";
|
|
961
1925
|
if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
|
|
@@ -976,51 +1940,32 @@ export class ClaudeLiveSession {
|
|
|
976
1940
|
for (const candidate of candidates)
|
|
977
1941
|
this.rememberSubmission(candidate);
|
|
978
1942
|
}
|
|
979
|
-
|
|
980
|
-
// it as a self-contained terminal_command turn (before the prompt check, as
|
|
981
|
-
// it too is `<`-prefixed).
|
|
982
|
-
const term = parseTerminalCommand(content);
|
|
983
|
-
if (term) {
|
|
984
|
-
this.emitTerminalCommand(rec, term);
|
|
1943
|
+
if (isClaudeUserScaffolding(content))
|
|
985
1944
|
return;
|
|
1945
|
+
if (isTaskNotificationText(content)) {
|
|
1946
|
+
this.sink.onMetaUserMessage?.(content, rec.uuid ?? rec.requestId);
|
|
986
1947
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
|
|
990
|
-
if (queuedIntoOpenTurn) {
|
|
991
|
-
// Claude type-ahead is one native busy interval: enqueue now, promote
|
|
992
|
-
// later, and emit one final Stop. Do not close/re-open the canonical
|
|
993
|
-
// response when the promoted user record arrives mid-turn.
|
|
994
|
-
this.sink.onUserMessage(content);
|
|
995
|
-
this.lastActivityAt = this.now();
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
999
|
-
if (this.turnOpen && this.syntheticTurn) {
|
|
1000
|
-
// A hook can flush just before the transcript's user record. The
|
|
1001
|
-
// interaction already opened a unique provisional response; adopt the
|
|
1002
|
-
// real turn id internally without closing/cancelling that same Turn.
|
|
1003
|
-
this.currentTurnId = turnId;
|
|
1004
|
-
this.syntheticTurn = false;
|
|
1005
|
-
}
|
|
1006
|
-
else {
|
|
1007
|
-
this.closeTurn();
|
|
1008
|
-
this.currentTurnId = turnId;
|
|
1009
|
-
this.turnOpen = true;
|
|
1010
|
-
this.turnInterrupted = false;
|
|
1011
|
-
this.syntheticTurn = false;
|
|
1012
|
-
this.providerIdleAt = null;
|
|
1013
|
-
this.openToolIds.clear();
|
|
1014
|
-
this.stopPendingAt = null;
|
|
1015
|
-
this.stopBackgroundTaskCount = undefined;
|
|
1016
|
-
this.stopBackgroundTaskCountDelivered = false;
|
|
1017
|
-
this.sink.onTurnStart(this.currentTurnId);
|
|
1018
|
-
}
|
|
1019
|
-
this.sink.onUserMessage(content);
|
|
1020
|
-
this.lastActivityAt = this.now();
|
|
1948
|
+
else {
|
|
1949
|
+
this.emitUserMessageRecord(rec, content, queuedPromotion);
|
|
1021
1950
|
}
|
|
1022
1951
|
return; // string-content user record: prompt, terminal_command, or skipped marker
|
|
1023
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
|
+
}
|
|
1024
1969
|
const events = parseTranscriptRecord(rec);
|
|
1025
1970
|
if (events.length === 0)
|
|
1026
1971
|
return;
|
|
@@ -1028,15 +1973,75 @@ export class ClaudeLiveSession {
|
|
|
1028
1973
|
for (const event of events) {
|
|
1029
1974
|
const mapped = this.remapMessageItem(event);
|
|
1030
1975
|
this.trackTool(mapped);
|
|
1976
|
+
this.rememberNativeSubagentParentResponse(mapped);
|
|
1031
1977
|
this.sink.onEvent(mapped);
|
|
1032
1978
|
}
|
|
1033
|
-
// A Task tool_result carries `toolUseResult.agentId` — replay the (now
|
|
1034
|
-
// complete) sub-agent transcript nested under this parent Task call.
|
|
1035
|
-
this.maybeForwardSubagent(rec);
|
|
1036
1979
|
// TaskCreate/TaskUpdate records also refresh the task-list snapshot.
|
|
1037
1980
|
this.maybeUpdateTodos(rec);
|
|
1038
1981
|
this.lastActivityAt = this.now();
|
|
1039
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
|
+
}
|
|
1040
2045
|
/** Fold a `TaskCreate` (new pending task) or `TaskUpdate` (status/subject/delete)
|
|
1041
2046
|
* record into the task list; on any change emit the whole list as a snapshot. */
|
|
1042
2047
|
maybeUpdateTodos(rec) {
|
|
@@ -1063,28 +2068,6 @@ export class ClaudeLiveSession {
|
|
|
1063
2068
|
if (changed)
|
|
1064
2069
|
this.sink.onTodos([...this.todos.values()]);
|
|
1065
2070
|
}
|
|
1066
|
-
/** On a `Task` tool_result, replay the sub-agent's own transcript
|
|
1067
|
-
* (`subagents/agent-<agentId>.jsonl`) as events tagged with the parent Task
|
|
1068
|
-
* tool-use id, so the canonical layer nests them under that call. Fired once
|
|
1069
|
-
* per sub-agent (at tool_result time the file is complete — no live race). */
|
|
1070
|
-
maybeForwardSubagent(rec) {
|
|
1071
|
-
if (!this.transcriptPath)
|
|
1072
|
-
return;
|
|
1073
|
-
const result = rec.toolUseResult;
|
|
1074
|
-
const agentId = isRecord(result) && typeof result.agentId === "string" ? result.agentId : undefined;
|
|
1075
|
-
if (!agentId || this.seenSubagents.has(agentId))
|
|
1076
|
-
return;
|
|
1077
|
-
const parentToolUseId = toolResultCallId(rec);
|
|
1078
|
-
if (!parentToolUseId)
|
|
1079
|
-
return;
|
|
1080
|
-
this.seenSubagents.add(agentId);
|
|
1081
|
-
const file = subagentTranscriptPath(this.transcriptPath, agentId);
|
|
1082
|
-
// Emit raw (no remap/track): sub-agent messages have their own itemIds and
|
|
1083
|
-
// must not consume the parent's MessageDisplay FIFO, and their tool pairs are
|
|
1084
|
-
// already complete so they need no open-tool tracking.
|
|
1085
|
-
for (const event of readSubagentEvents(file, parentToolUseId))
|
|
1086
|
-
this.sink.onEvent(event);
|
|
1087
|
-
}
|
|
1088
2071
|
/** Tail streamed assistant-text chunks (MessageDisplay) into live `token`
|
|
1089
2072
|
* events. Guarded on an open turn — deltas belong to the turn the user record
|
|
1090
2073
|
* opened; the offset advances only once processed. */
|
|
@@ -1093,9 +2076,21 @@ export class ClaudeLiveSession {
|
|
|
1093
2076
|
this.deltasOffset = nextOffset;
|
|
1094
2077
|
// MessageDisplay has no Turn id. Chunks observed while idle belong to the
|
|
1095
2078
|
// Turn that just closed and must never be replayed into a later Turn.
|
|
1096
|
-
if (!this.turnOpen)
|
|
2079
|
+
if (!this.turnOpen) {
|
|
2080
|
+
writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
|
|
1097
2081
|
return;
|
|
2082
|
+
}
|
|
1098
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
|
+
}
|
|
1099
2094
|
this.streamedMessageText.set(d.messageId, (this.streamedMessageText.get(d.messageId) ?? "") + d.delta);
|
|
1100
2095
|
this.sink.onEvent({
|
|
1101
2096
|
type: "token",
|
|
@@ -1111,6 +2106,9 @@ export class ClaudeLiveSession {
|
|
|
1111
2106
|
this.finalizeMessageDisplay(d.messageId);
|
|
1112
2107
|
}
|
|
1113
2108
|
}
|
|
2109
|
+
if (nextOffset > 0 || deltas.length > 0) {
|
|
2110
|
+
writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
|
|
2111
|
+
}
|
|
1114
2112
|
if (deltas.length)
|
|
1115
2113
|
this.lastActivityAt = this.now();
|
|
1116
2114
|
}
|
|
@@ -1143,8 +2141,10 @@ export class ClaudeLiveSession {
|
|
|
1143
2141
|
this.providerIdleAt = null;
|
|
1144
2142
|
}
|
|
1145
2143
|
handleSessionStatus(status) {
|
|
1146
|
-
this.
|
|
1147
|
-
|
|
2144
|
+
if (this.pendingTerminalHook?.eventName !== "Stop") {
|
|
2145
|
+
this.stopSignalPending = false;
|
|
2146
|
+
this.stopPendingAt = null;
|
|
2147
|
+
}
|
|
1148
2148
|
if (status.runnerStatus === "running") {
|
|
1149
2149
|
this.providerFailureSticky = false;
|
|
1150
2150
|
// A new native busy interval supersedes the prior Stop snapshot. The
|
|
@@ -1216,6 +2216,25 @@ export class ClaudeLiveSession {
|
|
|
1216
2216
|
this.openToolIds.delete(id);
|
|
1217
2217
|
}
|
|
1218
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
|
+
}
|
|
1219
2238
|
ensureTurn(provisionalTurnId) {
|
|
1220
2239
|
if (this.turnOpen)
|
|
1221
2240
|
return;
|
|
@@ -1230,15 +2249,30 @@ export class ClaudeLiveSession {
|
|
|
1230
2249
|
this.stopBackgroundTaskCountDelivered = false;
|
|
1231
2250
|
this.sink.onTurnStart(this.currentTurnId);
|
|
1232
2251
|
}
|
|
1233
|
-
/** Mirror a
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
+
}
|
|
1242
2276
|
}
|
|
1243
2277
|
/** The web Stop button interrupted this session (host sent Escape). claude
|
|
1244
2278
|
* records the interrupt in its own transcript but may not fire a Stop hook when
|
|
@@ -1281,7 +2315,7 @@ export class ClaudeLiveSession {
|
|
|
1281
2315
|
this.sink.onTurnError(error);
|
|
1282
2316
|
return failedOpenTurn;
|
|
1283
2317
|
}
|
|
1284
|
-
closeTurn() {
|
|
2318
|
+
closeTurn(reason) {
|
|
1285
2319
|
if (!this.turnOpen)
|
|
1286
2320
|
return;
|
|
1287
2321
|
this.drainInteractionCommits();
|
|
@@ -1304,9 +2338,11 @@ export class ClaudeLiveSession {
|
|
|
1304
2338
|
if (interrupted && this.sink.onTurnInterrupted)
|
|
1305
2339
|
this.sink.onTurnInterrupted(usage);
|
|
1306
2340
|
else
|
|
1307
|
-
this.sink.onTurnEnd(usage, backgroundTaskCount);
|
|
2341
|
+
this.sink.onTurnEnd(usage, backgroundTaskCount, reason);
|
|
2342
|
+
if (!this.transcriptDeliveryPending)
|
|
2343
|
+
this.persistForwardState();
|
|
1308
2344
|
}
|
|
1309
|
-
closeTurnError(error) {
|
|
2345
|
+
closeTurnError(error, sourceId) {
|
|
1310
2346
|
if (!this.turnOpen)
|
|
1311
2347
|
return;
|
|
1312
2348
|
this.drainInteractionCommits();
|
|
@@ -1321,7 +2357,10 @@ export class ClaudeLiveSession {
|
|
|
1321
2357
|
this.stopBackgroundTaskCount = undefined;
|
|
1322
2358
|
this.stopBackgroundTaskCountDelivered = false;
|
|
1323
2359
|
this.resetMessageCorrelation();
|
|
1324
|
-
this.sink.onTurnError(error);
|
|
2360
|
+
const delivery = this.sink.onTurnError(error, sourceId);
|
|
2361
|
+
if (!this.transcriptDeliveryPending)
|
|
2362
|
+
this.persistForwardState();
|
|
2363
|
+
return delivery;
|
|
1325
2364
|
}
|
|
1326
2365
|
resetMessageCorrelation() {
|
|
1327
2366
|
this.finalizedMessageIds.clear();
|