@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.20
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/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-integration.d.ts +12 -6
- package/dist/claude/native-integration.js +137 -28
- package/dist/codex-app-server/forwarder.d.ts +33 -0
- package/dist/codex-app-server/forwarder.js +159 -0
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/protocol.d.ts +7 -4
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +27 -7
- package/dist/host.js +298 -55
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +82 -3
- package/dist/runner/child.d.ts +22 -6
- package/dist/runner/child.js +237 -30
- package/dist/runner/manager.d.ts +72 -4
- package/dist/runner/manager.js +539 -54
- package/dist/runner/protocol.d.ts +6 -4
- package/dist/runner/startup-policy.d.ts +4 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/tmux.d.ts +14 -2
- package/dist/terminal/tmux.js +72 -14
- package/package.json +4 -3
package/dist/claude/models.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import type { ModelInfo, ModelListResponse } from "../codex-app-server/protocol.js";
|
|
2
|
-
/**
|
|
3
|
-
* Default model for the claude runtime when `CLAUDE_MODEL` is unset. Kept in
|
|
4
|
-
* sync with the `CLAUDE_MODEL` default in {@link import("../config.js")}.
|
|
5
|
-
*/
|
|
6
|
-
export declare const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
7
2
|
/** Build the `ModelInfo[]` for the claude runtime. */
|
|
8
3
|
export declare function claudeModelInfos(): ModelInfo[];
|
|
9
4
|
/** {@link ModelListResponse} for the claude runtime (broker `listModels`). */
|
package/dist/claude/models.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Default model for the claude runtime when `CLAUDE_MODEL` is unset. Kept in
|
|
3
|
-
* sync with the `CLAUDE_MODEL` default in {@link import("../config.js")}.
|
|
4
|
-
*/
|
|
5
|
-
export const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
6
1
|
/**
|
|
7
2
|
* Static catalogue of Claude models the `/model` card and `/models` reply offer
|
|
8
3
|
* for the claude runtime. Unlike codex/traex (which query a live app-server),
|
|
@@ -12,7 +7,7 @@ export const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
|
12
7
|
*/
|
|
13
8
|
const CLAUDE_MODEL_IDS = [
|
|
14
9
|
{ id: "claude-opus-4-8", displayName: "Claude Opus 4.8 · 最强" },
|
|
15
|
-
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 ·
|
|
10
|
+
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 · 均衡" },
|
|
16
11
|
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5 · 最快" },
|
|
17
12
|
{ id: "claude-opus-4-7", displayName: "Claude Opus 4.7" },
|
|
18
13
|
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6" },
|
|
@@ -24,7 +19,6 @@ export function claudeModelInfos() {
|
|
|
24
19
|
id,
|
|
25
20
|
model: id,
|
|
26
21
|
displayName,
|
|
27
|
-
isDefault: id === CLAUDE_DEFAULT_MODEL,
|
|
28
22
|
}));
|
|
29
23
|
}
|
|
30
24
|
/** {@link ModelListResponse} for the claude runtime (broker `listModels`). */
|
|
@@ -283,6 +283,8 @@ export declare class ClaudeLiveSession {
|
|
|
283
283
|
* sent an Escape — an Escape into idle claude submits an empty turn, which
|
|
284
284
|
* claude answers with a stray "No response requested." bubble. */
|
|
285
285
|
isTurnOpen(): boolean;
|
|
286
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
287
|
+
failOpenTurn(error: Error): boolean;
|
|
286
288
|
private closeTurn;
|
|
287
289
|
private closeTurnError;
|
|
288
290
|
private resetMessageCorrelation;
|
|
@@ -303,12 +305,16 @@ export interface InjectViaTerminalOptions {
|
|
|
303
305
|
promptGlyph?: string;
|
|
304
306
|
promptTimeoutMs?: number;
|
|
305
307
|
settleMs?: number;
|
|
308
|
+
/** Wait for the pasted draft to become visible before the first Enter. */
|
|
309
|
+
pasteCommitMs?: number;
|
|
306
310
|
/** Transcript-backed proof that Claude accepted this exact input. When
|
|
307
|
-
*
|
|
311
|
+
* present it takes precedence over pane heuristics. */
|
|
308
312
|
submissionObserved?: () => boolean;
|
|
309
|
-
/**
|
|
313
|
+
/** Total post-Enter verification budget. */
|
|
310
314
|
submitConfirmMs?: number;
|
|
311
|
-
/**
|
|
315
|
+
/** Minimum delay between Enter retries while the draft remains visible. */
|
|
316
|
+
submitRetryMs?: number;
|
|
317
|
+
/** Optional safety cap for tests/callers; the time budget remains authoritative. */
|
|
312
318
|
maxSubmitAttempts?: number;
|
|
313
319
|
pollMs?: number;
|
|
314
320
|
now?: () => number;
|
|
@@ -324,8 +330,8 @@ export interface InjectViaTerminalOptions {
|
|
|
324
330
|
*
|
|
325
331
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
326
332
|
* `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
|
|
327
|
-
* caller reports, NOT a signal to fall through to a second output path.
|
|
328
|
-
*
|
|
329
|
-
* neither a direct user prompt nor a type-ahead enqueue.
|
|
333
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
334
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
335
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
330
336
|
*/
|
|
331
337
|
export declare function injectViaTerminal(injector: TerminalInjector, text: string, opts?: InjectViaTerminalOptions): Promise<boolean>;
|
|
@@ -844,6 +844,15 @@ export class ClaudeLiveSession {
|
|
|
844
844
|
}
|
|
845
845
|
// A real prompt (not an XML-marker bookkeeping record) opens a new turn.
|
|
846
846
|
if (!content.startsWith("<")) {
|
|
847
|
+
const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
|
|
848
|
+
if (queuedIntoOpenTurn) {
|
|
849
|
+
// Claude type-ahead is one native busy interval: enqueue now, promote
|
|
850
|
+
// later, and emit one final Stop. Do not close/re-open the canonical
|
|
851
|
+
// response when the promoted user record arrives mid-turn.
|
|
852
|
+
this.sink.onUserMessage(content);
|
|
853
|
+
this.lastActivityAt = this.now();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
847
856
|
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
848
857
|
if (this.turnOpen && this.syntheticTurn) {
|
|
849
858
|
// A hook can flush just before the transcript's user record. The
|
|
@@ -1077,6 +1086,13 @@ export class ClaudeLiveSession {
|
|
|
1077
1086
|
isTurnOpen() {
|
|
1078
1087
|
return this.turnOpen;
|
|
1079
1088
|
}
|
|
1089
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
1090
|
+
failOpenTurn(error) {
|
|
1091
|
+
if (!this.turnOpen)
|
|
1092
|
+
return false;
|
|
1093
|
+
this.closeTurnError(error);
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1080
1096
|
closeTurn() {
|
|
1081
1097
|
if (!this.turnOpen)
|
|
1082
1098
|
return;
|
|
@@ -1132,14 +1148,79 @@ export class ClaudeLiveSession {
|
|
|
1132
1148
|
/** Claude Code renders this glyph once the input box is mounted (ready-gate). */
|
|
1133
1149
|
const CLAUDE_PROMPT_GLYPH = "❯";
|
|
1134
1150
|
const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
|
|
1151
|
+
const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
|
|
1152
|
+
const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
|
|
1153
|
+
const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
|
|
1154
|
+
/** Omnigent's Claude-native readiness window: tmux may exist well before the
|
|
1155
|
+
* first interactive composer mounts on a cold start. */
|
|
1156
|
+
const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
|
|
1157
|
+
const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
|
|
1158
|
+
const CLAUDE_PASTE_SETTLE_MS = 100;
|
|
1159
|
+
const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
|
|
1160
|
+
const CLAUDE_SUBMIT_RETRY_MS = 1_000;
|
|
1161
|
+
function selectedMenuRow(line, glyph) {
|
|
1162
|
+
const index = line.indexOf(glyph);
|
|
1163
|
+
if (index < 0)
|
|
1164
|
+
return false;
|
|
1165
|
+
return /^\d+\.\s/.test(line.slice(index + glyph.length).trimStart());
|
|
1166
|
+
}
|
|
1167
|
+
function boxRule(line) {
|
|
1168
|
+
const text = line.trim();
|
|
1169
|
+
return text.length >= 3 && [...text].every((character) => CLAUDE_BOX_RULE_CHARS.has(character));
|
|
1170
|
+
}
|
|
1135
1171
|
/** The live composer sits at the bottom of the pane. Restricting readiness to
|
|
1136
1172
|
* its trailing non-empty lines prevents an old prompt glyph in scrollback from
|
|
1137
1173
|
* accepting input while Claude is still booting or showing another screen. */
|
|
1138
1174
|
function claudePromptRendered(pane, glyph) {
|
|
1139
1175
|
const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1176
|
+
const tailStart = Math.max(0, nonEmpty.length - CLAUDE_PROMPT_SCAN_TAIL_LINES);
|
|
1177
|
+
for (let index = tailStart; index < nonEmpty.length; index += 1) {
|
|
1178
|
+
const line = nonEmpty[index] ?? "";
|
|
1179
|
+
if (!line.includes(glyph))
|
|
1180
|
+
continue;
|
|
1181
|
+
const framed = nonEmpty.slice(index + 1).some(boxRule);
|
|
1182
|
+
if (framed)
|
|
1183
|
+
return true;
|
|
1184
|
+
if (!selectedMenuRow(line, glyph))
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
// A fan-out of subagents can make the running footer arbitrarily tall. Above
|
|
1188
|
+
// the fixed tail, only a prompt framed by the composer's closing rule counts.
|
|
1189
|
+
for (let index = 0; index < tailStart; index += 1) {
|
|
1190
|
+
const line = nonEmpty[index] ?? "";
|
|
1191
|
+
if (line.includes(glyph) && nonEmpty.slice(index + 1).some(boxRule))
|
|
1192
|
+
return true;
|
|
1193
|
+
}
|
|
1194
|
+
return false;
|
|
1195
|
+
}
|
|
1196
|
+
function draftNeedle(content) {
|
|
1197
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
1198
|
+
for (const rawLine of normalized.split("\n")) {
|
|
1199
|
+
const control = rawLine.search(/[\x00-\x1f]/);
|
|
1200
|
+
const line = (control >= 0 ? rawLine.slice(0, control) : rawLine).trim();
|
|
1201
|
+
if (line)
|
|
1202
|
+
return line.slice(0, CLAUDE_DRAFT_NEEDLE_MAX_CHARS);
|
|
1203
|
+
}
|
|
1204
|
+
return "";
|
|
1205
|
+
}
|
|
1206
|
+
function draftInInputBox(pane, glyph, needle) {
|
|
1207
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.includes(glyph));
|
|
1208
|
+
const line = lines.at(-1);
|
|
1209
|
+
if (!line)
|
|
1210
|
+
return false;
|
|
1211
|
+
const tail = line.slice(line.lastIndexOf(glyph) + glyph.length);
|
|
1212
|
+
if (tail.includes(CLAUDE_PASTED_PLACEHOLDER_PREFIX))
|
|
1213
|
+
return true;
|
|
1214
|
+
return Boolean(needle) && tail.includes(needle);
|
|
1215
|
+
}
|
|
1216
|
+
function terminalFailureTail(pane) {
|
|
1217
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.trim()).slice(-12);
|
|
1218
|
+
if (lines.length === 0)
|
|
1219
|
+
return "";
|
|
1220
|
+
let tail = lines.join("\n");
|
|
1221
|
+
if (tail.length > 800)
|
|
1222
|
+
tail = `…${tail.slice(-800)}`;
|
|
1223
|
+
return `\nlast terminal output:\n${tail}`;
|
|
1143
1224
|
}
|
|
1144
1225
|
async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
1145
1226
|
const deadline = now() + timeoutMs;
|
|
@@ -1159,9 +1240,9 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
|
1159
1240
|
*
|
|
1160
1241
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
1161
1242
|
* `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
|
|
1162
|
-
* caller reports, NOT a signal to fall through to a second output path.
|
|
1163
|
-
*
|
|
1164
|
-
* neither a direct user prompt nor a type-ahead enqueue.
|
|
1243
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
1244
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
1245
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
1165
1246
|
*/
|
|
1166
1247
|
export async function injectViaTerminal(injector, text, opts = {}) {
|
|
1167
1248
|
const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
|
|
@@ -1180,12 +1261,23 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1180
1261
|
};
|
|
1181
1262
|
// 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
|
|
1182
1263
|
// not-ready pane is a hard error, never a fall-through-to-run signal.
|
|
1183
|
-
|
|
1264
|
+
let promptPolls = 0;
|
|
1265
|
+
let emptyPromptPolls = 0;
|
|
1266
|
+
let lastPromptPane = "";
|
|
1267
|
+
const ready = await pollUntil(() => {
|
|
1268
|
+
const pane = injector.capturePane();
|
|
1269
|
+
promptPolls += 1;
|
|
1270
|
+
if (pane.trim())
|
|
1271
|
+
lastPromptPane = pane;
|
|
1272
|
+
else
|
|
1273
|
+
emptyPromptPolls += 1;
|
|
1274
|
+
return claudePromptRendered(pane, glyph);
|
|
1275
|
+
}, opts.promptTimeoutMs ?? CLAUDE_PROMPT_READY_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1184
1276
|
if (cancelled())
|
|
1185
1277
|
return false;
|
|
1186
1278
|
if (!ready) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1279
|
+
throw new Error(`claude prompt not ready (no input composer within timeout; ${promptPolls} polls, ` +
|
|
1280
|
+
`${emptyPromptPolls} empty captures)${terminalFailureTail(lastPromptPane)}`);
|
|
1189
1281
|
}
|
|
1190
1282
|
// 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
|
|
1191
1283
|
// documented soft-newline escape: a bare submit Enter would consume it and
|
|
@@ -1193,30 +1285,47 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1193
1285
|
// the bracketed paste; normal messages must not gain an empty input row.
|
|
1194
1286
|
injector.clearInputLine();
|
|
1195
1287
|
injector.paste(text.endsWith("\\") ? `${text}\n` : text);
|
|
1196
|
-
// 3. `paste-buffer`
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
await
|
|
1288
|
+
// 3. tmux accepting `paste-buffer` does not mean Claude consumed it. Wait for
|
|
1289
|
+
// the draft (or its large-paste placeholder) to appear in the live composer;
|
|
1290
|
+
// otherwise Enter can be coalesced into the paste as a newline.
|
|
1291
|
+
const needle = draftNeedle(text);
|
|
1292
|
+
const draftSeen = await pollUntil(() => draftInInputBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? CLAUDE_PASTE_COMMIT_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1293
|
+
await sleep(opts.settleMs ?? CLAUDE_PASTE_SETTLE_MS);
|
|
1201
1294
|
if (cancelled())
|
|
1202
1295
|
return false; // Stop pressed mid-paste → don't submit
|
|
1203
|
-
// 4. Submit.
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
// queue enqueue ends the loop immediately.
|
|
1296
|
+
// 4. Submit and verify. Retry only while this exact draft is still visible;
|
|
1297
|
+
// once it leaves the composer, another Enter could hit a permission dialog or
|
|
1298
|
+
// an empty prompt. Transcript acknowledgement is the strongest success proof.
|
|
1207
1299
|
const observed = opts.submissionObserved;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1300
|
+
let attempts = 1;
|
|
1301
|
+
const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
|
|
1302
|
+
injector.sendEnter();
|
|
1303
|
+
// Omnigent preserves the old blind-submit fallback when capture-pane cannot
|
|
1304
|
+
// identify the draft; its absence would otherwise "prove" success trivially.
|
|
1305
|
+
if (!draftSeen)
|
|
1306
|
+
return true;
|
|
1307
|
+
const deadline = now() + (opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS);
|
|
1308
|
+
const retryMs = opts.submitRetryMs ?? CLAUDE_SUBMIT_RETRY_MS;
|
|
1309
|
+
let lastEnterAt = now();
|
|
1310
|
+
while (now() < deadline) {
|
|
1311
|
+
if (cancelled())
|
|
1312
|
+
return false;
|
|
1313
|
+
if (observed?.())
|
|
1214
1314
|
return true;
|
|
1215
|
-
if (
|
|
1315
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1216
1316
|
return true;
|
|
1317
|
+
if (now() - lastEnterAt >= retryMs && attempts < maxAttempts) {
|
|
1318
|
+
injector.sendEnter();
|
|
1319
|
+
attempts += 1;
|
|
1320
|
+
lastEnterAt = now();
|
|
1217
1321
|
}
|
|
1218
|
-
|
|
1219
|
-
return false;
|
|
1322
|
+
await sleep(pollMs);
|
|
1220
1323
|
}
|
|
1221
|
-
|
|
1324
|
+
if (observed?.())
|
|
1325
|
+
return true;
|
|
1326
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1327
|
+
return true;
|
|
1328
|
+
throw new Error(`Claude Code did not accept the submitted message within ` +
|
|
1329
|
+
`${opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS}ms ` +
|
|
1330
|
+
"(the draft is still in the input box). The message was not delivered.");
|
|
1222
1331
|
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { AgentEvent, UserContentPart } from "@rynx-ai/core";
|
|
25
25
|
import type { CodexAppServerClient } from "./client.js";
|
|
26
|
+
import type { McpStartupPlan } from "./mcp-startup.js";
|
|
26
27
|
import type { ResumedTurn } from "./protocol.js";
|
|
27
28
|
export interface CodexForwarderSink {
|
|
28
29
|
/** A turn began. `turnId` is codex's turn id, used to derive a stable
|
|
@@ -30,6 +31,9 @@ export interface CodexForwarderSink {
|
|
|
30
31
|
onTurnStart(turnId?: string): void;
|
|
31
32
|
/** One mapped event within the current turn. */
|
|
32
33
|
onEvent(event: AgentEvent): void;
|
|
34
|
+
/** Provider startup is session status, not a model item. Hosts that already
|
|
35
|
+
* published the response can forward it without synthesizing another start. */
|
|
36
|
+
onStatus?(note: string | undefined, statusKind?: "startup"): void;
|
|
33
37
|
/** The current turn finished; `usage` is the runtime's raw snapshot if any. */
|
|
34
38
|
onTurnEnd(usage?: Record<string, unknown>): void;
|
|
35
39
|
/** A turn failed on the runtime. */
|
|
@@ -58,6 +62,9 @@ export interface CodexSessionForwarderOptions {
|
|
|
58
62
|
assistantMessageGraceMs?: number;
|
|
59
63
|
/** Surface Traex's provider-capacity queue as a canonical running status. */
|
|
60
64
|
surfaceQueueStatus?: boolean;
|
|
65
|
+
/** Provider-configured MCP servers. Their startup round is synthesized because
|
|
66
|
+
* Codex currently sends per-server edges only to the thread-owning TUI. */
|
|
67
|
+
mcpStartup?: McpStartupPlan | null;
|
|
61
68
|
}
|
|
62
69
|
export declare class CodexSessionForwarder {
|
|
63
70
|
private readonly client;
|
|
@@ -72,6 +79,10 @@ export declare class CodexSessionForwarder {
|
|
|
72
79
|
private assistantMessageTimer;
|
|
73
80
|
private deferredAssistantMessage;
|
|
74
81
|
private pendingCompletion;
|
|
82
|
+
private readonly pendingMcpServers;
|
|
83
|
+
private readonly failedMcpServers;
|
|
84
|
+
private mcpStartupTimer;
|
|
85
|
+
private lastMcpStatusNote;
|
|
75
86
|
/** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
|
|
76
87
|
* `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
|
|
77
88
|
* counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
|
|
@@ -87,6 +98,18 @@ export declare class CodexSessionForwarder {
|
|
|
87
98
|
isTurnOpen(): boolean;
|
|
88
99
|
/** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
|
|
89
100
|
currentTurnId(): string | null;
|
|
101
|
+
/**
|
|
102
|
+
* Record a turn accepted by the injection connection before the independent
|
|
103
|
+
* observer receives `turn/started`. This closes the read-decide-RPC-write race:
|
|
104
|
+
* another web message arriving in that window must steer this turn, not start
|
|
105
|
+
* a second one.
|
|
106
|
+
*/
|
|
107
|
+
noteTurnAccepted(turnId: string): void;
|
|
108
|
+
hasPendingMcpStartup(): boolean;
|
|
109
|
+
/** Mark and return the startup servers cancelled by a web Stop. */
|
|
110
|
+
cancelMcpStartup(): string[];
|
|
111
|
+
/** Diagnostic suffix for an injection failure during Provider startup. */
|
|
112
|
+
mcpStartupDetail(): string | null;
|
|
90
113
|
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
91
114
|
threadId(): string | null;
|
|
92
115
|
/**
|
|
@@ -97,11 +120,21 @@ export declare class CodexSessionForwarder {
|
|
|
97
120
|
* not doubled.
|
|
98
121
|
*/
|
|
99
122
|
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
|
+
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
127
|
+
failOpenTurn(error: Error): boolean;
|
|
100
128
|
private handle;
|
|
101
129
|
private scheduleCompletion;
|
|
102
130
|
private refreshCompletionGrace;
|
|
103
131
|
private flushPendingCompletion;
|
|
104
132
|
private settle;
|
|
133
|
+
private handleMcpStartupStatus;
|
|
134
|
+
private settleMcpStartup;
|
|
135
|
+
private clearMcpStartupTimer;
|
|
136
|
+
private emitMcpStartupStatus;
|
|
137
|
+
private emitMcpStatus;
|
|
105
138
|
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
106
139
|
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
107
140
|
private processCompletedItem;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { codexUserContent } from "../input-resources.js";
|
|
2
2
|
import { mapCodexItem, mapCodexNotification } from "./mapping.js";
|
|
3
|
+
const MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated";
|
|
4
|
+
const MCP_TERMINAL_STATES = new Set(["ready", "failed", "cancelled"]);
|
|
3
5
|
function threadIdFrom(params) {
|
|
4
6
|
const p = params;
|
|
5
7
|
return p?.threadId ?? p?.thread?.id;
|
|
@@ -31,6 +33,18 @@ function indicatesActive(method, params) {
|
|
|
31
33
|
}
|
|
32
34
|
return false;
|
|
33
35
|
}
|
|
36
|
+
function isThreadIdle(method, params) {
|
|
37
|
+
if (method !== "thread/status/changed")
|
|
38
|
+
return false;
|
|
39
|
+
const status = params?.status;
|
|
40
|
+
return status === "idle" || (typeof status === "object" && status?.type === "idle");
|
|
41
|
+
}
|
|
42
|
+
function isModelOutput(method, params) {
|
|
43
|
+
if (method !== "item/started" && method !== "item/completed")
|
|
44
|
+
return false;
|
|
45
|
+
const item = params?.item;
|
|
46
|
+
return Boolean(item?.type && item.type !== "userMessage");
|
|
47
|
+
}
|
|
34
48
|
export class CodexSessionForwarder {
|
|
35
49
|
client;
|
|
36
50
|
sink;
|
|
@@ -44,6 +58,10 @@ export class CodexSessionForwarder {
|
|
|
44
58
|
assistantMessageTimer = null;
|
|
45
59
|
deferredAssistantMessage = null;
|
|
46
60
|
pendingCompletion = null;
|
|
61
|
+
pendingMcpServers = new Set();
|
|
62
|
+
failedMcpServers = new Map();
|
|
63
|
+
mcpStartupTimer = null;
|
|
64
|
+
lastMcpStatusNote = null;
|
|
47
65
|
/** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
|
|
48
66
|
* `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
|
|
49
67
|
* counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
|
|
@@ -63,10 +81,18 @@ export class CodexSessionForwarder {
|
|
|
63
81
|
this.unsubscribe = this.client.onNotification((method, params) => {
|
|
64
82
|
this.handle(method, params);
|
|
65
83
|
});
|
|
84
|
+
const startup = this.options.mcpStartup;
|
|
85
|
+
if (startup?.servers.length) {
|
|
86
|
+
for (const server of startup.servers)
|
|
87
|
+
this.pendingMcpServers.add(server);
|
|
88
|
+
this.mcpStartupTimer = setTimeout(() => this.settleMcpStartup(), startup.settleTimeoutMs);
|
|
89
|
+
this.mcpStartupTimer.unref?.();
|
|
90
|
+
}
|
|
66
91
|
}
|
|
67
92
|
stop() {
|
|
68
93
|
this.unsubscribe?.();
|
|
69
94
|
this.unsubscribe = null;
|
|
95
|
+
this.clearMcpStartupTimer();
|
|
70
96
|
this.flushPendingCompletion();
|
|
71
97
|
this.flushDeferredAssistantMessage();
|
|
72
98
|
if (this.turnOpen) {
|
|
@@ -82,6 +108,44 @@ export class CodexSessionForwarder {
|
|
|
82
108
|
currentTurnId() {
|
|
83
109
|
return this.currentTurnIdValue;
|
|
84
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Record a turn accepted by the injection connection before the independent
|
|
113
|
+
* observer receives `turn/started`. This closes the read-decide-RPC-write race:
|
|
114
|
+
* another web message arriving in that window must steer this turn, not start
|
|
115
|
+
* a second one.
|
|
116
|
+
*/
|
|
117
|
+
noteTurnAccepted(turnId) {
|
|
118
|
+
if (!turnId)
|
|
119
|
+
return;
|
|
120
|
+
this.currentTurnIdValue = turnId;
|
|
121
|
+
this.ensureTurn();
|
|
122
|
+
this.emitMcpStartupStatus();
|
|
123
|
+
}
|
|
124
|
+
hasPendingMcpStartup() {
|
|
125
|
+
return this.pendingMcpServers.size > 0;
|
|
126
|
+
}
|
|
127
|
+
/** Mark and return the startup servers cancelled by a web Stop. */
|
|
128
|
+
cancelMcpStartup() {
|
|
129
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
130
|
+
if (pending.length === 0)
|
|
131
|
+
return pending;
|
|
132
|
+
this.pendingMcpServers.clear();
|
|
133
|
+
this.clearMcpStartupTimer();
|
|
134
|
+
if (this.turnOpen) {
|
|
135
|
+
this.emitMcpStatus(`MCP startup cancelled: ${pending.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
return pending;
|
|
138
|
+
}
|
|
139
|
+
/** Diagnostic suffix for an injection failure during Provider startup. */
|
|
140
|
+
mcpStartupDetail() {
|
|
141
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
142
|
+
if (pending.length)
|
|
143
|
+
return `MCP startup still waiting on ${pending.join(", ")}`;
|
|
144
|
+
const failed = [...this.failedMcpServers.entries()]
|
|
145
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
146
|
+
.map(([name, error]) => error ? `${name}: ${error}` : name);
|
|
147
|
+
return failed.length ? `MCP startup failed for ${failed.join(", ")}` : null;
|
|
148
|
+
}
|
|
85
149
|
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
86
150
|
threadId() {
|
|
87
151
|
return this.currentThreadIdValue;
|
|
@@ -111,6 +175,27 @@ export class CodexSessionForwarder {
|
|
|
111
175
|
this.sink.onTurnEnd(mapped.usage);
|
|
112
176
|
}
|
|
113
177
|
}
|
|
178
|
+
/** Reconcile only the exact active turn after an observer resume. Historical
|
|
179
|
+
* items are intentionally not replayed on an existing-thread reconnect. */
|
|
180
|
+
reconcileActiveTurn(turn) {
|
|
181
|
+
if (!this.turnOpen || !turn || turn.status === "inProgress")
|
|
182
|
+
return false;
|
|
183
|
+
const resumedTurnId = turn.id ?? turn.turnId;
|
|
184
|
+
if (!this.currentTurnIdValue || resumedTurnId !== this.currentTurnIdValue)
|
|
185
|
+
return false;
|
|
186
|
+
const mapped = mapCodexNotification("turn/completed", { turn });
|
|
187
|
+
this.settle(mapped.fatalError
|
|
188
|
+
? { kind: "error", error: mapped.fatalError }
|
|
189
|
+
: { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
193
|
+
failOpenTurn(error) {
|
|
194
|
+
if (!this.turnOpen)
|
|
195
|
+
return false;
|
|
196
|
+
this.settle({ kind: "error", error });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
114
199
|
handle(method, params) {
|
|
115
200
|
if (method === "thread/started" || method === "thread.started") {
|
|
116
201
|
const tid = threadIdFrom(params);
|
|
@@ -138,6 +223,13 @@ export class CodexSessionForwarder {
|
|
|
138
223
|
notificationThreadId &&
|
|
139
224
|
notificationThreadId !== this.currentThreadIdValue)
|
|
140
225
|
return;
|
|
226
|
+
if (method === MCP_STARTUP_STATUS_METHOD) {
|
|
227
|
+
this.handleMcpStartupStatus(params);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (isThreadIdle(method, params) || isModelOutput(method, params)) {
|
|
231
|
+
this.settleMcpStartup();
|
|
232
|
+
}
|
|
141
233
|
// Release a parked resume as soon as the thread shows activity (rollout now
|
|
142
234
|
// exists). Fire once.
|
|
143
235
|
if (!this.activeSignaled && indicatesActive(method, params)) {
|
|
@@ -243,6 +335,73 @@ export class CodexSessionForwarder {
|
|
|
243
335
|
else
|
|
244
336
|
this.sink.onTurnEnd(completion.usage);
|
|
245
337
|
}
|
|
338
|
+
handleMcpStartupStatus(params) {
|
|
339
|
+
const update = params;
|
|
340
|
+
const name = typeof update?.name === "string" ? update.name : "";
|
|
341
|
+
const status = typeof update?.status === "string" ? update.status : "";
|
|
342
|
+
if (!name || (status !== "starting" && !MCP_TERMINAL_STATES.has(status)))
|
|
343
|
+
return;
|
|
344
|
+
if (status === "starting") {
|
|
345
|
+
this.pendingMcpServers.add(name);
|
|
346
|
+
this.failedMcpServers.delete(name);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
this.pendingMcpServers.delete(name);
|
|
350
|
+
if (status === "failed") {
|
|
351
|
+
this.failedMcpServers.set(name, typeof update?.error === "string" && update.error ? update.error : undefined);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
this.failedMcpServers.delete(name);
|
|
355
|
+
}
|
|
356
|
+
if (this.pendingMcpServers.size === 0)
|
|
357
|
+
this.clearMcpStartupTimer();
|
|
358
|
+
}
|
|
359
|
+
this.emitMcpStartupStatus();
|
|
360
|
+
}
|
|
361
|
+
settleMcpStartup() {
|
|
362
|
+
if (this.pendingMcpServers.size === 0) {
|
|
363
|
+
this.clearMcpStartupTimer();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.pendingMcpServers.clear();
|
|
367
|
+
this.clearMcpStartupTimer();
|
|
368
|
+
this.emitMcpStartupStatus();
|
|
369
|
+
}
|
|
370
|
+
clearMcpStartupTimer() {
|
|
371
|
+
if (this.mcpStartupTimer)
|
|
372
|
+
clearTimeout(this.mcpStartupTimer);
|
|
373
|
+
this.mcpStartupTimer = null;
|
|
374
|
+
}
|
|
375
|
+
emitMcpStartupStatus() {
|
|
376
|
+
if (!this.turnOpen)
|
|
377
|
+
return;
|
|
378
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
379
|
+
if (pending.length) {
|
|
380
|
+
this.emitMcpStatus(`Starting MCP servers: ${pending.join(", ")}`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const failed = [...this.failedMcpServers.keys()].sort();
|
|
384
|
+
if (failed.length) {
|
|
385
|
+
this.emitMcpStatus(`MCP startup failed: ${failed.join(", ")}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (this.lastMcpStatusNote !== null) {
|
|
389
|
+
this.lastMcpStatusNote = null;
|
|
390
|
+
if (this.sink.onStatus)
|
|
391
|
+
this.sink.onStatus(undefined);
|
|
392
|
+
else
|
|
393
|
+
this.sink.onEvent({ type: "status" });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
emitMcpStatus(note) {
|
|
397
|
+
if (this.lastMcpStatusNote === note)
|
|
398
|
+
return;
|
|
399
|
+
this.lastMcpStatusNote = note;
|
|
400
|
+
if (this.sink.onStatus)
|
|
401
|
+
this.sink.onStatus(note, "startup");
|
|
402
|
+
else
|
|
403
|
+
this.sink.onEvent({ type: "status", statusKind: "startup", note });
|
|
404
|
+
}
|
|
246
405
|
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
247
406
|
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
248
407
|
processCompletedItem(item) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CodexLineageRuntime } from "../codex-home.js";
|
|
2
|
+
export interface McpStartupPlan {
|
|
3
|
+
servers: string[];
|
|
4
|
+
settleTimeoutMs: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read the MCP table fields needed for startup tracking. Provider config stays
|
|
8
|
+
* authoritative: malformed TOML disables the synthesized status rather than
|
|
9
|
+
* preventing the native CLI from reporting its own startup error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseMcpStartupToml(input: string): McpStartupPlan | null;
|
|
12
|
+
/** Enabled Provider-configured MCP servers and their synthesized settle window. */
|
|
13
|
+
export declare function readMcpStartupPlan(runtimeHome: string, runtime: CodexLineageRuntime): McpStartupPlan | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "smol-toml";
|
|
4
|
+
const DEFAULT_SERVER_TIMEOUT_MS = 10_000;
|
|
5
|
+
const SETTLE_GRACE_MS = 15_000;
|
|
6
|
+
const MAX_SETTLE_TIMEOUT_MS = 240_000;
|
|
7
|
+
function configNames(runtime) {
|
|
8
|
+
return runtime === "codex"
|
|
9
|
+
? ["config.toml"]
|
|
10
|
+
: ["traecli.toml"];
|
|
11
|
+
}
|
|
12
|
+
function isTomlTable(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read the MCP table fields needed for startup tracking. Provider config stays
|
|
17
|
+
* authoritative: malformed TOML disables the synthesized status rather than
|
|
18
|
+
* preventing the native CLI from reporting its own startup error.
|
|
19
|
+
*/
|
|
20
|
+
export function parseMcpStartupToml(input) {
|
|
21
|
+
let config;
|
|
22
|
+
try {
|
|
23
|
+
config = parse(input);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const servers = config.mcp_servers;
|
|
29
|
+
if (!isTomlTable(servers))
|
|
30
|
+
return null;
|
|
31
|
+
const enabled = Object.entries(servers)
|
|
32
|
+
.filter(([name, server]) => Boolean(name) && isTomlTable(server) && server.enabled !== false)
|
|
33
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
34
|
+
if (enabled.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
const slowest = Math.max(DEFAULT_SERVER_TIMEOUT_MS, ...enabled.map(([, server]) => {
|
|
37
|
+
if (!isTomlTable(server))
|
|
38
|
+
return DEFAULT_SERVER_TIMEOUT_MS;
|
|
39
|
+
const seconds = server.startup_timeout_sec;
|
|
40
|
+
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0
|
|
41
|
+
? seconds * 1_000
|
|
42
|
+
: DEFAULT_SERVER_TIMEOUT_MS;
|
|
43
|
+
}));
|
|
44
|
+
return {
|
|
45
|
+
servers: enabled.map(([name]) => name),
|
|
46
|
+
settleTimeoutMs: Math.min(slowest + SETTLE_GRACE_MS, MAX_SETTLE_TIMEOUT_MS),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Enabled Provider-configured MCP servers and their synthesized settle window. */
|
|
50
|
+
export function readMcpStartupPlan(runtimeHome, runtime) {
|
|
51
|
+
for (const name of configNames(runtime)) {
|
|
52
|
+
try {
|
|
53
|
+
const plan = parseMcpStartupToml(readFileSync(join(runtimeHome, name), "utf8"));
|
|
54
|
+
if (plan)
|
|
55
|
+
return plan;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Missing/unreadable config: the Provider still owns startup; Rynx simply
|
|
59
|
+
// cannot synthesize per-server progress for this launch.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|