@rynx-ai/runtime 0.1.11-beta.17 → 0.1.11-beta.19
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 +10 -6
- package/dist/claude/native-integration.js +130 -28
- package/dist/codex-app-server/forwarder.d.ts +28 -0
- package/dist/codex-app-server/forwarder.js +138 -0
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/host.d.ts +4 -3
- package/dist/host.js +107 -28
- package/dist/runner/child.d.ts +4 -2
- package/dist/runner/child.js +10 -2
- package/dist/runner/manager.d.ts +5 -0
- package/dist/runner/manager.js +13 -2
- package/dist/runner/startup-policy.d.ts +4 -0
- package/dist/runner/startup-policy.js +5 -0
- package/package.json +3 -2
|
@@ -303,12 +303,16 @@ export interface InjectViaTerminalOptions {
|
|
|
303
303
|
promptGlyph?: string;
|
|
304
304
|
promptTimeoutMs?: number;
|
|
305
305
|
settleMs?: number;
|
|
306
|
+
/** Wait for the pasted draft to become visible before the first Enter. */
|
|
307
|
+
pasteCommitMs?: number;
|
|
306
308
|
/** Transcript-backed proof that Claude accepted this exact input. When
|
|
307
|
-
*
|
|
309
|
+
* present it takes precedence over pane heuristics. */
|
|
308
310
|
submissionObserved?: () => boolean;
|
|
309
|
-
/**
|
|
311
|
+
/** Total post-Enter verification budget. */
|
|
310
312
|
submitConfirmMs?: number;
|
|
311
|
-
/**
|
|
313
|
+
/** Minimum delay between Enter retries while the draft remains visible. */
|
|
314
|
+
submitRetryMs?: number;
|
|
315
|
+
/** Optional safety cap for tests/callers; the time budget remains authoritative. */
|
|
312
316
|
maxSubmitAttempts?: number;
|
|
313
317
|
pollMs?: number;
|
|
314
318
|
now?: () => number;
|
|
@@ -324,8 +328,8 @@ export interface InjectViaTerminalOptions {
|
|
|
324
328
|
*
|
|
325
329
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
326
330
|
* `_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.
|
|
331
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
332
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
333
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
330
334
|
*/
|
|
331
335
|
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
|
|
@@ -1132,14 +1141,79 @@ export class ClaudeLiveSession {
|
|
|
1132
1141
|
/** Claude Code renders this glyph once the input box is mounted (ready-gate). */
|
|
1133
1142
|
const CLAUDE_PROMPT_GLYPH = "❯";
|
|
1134
1143
|
const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
|
|
1144
|
+
const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
|
|
1145
|
+
const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
|
|
1146
|
+
const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
|
|
1147
|
+
/** Omnigent's Claude-native readiness window: tmux may exist well before the
|
|
1148
|
+
* first interactive composer mounts on a cold start. */
|
|
1149
|
+
const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
|
|
1150
|
+
const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
|
|
1151
|
+
const CLAUDE_PASTE_SETTLE_MS = 100;
|
|
1152
|
+
const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
|
|
1153
|
+
const CLAUDE_SUBMIT_RETRY_MS = 1_000;
|
|
1154
|
+
function selectedMenuRow(line, glyph) {
|
|
1155
|
+
const index = line.indexOf(glyph);
|
|
1156
|
+
if (index < 0)
|
|
1157
|
+
return false;
|
|
1158
|
+
return /^\d+\.\s/.test(line.slice(index + glyph.length).trimStart());
|
|
1159
|
+
}
|
|
1160
|
+
function boxRule(line) {
|
|
1161
|
+
const text = line.trim();
|
|
1162
|
+
return text.length >= 3 && [...text].every((character) => CLAUDE_BOX_RULE_CHARS.has(character));
|
|
1163
|
+
}
|
|
1135
1164
|
/** The live composer sits at the bottom of the pane. Restricting readiness to
|
|
1136
1165
|
* its trailing non-empty lines prevents an old prompt glyph in scrollback from
|
|
1137
1166
|
* accepting input while Claude is still booting or showing another screen. */
|
|
1138
1167
|
function claudePromptRendered(pane, glyph) {
|
|
1139
1168
|
const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1169
|
+
const tailStart = Math.max(0, nonEmpty.length - CLAUDE_PROMPT_SCAN_TAIL_LINES);
|
|
1170
|
+
for (let index = tailStart; index < nonEmpty.length; index += 1) {
|
|
1171
|
+
const line = nonEmpty[index] ?? "";
|
|
1172
|
+
if (!line.includes(glyph))
|
|
1173
|
+
continue;
|
|
1174
|
+
const framed = nonEmpty.slice(index + 1).some(boxRule);
|
|
1175
|
+
if (framed)
|
|
1176
|
+
return true;
|
|
1177
|
+
if (!selectedMenuRow(line, glyph))
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
// A fan-out of subagents can make the running footer arbitrarily tall. Above
|
|
1181
|
+
// the fixed tail, only a prompt framed by the composer's closing rule counts.
|
|
1182
|
+
for (let index = 0; index < tailStart; index += 1) {
|
|
1183
|
+
const line = nonEmpty[index] ?? "";
|
|
1184
|
+
if (line.includes(glyph) && nonEmpty.slice(index + 1).some(boxRule))
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
function draftNeedle(content) {
|
|
1190
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
1191
|
+
for (const rawLine of normalized.split("\n")) {
|
|
1192
|
+
const control = rawLine.search(/[\x00-\x1f]/);
|
|
1193
|
+
const line = (control >= 0 ? rawLine.slice(0, control) : rawLine).trim();
|
|
1194
|
+
if (line)
|
|
1195
|
+
return line.slice(0, CLAUDE_DRAFT_NEEDLE_MAX_CHARS);
|
|
1196
|
+
}
|
|
1197
|
+
return "";
|
|
1198
|
+
}
|
|
1199
|
+
function draftInInputBox(pane, glyph, needle) {
|
|
1200
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.includes(glyph));
|
|
1201
|
+
const line = lines.at(-1);
|
|
1202
|
+
if (!line)
|
|
1203
|
+
return false;
|
|
1204
|
+
const tail = line.slice(line.lastIndexOf(glyph) + glyph.length);
|
|
1205
|
+
if (tail.includes(CLAUDE_PASTED_PLACEHOLDER_PREFIX))
|
|
1206
|
+
return true;
|
|
1207
|
+
return Boolean(needle) && tail.includes(needle);
|
|
1208
|
+
}
|
|
1209
|
+
function terminalFailureTail(pane) {
|
|
1210
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.trim()).slice(-12);
|
|
1211
|
+
if (lines.length === 0)
|
|
1212
|
+
return "";
|
|
1213
|
+
let tail = lines.join("\n");
|
|
1214
|
+
if (tail.length > 800)
|
|
1215
|
+
tail = `…${tail.slice(-800)}`;
|
|
1216
|
+
return `\nlast terminal output:\n${tail}`;
|
|
1143
1217
|
}
|
|
1144
1218
|
async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
1145
1219
|
const deadline = now() + timeoutMs;
|
|
@@ -1159,9 +1233,9 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
|
1159
1233
|
*
|
|
1160
1234
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
1161
1235
|
* `_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.
|
|
1236
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
1237
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
1238
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
1165
1239
|
*/
|
|
1166
1240
|
export async function injectViaTerminal(injector, text, opts = {}) {
|
|
1167
1241
|
const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
|
|
@@ -1180,12 +1254,23 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1180
1254
|
};
|
|
1181
1255
|
// 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
|
|
1182
1256
|
// not-ready pane is a hard error, never a fall-through-to-run signal.
|
|
1183
|
-
|
|
1257
|
+
let promptPolls = 0;
|
|
1258
|
+
let emptyPromptPolls = 0;
|
|
1259
|
+
let lastPromptPane = "";
|
|
1260
|
+
const ready = await pollUntil(() => {
|
|
1261
|
+
const pane = injector.capturePane();
|
|
1262
|
+
promptPolls += 1;
|
|
1263
|
+
if (pane.trim())
|
|
1264
|
+
lastPromptPane = pane;
|
|
1265
|
+
else
|
|
1266
|
+
emptyPromptPolls += 1;
|
|
1267
|
+
return claudePromptRendered(pane, glyph);
|
|
1268
|
+
}, opts.promptTimeoutMs ?? CLAUDE_PROMPT_READY_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1184
1269
|
if (cancelled())
|
|
1185
1270
|
return false;
|
|
1186
1271
|
if (!ready) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1272
|
+
throw new Error(`claude prompt not ready (no input composer within timeout; ${promptPolls} polls, ` +
|
|
1273
|
+
`${emptyPromptPolls} empty captures)${terminalFailureTail(lastPromptPane)}`);
|
|
1189
1274
|
}
|
|
1190
1275
|
// 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
|
|
1191
1276
|
// documented soft-newline escape: a bare submit Enter would consume it and
|
|
@@ -1193,30 +1278,47 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1193
1278
|
// the bracketed paste; normal messages must not gain an empty input row.
|
|
1194
1279
|
injector.clearInputLine();
|
|
1195
1280
|
injector.paste(text.endsWith("\\") ? `${text}\n` : text);
|
|
1196
|
-
// 3. `paste-buffer`
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
await
|
|
1281
|
+
// 3. tmux accepting `paste-buffer` does not mean Claude consumed it. Wait for
|
|
1282
|
+
// the draft (or its large-paste placeholder) to appear in the live composer;
|
|
1283
|
+
// otherwise Enter can be coalesced into the paste as a newline.
|
|
1284
|
+
const needle = draftNeedle(text);
|
|
1285
|
+
const draftSeen = await pollUntil(() => draftInInputBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? CLAUDE_PASTE_COMMIT_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1286
|
+
await sleep(opts.settleMs ?? CLAUDE_PASTE_SETTLE_MS);
|
|
1201
1287
|
if (cancelled())
|
|
1202
1288
|
return false; // Stop pressed mid-paste → don't submit
|
|
1203
|
-
// 4. Submit.
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
// queue enqueue ends the loop immediately.
|
|
1289
|
+
// 4. Submit and verify. Retry only while this exact draft is still visible;
|
|
1290
|
+
// once it leaves the composer, another Enter could hit a permission dialog or
|
|
1291
|
+
// an empty prompt. Transcript acknowledgement is the strongest success proof.
|
|
1207
1292
|
const observed = opts.submissionObserved;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1293
|
+
let attempts = 1;
|
|
1294
|
+
const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
|
|
1295
|
+
injector.sendEnter();
|
|
1296
|
+
// Omnigent preserves the old blind-submit fallback when capture-pane cannot
|
|
1297
|
+
// identify the draft; its absence would otherwise "prove" success trivially.
|
|
1298
|
+
if (!draftSeen)
|
|
1299
|
+
return true;
|
|
1300
|
+
const deadline = now() + (opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS);
|
|
1301
|
+
const retryMs = opts.submitRetryMs ?? CLAUDE_SUBMIT_RETRY_MS;
|
|
1302
|
+
let lastEnterAt = now();
|
|
1303
|
+
while (now() < deadline) {
|
|
1304
|
+
if (cancelled())
|
|
1305
|
+
return false;
|
|
1306
|
+
if (observed?.())
|
|
1214
1307
|
return true;
|
|
1215
|
-
if (
|
|
1308
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1216
1309
|
return true;
|
|
1310
|
+
if (now() - lastEnterAt >= retryMs && attempts < maxAttempts) {
|
|
1311
|
+
injector.sendEnter();
|
|
1312
|
+
attempts += 1;
|
|
1313
|
+
lastEnterAt = now();
|
|
1217
1314
|
}
|
|
1218
|
-
|
|
1219
|
-
return false;
|
|
1315
|
+
await sleep(pollMs);
|
|
1220
1316
|
}
|
|
1221
|
-
|
|
1317
|
+
if (observed?.())
|
|
1318
|
+
return true;
|
|
1319
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1320
|
+
return true;
|
|
1321
|
+
throw new Error(`Claude Code did not accept the submitted message within ` +
|
|
1322
|
+
`${opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS}ms ` +
|
|
1323
|
+
"(the draft is still in the input box). The message was not delivered.");
|
|
1222
1324
|
}
|
|
@@ -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
|
/**
|
|
@@ -102,6 +125,11 @@ export declare class CodexSessionForwarder {
|
|
|
102
125
|
private refreshCompletionGrace;
|
|
103
126
|
private flushPendingCompletion;
|
|
104
127
|
private settle;
|
|
128
|
+
private handleMcpStartupStatus;
|
|
129
|
+
private settleMcpStartup;
|
|
130
|
+
private clearMcpStartupTimer;
|
|
131
|
+
private emitMcpStartupStatus;
|
|
132
|
+
private emitMcpStatus;
|
|
105
133
|
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
106
134
|
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
107
135
|
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;
|
|
@@ -138,6 +202,13 @@ export class CodexSessionForwarder {
|
|
|
138
202
|
notificationThreadId &&
|
|
139
203
|
notificationThreadId !== this.currentThreadIdValue)
|
|
140
204
|
return;
|
|
205
|
+
if (method === MCP_STARTUP_STATUS_METHOD) {
|
|
206
|
+
this.handleMcpStartupStatus(params);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (isThreadIdle(method, params) || isModelOutput(method, params)) {
|
|
210
|
+
this.settleMcpStartup();
|
|
211
|
+
}
|
|
141
212
|
// Release a parked resume as soon as the thread shows activity (rollout now
|
|
142
213
|
// exists). Fire once.
|
|
143
214
|
if (!this.activeSignaled && indicatesActive(method, params)) {
|
|
@@ -243,6 +314,73 @@ export class CodexSessionForwarder {
|
|
|
243
314
|
else
|
|
244
315
|
this.sink.onTurnEnd(completion.usage);
|
|
245
316
|
}
|
|
317
|
+
handleMcpStartupStatus(params) {
|
|
318
|
+
const update = params;
|
|
319
|
+
const name = typeof update?.name === "string" ? update.name : "";
|
|
320
|
+
const status = typeof update?.status === "string" ? update.status : "";
|
|
321
|
+
if (!name || (status !== "starting" && !MCP_TERMINAL_STATES.has(status)))
|
|
322
|
+
return;
|
|
323
|
+
if (status === "starting") {
|
|
324
|
+
this.pendingMcpServers.add(name);
|
|
325
|
+
this.failedMcpServers.delete(name);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
this.pendingMcpServers.delete(name);
|
|
329
|
+
if (status === "failed") {
|
|
330
|
+
this.failedMcpServers.set(name, typeof update?.error === "string" && update.error ? update.error : undefined);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
this.failedMcpServers.delete(name);
|
|
334
|
+
}
|
|
335
|
+
if (this.pendingMcpServers.size === 0)
|
|
336
|
+
this.clearMcpStartupTimer();
|
|
337
|
+
}
|
|
338
|
+
this.emitMcpStartupStatus();
|
|
339
|
+
}
|
|
340
|
+
settleMcpStartup() {
|
|
341
|
+
if (this.pendingMcpServers.size === 0) {
|
|
342
|
+
this.clearMcpStartupTimer();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.pendingMcpServers.clear();
|
|
346
|
+
this.clearMcpStartupTimer();
|
|
347
|
+
this.emitMcpStartupStatus();
|
|
348
|
+
}
|
|
349
|
+
clearMcpStartupTimer() {
|
|
350
|
+
if (this.mcpStartupTimer)
|
|
351
|
+
clearTimeout(this.mcpStartupTimer);
|
|
352
|
+
this.mcpStartupTimer = null;
|
|
353
|
+
}
|
|
354
|
+
emitMcpStartupStatus() {
|
|
355
|
+
if (!this.turnOpen)
|
|
356
|
+
return;
|
|
357
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
358
|
+
if (pending.length) {
|
|
359
|
+
this.emitMcpStatus(`Starting MCP servers: ${pending.join(", ")}`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const failed = [...this.failedMcpServers.keys()].sort();
|
|
363
|
+
if (failed.length) {
|
|
364
|
+
this.emitMcpStatus(`MCP startup failed: ${failed.join(", ")}`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (this.lastMcpStatusNote !== null) {
|
|
368
|
+
this.lastMcpStatusNote = null;
|
|
369
|
+
if (this.sink.onStatus)
|
|
370
|
+
this.sink.onStatus(undefined);
|
|
371
|
+
else
|
|
372
|
+
this.sink.onEvent({ type: "status" });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
emitMcpStatus(note) {
|
|
376
|
+
if (this.lastMcpStatusNote === note)
|
|
377
|
+
return;
|
|
378
|
+
this.lastMcpStatusNote = note;
|
|
379
|
+
if (this.sink.onStatus)
|
|
380
|
+
this.sink.onStatus(note, "startup");
|
|
381
|
+
else
|
|
382
|
+
this.sink.onEvent({ type: "status", statusKind: "startup", note });
|
|
383
|
+
}
|
|
246
384
|
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
247
385
|
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
248
386
|
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
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -223,13 +223,14 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
223
223
|
* succeeds, subsequent turns arrive as live notifications on this connection.
|
|
224
224
|
*/
|
|
225
225
|
private subscribeUntilReady;
|
|
226
|
-
/** Await a live session's thread binding
|
|
226
|
+
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
227
|
+
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
227
228
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
228
|
-
waitLiveReady(localThreadId: string, timeoutMs?: number): Promise<boolean>;
|
|
229
|
+
waitLiveReady(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
229
230
|
/** Await the stronger Terminal gate: another app-server connection has
|
|
230
231
|
* successfully resumed the thread, so the detached TUI cannot race rollout
|
|
231
232
|
* discovery or indexing. */
|
|
232
|
-
waitTerminalReady(localThreadId: string, timeoutMs?: number): Promise<boolean>;
|
|
233
|
+
waitTerminalReady(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
233
234
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
234
235
|
liveSessionError(localThreadId: string): string | undefined;
|
|
235
236
|
/**
|
package/dist/host.js
CHANGED
|
@@ -15,6 +15,7 @@ import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server
|
|
|
15
15
|
import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
|
|
16
16
|
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
17
17
|
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
18
|
+
import { readMcpStartupPlan } from "./codex-app-server/mcp-startup.js";
|
|
18
19
|
import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
19
20
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
20
21
|
import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
|
|
@@ -946,6 +947,18 @@ export class LocalAgentHost {
|
|
|
946
947
|
for (const se of n.next(event))
|
|
947
948
|
emitCurrent(se);
|
|
948
949
|
},
|
|
950
|
+
onStatus: (note, statusKind) => {
|
|
951
|
+
const responseId = currentResponseId ??
|
|
952
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
953
|
+
emitCurrent({
|
|
954
|
+
type: "session.status",
|
|
955
|
+
sessionId: currentSessionId,
|
|
956
|
+
...(responseId ? { responseId } : {}),
|
|
957
|
+
status: "running",
|
|
958
|
+
...(note ? { note } : {}),
|
|
959
|
+
...(statusKind ? { statusKind } : {}),
|
|
960
|
+
});
|
|
961
|
+
},
|
|
949
962
|
onTurnEnd: (usage) => {
|
|
950
963
|
closeCanonicalInteractions();
|
|
951
964
|
if (!normalizer)
|
|
@@ -999,6 +1012,7 @@ export class LocalAgentHost {
|
|
|
999
1012
|
turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
|
|
1000
1013
|
assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
|
|
1001
1014
|
surfaceQueueStatus: runtime === "traex",
|
|
1015
|
+
mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
|
|
1002
1016
|
});
|
|
1003
1017
|
live.forwarder = forwarder;
|
|
1004
1018
|
live.onNativeThreadRotated = (threadId, kind) => {
|
|
@@ -1217,7 +1231,8 @@ export class LocalAgentHost {
|
|
|
1217
1231
|
}
|
|
1218
1232
|
return false;
|
|
1219
1233
|
}
|
|
1220
|
-
/** Await a live session's thread binding
|
|
1234
|
+
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1235
|
+
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1221
1236
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1222
1237
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1223
1238
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
@@ -1226,6 +1241,8 @@ export class LocalAgentHost {
|
|
|
1226
1241
|
const live = this.liveSessions.get(localThreadId);
|
|
1227
1242
|
if (!live)
|
|
1228
1243
|
return false;
|
|
1244
|
+
if (timeoutMs === null)
|
|
1245
|
+
return live.ready.then(() => true);
|
|
1229
1246
|
let timer;
|
|
1230
1247
|
const timeout = new Promise((resolve) => {
|
|
1231
1248
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
@@ -1247,6 +1264,8 @@ export class LocalAgentHost {
|
|
|
1247
1264
|
const live = this.liveSessions.get(localThreadId);
|
|
1248
1265
|
if (!live)
|
|
1249
1266
|
return false;
|
|
1267
|
+
if (timeoutMs === null)
|
|
1268
|
+
return live.terminalReady;
|
|
1250
1269
|
let timer;
|
|
1251
1270
|
const timeout = new Promise((resolve) => {
|
|
1252
1271
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
@@ -1333,6 +1352,7 @@ export class LocalAgentHost {
|
|
|
1333
1352
|
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1334
1353
|
};
|
|
1335
1354
|
live.pendingInjectedInputs.push(pendingInput);
|
|
1355
|
+
let injectionMethod = "turn/start";
|
|
1336
1356
|
const forgetPendingInput = () => {
|
|
1337
1357
|
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1338
1358
|
if (index >= 0)
|
|
@@ -1343,11 +1363,31 @@ export class LocalAgentHost {
|
|
|
1343
1363
|
if (live.forwarder.isTurnOpen()) {
|
|
1344
1364
|
const turnId = live.forwarder.currentTurnId();
|
|
1345
1365
|
if (turnId) {
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1366
|
+
injectionMethod = "turn/steer";
|
|
1367
|
+
let steered;
|
|
1368
|
+
let retryIndex = 0;
|
|
1369
|
+
while (!steered) {
|
|
1370
|
+
try {
|
|
1371
|
+
steered = await live.injectClient.turnSteer({
|
|
1372
|
+
threadId,
|
|
1373
|
+
expectedTurnId: turnId,
|
|
1374
|
+
input: nativeInput,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
catch (error) {
|
|
1378
|
+
const delayMs = TURN_STEER_ACTIVATION_RETRY_DELAYS_MS[retryIndex];
|
|
1379
|
+
if (delayMs === undefined ||
|
|
1380
|
+
!isTransientTurnSteerActivationRace(error) ||
|
|
1381
|
+
live.stopped ||
|
|
1382
|
+
live.rotationPending ||
|
|
1383
|
+
!live.forwarder.isTurnOpen() ||
|
|
1384
|
+
live.forwarder.currentTurnId() !== turnId) {
|
|
1385
|
+
throw error;
|
|
1386
|
+
}
|
|
1387
|
+
retryIndex += 1;
|
|
1388
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1351
1391
|
if (pendingInput.state === "prepublished") {
|
|
1352
1392
|
// The caller already persisted and published this user input
|
|
1353
1393
|
// before waiting for the native Terminal to become ready.
|
|
@@ -1359,6 +1399,7 @@ export class LocalAgentHost {
|
|
|
1359
1399
|
live.publishInjectedInput(steered.turnId, pendingInput.content);
|
|
1360
1400
|
pendingInput.state = "optimistic";
|
|
1361
1401
|
}
|
|
1402
|
+
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1362
1403
|
return "injected";
|
|
1363
1404
|
}
|
|
1364
1405
|
}
|
|
@@ -1383,13 +1424,19 @@ export class LocalAgentHost {
|
|
|
1383
1424
|
live.publishInjectedInput(started.turnId, pendingInput.content);
|
|
1384
1425
|
pendingInput.state = "optimistic";
|
|
1385
1426
|
}
|
|
1427
|
+
// The injection RPC and active-turn write are one serialized operation.
|
|
1428
|
+
// Do not wait for the independent observer connection's `turn/started`:
|
|
1429
|
+
// a second message accepted in that window must steer, not double-start.
|
|
1430
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1386
1431
|
return "injected";
|
|
1387
1432
|
}
|
|
1388
1433
|
catch (error) {
|
|
1389
1434
|
forgetPendingInput();
|
|
1390
1435
|
// Preserve the app-server's error instead of collapsing every failure
|
|
1391
1436
|
// into the unactionable `live injection failed` string.
|
|
1392
|
-
const
|
|
1437
|
+
const baseDetail = codexRpcError(error, injectionMethod);
|
|
1438
|
+
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
1439
|
+
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
1393
1440
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1394
1441
|
throw new Error(detail, { cause: error });
|
|
1395
1442
|
}
|
|
@@ -1434,21 +1481,35 @@ export class LocalAgentHost {
|
|
|
1434
1481
|
const live = this.liveSessions.get(localThreadId);
|
|
1435
1482
|
if (!live)
|
|
1436
1483
|
return false;
|
|
1437
|
-
// Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
|
|
1438
|
-
// reference implementation no-ops (204) when no turn is active.
|
|
1439
|
-
if (!live.forwarder.isTurnOpen())
|
|
1440
|
-
return false;
|
|
1441
1484
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1442
|
-
const
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
return
|
|
1485
|
+
const pendingMcp = live.forwarder.cancelMcpStartup();
|
|
1486
|
+
const turnId = live.forwarder.isTurnOpen()
|
|
1487
|
+
? live.forwarder.currentTurnId()
|
|
1488
|
+
: null;
|
|
1489
|
+
if (!threadId)
|
|
1490
|
+
return pendingMcp.length > 0;
|
|
1491
|
+
let handled = pendingMcp.length > 0;
|
|
1492
|
+
if (pendingMcp.length > 0) {
|
|
1493
|
+
// Codex's TUI cancels a provider-owned MCP startup round with an empty
|
|
1494
|
+
// turn id. Best-effort for Traex: it shares the app-server surface, while
|
|
1495
|
+
// the active-turn interrupt below remains authoritative if it rejects this.
|
|
1496
|
+
try {
|
|
1497
|
+
await live.injectClient.turnInterrupt({ threadId, turnId: "" });
|
|
1498
|
+
}
|
|
1499
|
+
catch (error) {
|
|
1500
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
|
|
1501
|
+
}
|
|
1448
1502
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1503
|
+
if (turnId) {
|
|
1504
|
+
try {
|
|
1505
|
+
await live.injectClient.turnInterrupt({ threadId, turnId });
|
|
1506
|
+
handled = true;
|
|
1507
|
+
}
|
|
1508
|
+
catch {
|
|
1509
|
+
// The startup cancellation above is still a handled Stop operation.
|
|
1510
|
+
}
|
|
1451
1511
|
}
|
|
1512
|
+
return handled;
|
|
1452
1513
|
}
|
|
1453
1514
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
1454
1515
|
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
@@ -1984,7 +2045,8 @@ export class LocalAgentHost {
|
|
|
1984
2045
|
});
|
|
1985
2046
|
return ok ? "injected" : "failed";
|
|
1986
2047
|
}
|
|
1987
|
-
catch {
|
|
2048
|
+
catch (error) {
|
|
2049
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1988
2050
|
return "failed";
|
|
1989
2051
|
}
|
|
1990
2052
|
finally {
|
|
@@ -2183,12 +2245,26 @@ export class LocalAgentHost {
|
|
|
2183
2245
|
}
|
|
2184
2246
|
}
|
|
2185
2247
|
}
|
|
2186
|
-
function
|
|
2248
|
+
function codexRpcError(error, method) {
|
|
2187
2249
|
if (error instanceof CodexTransportError) {
|
|
2188
|
-
return `Codex app-server rejected
|
|
2250
|
+
return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
|
|
2189
2251
|
}
|
|
2190
|
-
return `Codex
|
|
2252
|
+
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2191
2253
|
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Codex 0.146 can acknowledge `turn/start` a few milliseconds before its
|
|
2256
|
+
* internal active-turn pointer becomes visible to `turn/steer`. The injection
|
|
2257
|
+
* lock still prevents double-starts, but the immediately following steer must
|
|
2258
|
+
* briefly retry the same expected turn id. Keep this narrow: all other RPC
|
|
2259
|
+
* errors remain terminal and visible to the caller.
|
|
2260
|
+
*/
|
|
2261
|
+
function isTransientTurnSteerActivationRace(error) {
|
|
2262
|
+
return error instanceof CodexTransportError &&
|
|
2263
|
+
error.code === -32600 &&
|
|
2264
|
+
(/no active turn to steer/i.test(error.message) ||
|
|
2265
|
+
/expected active turn id\b.+\bfound\b/i.test(error.message));
|
|
2266
|
+
}
|
|
2267
|
+
const TURN_STEER_ACTIVATION_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 250, 440];
|
|
2192
2268
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2193
2269
|
const normalized = output.trim();
|
|
2194
2270
|
const match = normalized.match(/Logged in using (.+)$/im);
|
|
@@ -2212,15 +2288,18 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
2212
2288
|
issues: normalized ? [normalized] : [],
|
|
2213
2289
|
};
|
|
2214
2290
|
}
|
|
2215
|
-
/** Race a live session's `ready` promise against failure
|
|
2291
|
+
/** Race a live session's `ready` promise against failure and, when supplied,
|
|
2292
|
+
* a timeout. */
|
|
2216
2293
|
function raceReady(ready, timeoutMs, failed) {
|
|
2217
2294
|
let timer;
|
|
2218
|
-
const
|
|
2219
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2220
|
-
});
|
|
2221
|
-
const outcomes = [ready.then(() => true), timeout];
|
|
2295
|
+
const outcomes = [ready.then(() => true)];
|
|
2222
2296
|
if (failed)
|
|
2223
2297
|
outcomes.push(failed.then(() => false));
|
|
2298
|
+
if (timeoutMs !== null) {
|
|
2299
|
+
outcomes.push(new Promise((resolve) => {
|
|
2300
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2301
|
+
}));
|
|
2302
|
+
}
|
|
2224
2303
|
return Promise.race(outcomes).finally(() => {
|
|
2225
2304
|
if (timer)
|
|
2226
2305
|
clearTimeout(timer);
|
package/dist/runner/child.d.ts
CHANGED
|
@@ -38,8 +38,10 @@ interface LiveCodexProvider {
|
|
|
38
38
|
execution: ResolvedExecutionSnapshot;
|
|
39
39
|
retargetMirror?: RetargetMirror;
|
|
40
40
|
}): Promise<boolean>;
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
/** `null` delegates the only deadline to RunnerManager. */
|
|
42
|
+
waitLiveReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
43
|
+
/** `null` delegates the only deadline to RunnerManager. */
|
|
44
|
+
waitTerminalReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
43
45
|
liveSessionError?(localThreadId: string): string | undefined;
|
|
44
46
|
injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
|
|
45
47
|
interruptLive?(localThreadId: string): Promise<boolean>;
|
package/dist/runner/child.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TerminalRegistry } from "../terminal/registry.js";
|
|
2
2
|
import { toWireError } from "./protocol.js";
|
|
3
|
+
import { isManagedNativeProvider } from "./startup-policy.js";
|
|
3
4
|
const TRAEX_STARTUP_WATCH_MS = 20_000;
|
|
4
5
|
const TRAEX_STARTUP_POLL_MS = 100;
|
|
5
6
|
const TRAEX_AUTHORIZATION_POLL_MS = 500;
|
|
@@ -181,13 +182,20 @@ export class RunnerSession {
|
|
|
181
182
|
return;
|
|
182
183
|
}
|
|
183
184
|
this.liveIds.add(msg.localThreadId);
|
|
185
|
+
// Native Provider startup has one authoritative parent-side deadline.
|
|
186
|
+
// Codex and Traex can accept/queue a turn while MCP startup settles;
|
|
187
|
+
// Claude instead waits for SessionStart before terminal injection. The
|
|
188
|
+
// historical 20-second gates raced all three valid startup paths.
|
|
189
|
+
const providerStartupTimeoutMs = isManagedNativeProvider(msg.execution?.provider)
|
|
190
|
+
? null
|
|
191
|
+
: undefined;
|
|
184
192
|
// Launch the TUI attached to the already-bound thread. Re-launch when the
|
|
185
193
|
// pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
|
|
186
194
|
// reconnect after the TUI exited restarts it instead of skipping (a
|
|
187
195
|
// launched-once guard would leave a dead "Pane is dead" husk forever).
|
|
188
196
|
if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
|
|
189
197
|
const terminalReady = provider.waitTerminalReady
|
|
190
|
-
? await provider.waitTerminalReady(msg.localThreadId)
|
|
198
|
+
? await provider.waitTerminalReady(msg.localThreadId, providerStartupTimeoutMs)
|
|
191
199
|
: true;
|
|
192
200
|
if (!terminalReady) {
|
|
193
201
|
this.transport.send({
|
|
@@ -204,7 +212,7 @@ export class RunnerSession {
|
|
|
204
212
|
const ready = msg.waitForReady === false
|
|
205
213
|
? true
|
|
206
214
|
: provider.waitLiveReady
|
|
207
|
-
? await provider.waitLiveReady(msg.localThreadId)
|
|
215
|
+
? await provider.waitLiveReady(msg.localThreadId, providerStartupTimeoutMs)
|
|
208
216
|
: true;
|
|
209
217
|
const terminal = this.terminals.get(`${msg.localThreadId}-main`);
|
|
210
218
|
const paneFailure = !ready && terminal && !terminal.isAlive()
|
package/dist/runner/manager.d.ts
CHANGED
|
@@ -56,6 +56,10 @@ export interface RunnerManagerOptions {
|
|
|
56
56
|
liveStartTimeoutMs?: number;
|
|
57
57
|
/** Max wait for a Provider-native thread to become ready. */
|
|
58
58
|
liveReadyTimeoutMs?: number;
|
|
59
|
+
/** End-to-end native readiness deadline for message delivery. The runner
|
|
60
|
+
* child delegates its Provider-local gates to this single timeout. Setup-pane
|
|
61
|
+
* requests keep the shorter `liveStartTimeoutMs`. */
|
|
62
|
+
nativeLiveStartTimeoutMs?: number;
|
|
59
63
|
/** Max wait for a native interrupt acknowledgement before reporting it
|
|
60
64
|
* unproven. Callers may then use the explicit force-stop path. */
|
|
61
65
|
liveInterruptTimeoutMs?: number;
|
|
@@ -158,6 +162,7 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
158
162
|
private readonly terminateTerminalServer;
|
|
159
163
|
private readonly liveStartTimeoutMs;
|
|
160
164
|
private readonly liveReadyTimeoutMs;
|
|
165
|
+
private readonly nativeLiveStartTimeoutMs;
|
|
161
166
|
private readonly liveInterruptTimeoutMs;
|
|
162
167
|
private readonly terminalInputHandoffTimeoutMs;
|
|
163
168
|
private readonly terminalInputCleanupRetryMs;
|
package/dist/runner/manager.js
CHANGED
|
@@ -24,6 +24,7 @@ import { listRuntimeModels } from "../models-catalog.js";
|
|
|
24
24
|
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
25
|
import { terminateTmuxServer } from "../terminal/tmux.js";
|
|
26
26
|
import { fromWireError, } from "./protocol.js";
|
|
27
|
+
import { isManagedNativeProvider } from "./startup-policy.js";
|
|
27
28
|
import { StdioRunnerTransport } from "./transport.js";
|
|
28
29
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
29
30
|
const CAP_KEY = "__cap__";
|
|
@@ -35,8 +36,12 @@ const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
|
35
36
|
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
36
37
|
/** Setup-pane launch should acknowledge quickly; never pin its HTTP request. */
|
|
37
38
|
const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
|
|
38
|
-
/**
|
|
39
|
+
/** Legacy fallback for a provider without a native startup policy. */
|
|
39
40
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
41
|
+
/** Native CLIs can spend tens of seconds in provider-owned startup (MCP
|
|
42
|
+
* degradation for Codex/Traex; terminal + SessionStart discovery for Claude).
|
|
43
|
+
* Keep one outer deadline instead of racing it with child-local gates. */
|
|
44
|
+
const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
|
|
40
45
|
/** A submitted TUI command should become a mirrored turn or native rotation
|
|
41
46
|
* quickly. If it does not, the runner is fenced by a verified process-tree
|
|
42
47
|
* shutdown before maintenance may treat the submission as settled. */
|
|
@@ -169,6 +174,7 @@ export class RunnerManager {
|
|
|
169
174
|
terminateTerminalServer;
|
|
170
175
|
liveStartTimeoutMs;
|
|
171
176
|
liveReadyTimeoutMs;
|
|
177
|
+
nativeLiveStartTimeoutMs;
|
|
172
178
|
liveInterruptTimeoutMs;
|
|
173
179
|
terminalInputHandoffTimeoutMs;
|
|
174
180
|
terminalInputCleanupRetryMs;
|
|
@@ -219,6 +225,7 @@ export class RunnerManager {
|
|
|
219
225
|
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
220
226
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
221
227
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
228
|
+
this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
|
|
222
229
|
this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
|
|
223
230
|
this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
|
|
224
231
|
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
@@ -533,7 +540,11 @@ export class RunnerManager {
|
|
|
533
540
|
this.liveSessionKeys.add(localThreadId);
|
|
534
541
|
const reqId = randomUUID();
|
|
535
542
|
return new Promise((resolve) => {
|
|
536
|
-
const timeoutMs = waitForReady
|
|
543
|
+
const timeoutMs = !waitForReady
|
|
544
|
+
? this.liveStartTimeoutMs
|
|
545
|
+
: isManagedNativeProvider(opts.execution.provider)
|
|
546
|
+
? this.nativeLiveStartTimeoutMs
|
|
547
|
+
: this.liveReadyTimeoutMs;
|
|
537
548
|
const timeout = setTimeout(() => {
|
|
538
549
|
if (!handle.live.delete(reqId))
|
|
539
550
|
return;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AgentRuntimeId } from "@rynx-ai/core";
|
|
2
|
+
/** Current live providers are native CLI sessions. Keep this explicit so a
|
|
3
|
+
* future non-native provider does not silently inherit their startup budget. */
|
|
4
|
+
export declare function isManagedNativeProvider(provider: AgentRuntimeId | undefined): provider is AgentRuntimeId;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Current live providers are native CLI sessions. Keep this explicit so a
|
|
2
|
+
* future non-native provider does not silently inherit their startup budget. */
|
|
3
|
+
export function isManagedNativeProvider(provider) {
|
|
4
|
+
return provider === "codex" || provider === "traex" || provider === "claude";
|
|
5
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rynx-ai/runtime",
|
|
3
|
-
"version": "0.1.11-beta.
|
|
3
|
+
"version": "0.1.11-beta.19",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/rynx-ai/rynx.git",
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"node-pty": "1.2.0-beta.15",
|
|
28
|
+
"smol-toml": "1.7.1",
|
|
28
29
|
"ws": "^8.21.0",
|
|
29
|
-
"@rynx-ai/core": "0.1.11-beta.
|
|
30
|
+
"@rynx-ai/core": "0.1.11-beta.19"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@types/ws": "^8.18.1"
|