@rynx-ai/runtime 0.1.11-beta.36 → 0.1.11-beta.38
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
|
@@ -5,6 +5,9 @@ export declare const DELTAS_FILE = "message_deltas.jsonl";
|
|
|
5
5
|
export declare const STATUS_FILE = "status.json";
|
|
6
6
|
export declare const FORWARDER_STATE_FILE = "transcript_forwarder.json";
|
|
7
7
|
export declare const HOOK_FORWARDER_STATE_FILE = "hook_forwarder.json";
|
|
8
|
+
export declare const SUBAGENT_FORWARDER_STATE_FILE = "subagent_forwarder.json";
|
|
9
|
+
export declare const DELTA_FORWARDER_STATE_FILE = "message_deltas_forwarder.json";
|
|
10
|
+
export declare const COMPACTION_FORWARDER_STATE_FILE = "compaction_forwarder.json";
|
|
8
11
|
export declare const INTERACTIONS_FILE = "interactions.jsonl";
|
|
9
12
|
export declare const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
|
|
10
13
|
export declare const INTERACTION_RESULTS_DIR = "interaction-results";
|
|
@@ -83,9 +86,15 @@ export interface ClaudeBridgeState {
|
|
|
83
86
|
transcriptPath?: string;
|
|
84
87
|
/** Claude's own session uuid (from the hook payload `session_id`). */
|
|
85
88
|
claudeSessionId?: string;
|
|
89
|
+
/** Native Session ids already observed by this stable bridge owner. */
|
|
90
|
+
seenClaudeSessionIds?: string[];
|
|
86
91
|
lastHookEventName?: string;
|
|
87
92
|
}
|
|
88
93
|
export declare function readClaudeState(bridgeDir: string): ClaudeBridgeState;
|
|
94
|
+
/** Capture the pre-SessionStart identity before state.json is advanced. The
|
|
95
|
+
* observer hook and forwarder use these stable facts to distinguish a new
|
|
96
|
+
* branch from a later resume into an already-seen native Session. */
|
|
97
|
+
export declare function annotateClaudeResumeContext(bridgeDir: string, payload: Record<string, unknown>): void;
|
|
89
98
|
/**
|
|
90
99
|
* Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
|
|
91
100
|
* `state.json` (transcript path + claude session id + last event). The payload
|
|
@@ -101,14 +110,29 @@ export interface JsonlReadResult<T> {
|
|
|
101
110
|
* line is left unconsumed so the next poll retries it once its newline lands. */
|
|
102
111
|
nextOffset: number;
|
|
103
112
|
}
|
|
113
|
+
export interface JsonlRecordEntry<T> {
|
|
114
|
+
record: T;
|
|
115
|
+
/** Byte offset at which this complete record begins. */
|
|
116
|
+
byteOffset: number;
|
|
117
|
+
/** One-based physical line number, including blank/malformed lines. */
|
|
118
|
+
lineNumber: number;
|
|
119
|
+
}
|
|
120
|
+
export interface JsonlEntryReadResult<T> extends JsonlReadResult<T> {
|
|
121
|
+
entries: JsonlRecordEntry<T>[];
|
|
122
|
+
nextLineCursor: number;
|
|
123
|
+
}
|
|
104
124
|
/** Read complete newline-terminated JSON records appended after `byteOffset`. */
|
|
105
125
|
export declare function readJsonlFrom<T = unknown>(path: string, byteOffset: number): JsonlReadResult<T>;
|
|
126
|
+
/** Read complete records with their stable byte/line source positions. */
|
|
127
|
+
export declare function readJsonlEntriesFrom<T = unknown>(path: string, byteOffset: number, startLineCursor?: number): JsonlEntryReadResult<T>;
|
|
106
128
|
/** One parsed Claude hook event (the fields the forwarder acts on + the raw payload). */
|
|
107
129
|
export interface HookEvent {
|
|
108
130
|
/** One-based record position in the append-only hook stream. */
|
|
109
131
|
eventCursor: number;
|
|
110
132
|
/** Byte offset immediately after this complete JSONL record. */
|
|
111
133
|
byteOffset: number;
|
|
134
|
+
/** Wall-clock time when the hook observer captured this payload. */
|
|
135
|
+
recordedAt?: number;
|
|
112
136
|
eventName?: string;
|
|
113
137
|
transcriptPath?: string;
|
|
114
138
|
/** Claude's session uuid — subagent hooks report a `subagents/…` transcript path. */
|
|
@@ -140,6 +164,31 @@ export interface MessageDelta {
|
|
|
140
164
|
final: boolean;
|
|
141
165
|
delta: string;
|
|
142
166
|
}
|
|
167
|
+
export interface DeltaForwardState {
|
|
168
|
+
byteOffset: number;
|
|
169
|
+
}
|
|
170
|
+
export interface PendingCompactionState {
|
|
171
|
+
sequence: number;
|
|
172
|
+
claudeSessionId?: string;
|
|
173
|
+
transcriptPath?: string;
|
|
174
|
+
}
|
|
175
|
+
export interface CompactionForwardState {
|
|
176
|
+
/** Logical Provider generation. clear/fork starts a fresh reconciliation
|
|
177
|
+
* domain even though the bridge directory remains stable. */
|
|
178
|
+
generation: string;
|
|
179
|
+
lastSequence: number;
|
|
180
|
+
persistedSequences: number[];
|
|
181
|
+
lastPrecompactCursor: number;
|
|
182
|
+
pending?: PendingCompactionState;
|
|
183
|
+
expectCompletionAckSequence?: number;
|
|
184
|
+
}
|
|
185
|
+
export declare function readCompactionForwardState(bridgeDir: string, generation: string): CompactionForwardState;
|
|
186
|
+
export declare function writeCompactionForwardState(bridgeDir: string, state: CompactionForwardState): void;
|
|
187
|
+
/** Read the process-wide MessageDisplay cursor. It deliberately survives
|
|
188
|
+
* logical Session rotation; if the append log was truncated by a fresh Claude
|
|
189
|
+
* process, restart at zero. */
|
|
190
|
+
export declare function readDeltaForwardState(bridgeDir: string): DeltaForwardState;
|
|
191
|
+
export declare function writeDeltaForwardState(bridgeDir: string, state: DeltaForwardState): void;
|
|
143
192
|
/** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
|
|
144
193
|
export declare function readMessageDeltasFrom(bridgeDir: string, byteOffset: number): {
|
|
145
194
|
deltas: MessageDelta[];
|
|
@@ -183,12 +232,26 @@ export interface TranscriptForwardState {
|
|
|
183
232
|
transcriptPath: string;
|
|
184
233
|
/** Primary dedup: bytes already forwarded. A restart seeks here. */
|
|
185
234
|
byteOffset: number;
|
|
235
|
+
/** Complete physical JSONL lines consumed at `byteOffset`. */
|
|
236
|
+
lineCursor?: number;
|
|
186
237
|
/** Secondary dedup: recently-forwarded record source ids (a bounded ring), so a
|
|
187
238
|
* re-read (fingerprint reset / mid-poll death) doesn't re-emit. */
|
|
188
239
|
seenSourceIds: string[];
|
|
189
240
|
/** SHA-256 of `byteOffset` (8-byte BE) + up to 256 bytes before it. On restart a
|
|
190
241
|
* mismatch means the file was truncated/replaced → don't seek into a stale offset. */
|
|
191
242
|
cursorFingerprint?: string;
|
|
243
|
+
/** Open Claude turn restored across a runner restart so later records remain
|
|
244
|
+
* grouped under the same canonical response. */
|
|
245
|
+
currentTurnId?: string;
|
|
246
|
+
currentResponseId?: string;
|
|
247
|
+
turnOpen?: boolean;
|
|
248
|
+
/** Split local-command input awaiting its later output record. Rynx stores a
|
|
249
|
+
* combined terminal item, so the command text and owning turn must survive a
|
|
250
|
+
* runner restart between those two native transcript records. */
|
|
251
|
+
activeTerminalCommand?: {
|
|
252
|
+
command: string;
|
|
253
|
+
turnId?: string;
|
|
254
|
+
};
|
|
192
255
|
}
|
|
193
256
|
/** Cap on `seenSourceIds` (matches reference implementation's `_MAX_SEEN_SOURCE_IDS`). */
|
|
194
257
|
export declare const MAX_SEEN_SOURCE_IDS = 2000;
|
|
@@ -203,5 +266,27 @@ export declare function writeForwardState(bridgeDir: string, state: TranscriptFo
|
|
|
203
266
|
export declare function readForwardState(bridgeDir: string): TranscriptForwardState | undefined;
|
|
204
267
|
/** Drop the forwarder cursor (a `/clear` starts fresh). */
|
|
205
268
|
export declare function resetForwardState(bridgeDir: string): void;
|
|
269
|
+
export interface NativeSubagentForwardState {
|
|
270
|
+
agentId: string;
|
|
271
|
+
parentToolCallId: string;
|
|
272
|
+
/** Canonical parent response retained after the parent transcript cursor has
|
|
273
|
+
* advanced, so a runner restart keeps late child items in the same Turn. */
|
|
274
|
+
parentResponseId?: string;
|
|
275
|
+
transcriptPath: string;
|
|
276
|
+
byteOffset: number;
|
|
277
|
+
lineCursor?: number;
|
|
278
|
+
seenSourceIds: string[];
|
|
279
|
+
/** Split local-command input represented by the latest handled child item. */
|
|
280
|
+
terminalCommand?: string;
|
|
281
|
+
}
|
|
282
|
+
/** Parent Task/tool ids already consumed by the main transcript cursor. Kept
|
|
283
|
+
* independently of concrete child agents because Claude may create the child
|
|
284
|
+
* meta file only after the parent record was durably acknowledged. */
|
|
285
|
+
export declare function readSubagentParentResponses(bridgeDir: string, parentTranscriptPath: string): Map<string, string>;
|
|
286
|
+
/** Read independent Claude native sub-agent cursors for one parent transcript. */
|
|
287
|
+
export declare function readSubagentForwardStates(bridgeDir: string, parentTranscriptPath: string): NativeSubagentForwardState[];
|
|
288
|
+
/** Atomically persist every native sub-agent's independent delivery cursor. */
|
|
289
|
+
export declare function writeSubagentForwardStates(bridgeDir: string, parentTranscriptPath: string, states: readonly NativeSubagentForwardState[], parentResponses?: ReadonlyMap<string, string>): void;
|
|
290
|
+
export declare function resetSubagentForwardStates(bridgeDir: string): void;
|
|
206
291
|
/** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
|
|
207
292
|
export declare function readClaudeStatus(bridgeDir: string): ClaudeStatusState | undefined;
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* Dependency-light on purpose (node builtins plus erased protocol types) so the
|
|
12
12
|
* standalone hook entrypoints do not pull the whole runtime into every process.
|
|
13
13
|
*/
|
|
14
|
-
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
14
|
+
import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { adoptLegacyRuntimeDirectory, legacyRuntimeStateRoot, runtimeSessionDigest, runtimeSessionStateDir, } from "../runtime-state-paths.js";
|
|
18
18
|
export const HOOKS_FILE = "hooks.jsonl";
|
|
@@ -21,12 +21,41 @@ export const DELTAS_FILE = "message_deltas.jsonl";
|
|
|
21
21
|
export const STATUS_FILE = "status.json";
|
|
22
22
|
export const FORWARDER_STATE_FILE = "transcript_forwarder.json";
|
|
23
23
|
export const HOOK_FORWARDER_STATE_FILE = "hook_forwarder.json";
|
|
24
|
+
export const SUBAGENT_FORWARDER_STATE_FILE = "subagent_forwarder.json";
|
|
25
|
+
export const DELTA_FORWARDER_STATE_FILE = "message_deltas_forwarder.json";
|
|
26
|
+
export const COMPACTION_FORWARDER_STATE_FILE = "compaction_forwarder.json";
|
|
24
27
|
export const INTERACTIONS_FILE = "interactions.jsonl";
|
|
25
28
|
export const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
|
|
26
29
|
export const INTERACTION_RESULTS_DIR = "interaction-results";
|
|
27
30
|
export const INTERACTION_CLAIMS_DIR = "interaction-claims";
|
|
28
31
|
export const INTERACTION_LEASES_DIR = "interaction-leases";
|
|
29
32
|
export const MANAGED_SETTINGS_FILE = "managed-settings.json";
|
|
33
|
+
/** Reference bridge state uses temp + fsync + replace so a hook crash can
|
|
34
|
+
* never leave readers with a truncated JSON object. A unique temp name also
|
|
35
|
+
* keeps concurrent native hook subprocesses from clobbering one another's
|
|
36
|
+
* staging file. */
|
|
37
|
+
function writeJsonFileAtomic(path, payload) {
|
|
38
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
39
|
+
let fd;
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(tmp, "wx", 0o600);
|
|
42
|
+
writeFileSync(fd, JSON.stringify(payload));
|
|
43
|
+
fsyncSync(fd);
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
fd = undefined;
|
|
46
|
+
renameSync(tmp, path);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (fd !== undefined)
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
try {
|
|
52
|
+
rmSync(tmp);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// rename succeeded, or a competing cleanup already removed the temp.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
30
59
|
/** The deterministic bridge directory for a rynx session id. */
|
|
31
60
|
export function claudeBridgeDir(sessionId) {
|
|
32
61
|
return join(runtimeSessionStateDir(sessionId), "claude-bridge");
|
|
@@ -293,12 +322,35 @@ export function readClaudeState(bridgeDir) {
|
|
|
293
322
|
if (!raw || typeof raw !== "object")
|
|
294
323
|
return {};
|
|
295
324
|
const s = raw;
|
|
325
|
+
const seen = Array.isArray(s.seenClaudeSessionIds)
|
|
326
|
+
? s.seenClaudeSessionIds.filter((value) => typeof value === "string" && value.length > 0)
|
|
327
|
+
: [];
|
|
328
|
+
const current = asString(s.claudeSessionId);
|
|
329
|
+
if (current && !seen.includes(current))
|
|
330
|
+
seen.push(current);
|
|
296
331
|
return {
|
|
297
332
|
transcriptPath: asString(s.transcriptPath),
|
|
298
|
-
claudeSessionId:
|
|
333
|
+
claudeSessionId: current,
|
|
334
|
+
...(seen.length > 0 ? { seenClaudeSessionIds: seen } : {}),
|
|
299
335
|
lastHookEventName: asString(s.lastHookEventName),
|
|
300
336
|
};
|
|
301
337
|
}
|
|
338
|
+
/** Capture the pre-SessionStart identity before state.json is advanced. The
|
|
339
|
+
* observer hook and forwarder use these stable facts to distinguish a new
|
|
340
|
+
* branch from a later resume into an already-seen native Session. */
|
|
341
|
+
export function annotateClaudeResumeContext(bridgeDir, payload) {
|
|
342
|
+
if (payload.hook_event_name !== "SessionStart" || payload.source !== "resume")
|
|
343
|
+
return;
|
|
344
|
+
const next = asString(payload.session_id);
|
|
345
|
+
if (!next)
|
|
346
|
+
return;
|
|
347
|
+
const state = readClaudeState(bridgeDir);
|
|
348
|
+
if (state.claudeSessionId && state.claudeSessionId !== next) {
|
|
349
|
+
payload.rynx_previous_claude_session_id = state.claudeSessionId;
|
|
350
|
+
}
|
|
351
|
+
payload.rynx_claude_session_was_seen =
|
|
352
|
+
(state.seenClaudeSessionIds ?? []).includes(next);
|
|
353
|
+
}
|
|
302
354
|
/**
|
|
303
355
|
* Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
|
|
304
356
|
* `state.json` (transcript path + claude session id + last event). The payload
|
|
@@ -309,31 +361,58 @@ export function readClaudeState(bridgeDir) {
|
|
|
309
361
|
*/
|
|
310
362
|
export function recordHookEvent(bridgeDir, payload) {
|
|
311
363
|
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
312
|
-
|
|
313
|
-
|
|
364
|
+
if (payload.rynx_claude_session_was_seen === undefined) {
|
|
365
|
+
annotateClaudeResumeContext(bridgeDir, payload);
|
|
366
|
+
}
|
|
314
367
|
const state = readClaudeState(bridgeDir);
|
|
315
368
|
const eventName = asString(payload.hook_event_name);
|
|
316
369
|
if (eventName)
|
|
317
370
|
state.lastHookEventName = eventName;
|
|
318
|
-
const transcriptPath = asString(payload.transcript_path);
|
|
319
|
-
if (transcriptPath)
|
|
320
|
-
state.transcriptPath = transcriptPath;
|
|
321
371
|
const claudeSessionId = asString(payload.session_id);
|
|
322
|
-
|
|
323
|
-
state.claudeSessionId
|
|
324
|
-
|
|
372
|
+
const identityAllowed = eventName === "SessionStart" || (claudeSessionId !== undefined &&
|
|
373
|
+
(!state.claudeSessionId || claudeSessionId === state.claudeSessionId));
|
|
374
|
+
if (identityAllowed) {
|
|
375
|
+
const transcriptPath = asString(payload.transcript_path);
|
|
376
|
+
if (transcriptPath)
|
|
377
|
+
state.transcriptPath = transcriptPath;
|
|
378
|
+
if (claudeSessionId) {
|
|
379
|
+
state.claudeSessionId = claudeSessionId;
|
|
380
|
+
const seen = new Set(state.seenClaudeSessionIds ?? []);
|
|
381
|
+
seen.add(claudeSessionId);
|
|
382
|
+
state.seenClaudeSessionIds = [...seen].sort();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const envelope = { recordedAt: Date.now(), payload };
|
|
386
|
+
appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
|
|
387
|
+
writeJsonFileAtomic(join(bridgeDir, STATE_FILE), state);
|
|
325
388
|
}
|
|
326
389
|
/** Read complete newline-terminated JSON records appended after `byteOffset`. */
|
|
327
390
|
export function readJsonlFrom(path, byteOffset) {
|
|
391
|
+
const result = readJsonlEntriesFrom(path, byteOffset);
|
|
392
|
+
return { records: result.records, nextOffset: result.nextOffset };
|
|
393
|
+
}
|
|
394
|
+
/** Read complete records with their stable byte/line source positions. */
|
|
395
|
+
export function readJsonlEntriesFrom(path, byteOffset, startLineCursor = 0) {
|
|
328
396
|
let info;
|
|
329
397
|
try {
|
|
330
398
|
info = statSync(path);
|
|
331
399
|
}
|
|
332
400
|
catch {
|
|
333
|
-
return {
|
|
401
|
+
return {
|
|
402
|
+
records: [],
|
|
403
|
+
entries: [],
|
|
404
|
+
nextOffset: byteOffset,
|
|
405
|
+
nextLineCursor: startLineCursor,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (info.size <= byteOffset) {
|
|
409
|
+
return {
|
|
410
|
+
records: [],
|
|
411
|
+
entries: [],
|
|
412
|
+
nextOffset: byteOffset,
|
|
413
|
+
nextLineCursor: startLineCursor,
|
|
414
|
+
};
|
|
334
415
|
}
|
|
335
|
-
if (info.size <= byteOffset)
|
|
336
|
-
return { records: [], nextOffset: byteOffset };
|
|
337
416
|
const fd = openSync(path, "r");
|
|
338
417
|
try {
|
|
339
418
|
const length = info.size - byteOffset;
|
|
@@ -344,18 +423,31 @@ export function readJsonlFrom(path, byteOffset) {
|
|
|
344
423
|
const trailing = lines.pop() ?? ""; // partial (no trailing newline yet)
|
|
345
424
|
const consumed = length - Buffer.byteLength(trailing, "utf8");
|
|
346
425
|
const records = [];
|
|
426
|
+
const entries = [];
|
|
427
|
+
let recordOffset = byteOffset;
|
|
428
|
+
let lineNumber = startLineCursor;
|
|
347
429
|
for (const line of lines) {
|
|
430
|
+
const lineOffset = recordOffset;
|
|
431
|
+
recordOffset += Buffer.byteLength(`${line}\n`, "utf8");
|
|
432
|
+
lineNumber += 1;
|
|
348
433
|
const trimmed = line.trim();
|
|
349
434
|
if (!trimmed)
|
|
350
435
|
continue;
|
|
351
436
|
try {
|
|
352
|
-
|
|
437
|
+
const record = JSON.parse(trimmed);
|
|
438
|
+
records.push(record);
|
|
439
|
+
entries.push({ record, byteOffset: lineOffset, lineNumber });
|
|
353
440
|
}
|
|
354
441
|
catch {
|
|
355
442
|
// Skip a malformed complete line; the offset still advances past it.
|
|
356
443
|
}
|
|
357
444
|
}
|
|
358
|
-
return {
|
|
445
|
+
return {
|
|
446
|
+
records,
|
|
447
|
+
entries,
|
|
448
|
+
nextOffset: byteOffset + consumed,
|
|
449
|
+
nextLineCursor: lineNumber,
|
|
450
|
+
};
|
|
359
451
|
}
|
|
360
452
|
finally {
|
|
361
453
|
closeSync(fd);
|
|
@@ -407,6 +499,9 @@ export function readHookEventsFrom(bridgeDir, byteOffset, startEventCursor = 0)
|
|
|
407
499
|
events.push({
|
|
408
500
|
eventCursor,
|
|
409
501
|
byteOffset: nextOffset,
|
|
502
|
+
...(typeof rec.recordedAt === "number" && Number.isFinite(rec.recordedAt)
|
|
503
|
+
? { recordedAt: rec.recordedAt }
|
|
504
|
+
: {}),
|
|
410
505
|
eventName: asString(payload.hook_event_name),
|
|
411
506
|
transcriptPath: asString(payload.transcript_path),
|
|
412
507
|
sessionId: asString(payload.session_id),
|
|
@@ -455,6 +550,118 @@ export function writeHookForwardState(bridgeDir, state) {
|
|
|
455
550
|
writeFileSync(tmp, JSON.stringify(state));
|
|
456
551
|
renameSync(tmp, join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
|
|
457
552
|
}
|
|
553
|
+
export function readCompactionForwardState(bridgeDir, generation) {
|
|
554
|
+
let raw;
|
|
555
|
+
try {
|
|
556
|
+
raw = JSON.parse(readFileSync(join(bridgeDir, COMPACTION_FORWARDER_STATE_FILE), "utf8"));
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return {
|
|
560
|
+
generation,
|
|
561
|
+
lastSequence: 0,
|
|
562
|
+
persistedSequences: [],
|
|
563
|
+
lastPrecompactCursor: 0,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (!raw || typeof raw !== "object") {
|
|
567
|
+
return {
|
|
568
|
+
generation,
|
|
569
|
+
lastSequence: 0,
|
|
570
|
+
persistedSequences: [],
|
|
571
|
+
lastPrecompactCursor: 0,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
const value = raw;
|
|
575
|
+
if (asString(value.generation) !== generation) {
|
|
576
|
+
return {
|
|
577
|
+
generation,
|
|
578
|
+
lastSequence: 0,
|
|
579
|
+
persistedSequences: [],
|
|
580
|
+
lastPrecompactCursor: 0,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const lastSequence = asNumber(value.lastSequence);
|
|
584
|
+
const lastPrecompactCursor = asNumber(value.lastPrecompactCursor);
|
|
585
|
+
const persistedSequences = Array.isArray(value.persistedSequences)
|
|
586
|
+
? value.persistedSequences.filter((entry) => typeof entry === "number" && Number.isInteger(entry) && entry > 0).slice(-16)
|
|
587
|
+
: [];
|
|
588
|
+
const pendingRaw = value.pending;
|
|
589
|
+
let pending;
|
|
590
|
+
if (pendingRaw && typeof pendingRaw === "object") {
|
|
591
|
+
const candidate = pendingRaw;
|
|
592
|
+
const sequence = asNumber(candidate.sequence);
|
|
593
|
+
if (sequence !== undefined && Number.isInteger(sequence) && sequence > 0) {
|
|
594
|
+
pending = {
|
|
595
|
+
sequence,
|
|
596
|
+
...(asString(candidate.claudeSessionId)
|
|
597
|
+
? { claudeSessionId: asString(candidate.claudeSessionId) }
|
|
598
|
+
: {}),
|
|
599
|
+
...(asString(candidate.transcriptPath)
|
|
600
|
+
? { transcriptPath: asString(candidate.transcriptPath) }
|
|
601
|
+
: {}),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const expectCompletionAckSequence = asNumber(value.expectCompletionAckSequence);
|
|
606
|
+
return {
|
|
607
|
+
generation,
|
|
608
|
+
lastSequence: lastSequence !== undefined && Number.isInteger(lastSequence) && lastSequence >= 0
|
|
609
|
+
? lastSequence
|
|
610
|
+
: 0,
|
|
611
|
+
persistedSequences,
|
|
612
|
+
lastPrecompactCursor: lastPrecompactCursor !== undefined && Number.isInteger(lastPrecompactCursor) &&
|
|
613
|
+
lastPrecompactCursor >= 0
|
|
614
|
+
? lastPrecompactCursor
|
|
615
|
+
: 0,
|
|
616
|
+
...(pending ? { pending } : {}),
|
|
617
|
+
...(expectCompletionAckSequence !== undefined &&
|
|
618
|
+
Number.isInteger(expectCompletionAckSequence) && expectCompletionAckSequence > 0
|
|
619
|
+
? { expectCompletionAckSequence }
|
|
620
|
+
: {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
export function writeCompactionForwardState(bridgeDir, state) {
|
|
624
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
625
|
+
const tmp = join(bridgeDir, `${COMPACTION_FORWARDER_STATE_FILE}.tmp`);
|
|
626
|
+
writeFileSync(tmp, JSON.stringify({
|
|
627
|
+
...state,
|
|
628
|
+
persistedSequences: state.persistedSequences.slice(-16),
|
|
629
|
+
}));
|
|
630
|
+
renameSync(tmp, join(bridgeDir, COMPACTION_FORWARDER_STATE_FILE));
|
|
631
|
+
}
|
|
632
|
+
/** Read the process-wide MessageDisplay cursor. It deliberately survives
|
|
633
|
+
* logical Session rotation; if the append log was truncated by a fresh Claude
|
|
634
|
+
* process, restart at zero. */
|
|
635
|
+
export function readDeltaForwardState(bridgeDir) {
|
|
636
|
+
let raw;
|
|
637
|
+
try {
|
|
638
|
+
raw = JSON.parse(readFileSync(join(bridgeDir, DELTA_FORWARDER_STATE_FILE), "utf8"));
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return { byteOffset: 0 };
|
|
642
|
+
}
|
|
643
|
+
const byteOffset = raw && typeof raw === "object"
|
|
644
|
+
? asNumber(raw.byteOffset)
|
|
645
|
+
: undefined;
|
|
646
|
+
if (byteOffset === undefined || byteOffset < 0 || !Number.isInteger(byteOffset)) {
|
|
647
|
+
return { byteOffset: 0 };
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
if (statSync(join(bridgeDir, DELTAS_FILE)).size < byteOffset)
|
|
651
|
+
return { byteOffset: 0 };
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
if (byteOffset > 0)
|
|
655
|
+
return { byteOffset: 0 };
|
|
656
|
+
}
|
|
657
|
+
return { byteOffset };
|
|
658
|
+
}
|
|
659
|
+
export function writeDeltaForwardState(bridgeDir, state) {
|
|
660
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
661
|
+
const tmp = join(bridgeDir, `${DELTA_FORWARDER_STATE_FILE}.tmp`);
|
|
662
|
+
writeFileSync(tmp, JSON.stringify({ byteOffset: state.byteOffset }));
|
|
663
|
+
renameSync(tmp, join(bridgeDir, DELTA_FORWARDER_STATE_FILE));
|
|
664
|
+
}
|
|
458
665
|
/** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
|
|
459
666
|
export function readMessageDeltasFrom(bridgeDir, byteOffset) {
|
|
460
667
|
const { records, nextOffset } = readJsonlFrom(join(bridgeDir, DELTAS_FILE), byteOffset);
|
|
@@ -548,7 +755,36 @@ export function readForwardState(bridgeDir) {
|
|
|
548
755
|
? s.seenSourceIds.filter((x) => typeof x === "string")
|
|
549
756
|
: [];
|
|
550
757
|
const cursorFingerprint = asString(s.cursorFingerprint);
|
|
551
|
-
|
|
758
|
+
const lineCursor = asNumber(s.lineCursor);
|
|
759
|
+
const currentTurnId = asString(s.currentTurnId);
|
|
760
|
+
const currentResponseId = asString(s.currentResponseId);
|
|
761
|
+
const activeTerminalCommandRaw = s.activeTerminalCommand;
|
|
762
|
+
const activeTerminalCommandRecord = activeTerminalCommandRaw &&
|
|
763
|
+
typeof activeTerminalCommandRaw === "object" &&
|
|
764
|
+
!Array.isArray(activeTerminalCommandRaw)
|
|
765
|
+
? activeTerminalCommandRaw
|
|
766
|
+
: undefined;
|
|
767
|
+
const activeTerminalCommandText = asString(activeTerminalCommandRecord?.command);
|
|
768
|
+
const activeTerminalCommandTurnId = asString(activeTerminalCommandRecord?.turnId);
|
|
769
|
+
const activeTerminalCommand = activeTerminalCommandText
|
|
770
|
+
? {
|
|
771
|
+
command: activeTerminalCommandText,
|
|
772
|
+
...(activeTerminalCommandTurnId ? { turnId: activeTerminalCommandTurnId } : {}),
|
|
773
|
+
}
|
|
774
|
+
: undefined;
|
|
775
|
+
return {
|
|
776
|
+
transcriptPath,
|
|
777
|
+
byteOffset,
|
|
778
|
+
seenSourceIds,
|
|
779
|
+
...(lineCursor !== undefined && Number.isInteger(lineCursor) && lineCursor >= 0
|
|
780
|
+
? { lineCursor }
|
|
781
|
+
: {}),
|
|
782
|
+
...(cursorFingerprint ? { cursorFingerprint } : {}),
|
|
783
|
+
...(currentTurnId ? { currentTurnId } : {}),
|
|
784
|
+
...(currentResponseId ? { currentResponseId } : {}),
|
|
785
|
+
...(s.turnOpen === true ? { turnOpen: true } : {}),
|
|
786
|
+
...(activeTerminalCommand ? { activeTerminalCommand } : {}),
|
|
787
|
+
};
|
|
552
788
|
}
|
|
553
789
|
/** Drop the forwarder cursor (a `/clear` starts fresh). */
|
|
554
790
|
export function resetForwardState(bridgeDir) {
|
|
@@ -559,6 +795,88 @@ export function resetForwardState(bridgeDir) {
|
|
|
559
795
|
// absent — fine
|
|
560
796
|
}
|
|
561
797
|
}
|
|
798
|
+
/** Parent Task/tool ids already consumed by the main transcript cursor. Kept
|
|
799
|
+
* independently of concrete child agents because Claude may create the child
|
|
800
|
+
* meta file only after the parent record was durably acknowledged. */
|
|
801
|
+
export function readSubagentParentResponses(bridgeDir, parentTranscriptPath) {
|
|
802
|
+
const raw = readJsonFile(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE));
|
|
803
|
+
if (!raw || typeof raw !== "object")
|
|
804
|
+
return new Map();
|
|
805
|
+
const file = raw;
|
|
806
|
+
if (file.parentTranscriptPath !== parentTranscriptPath)
|
|
807
|
+
return new Map();
|
|
808
|
+
const responses = file.parentResponses;
|
|
809
|
+
if (!responses || typeof responses !== "object" || Array.isArray(responses))
|
|
810
|
+
return new Map();
|
|
811
|
+
const result = new Map();
|
|
812
|
+
for (const [parentToolCallId, responseId] of Object.entries(responses)) {
|
|
813
|
+
if (!parentToolCallId || typeof responseId !== "string" || !responseId)
|
|
814
|
+
continue;
|
|
815
|
+
result.set(parentToolCallId, responseId);
|
|
816
|
+
}
|
|
817
|
+
return result;
|
|
818
|
+
}
|
|
819
|
+
/** Read independent Claude native sub-agent cursors for one parent transcript. */
|
|
820
|
+
export function readSubagentForwardStates(bridgeDir, parentTranscriptPath) {
|
|
821
|
+
const raw = readJsonFile(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE));
|
|
822
|
+
if (!raw || typeof raw !== "object")
|
|
823
|
+
return [];
|
|
824
|
+
const file = raw;
|
|
825
|
+
if (file.parentTranscriptPath !== parentTranscriptPath || !Array.isArray(file.agents)) {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
const states = [];
|
|
829
|
+
for (const value of file.agents) {
|
|
830
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
831
|
+
continue;
|
|
832
|
+
const state = value;
|
|
833
|
+
const agentId = asString(state.agentId);
|
|
834
|
+
const parentToolCallId = asString(state.parentToolCallId);
|
|
835
|
+
const parentResponseId = asString(state.parentResponseId);
|
|
836
|
+
const transcriptPath = asString(state.transcriptPath);
|
|
837
|
+
const byteOffset = asNumber(state.byteOffset);
|
|
838
|
+
const lineCursor = asNumber(state.lineCursor);
|
|
839
|
+
const terminalCommand = asString(state.terminalCommand);
|
|
840
|
+
if (!agentId || !parentToolCallId || !transcriptPath || byteOffset === undefined)
|
|
841
|
+
continue;
|
|
842
|
+
states.push({
|
|
843
|
+
agentId,
|
|
844
|
+
parentToolCallId,
|
|
845
|
+
...(parentResponseId ? { parentResponseId } : {}),
|
|
846
|
+
transcriptPath,
|
|
847
|
+
byteOffset,
|
|
848
|
+
...(lineCursor !== undefined && Number.isInteger(lineCursor) && lineCursor >= 0
|
|
849
|
+
? { lineCursor }
|
|
850
|
+
: {}),
|
|
851
|
+
...(terminalCommand ? { terminalCommand } : {}),
|
|
852
|
+
seenSourceIds: Array.isArray(state.seenSourceIds)
|
|
853
|
+
? state.seenSourceIds
|
|
854
|
+
.filter((id) => typeof id === "string")
|
|
855
|
+
.slice(-MAX_SEEN_SOURCE_IDS)
|
|
856
|
+
: [],
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
return states;
|
|
860
|
+
}
|
|
861
|
+
/** Atomically persist every native sub-agent's independent delivery cursor. */
|
|
862
|
+
export function writeSubagentForwardStates(bridgeDir, parentTranscriptPath, states, parentResponses = new Map()) {
|
|
863
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
864
|
+
const file = {
|
|
865
|
+
parentTranscriptPath,
|
|
866
|
+
parentResponses: Object.fromEntries([...parentResponses].slice(-MAX_SEEN_SOURCE_IDS)),
|
|
867
|
+
agents: states.map((state) => ({
|
|
868
|
+
...state,
|
|
869
|
+
seenSourceIds: state.seenSourceIds.slice(-MAX_SEEN_SOURCE_IDS),
|
|
870
|
+
})),
|
|
871
|
+
};
|
|
872
|
+
const target = join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE);
|
|
873
|
+
const tmp = `${target}.tmp`;
|
|
874
|
+
writeFileSync(tmp, JSON.stringify(file));
|
|
875
|
+
renameSync(tmp, target);
|
|
876
|
+
}
|
|
877
|
+
export function resetSubagentForwardStates(bridgeDir) {
|
|
878
|
+
rmSync(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE), { force: true });
|
|
879
|
+
}
|
|
562
880
|
/** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
|
|
563
881
|
export function readClaudeStatus(bridgeDir) {
|
|
564
882
|
const raw = readJsonFile(join(bridgeDir, STATUS_FILE));
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* interaction hooks rendezvous with the runner through the session bridge. */
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { realpathSync } from "node:fs";
|
|
6
|
-
import { claimInteractionResult, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
|
|
6
|
+
import { claimInteractionResult, annotateClaudeResumeContext, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
|
|
7
|
+
import { waitForTranscriptForkSignal, } from "./transcript.js";
|
|
7
8
|
import { boundInteractionRequest, redactInteractionResolution, } from "../interactions.js";
|
|
8
9
|
function argValue(argv, flag) {
|
|
9
10
|
const i = argv.indexOf(flag);
|
|
@@ -23,6 +24,21 @@ function asRecord(value) {
|
|
|
23
24
|
function asString(value) {
|
|
24
25
|
return typeof value === "string" && value ? value : undefined;
|
|
25
26
|
}
|
|
27
|
+
async function annotateClaudeForkSignal(bridgeDir, payload) {
|
|
28
|
+
annotateClaudeResumeContext(bridgeDir, payload);
|
|
29
|
+
if (payload.hook_event_name !== "SessionStart" ||
|
|
30
|
+
payload.source !== "resume" ||
|
|
31
|
+
payload.rynx_claude_session_was_seen === true)
|
|
32
|
+
return;
|
|
33
|
+
const transcriptPath = asString(payload.transcript_path);
|
|
34
|
+
const sessionId = asString(payload.session_id);
|
|
35
|
+
const sourceSessionId = asString(payload.rynx_previous_claude_session_id);
|
|
36
|
+
if (!transcriptPath || !sessionId || !sourceSessionId)
|
|
37
|
+
return;
|
|
38
|
+
const recordedAt = Date.now();
|
|
39
|
+
if (await waitForTranscriptForkSignal(transcriptPath, sessionId, sourceSessionId, recordedAt))
|
|
40
|
+
payload.rynx_fork_detected = true;
|
|
41
|
+
}
|
|
26
42
|
function interactionId(payload) {
|
|
27
43
|
// PermissionRequest does not carry a tool_use_id. A per-process nonce keeps
|
|
28
44
|
// repeated or concurrent identical prompts from collapsing into one bridge id.
|
|
@@ -364,6 +380,7 @@ async function main() {
|
|
|
364
380
|
}
|
|
365
381
|
if (!hookKind) {
|
|
366
382
|
try {
|
|
383
|
+
await annotateClaudeForkSignal(bridgeDir, payload);
|
|
367
384
|
recordHookEvent(bridgeDir, payload);
|
|
368
385
|
}
|
|
369
386
|
catch {
|
|
@@ -48,6 +48,13 @@ export function buildClaudeHookSettings(options) {
|
|
|
48
48
|
SessionStart: [{ hooks: [observerHook] }],
|
|
49
49
|
Stop: [{ hooks: [observerHook] }],
|
|
50
50
|
StopFailure: [{ hooks: [observerHook] }],
|
|
51
|
+
TaskCreated: [{ hooks: [observerHook] }],
|
|
52
|
+
TaskCompleted: [{ hooks: [observerHook] }],
|
|
53
|
+
PostToolUse: [
|
|
54
|
+
{ matcher: "TodoWrite", hooks: [observerHook] },
|
|
55
|
+
{ matcher: "TaskUpdate", hooks: [observerHook] },
|
|
56
|
+
],
|
|
57
|
+
PreCompact: [{ hooks: [observerHook] }],
|
|
51
58
|
};
|
|
52
59
|
if (options.permissionMode === "bypassPermissions") {
|
|
53
60
|
const ask = shJoin([node, entry, "ask-user-question", "--bridge-dir", options.bridgeDir]);
|