@rynx-ai/runtime 0.1.11-beta.33 → 0.1.11-beta.35
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-hook-main.js +1 -0
- package/dist/claude/native-integration.d.ts +10 -0
- package/dist/claude/native-integration.js +100 -6
- package/dist/codex-app-server/forwarder.js +23 -1
- package/dist/codex-app-server/mapping.js +34 -10
- 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/codex-home.d.ts +35 -3
- package/dist/codex-home.js +321 -14
- package/dist/host.d.ts +11 -25
- package/dist/host.js +147 -132
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/runner/child.d.ts +25 -3
- package/dist/runner/child.js +359 -64
- 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 +18 -15
- package/dist/terminal/registry.js +43 -23
- 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 +675 -200
- 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);
|
|
@@ -312,6 +312,7 @@ function nativeVerdict(hookKind, payload, result, suggestions) {
|
|
|
312
312
|
...(behavior === "deny" && feedback ? { message: feedback } : {}),
|
|
313
313
|
...(behavior === "allow"
|
|
314
314
|
? {
|
|
315
|
+
updatedInput: toolInput,
|
|
315
316
|
updatedPermissions: [{
|
|
316
317
|
type: "setMode",
|
|
317
318
|
mode: resolution.actionId === "allow_auto" ? "auto" : "default",
|
|
@@ -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,18 @@ 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
|
+
}
|
|
16
|
+
/** Internal system threads are non-persistable and never replace the parent
|
|
17
|
+
* TUI thread, even though the app-server broadcasts `thread/started`. */
|
|
18
|
+
function isEphemeralThreadStarted(params) {
|
|
19
|
+
return params?.thread?.ephemeral === true;
|
|
20
|
+
}
|
|
9
21
|
function turnIdFrom(params) {
|
|
10
22
|
const p = params;
|
|
11
23
|
return p?.turnId ?? p?.turn?.id;
|
|
@@ -108,7 +120,14 @@ export class CodexSessionForwarder {
|
|
|
108
120
|
if (this.unsubscribe)
|
|
109
121
|
return;
|
|
110
122
|
this.unsubscribe = this.client.onNotification((method, params) => {
|
|
111
|
-
|
|
123
|
+
try {
|
|
124
|
+
this.handle(method, params);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// One malformed or unsupported notification must not detach the
|
|
128
|
+
// long-lived observer from every later event in this session.
|
|
129
|
+
console.error(`[codex-forwarder] notification handler failed for ${method}; continuing:`, error);
|
|
130
|
+
}
|
|
112
131
|
});
|
|
113
132
|
const startup = this.options.mcpStartup;
|
|
114
133
|
if (startup?.servers.length) {
|
|
@@ -247,6 +266,9 @@ export class CodexSessionForwarder {
|
|
|
247
266
|
}
|
|
248
267
|
handle(method, params) {
|
|
249
268
|
if (method === "thread/started" || method === "thread.started") {
|
|
269
|
+
if (method === "thread/started" &&
|
|
270
|
+
(isSubagentThreadStarted(params) || isEphemeralThreadStarted(params)))
|
|
271
|
+
return;
|
|
250
272
|
const tid = threadIdFrom(params);
|
|
251
273
|
if (tid && tid !== this.currentThreadIdValue) {
|
|
252
274
|
const forkedFromId = params?.thread?.forkedFromId;
|
|
@@ -127,10 +127,12 @@ function fileChangeSummary(changes) {
|
|
|
127
127
|
for (const change of changes) {
|
|
128
128
|
if (!isRecord(change))
|
|
129
129
|
continue;
|
|
130
|
+
if (typeof change.path !== "string")
|
|
131
|
+
continue;
|
|
130
132
|
const kind = isRecord(change.kind) && typeof change.kind.type === "string" && change.kind.type
|
|
131
133
|
? change.kind.type
|
|
132
134
|
: "change";
|
|
133
|
-
lines.push(`${kind} ${
|
|
135
|
+
lines.push(`${kind} ${change.path}`);
|
|
134
136
|
}
|
|
135
137
|
return lines.join("\n");
|
|
136
138
|
}
|
|
@@ -147,20 +149,37 @@ export function mapCodexItem(method, item) {
|
|
|
147
149
|
switch (item.type) {
|
|
148
150
|
case "commandExecution": {
|
|
149
151
|
const commandItem = item;
|
|
152
|
+
const id = typeof commandItem.id === "string" ? commandItem.id : "";
|
|
153
|
+
const command = typeof commandItem.command === "string" ? commandItem.command : "";
|
|
154
|
+
if (!id || !command)
|
|
155
|
+
return { events };
|
|
156
|
+
const exitCode = typeof commandItem.exitCode === "number"
|
|
157
|
+
? commandItem.exitCode
|
|
158
|
+
: null;
|
|
159
|
+
const rawOutput = commandItem.aggregatedOutput ?? "";
|
|
160
|
+
const aggregatedOutput = exitCode !== null && exitCode !== 0
|
|
161
|
+
? `${rawOutput}${rawOutput ? "\n" : ""}[exit code: ${exitCode}]`
|
|
162
|
+
: rawOutput;
|
|
150
163
|
events.push({
|
|
151
164
|
type: "tool",
|
|
152
165
|
event: isStart ? "on_tool_start" : "on_tool_end",
|
|
153
|
-
name: "
|
|
154
|
-
input: isStart
|
|
155
|
-
? {
|
|
166
|
+
name: "shell",
|
|
167
|
+
input: isStart || isEnd
|
|
168
|
+
? {
|
|
169
|
+
id,
|
|
170
|
+
command,
|
|
171
|
+
...(typeof commandItem.cwd === "string" && commandItem.cwd
|
|
172
|
+
? { cwd: commandItem.cwd }
|
|
173
|
+
: {}),
|
|
174
|
+
}
|
|
156
175
|
: undefined,
|
|
157
176
|
output: isEnd
|
|
158
177
|
? {
|
|
159
|
-
id
|
|
160
|
-
command
|
|
178
|
+
id,
|
|
179
|
+
command,
|
|
161
180
|
status: commandItem.status,
|
|
162
|
-
aggregatedOutput
|
|
163
|
-
exitCode
|
|
181
|
+
aggregatedOutput,
|
|
182
|
+
exitCode,
|
|
164
183
|
}
|
|
165
184
|
: undefined,
|
|
166
185
|
data: { method, item },
|
|
@@ -170,14 +189,19 @@ export function mapCodexItem(method, item) {
|
|
|
170
189
|
case "fileChange": {
|
|
171
190
|
const fileChange = item;
|
|
172
191
|
const changes = Array.isArray(fileChange.changes) ? fileChange.changes : [];
|
|
192
|
+
const id = typeof item.id === "string" ? item.id : "";
|
|
193
|
+
if (!id ||
|
|
194
|
+
changes.length === 0 ||
|
|
195
|
+
!changes.some((change) => isRecord(change) && typeof change.path === "string"))
|
|
196
|
+
return { events };
|
|
173
197
|
events.push({
|
|
174
198
|
type: "tool",
|
|
175
199
|
event: isStart ? "on_tool_start" : "on_tool_end",
|
|
176
200
|
name: "apply_patch",
|
|
177
|
-
input: isStart || isEnd ? { id
|
|
201
|
+
input: isStart || isEnd ? { id, changes } : undefined,
|
|
178
202
|
output: isEnd
|
|
179
203
|
? {
|
|
180
|
-
id
|
|
204
|
+
id,
|
|
181
205
|
status: fileChange.status,
|
|
182
206
|
aggregatedOutput: fileChangeSummary(changes),
|
|
183
207
|
}
|
|
@@ -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;
|