@rynx-ai/runtime 0.1.11-beta.32 → 0.1.11-beta.34
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 +16 -1
- package/dist/claude/native-bridge.js +91 -17
- package/dist/claude/native-integration.d.ts +10 -0
- package/dist/claude/native-integration.js +100 -6
- package/dist/codex-app-server/forwarder.js +17 -1
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/ws-channel.d.ts +7 -0
- package/dist/codex-app-server/ws-channel.js +85 -9
- package/dist/host.d.ts +9 -1
- package/dist/host.js +48 -14
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/runner/child.d.ts +24 -3
- package/dist/runner/child.js +327 -58
- package/dist/runner/manager.d.ts +33 -18
- package/dist/runner/manager.js +229 -49
- package/dist/runner/protocol.d.ts +79 -7
- package/dist/runner/transport.d.ts +9 -2
- package/dist/runner/transport.js +55 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +15 -13
- package/dist/terminal/registry.js +38 -20
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +93 -84
- package/dist/terminal/tmux.js +666 -199
- package/package.json +3 -4
|
@@ -4,6 +4,7 @@ export declare const STATE_FILE = "state.json";
|
|
|
4
4
|
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
|
+
export declare const HOOK_FORWARDER_STATE_FILE = "hook_forwarder.json";
|
|
7
8
|
export declare const INTERACTIONS_FILE = "interactions.jsonl";
|
|
8
9
|
export declare const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
|
|
9
10
|
export declare const INTERACTION_RESULTS_DIR = "interaction-results";
|
|
@@ -104,6 +105,10 @@ export interface JsonlReadResult<T> {
|
|
|
104
105
|
export declare function readJsonlFrom<T = unknown>(path: string, byteOffset: number): JsonlReadResult<T>;
|
|
105
106
|
/** One parsed Claude hook event (the fields the forwarder acts on + the raw payload). */
|
|
106
107
|
export interface HookEvent {
|
|
108
|
+
/** One-based record position in the append-only hook stream. */
|
|
109
|
+
eventCursor: number;
|
|
110
|
+
/** Byte offset immediately after this complete JSONL record. */
|
|
111
|
+
byteOffset: number;
|
|
107
112
|
eventName?: string;
|
|
108
113
|
transcriptPath?: string;
|
|
109
114
|
/** Claude's session uuid — subagent hooks report a `subagents/…` transcript path. */
|
|
@@ -114,10 +119,20 @@ export interface HookEvent {
|
|
|
114
119
|
payload: Record<string, unknown>;
|
|
115
120
|
}
|
|
116
121
|
/** Tail `hooks.jsonl` from a byte offset into parsed {@link HookEvent}s. */
|
|
117
|
-
export declare function readHookEventsFrom(bridgeDir: string, byteOffset: number): {
|
|
122
|
+
export declare function readHookEventsFrom(bridgeDir: string, byteOffset: number, startEventCursor?: number): {
|
|
118
123
|
events: HookEvent[];
|
|
119
124
|
nextOffset: number;
|
|
120
125
|
};
|
|
126
|
+
export interface HookForwardState {
|
|
127
|
+
eventCursor: number;
|
|
128
|
+
byteOffset: number;
|
|
129
|
+
cursorFingerprint?: string;
|
|
130
|
+
}
|
|
131
|
+
/** Read and validate the durable hook cursor. A replaced/truncated hook stream
|
|
132
|
+
* restarts at zero; event handlers provide their own stable deduplication. */
|
|
133
|
+
export declare function readHookForwardState(bridgeDir: string): HookForwardState | undefined;
|
|
134
|
+
/** Persist one consumed hook record before another poll can observe it. */
|
|
135
|
+
export declare function writeHookForwardState(bridgeDir: string, state: HookForwardState): void;
|
|
121
136
|
/** One streamed assistant-text chunk from `message_deltas.jsonl` (MessageDisplay). */
|
|
122
137
|
export interface MessageDelta {
|
|
123
138
|
messageId: string;
|
|
@@ -20,6 +20,7 @@ export const STATE_FILE = "state.json";
|
|
|
20
20
|
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
|
+
export const HOOK_FORWARDER_STATE_FILE = "hook_forwarder.json";
|
|
23
24
|
export const INTERACTIONS_FILE = "interactions.jsonl";
|
|
24
25
|
export const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
|
|
25
26
|
export const INTERACTION_RESULTS_DIR = "interaction-results";
|
|
@@ -47,6 +48,7 @@ export function prepareClaudeBridgeDir(sessionId) {
|
|
|
47
48
|
INTERACTIONS_FILE,
|
|
48
49
|
INTERACTION_ACKS_FILE,
|
|
49
50
|
MANAGED_SETTINGS_FILE,
|
|
51
|
+
HOOK_FORWARDER_STATE_FILE,
|
|
50
52
|
]) {
|
|
51
53
|
try {
|
|
52
54
|
rmSync(join(dir, file));
|
|
@@ -360,27 +362,99 @@ export function readJsonlFrom(path, byteOffset) {
|
|
|
360
362
|
}
|
|
361
363
|
}
|
|
362
364
|
/** Tail `hooks.jsonl` from a byte offset into parsed {@link HookEvent}s. */
|
|
363
|
-
export function readHookEventsFrom(bridgeDir, byteOffset) {
|
|
364
|
-
const
|
|
365
|
+
export function readHookEventsFrom(bridgeDir, byteOffset, startEventCursor = 0) {
|
|
366
|
+
const path = join(bridgeDir, HOOKS_FILE);
|
|
367
|
+
let info;
|
|
368
|
+
try {
|
|
369
|
+
info = statSync(path);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return { events: [], nextOffset: byteOffset };
|
|
373
|
+
}
|
|
374
|
+
if (info.size <= byteOffset)
|
|
375
|
+
return { events: [], nextOffset: byteOffset };
|
|
376
|
+
const fd = openSync(path, "r");
|
|
365
377
|
const events = [];
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
378
|
+
let nextOffset = byteOffset;
|
|
379
|
+
let eventCursor = startEventCursor;
|
|
380
|
+
try {
|
|
381
|
+
const length = info.size - byteOffset;
|
|
382
|
+
const buf = Buffer.alloc(length);
|
|
383
|
+
readSync(fd, buf, 0, length, byteOffset);
|
|
384
|
+
const text = buf.toString("utf8");
|
|
385
|
+
const lines = text.split("\n");
|
|
386
|
+
lines.pop(); // retain a partial trailing record for the next poll
|
|
387
|
+
for (const line of lines) {
|
|
388
|
+
const lineBytes = Buffer.byteLength(`${line}\n`, "utf8");
|
|
389
|
+
nextOffset += lineBytes;
|
|
390
|
+
if (!line.trim())
|
|
391
|
+
continue;
|
|
392
|
+
eventCursor += 1;
|
|
393
|
+
let rec;
|
|
394
|
+
try {
|
|
395
|
+
rec = JSON.parse(line);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
events.push({ eventCursor, byteOffset: nextOffset, payload: {} });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const payload = rec.payload;
|
|
402
|
+
if (!payload || typeof payload !== "object") {
|
|
403
|
+
events.push({ eventCursor, byteOffset: nextOffset, payload: {} });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const liveBackgroundTasks = backgroundTaskCount(payload);
|
|
407
|
+
events.push({
|
|
408
|
+
eventCursor,
|
|
409
|
+
byteOffset: nextOffset,
|
|
410
|
+
eventName: asString(payload.hook_event_name),
|
|
411
|
+
transcriptPath: asString(payload.transcript_path),
|
|
412
|
+
sessionId: asString(payload.session_id),
|
|
413
|
+
source: asString(payload.source),
|
|
414
|
+
...(liveBackgroundTasks === undefined
|
|
415
|
+
? {}
|
|
416
|
+
: { backgroundTaskCount: liveBackgroundTasks }),
|
|
417
|
+
payload,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
closeSync(fd);
|
|
381
423
|
}
|
|
382
424
|
return { events, nextOffset };
|
|
383
425
|
}
|
|
426
|
+
/** Read and validate the durable hook cursor. A replaced/truncated hook stream
|
|
427
|
+
* restarts at zero; event handlers provide their own stable deduplication. */
|
|
428
|
+
export function readHookForwardState(bridgeDir) {
|
|
429
|
+
const raw = readJsonFile(join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
|
|
430
|
+
if (!raw || typeof raw !== "object")
|
|
431
|
+
return undefined;
|
|
432
|
+
const state = raw;
|
|
433
|
+
const eventCursor = asNumber(state.eventCursor);
|
|
434
|
+
const byteOffset = asNumber(state.byteOffset);
|
|
435
|
+
const cursorFingerprint = asString(state.cursorFingerprint);
|
|
436
|
+
if (eventCursor === undefined || eventCursor < 0 || !Number.isInteger(eventCursor) ||
|
|
437
|
+
byteOffset === undefined || byteOffset < 0 || !Number.isInteger(byteOffset))
|
|
438
|
+
return undefined;
|
|
439
|
+
const current = jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), byteOffset);
|
|
440
|
+
if (!cursorFingerprint || current !== cursorFingerprint) {
|
|
441
|
+
return {
|
|
442
|
+
eventCursor: 0,
|
|
443
|
+
byteOffset: 0,
|
|
444
|
+
...(jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), 0)
|
|
445
|
+
? { cursorFingerprint: jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), 0) }
|
|
446
|
+
: {}),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return { eventCursor, byteOffset, cursorFingerprint };
|
|
450
|
+
}
|
|
451
|
+
/** Persist one consumed hook record before another poll can observe it. */
|
|
452
|
+
export function writeHookForwardState(bridgeDir, state) {
|
|
453
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
454
|
+
const tmp = join(bridgeDir, `${HOOK_FORWARDER_STATE_FILE}.tmp`);
|
|
455
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
456
|
+
renameSync(tmp, join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
|
|
457
|
+
}
|
|
384
458
|
/** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
|
|
385
459
|
export function readMessageDeltasFrom(bridgeDir, byteOffset) {
|
|
386
460
|
const { records, nextOffset } = readJsonlFrom(join(bridgeDir, DELTAS_FILE), byteOffset);
|
|
@@ -88,7 +88,10 @@ export declare class ClaudeLiveSession {
|
|
|
88
88
|
private readonly leaseUpdatedAt;
|
|
89
89
|
private started;
|
|
90
90
|
private stopped;
|
|
91
|
+
private supervisorTask?;
|
|
92
|
+
private releaseSleep?;
|
|
91
93
|
private hooksOffset;
|
|
94
|
+
private hookEventCursor;
|
|
92
95
|
private interactionsOffset;
|
|
93
96
|
private interactionAcksOffset;
|
|
94
97
|
private transcriptOffset;
|
|
@@ -202,6 +205,10 @@ export declare class ClaudeLiveSession {
|
|
|
202
205
|
private consumeQueuedPromotion;
|
|
203
206
|
start(): void;
|
|
204
207
|
stop(): void;
|
|
208
|
+
/** Wait for the supervised polling task to retire. Terminal teardown starts
|
|
209
|
+
* synchronously in {@link stop}; callers use this bounded join before their
|
|
210
|
+
* runner process exits. */
|
|
211
|
+
waitForStop(timeoutMs?: number): Promise<void>;
|
|
205
212
|
/** Phase two of runner shutdown. The caller must stop the Claude terminal
|
|
206
213
|
* first so no hook can still be opening an atomically claimed answer. This is
|
|
207
214
|
* synchronous because the runner process exits immediately afterwards. */
|
|
@@ -214,8 +221,11 @@ export declare class ClaudeLiveSession {
|
|
|
214
221
|
panePid: () => number | undefined;
|
|
215
222
|
configDir?: string;
|
|
216
223
|
}): void;
|
|
224
|
+
private supervise;
|
|
217
225
|
private loop;
|
|
226
|
+
private sleepUntilWake;
|
|
218
227
|
private pollHooks;
|
|
228
|
+
private persistHookCursor;
|
|
219
229
|
private handleHook;
|
|
220
230
|
/** Process a provider failure only after this tick has discovered every
|
|
221
231
|
* blocking request already appended by its hook subprocess. */
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { statSync } from "node:fs";
|
|
22
22
|
import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
|
|
23
|
-
import { jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, } from "./native-bridge.js";
|
|
23
|
+
import { HOOKS_FILE, jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readHookForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, writeHookForwardState, } from "./native-bridge.js";
|
|
24
24
|
import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
|
|
25
25
|
import { ClaudeSessionStatusPoller, } from "./session-status.js";
|
|
26
26
|
const MAX_SETTLED_INTERACTIONS = 512;
|
|
@@ -32,6 +32,9 @@ const INTERACTION_ACK_TIMEOUT_MS = 5_000;
|
|
|
32
32
|
// reference implementation's `_CLAUDE_INTERRUPT_RECORD_RE`.
|
|
33
33
|
const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
|
|
34
34
|
const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
|
|
35
|
+
const FORWARDER_RESTART_INITIAL_MS = 1_000;
|
|
36
|
+
const FORWARDER_RESTART_MAX_MS = 30_000;
|
|
37
|
+
const FORWARDER_HEALTHY_UPTIME_MS = 60_000;
|
|
35
38
|
function processIsAlive(pid) {
|
|
36
39
|
try {
|
|
37
40
|
process.kill(pid, 0);
|
|
@@ -149,7 +152,10 @@ export class ClaudeLiveSession {
|
|
|
149
152
|
leaseUpdatedAt;
|
|
150
153
|
started = false;
|
|
151
154
|
stopped = false;
|
|
155
|
+
supervisorTask;
|
|
156
|
+
releaseSleep;
|
|
152
157
|
hooksOffset = 0;
|
|
158
|
+
hookEventCursor = 0;
|
|
153
159
|
interactionsOffset = 0;
|
|
154
160
|
interactionAcksOffset = 0;
|
|
155
161
|
transcriptOffset = 0;
|
|
@@ -249,6 +255,11 @@ export class ClaudeLiveSession {
|
|
|
249
255
|
this.expectedClaudeSessionId = opts.claudeSessionId;
|
|
250
256
|
this.resumeAtEndOnDiscovery = opts.resumeAtEndOnDiscovery ?? false;
|
|
251
257
|
this.transcriptPath = opts.transcriptPath;
|
|
258
|
+
const hookState = readHookForwardState(this.bridgeDir);
|
|
259
|
+
if (hookState) {
|
|
260
|
+
this.hooksOffset = hookState.byteOffset;
|
|
261
|
+
this.hookEventCursor = hookState.eventCursor;
|
|
262
|
+
}
|
|
252
263
|
// Resume path: bind the session id + restore the persisted forwarder cursor so
|
|
253
264
|
// we continue from where a prior forwarder left off (no re-mirror on relaunch).
|
|
254
265
|
if (opts.transcriptPath) {
|
|
@@ -342,12 +353,13 @@ export class ClaudeLiveSession {
|
|
|
342
353
|
if (this.started)
|
|
343
354
|
return;
|
|
344
355
|
this.started = true;
|
|
345
|
-
|
|
356
|
+
this.supervisorTask = this.supervise();
|
|
346
357
|
}
|
|
347
358
|
stop() {
|
|
348
359
|
if (this.stopped)
|
|
349
360
|
return;
|
|
350
361
|
this.stopped = true;
|
|
362
|
+
this.releaseSleep?.();
|
|
351
363
|
this.statusPoller?.retire();
|
|
352
364
|
// A hook may have appended its request just before shutdown but not yet
|
|
353
365
|
// reached a scheduled poll. Drain it so the subprocess receives cancellation
|
|
@@ -360,6 +372,24 @@ export class ClaudeLiveSession {
|
|
|
360
372
|
this.closeTurn();
|
|
361
373
|
this.scheduleClaimedInteractionScrubs();
|
|
362
374
|
}
|
|
375
|
+
/** Wait for the supervised polling task to retire. Terminal teardown starts
|
|
376
|
+
* synchronously in {@link stop}; callers use this bounded join before their
|
|
377
|
+
* runner process exits. */
|
|
378
|
+
async waitForStop(timeoutMs = 5_000) {
|
|
379
|
+
const task = this.supervisorTask;
|
|
380
|
+
if (!task)
|
|
381
|
+
return;
|
|
382
|
+
let timer;
|
|
383
|
+
await Promise.race([
|
|
384
|
+
task,
|
|
385
|
+
new Promise((resolve) => {
|
|
386
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
387
|
+
timer.unref?.();
|
|
388
|
+
}),
|
|
389
|
+
]);
|
|
390
|
+
if (timer)
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
}
|
|
363
393
|
/** Phase two of runner shutdown. The caller must stop the Claude terminal
|
|
364
394
|
* first so no hook can still be opening an atomically claimed answer. This is
|
|
365
395
|
* synchronous because the runner process exits immediately afterwards. */
|
|
@@ -400,6 +430,32 @@ export class ClaudeLiveSession {
|
|
|
400
430
|
...(configDir ? { configDir } : {}),
|
|
401
431
|
});
|
|
402
432
|
}
|
|
433
|
+
async supervise() {
|
|
434
|
+
let backoffMs = FORWARDER_RESTART_INITIAL_MS;
|
|
435
|
+
while (!this.stopped) {
|
|
436
|
+
const startedAt = this.now();
|
|
437
|
+
let crashed;
|
|
438
|
+
try {
|
|
439
|
+
await this.loop();
|
|
440
|
+
if (!this.stopped) {
|
|
441
|
+
console.warn("[claude-forwarder] polling loop returned unexpectedly; restarting");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
crashed = error;
|
|
446
|
+
}
|
|
447
|
+
if (this.stopped)
|
|
448
|
+
break;
|
|
449
|
+
if (this.now() - startedAt >= FORWARDER_HEALTHY_UPTIME_MS) {
|
|
450
|
+
backoffMs = FORWARDER_RESTART_INITIAL_MS;
|
|
451
|
+
}
|
|
452
|
+
if (crashed !== undefined) {
|
|
453
|
+
console.error(`[claude-forwarder] polling loop crashed; restarting in ${backoffMs}ms:`, crashed);
|
|
454
|
+
}
|
|
455
|
+
await this.sleepUntilWake(backoffMs);
|
|
456
|
+
backoffMs = Math.min(backoffMs * 2, FORWARDER_RESTART_MAX_MS);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
403
459
|
async loop() {
|
|
404
460
|
while (!this.stopped) {
|
|
405
461
|
try {
|
|
@@ -411,7 +467,7 @@ export class ClaudeLiveSession {
|
|
|
411
467
|
// per-iteration `except Exception` in the transcript forwarder loop.
|
|
412
468
|
console.error("[claude-forwarder] tick failed; continuing:", err);
|
|
413
469
|
}
|
|
414
|
-
await
|
|
470
|
+
await this.sleepUntilWake(this.pollMs);
|
|
415
471
|
}
|
|
416
472
|
// Catch a request appended while the loop was waking up to observe stopped.
|
|
417
473
|
this.pollInteractions();
|
|
@@ -420,11 +476,49 @@ export class ClaudeLiveSession {
|
|
|
420
476
|
this.closeTurn();
|
|
421
477
|
this.scheduleClaimedInteractionScrubs();
|
|
422
478
|
}
|
|
479
|
+
sleepUntilWake(delayMs) {
|
|
480
|
+
if (this.stopped)
|
|
481
|
+
return Promise.resolve();
|
|
482
|
+
return new Promise((resolve) => {
|
|
483
|
+
const timer = setTimeout(done, delayMs);
|
|
484
|
+
timer.unref?.();
|
|
485
|
+
const self = this;
|
|
486
|
+
function done() {
|
|
487
|
+
if (self.releaseSleep === done)
|
|
488
|
+
self.releaseSleep = undefined;
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
resolve();
|
|
491
|
+
}
|
|
492
|
+
this.releaseSleep = done;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
423
495
|
pollHooks() {
|
|
424
|
-
const { events
|
|
425
|
-
|
|
426
|
-
|
|
496
|
+
const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
|
|
497
|
+
for (const ev of events) {
|
|
498
|
+
// Rotation is an exactly-once edge. Persist it before the callback so a
|
|
499
|
+
// partial publication failure cannot create another replacement session
|
|
500
|
+
// when this forwarder (or its supervisor) restarts.
|
|
501
|
+
const rotation = ev.eventName === "SessionStart" && (ev.source === "clear" ||
|
|
502
|
+
(ev.source === "resume" && Boolean(ev.sessionId &&
|
|
503
|
+
ev.transcriptPath &&
|
|
504
|
+
!this.seenClaudeSessionIds.has(ev.sessionId) &&
|
|
505
|
+
transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId))));
|
|
506
|
+
if (rotation)
|
|
507
|
+
this.persistHookCursor(ev);
|
|
427
508
|
this.handleHook(ev);
|
|
509
|
+
if (!rotation)
|
|
510
|
+
this.persistHookCursor(ev);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
persistHookCursor(ev) {
|
|
514
|
+
this.hooksOffset = ev.byteOffset;
|
|
515
|
+
this.hookEventCursor = ev.eventCursor;
|
|
516
|
+
const fingerprint = jsonlCursorFingerprint(`${this.bridgeDir}/${HOOKS_FILE}`, ev.byteOffset);
|
|
517
|
+
writeHookForwardState(this.bridgeDir, {
|
|
518
|
+
eventCursor: ev.eventCursor,
|
|
519
|
+
byteOffset: ev.byteOffset,
|
|
520
|
+
...(fingerprint ? { cursorFingerprint: fingerprint } : {}),
|
|
521
|
+
});
|
|
428
522
|
}
|
|
429
523
|
handleHook(ev) {
|
|
430
524
|
if (ev.eventName === "SessionStart") {
|
|
@@ -6,6 +6,13 @@ function threadIdFrom(params) {
|
|
|
6
6
|
const p = params;
|
|
7
7
|
return p?.threadId ?? p?.thread?.id;
|
|
8
8
|
}
|
|
9
|
+
/** Agent-control children announce their own thread on the shared app-server.
|
|
10
|
+
* They do not replace the parent TUI thread: only a top-level thread switch is
|
|
11
|
+
* a Session rotation boundary. */
|
|
12
|
+
function isSubagentThreadStarted(params) {
|
|
13
|
+
const source = params?.thread?.source?.subAgent?.thread_spawn;
|
|
14
|
+
return source !== null && typeof source === "object" && !Array.isArray(source);
|
|
15
|
+
}
|
|
9
16
|
function turnIdFrom(params) {
|
|
10
17
|
const p = params;
|
|
11
18
|
return p?.turnId ?? p?.turn?.id;
|
|
@@ -108,7 +115,14 @@ export class CodexSessionForwarder {
|
|
|
108
115
|
if (this.unsubscribe)
|
|
109
116
|
return;
|
|
110
117
|
this.unsubscribe = this.client.onNotification((method, params) => {
|
|
111
|
-
|
|
118
|
+
try {
|
|
119
|
+
this.handle(method, params);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// One malformed or unsupported notification must not detach the
|
|
123
|
+
// long-lived observer from every later event in this session.
|
|
124
|
+
console.error(`[codex-forwarder] notification handler failed for ${method}; continuing:`, error);
|
|
125
|
+
}
|
|
112
126
|
});
|
|
113
127
|
const startup = this.options.mcpStartup;
|
|
114
128
|
if (startup?.servers.length) {
|
|
@@ -247,6 +261,8 @@ export class CodexSessionForwarder {
|
|
|
247
261
|
}
|
|
248
262
|
handle(method, params) {
|
|
249
263
|
if (method === "thread/started" || method === "thread.started") {
|
|
264
|
+
if (method === "thread/started" && isSubagentThreadStarted(params))
|
|
265
|
+
return;
|
|
250
266
|
const tid = threadIdFrom(params);
|
|
251
267
|
if (tid && tid !== this.currentThreadIdValue) {
|
|
252
268
|
const forkedFromId = params?.thread?.forkedFromId;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface RuntimeProcessEntry {
|
|
2
|
+
pid: number;
|
|
3
|
+
pgid: number;
|
|
4
|
+
ownerPid: number;
|
|
5
|
+
/** OS process-start identity. Unlike a PID, this cannot silently refer to a
|
|
6
|
+
* later process after the launcher crashes and the PID is recycled. */
|
|
7
|
+
ownerIdentity?: string;
|
|
8
|
+
sessionTag: string;
|
|
9
|
+
stateDir: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function runtimeProcessRegistryPath(): string;
|
|
12
|
+
export declare function runtimeProcessTagArg(sessionTag: string): string;
|
|
13
|
+
export declare function runtimeProcessArgv0(cliPath: string, sessionTag: string): string;
|
|
14
|
+
export declare function withRuntimeProcessStateArg(baseArgs: string[], stateDir: string): string[];
|
|
15
|
+
export declare function registerRuntimeProcess(entry: RuntimeProcessEntry, registryPath?: string): void;
|
|
16
|
+
export declare function unregisterRuntimeProcess(sessionTag: string, registryPath?: string): void;
|
|
17
|
+
export interface RuntimeProcessReconcileOptions {
|
|
18
|
+
registryPath?: string;
|
|
19
|
+
ownerAlive?: (pid: number) => boolean;
|
|
20
|
+
ownerIdentity?: (pid: number) => string;
|
|
21
|
+
childAlive?: (pid: number) => boolean;
|
|
22
|
+
commandLine?: (pid: number) => string;
|
|
23
|
+
terminateGroup?: (pgid: number) => boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function reconcileRuntimeProcesses(opts?: RuntimeProcessReconcileOptions): number;
|
|
26
|
+
export declare function reapRuntimeProcessesForStateDir(stateDir: string, opts?: RuntimeProcessStateReapOptions): number;
|
|
27
|
+
export interface RuntimeProcessStateReapOptions {
|
|
28
|
+
processListing?: () => string;
|
|
29
|
+
currentPgid?: () => number;
|
|
30
|
+
childAlive?: (pid: number) => boolean;
|
|
31
|
+
signalGroup?: (pgid: number, signal: NodeJS.Signals) => boolean;
|
|
32
|
+
graceMs?: number;
|
|
33
|
+
}
|
|
34
|
+
/** Return an OS process-start identity used to distinguish PID reuse. */
|
|
35
|
+
export declare function runtimeProcessOwnerIdentity(pid: number): string;
|
|
36
|
+
export declare function newRuntimeProcessTag(): string;
|