@rynx-ai/runtime 0.1.11-beta.20 → 0.1.11-beta.22
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-integration.d.ts +23 -1
- package/dist/claude/native-integration.js +69 -6
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/client.d.ts +4 -3
- package/dist/codex-app-server/client.js +35 -12
- package/dist/codex-app-server/forwarder.d.ts +29 -5
- package/dist/codex-app-server/forwarder.js +140 -29
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- package/dist/codex-app-server/protocol.d.ts +2 -3
- package/dist/host.d.ts +27 -20
- package/dist/host.js +337 -455
- package/dist/runner/child.d.ts +12 -5
- package/dist/runner/child.js +116 -37
- package/dist/runner/manager.d.ts +36 -18
- package/dist/runner/manager.js +176 -81
- package/dist/runner/protocol.d.ts +15 -20
- package/dist/runner/startup-policy.d.ts +3 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/tmux.d.ts +15 -0
- package/dist/terminal/tmux.js +50 -0
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentEvent, SessionInteractionResolution, TerminalCommandData, TodoItem } from "@rynx-ai/core";
|
|
2
2
|
import type { ResolveInteractionResult, RuntimeInteractionEvent } from "../interactions.js";
|
|
3
|
+
import { type ClaudeRunnerStatus } from "./session-status.js";
|
|
3
4
|
/** Mirror sink — the same shape as `CodexForwarderSink` so the host reuses one
|
|
4
5
|
* per-turn normalizer wiring for both runtimes. */
|
|
5
6
|
export interface ClaudeForwarderSink {
|
|
@@ -21,7 +22,9 @@ export interface ClaudeForwarderSink {
|
|
|
21
22
|
/** The current turn finished (a new user prompt, or the inactivity backstop).
|
|
22
23
|
* `usage` carries the latest statusLine context/cost snapshot, when captured. */
|
|
23
24
|
onTurnEnd(usage?: Record<string, unknown>): void;
|
|
24
|
-
/** The runtime
|
|
25
|
+
/** The runtime status changed according to Claude's session metadata. */
|
|
26
|
+
onStatus?(status: ClaudeRunnerStatus, blockedOn?: string): void;
|
|
27
|
+
/** The runtime went idle on the legacy hook fallback — surface idle WITHOUT finalizing
|
|
25
28
|
* the turn. claude fires Stop around the same time it flushes the final
|
|
26
29
|
* assistant record and the two orderings race; finalizing here would split a
|
|
27
30
|
* late assistant record into its own turn. The turn is finalized by the next
|
|
@@ -105,6 +108,14 @@ export declare class ClaudeLiveSession {
|
|
|
105
108
|
private lastActivityAt;
|
|
106
109
|
/** When the Stop hook fired (null = not yet) — drives the grace-period close. */
|
|
107
110
|
private stopPendingAt;
|
|
111
|
+
/** An idle status from Claude's session metadata. The short delay keeps a
|
|
112
|
+
* final transcript record in the response without letting stale tool state
|
|
113
|
+
* override the provider's terminal status. */
|
|
114
|
+
private providerIdleAt;
|
|
115
|
+
private statusPoller?;
|
|
116
|
+
/** A failed hook is authoritative until the next running edge. Claude writes
|
|
117
|
+
* idle after failures too, so that trailing file update must not erase it. */
|
|
118
|
+
private providerFailureSticky;
|
|
108
119
|
/** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
|
|
109
120
|
private readonly openToolIds;
|
|
110
121
|
/** Transcript-backed acknowledgements for web→TUI input. A direct idle
|
|
@@ -186,6 +197,11 @@ export declare class ClaudeLiveSession {
|
|
|
186
197
|
/** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
|
|
187
198
|
* drive deterministically; the async {@link loop} just calls it on an interval. */
|
|
188
199
|
tick(): void;
|
|
200
|
+
/** Bind Claude's per-process session metadata after the terminal exists. */
|
|
201
|
+
attachStatusSource({ panePid, configDir, }: {
|
|
202
|
+
panePid: () => number | undefined;
|
|
203
|
+
configDir?: string;
|
|
204
|
+
}): void;
|
|
189
205
|
private loop;
|
|
190
206
|
private pollHooks;
|
|
191
207
|
private handleHook;
|
|
@@ -255,6 +271,8 @@ export declare class ClaudeLiveSession {
|
|
|
255
271
|
/** Refresh the latest statusLine context/cost snapshot (a last-writer-wins file
|
|
256
272
|
* the statusLine hook overwrites on every TUI render). */
|
|
257
273
|
private pollStatus;
|
|
274
|
+
private pollSessionStatus;
|
|
275
|
+
private handleSessionStatus;
|
|
258
276
|
/** A usage record from the latest statusLine snapshot (snake_case, the keys the
|
|
259
277
|
* normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
|
|
260
278
|
* if the statusLine hook has not fired yet. */
|
|
@@ -285,6 +303,8 @@ export declare class ClaudeLiveSession {
|
|
|
285
303
|
isTurnOpen(): boolean;
|
|
286
304
|
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
287
305
|
failOpenTurn(error: Error): boolean;
|
|
306
|
+
/** Retire process-scoped metadata before classifying a terminal exit. */
|
|
307
|
+
noteTerminalExit(error: Error): boolean;
|
|
288
308
|
private closeTurn;
|
|
289
309
|
private closeTurnError;
|
|
290
310
|
private resetMessageCorrelation;
|
|
@@ -294,6 +314,8 @@ export declare class ClaudeLiveSession {
|
|
|
294
314
|
* {@link import("../terminal/tmux.js").TmuxTerminal}). The runner-child hands
|
|
295
315
|
* this to the host after launching the pane, since the host doesn't own tmux. */
|
|
296
316
|
export interface TerminalInjector {
|
|
317
|
+
/** PID of the process owning the tmux pane, when available. */
|
|
318
|
+
panePid?(): number | undefined;
|
|
297
319
|
capturePane(): string;
|
|
298
320
|
clearInputLine(): void;
|
|
299
321
|
paste(text: string): void;
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* the Claude Code TUI feeds through its hooks + transcript:
|
|
6
6
|
*
|
|
7
7
|
* - `hooks.jsonl` (bridge) — `SessionStart` reveals the transcript path + claude
|
|
8
|
-
* session id
|
|
8
|
+
* session id; `StopFailure` supplies the failure edge and `Stop` carries the
|
|
9
|
+
* legacy fallback boundary.
|
|
9
10
|
* - the transcript JSONL — the turn OPENS on a `role:user` prompt record and its
|
|
10
|
-
* assistant/tool records become {@link AgentEvent}s.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* assistant/tool records become {@link AgentEvent}s.
|
|
12
|
+
* - Claude's per-process session file — while readable, it is the source of
|
|
13
|
+
* truth for running/idle and supersedes the hook/pane heuristics.
|
|
13
14
|
*
|
|
14
15
|
* The mapped events drive the SAME per-turn {@link SessionNormalizer} sink shape
|
|
15
16
|
* the codex forwarder uses (see the host), so mirroring is identical downstream.
|
|
@@ -21,6 +22,7 @@ import { statSync } from "node:fs";
|
|
|
21
22
|
import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
|
|
22
23
|
import { jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, } from "./native-bridge.js";
|
|
23
24
|
import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
|
|
25
|
+
import { ClaudeSessionStatusPoller, } from "./session-status.js";
|
|
24
26
|
const MAX_SETTLED_INTERACTIONS = 512;
|
|
25
27
|
const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
|
|
26
28
|
const MAX_SUBMISSION_OBSERVATIONS = 64;
|
|
@@ -169,6 +171,14 @@ export class ClaudeLiveSession {
|
|
|
169
171
|
lastActivityAt = 0;
|
|
170
172
|
/** When the Stop hook fired (null = not yet) — drives the grace-period close. */
|
|
171
173
|
stopPendingAt = null;
|
|
174
|
+
/** An idle status from Claude's session metadata. The short delay keeps a
|
|
175
|
+
* final transcript record in the response without letting stale tool state
|
|
176
|
+
* override the provider's terminal status. */
|
|
177
|
+
providerIdleAt = null;
|
|
178
|
+
statusPoller;
|
|
179
|
+
/** A failed hook is authoritative until the next running edge. Claude writes
|
|
180
|
+
* idle after failures too, so that trailing file update must not erase it. */
|
|
181
|
+
providerFailureSticky = false;
|
|
172
182
|
/** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
|
|
173
183
|
openToolIds = new Set();
|
|
174
184
|
/** Transcript-backed acknowledgements for web→TUI input. A direct idle
|
|
@@ -328,6 +338,7 @@ export class ClaudeLiveSession {
|
|
|
328
338
|
if (this.stopped)
|
|
329
339
|
return;
|
|
330
340
|
this.stopped = true;
|
|
341
|
+
this.statusPoller?.retire();
|
|
331
342
|
// A hook may have appended its request just before shutdown but not yet
|
|
332
343
|
// reached a scheduled poll. Drain it so the subprocess receives cancellation
|
|
333
344
|
// instead of remaining blocked until Claude's one-day hook timeout.
|
|
@@ -365,9 +376,20 @@ export class ClaudeLiveSession {
|
|
|
365
376
|
this.pollAbandonedInteractions();
|
|
366
377
|
this.flushTurnFailure();
|
|
367
378
|
this.pollStatus();
|
|
379
|
+
this.pollSessionStatus();
|
|
368
380
|
this.flushStopSignal();
|
|
369
381
|
this.maybeIdleClose();
|
|
370
382
|
}
|
|
383
|
+
/** Bind Claude's per-process session metadata after the terminal exists. */
|
|
384
|
+
attachStatusSource({ panePid, configDir, }) {
|
|
385
|
+
this.statusPoller?.retire();
|
|
386
|
+
this.statusPoller = new ClaudeSessionStatusPoller({
|
|
387
|
+
panePid,
|
|
388
|
+
sessionId: () => this.currentClaudeSessionId,
|
|
389
|
+
onStatus: (status) => this.handleSessionStatus(status),
|
|
390
|
+
...(configDir ? { configDir } : {}),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
371
393
|
async loop() {
|
|
372
394
|
while (!this.stopped) {
|
|
373
395
|
try {
|
|
@@ -423,6 +445,7 @@ export class ClaudeLiveSession {
|
|
|
423
445
|
if (!error)
|
|
424
446
|
return;
|
|
425
447
|
this.turnFailurePending = null;
|
|
448
|
+
this.providerFailureSticky = true;
|
|
426
449
|
this.closeTurnError(error);
|
|
427
450
|
}
|
|
428
451
|
/** Emit the Stop idle signal only after this tick has discovered native
|
|
@@ -431,6 +454,8 @@ export class ClaudeLiveSession {
|
|
|
431
454
|
if (!this.stopSignalPending)
|
|
432
455
|
return;
|
|
433
456
|
this.stopSignalPending = false;
|
|
457
|
+
if (this.statusPoller?.active)
|
|
458
|
+
return;
|
|
434
459
|
if (this.pendingInteractions.size === 0)
|
|
435
460
|
this.sink.onIdle();
|
|
436
461
|
}
|
|
@@ -866,6 +891,7 @@ export class ClaudeLiveSession {
|
|
|
866
891
|
this.currentTurnId = turnId;
|
|
867
892
|
this.turnOpen = true;
|
|
868
893
|
this.syntheticTurn = false;
|
|
894
|
+
this.providerIdleAt = null;
|
|
869
895
|
this.openToolIds.clear();
|
|
870
896
|
this.stopPendingAt = null;
|
|
871
897
|
this.sink.onTurnStart(this.currentTurnId);
|
|
@@ -987,6 +1013,27 @@ export class ClaudeLiveSession {
|
|
|
987
1013
|
if (status)
|
|
988
1014
|
this.latestStatus = status;
|
|
989
1015
|
}
|
|
1016
|
+
pollSessionStatus() {
|
|
1017
|
+
const poller = this.statusPoller;
|
|
1018
|
+
if (!poller)
|
|
1019
|
+
return;
|
|
1020
|
+
const wasActive = poller.active;
|
|
1021
|
+
poller.tick();
|
|
1022
|
+
if (wasActive && !poller.active)
|
|
1023
|
+
this.providerIdleAt = null;
|
|
1024
|
+
}
|
|
1025
|
+
handleSessionStatus(status) {
|
|
1026
|
+
this.stopSignalPending = false;
|
|
1027
|
+
this.stopPendingAt = null;
|
|
1028
|
+
if (status.runnerStatus === "running") {
|
|
1029
|
+
this.providerFailureSticky = false;
|
|
1030
|
+
}
|
|
1031
|
+
else if (this.providerFailureSticky) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
this.sink.onStatus?.(status.runnerStatus, status.blockedOn);
|
|
1035
|
+
this.providerIdleAt = status.runnerStatus === "idle" ? this.now() : null;
|
|
1036
|
+
}
|
|
990
1037
|
/** A usage record from the latest statusLine snapshot (snake_case, the keys the
|
|
991
1038
|
* normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
|
|
992
1039
|
* if the statusLine hook has not fired yet. */
|
|
@@ -1051,6 +1098,7 @@ export class ClaudeLiveSession {
|
|
|
1051
1098
|
this.currentTurnId = provisionalTurnId;
|
|
1052
1099
|
this.syntheticTurn = true;
|
|
1053
1100
|
}
|
|
1101
|
+
this.providerIdleAt = null;
|
|
1054
1102
|
this.turnOpen = true;
|
|
1055
1103
|
this.sink.onTurnStart(this.currentTurnId);
|
|
1056
1104
|
}
|
|
@@ -1076,6 +1124,8 @@ export class ClaudeLiveSession {
|
|
|
1076
1124
|
return;
|
|
1077
1125
|
this.cancelPendingInteractions("turn_interrupted");
|
|
1078
1126
|
this.stopSignalPending = false;
|
|
1127
|
+
if (this.statusPoller?.active)
|
|
1128
|
+
return;
|
|
1079
1129
|
this.sink.onIdle();
|
|
1080
1130
|
this.stopPendingAt = this.now();
|
|
1081
1131
|
}
|
|
@@ -1093,6 +1143,11 @@ export class ClaudeLiveSession {
|
|
|
1093
1143
|
this.closeTurnError(error);
|
|
1094
1144
|
return true;
|
|
1095
1145
|
}
|
|
1146
|
+
/** Retire process-scoped metadata before classifying a terminal exit. */
|
|
1147
|
+
noteTerminalExit(error) {
|
|
1148
|
+
this.statusPoller?.retire();
|
|
1149
|
+
return this.failOpenTurn(error);
|
|
1150
|
+
}
|
|
1096
1151
|
closeTurn() {
|
|
1097
1152
|
if (!this.turnOpen)
|
|
1098
1153
|
return;
|
|
@@ -1128,6 +1183,14 @@ export class ClaudeLiveSession {
|
|
|
1128
1183
|
this.streamedMessageText.clear();
|
|
1129
1184
|
}
|
|
1130
1185
|
maybeIdleClose() {
|
|
1186
|
+
if (this.statusPoller?.active) {
|
|
1187
|
+
if (this.turnOpen &&
|
|
1188
|
+
this.providerIdleAt !== null &&
|
|
1189
|
+
this.now() - this.providerIdleAt >= this.stopGraceMs) {
|
|
1190
|
+
this.closeTurn();
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1131
1194
|
if (!this.turnOpen ||
|
|
1132
1195
|
this.openToolIds.size > 0 ||
|
|
1133
1196
|
this.pendingInteractions.size > 0)
|
|
@@ -1151,7 +1214,7 @@ const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
|
|
|
1151
1214
|
const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
|
|
1152
1215
|
const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
|
|
1153
1216
|
const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
|
|
1154
|
-
/**
|
|
1217
|
+
/** Claude-native readiness window: tmux may exist well before the
|
|
1155
1218
|
* first interactive composer mounts on a cold start. */
|
|
1156
1219
|
const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
|
|
1157
1220
|
const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
|
|
@@ -1300,7 +1363,7 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1300
1363
|
let attempts = 1;
|
|
1301
1364
|
const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
|
|
1302
1365
|
injector.sendEnter();
|
|
1303
|
-
//
|
|
1366
|
+
// Preserve the old blind-submit fallback when capture-pane cannot
|
|
1304
1367
|
// identify the draft; its absence would otherwise "prove" success trivially.
|
|
1305
1368
|
if (!draftSeen)
|
|
1306
1369
|
return true;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ClaudeRunnerStatus = "running" | "idle";
|
|
2
|
+
export interface ClaudeSessionStatus {
|
|
3
|
+
runnerStatus: ClaudeRunnerStatus;
|
|
4
|
+
rawStatus: string;
|
|
5
|
+
statusUpdatedAt?: number;
|
|
6
|
+
blockedOn?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function claudeSessionsDir(configDir?: string): string;
|
|
9
|
+
export declare function resolveClaudeSessionStatusFile({ panePid, expectedSessionId, configDir, now, }: {
|
|
10
|
+
panePid?: number;
|
|
11
|
+
expectedSessionId?: string;
|
|
12
|
+
configDir?: string;
|
|
13
|
+
now?: number;
|
|
14
|
+
}): string | undefined;
|
|
15
|
+
export declare function readClaudeSessionStatus(path: string): ClaudeSessionStatus | undefined;
|
|
16
|
+
export declare class ClaudeSessionStatusPoller {
|
|
17
|
+
private readonly options;
|
|
18
|
+
private path?;
|
|
19
|
+
private attempts;
|
|
20
|
+
private exhausted;
|
|
21
|
+
private lastMtime?;
|
|
22
|
+
private lastEdge?;
|
|
23
|
+
private lastStatus?;
|
|
24
|
+
constructor(options: {
|
|
25
|
+
onStatus: (status: ClaudeSessionStatus) => void;
|
|
26
|
+
panePid: () => number | undefined;
|
|
27
|
+
sessionId: () => string | undefined;
|
|
28
|
+
configDir?: string;
|
|
29
|
+
maxResolveAttempts?: number;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
});
|
|
32
|
+
get active(): boolean;
|
|
33
|
+
get status(): ClaudeSessionStatus | undefined;
|
|
34
|
+
tick(): void;
|
|
35
|
+
retire(): void;
|
|
36
|
+
resync(): void;
|
|
37
|
+
private tryResolve;
|
|
38
|
+
private readAndPublish;
|
|
39
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const STATUS_TO_RUNNER = {
|
|
5
|
+
busy: "running",
|
|
6
|
+
waiting: "running",
|
|
7
|
+
idle: "idle",
|
|
8
|
+
shell: "idle",
|
|
9
|
+
running: "running",
|
|
10
|
+
completed: "idle",
|
|
11
|
+
failed: "idle",
|
|
12
|
+
error: "idle",
|
|
13
|
+
done: "idle",
|
|
14
|
+
};
|
|
15
|
+
const SCAN_FRESHNESS_MS = 120_000;
|
|
16
|
+
const DEFAULT_MAX_RESOLVE_ATTEMPTS = 40;
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function readJsonRecord(path) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
23
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function matchesSession(record, expectedSessionId) {
|
|
30
|
+
if (record.kind !== "interactive")
|
|
31
|
+
return false;
|
|
32
|
+
return expectedSessionId === undefined || record.sessionId === expectedSessionId;
|
|
33
|
+
}
|
|
34
|
+
export function claudeSessionsDir(configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude")) {
|
|
35
|
+
return join(configDir, "sessions");
|
|
36
|
+
}
|
|
37
|
+
export function resolveClaudeSessionStatusFile({ panePid, expectedSessionId, configDir, now = Date.now(), }) {
|
|
38
|
+
const directory = claudeSessionsDir(configDir);
|
|
39
|
+
if (panePid !== undefined) {
|
|
40
|
+
const candidate = join(directory, `${panePid}.json`);
|
|
41
|
+
const record = readJsonRecord(candidate);
|
|
42
|
+
if (record && matchesSession(record, expectedSessionId))
|
|
43
|
+
return candidate;
|
|
44
|
+
}
|
|
45
|
+
if (!expectedSessionId)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
49
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
50
|
+
continue;
|
|
51
|
+
const candidate = join(directory, entry.name);
|
|
52
|
+
let modifiedAt;
|
|
53
|
+
try {
|
|
54
|
+
modifiedAt = statSync(candidate).mtimeMs;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (now - modifiedAt > SCAN_FRESHNESS_MS)
|
|
60
|
+
continue;
|
|
61
|
+
const record = readJsonRecord(candidate);
|
|
62
|
+
if (record && matchesSession(record, expectedSessionId))
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
export function readClaudeSessionStatus(path) {
|
|
72
|
+
const record = readJsonRecord(path);
|
|
73
|
+
if (!record || typeof record.status !== "string")
|
|
74
|
+
return undefined;
|
|
75
|
+
const runnerStatus = STATUS_TO_RUNNER[record.status];
|
|
76
|
+
if (!runnerStatus)
|
|
77
|
+
return undefined;
|
|
78
|
+
const updatedAt = record.statusUpdatedAt;
|
|
79
|
+
const waitingFor = record.status === "waiting" ? record.waitingFor : undefined;
|
|
80
|
+
return {
|
|
81
|
+
runnerStatus,
|
|
82
|
+
rawStatus: record.status,
|
|
83
|
+
...(Number.isSafeInteger(updatedAt) ? { statusUpdatedAt: updatedAt } : {}),
|
|
84
|
+
...(typeof waitingFor === "string" && waitingFor ? { blockedOn: waitingFor } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export class ClaudeSessionStatusPoller {
|
|
88
|
+
options;
|
|
89
|
+
path;
|
|
90
|
+
attempts = 0;
|
|
91
|
+
exhausted = false;
|
|
92
|
+
lastMtime;
|
|
93
|
+
lastEdge;
|
|
94
|
+
lastStatus;
|
|
95
|
+
constructor(options) {
|
|
96
|
+
this.options = options;
|
|
97
|
+
}
|
|
98
|
+
get active() {
|
|
99
|
+
return this.path !== undefined && !this.exhausted;
|
|
100
|
+
}
|
|
101
|
+
get status() {
|
|
102
|
+
return this.lastStatus;
|
|
103
|
+
}
|
|
104
|
+
tick() {
|
|
105
|
+
if (this.exhausted)
|
|
106
|
+
return;
|
|
107
|
+
if (!this.path) {
|
|
108
|
+
this.tryResolve();
|
|
109
|
+
if (!this.path)
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.readAndPublish();
|
|
113
|
+
}
|
|
114
|
+
retire() {
|
|
115
|
+
this.exhausted = true;
|
|
116
|
+
}
|
|
117
|
+
resync() {
|
|
118
|
+
if (!this.active)
|
|
119
|
+
return;
|
|
120
|
+
this.lastMtime = undefined;
|
|
121
|
+
this.lastEdge = undefined;
|
|
122
|
+
}
|
|
123
|
+
tryResolve() {
|
|
124
|
+
this.attempts += 1;
|
|
125
|
+
this.path = resolveClaudeSessionStatusFile({
|
|
126
|
+
panePid: this.options.panePid(),
|
|
127
|
+
expectedSessionId: this.options.sessionId(),
|
|
128
|
+
...(this.options.configDir ? { configDir: this.options.configDir } : {}),
|
|
129
|
+
now: this.options.now?.() ?? Date.now(),
|
|
130
|
+
});
|
|
131
|
+
if (!this.path &&
|
|
132
|
+
this.attempts >= (this.options.maxResolveAttempts ?? DEFAULT_MAX_RESOLVE_ATTEMPTS)) {
|
|
133
|
+
this.exhausted = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
readAndPublish() {
|
|
137
|
+
if (!this.path)
|
|
138
|
+
return;
|
|
139
|
+
let mtime;
|
|
140
|
+
try {
|
|
141
|
+
mtime = statSync(this.path).mtimeMs;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
this.exhausted = true;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (this.lastMtime === mtime)
|
|
148
|
+
return;
|
|
149
|
+
this.lastMtime = mtime;
|
|
150
|
+
const status = readClaudeSessionStatus(this.path);
|
|
151
|
+
if (!status) {
|
|
152
|
+
this.lastStatus = undefined;
|
|
153
|
+
this.lastEdge = undefined;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.lastStatus = status;
|
|
157
|
+
const edge = `${status.runnerStatus}\0${status.blockedOn ?? ""}`;
|
|
158
|
+
if (edge === this.lastEdge)
|
|
159
|
+
return;
|
|
160
|
+
this.lastEdge = edge;
|
|
161
|
+
this.options.onStatus(status);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -29,9 +29,11 @@ export declare class CodexAppServerClient {
|
|
|
29
29
|
private interactionListener;
|
|
30
30
|
private connectionListener;
|
|
31
31
|
private connectionState;
|
|
32
|
+
private connectionEstablished;
|
|
32
33
|
private readonly pendingInteractions;
|
|
33
34
|
private readonly settledInteractions;
|
|
34
35
|
private initializeResponse;
|
|
36
|
+
private initializePromise;
|
|
35
37
|
constructor({ spawner, channel, logger, clientInfo, approvalDecisionPolicy, }: CodexAppServerClientOptions);
|
|
36
38
|
/**
|
|
37
39
|
* The multi-client endpoint a `codex --remote` TUI can attach to, when this
|
|
@@ -94,9 +96,8 @@ export declare class CodexAppServerClient {
|
|
|
94
96
|
* resolver still wins correctly.
|
|
95
97
|
*/
|
|
96
98
|
setInteractionListener(listener: RuntimeInteractionListener | null): void;
|
|
97
|
-
/** Observe the
|
|
98
|
-
*
|
|
99
|
-
* native request still has to count as unavailable during host failover. */
|
|
99
|
+
/** Observe the initialized connection lifecycle. Registration never reports
|
|
100
|
+
* disconnected for a client that has not connected yet. */
|
|
100
101
|
setConnectionListener(listener: ((state: "connected" | "disconnected") => void) | null): void;
|
|
101
102
|
private setConnectionState;
|
|
102
103
|
resolveInteraction(interactionId: string, resolution: SessionInteractionResolution): ResolveInteractionResult;
|
|
@@ -953,9 +953,11 @@ export class CodexAppServerClient {
|
|
|
953
953
|
interactionListener = null;
|
|
954
954
|
connectionListener = null;
|
|
955
955
|
connectionState = "disconnected";
|
|
956
|
+
connectionEstablished = false;
|
|
956
957
|
pendingInteractions = new Map();
|
|
957
958
|
settledInteractions = new Set();
|
|
958
959
|
initializeResponse = null;
|
|
960
|
+
initializePromise = null;
|
|
959
961
|
constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", }) {
|
|
960
962
|
this.logger = logger;
|
|
961
963
|
this.clientInfo = clientInfo;
|
|
@@ -988,14 +990,31 @@ export class CodexAppServerClient {
|
|
|
988
990
|
if (this.initializeResponse) {
|
|
989
991
|
return this.initializeResponse;
|
|
990
992
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
993
|
+
if (this.initializePromise)
|
|
994
|
+
return this.initializePromise;
|
|
995
|
+
const initializing = (async () => {
|
|
996
|
+
await this.transport.ensureStarted();
|
|
997
|
+
const response = await this.transport.sendRequest("initialize", {
|
|
998
|
+
clientInfo: this.clientInfo,
|
|
999
|
+
capabilities: { experimentalApi: true },
|
|
1000
|
+
});
|
|
1001
|
+
// Codex app-server uses the full initialize handshake: it does not accept
|
|
1002
|
+
// capability requests after merely replying to `initialize`. The client
|
|
1003
|
+
// must acknowledge that response with the `initialized` notification
|
|
1004
|
+
// before `thread/resume`, `turn/start`, and the other APIs are legal.
|
|
1005
|
+
await this.transport.sendNotification("initialized");
|
|
1006
|
+
this.initializeResponse = response;
|
|
1007
|
+
this.setConnectionState("connected");
|
|
1008
|
+
return response;
|
|
1009
|
+
})();
|
|
1010
|
+
this.initializePromise = initializing;
|
|
1011
|
+
try {
|
|
1012
|
+
return await initializing;
|
|
1013
|
+
}
|
|
1014
|
+
finally {
|
|
1015
|
+
if (this.initializePromise === initializing)
|
|
1016
|
+
this.initializePromise = null;
|
|
1017
|
+
}
|
|
999
1018
|
}
|
|
1000
1019
|
async getAuthStatus(params = {}) {
|
|
1001
1020
|
await this.ensureInitialized();
|
|
@@ -1146,14 +1165,18 @@ export class CodexAppServerClient {
|
|
|
1146
1165
|
setInteractionListener(listener) {
|
|
1147
1166
|
this.interactionListener = listener;
|
|
1148
1167
|
}
|
|
1149
|
-
/** Observe the
|
|
1150
|
-
*
|
|
1151
|
-
* native request still has to count as unavailable during host failover. */
|
|
1168
|
+
/** Observe the initialized connection lifecycle. Registration never reports
|
|
1169
|
+
* disconnected for a client that has not connected yet. */
|
|
1152
1170
|
setConnectionListener(listener) {
|
|
1153
1171
|
this.connectionListener = listener;
|
|
1154
|
-
listener
|
|
1172
|
+
if (listener && this.connectionEstablished)
|
|
1173
|
+
listener(this.connectionState);
|
|
1155
1174
|
}
|
|
1156
1175
|
setConnectionState(state) {
|
|
1176
|
+
if (state === "connected")
|
|
1177
|
+
this.connectionEstablished = true;
|
|
1178
|
+
if (state === "disconnected" && !this.connectionEstablished)
|
|
1179
|
+
return;
|
|
1157
1180
|
if (this.connectionState === state)
|
|
1158
1181
|
return;
|
|
1159
1182
|
this.connectionState = state;
|
|
@@ -29,13 +29,19 @@ export interface CodexForwarderSink {
|
|
|
29
29
|
/** A turn began. `turnId` is codex's turn id, used to derive a stable
|
|
30
30
|
* `responseId`. Start a fresh normalizer/response. */
|
|
31
31
|
onTurnStart(turnId?: string): void;
|
|
32
|
+
/** The observer received the provider's authoritative `turn/started` edge.
|
|
33
|
+
* Unlike `onTurnStart`, this is not fired early by turn/start acceptance. */
|
|
34
|
+
onTurnObserved?(turnId?: string): void;
|
|
32
35
|
/** One mapped event within the current turn. */
|
|
33
36
|
onEvent(event: AgentEvent): void;
|
|
34
37
|
/** Provider startup is session status, not a model item. Hosts that already
|
|
35
38
|
* published the response can forward it without synthesizing another start. */
|
|
36
39
|
onStatus?(note: string | undefined, statusKind?: "startup"): void;
|
|
37
40
|
/** The current turn finished; `usage` is the runtime's raw snapshot if any. */
|
|
38
|
-
onTurnEnd(usage?: Record<string, unknown
|
|
41
|
+
onTurnEnd(usage?: Record<string, unknown>, reason?: "superseded"): void;
|
|
42
|
+
/** Resume proved that the newest turn is terminal even though its live edge
|
|
43
|
+
* was missed. This updates session state without replaying historical items. */
|
|
44
|
+
onRecoveredTurnStatus?(status: "idle" | "failed", turnId: string | undefined, error?: Error): void;
|
|
39
45
|
/** A turn failed on the runtime. */
|
|
40
46
|
onTurnError(error: Error): void;
|
|
41
47
|
/** The user's turn text (sourced from codex's `userMessage` item), so a
|
|
@@ -76,6 +82,9 @@ export declare class CodexSessionForwarder {
|
|
|
76
82
|
private currentThreadIdValue;
|
|
77
83
|
private activeSignaled;
|
|
78
84
|
private completionTimer;
|
|
85
|
+
/** Turn id retained only for late-item dedup while a terminal response waits
|
|
86
|
+
* for its bounded output-ordering grace. It is not an active provider turn. */
|
|
87
|
+
private pendingCompletionTurnId;
|
|
79
88
|
private assistantMessageTimer;
|
|
80
89
|
private deferredAssistantMessage;
|
|
81
90
|
private pendingCompletion;
|
|
@@ -94,7 +103,8 @@ export declare class CodexSessionForwarder {
|
|
|
94
103
|
/** Begin mirroring. Idempotent. */
|
|
95
104
|
start(): void;
|
|
96
105
|
stop(): void;
|
|
97
|
-
/** True while
|
|
106
|
+
/** True while the provider owns an active turn, including the short interval
|
|
107
|
+
* between injection acceptance and observer confirmation. */
|
|
98
108
|
isTurnOpen(): boolean;
|
|
99
109
|
/** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
|
|
100
110
|
currentTurnId(): string | null;
|
|
@@ -112,6 +122,10 @@ export declare class CodexSessionForwarder {
|
|
|
112
122
|
mcpStartupDetail(): string | null;
|
|
113
123
|
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
114
124
|
threadId(): string | null;
|
|
125
|
+
/** Seed an already-persisted/resumed thread binding. The bridge retains this
|
|
126
|
+
* state even when app-server does not rebroadcast `thread/started`, so
|
|
127
|
+
* terminal-boundary recovery must know it too. */
|
|
128
|
+
noteThreadBound(threadId: string): void;
|
|
115
129
|
/**
|
|
116
130
|
* Replay the backlog turns from a `thread/resume` response as if they were live
|
|
117
131
|
* `item/completed` notifications — the fresh-thread first-turn backfill. Each
|
|
@@ -120,9 +134,6 @@ export declare class CodexSessionForwarder {
|
|
|
120
134
|
* not doubled.
|
|
121
135
|
*/
|
|
122
136
|
replayBackfill(turns: ResumedTurn[]): void;
|
|
123
|
-
/** Reconcile only the exact active turn after an observer resume. Historical
|
|
124
|
-
* items are intentionally not replayed on an existing-thread reconnect. */
|
|
125
|
-
reconcileActiveTurn(turn: ResumedTurn | undefined): boolean;
|
|
126
137
|
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
127
138
|
failOpenTurn(error: Error): boolean;
|
|
128
139
|
private handle;
|
|
@@ -151,4 +162,17 @@ export declare class CodexSessionForwarder {
|
|
|
151
162
|
private completedItemKey;
|
|
152
163
|
private advanceAnonCounter;
|
|
153
164
|
private ensureTurn;
|
|
165
|
+
/** Start (or confirm) the app-server's authoritative active turn. A newer
|
|
166
|
+
* start supersedes an older response whose terminal edge arrived late; a
|
|
167
|
+
* pending Traex completion is flushed first so its final item grace remains
|
|
168
|
+
* intact. */
|
|
169
|
+
private beginTurn;
|
|
170
|
+
/** Active-turn clearing contract:
|
|
171
|
+
*
|
|
172
|
+
* - an identified active turn is closed only by the same id;
|
|
173
|
+
* - an id-less boundary cannot close an identified active turn;
|
|
174
|
+
* - with no observed active turn, an identified boundary may recover a
|
|
175
|
+
* missed start only when it carries the currently-bound thread id. */
|
|
176
|
+
private terminalBoundaryMatchesActiveTurn;
|
|
177
|
+
private notificationMatchesCurrentThread;
|
|
154
178
|
}
|