@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.21
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 +35 -7
- package/dist/claude/native-integration.js +204 -32
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/forwarder.d.ts +66 -2
- package/dist/codex-app-server/forwarder.js +329 -11
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- 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 +8 -5
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +28 -7
- package/dist/host.js +356 -92
- 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 +87 -4
- package/dist/runner/manager.js +610 -56
- package/dist/runner/protocol.d.ts +11 -15
- 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 +29 -2
- package/dist/terminal/tmux.js +122 -14
- package/package.json +4 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { codexUserContent } from "../input-resources.js";
|
|
2
|
-
import { mapCodexItem, mapCodexNotification } from "./mapping.js";
|
|
2
|
+
import { codexResumeTerminalStatus, 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,32 @@ 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
|
+
}
|
|
48
|
+
function isTerminalTurnMethod(method) {
|
|
49
|
+
return method === "turn/completed" || method === "turn/failed";
|
|
50
|
+
}
|
|
51
|
+
/** Turn-scoped notifications must never be reassigned to whichever newer turn
|
|
52
|
+
* happens to be current when an app-server frame arrives late. */
|
|
53
|
+
function isTurnScopedMethod(method) {
|
|
54
|
+
return method.startsWith("turn/") || method.startsWith("item/") ||
|
|
55
|
+
method === "queue/status" || method === "error";
|
|
56
|
+
}
|
|
57
|
+
/** Recover a missed turn/started only from visible, fully-scoped assistant/plan
|
|
58
|
+
* deltas. Other deltas require an already matching active turn. */
|
|
59
|
+
function canRecoverMissedTurnFrom(method) {
|
|
60
|
+
return method === "item/agentMessage/delta" || method === "item/plan/delta";
|
|
61
|
+
}
|
|
34
62
|
export class CodexSessionForwarder {
|
|
35
63
|
client;
|
|
36
64
|
sink;
|
|
@@ -41,9 +69,16 @@ export class CodexSessionForwarder {
|
|
|
41
69
|
currentThreadIdValue = null;
|
|
42
70
|
activeSignaled = false;
|
|
43
71
|
completionTimer = null;
|
|
72
|
+
/** Turn id retained only for late-item dedup while a terminal response waits
|
|
73
|
+
* for its bounded output-ordering grace. It is not an active provider turn. */
|
|
74
|
+
pendingCompletionTurnId = null;
|
|
44
75
|
assistantMessageTimer = null;
|
|
45
76
|
deferredAssistantMessage = null;
|
|
46
77
|
pendingCompletion = null;
|
|
78
|
+
pendingMcpServers = new Set();
|
|
79
|
+
failedMcpServers = new Map();
|
|
80
|
+
mcpStartupTimer = null;
|
|
81
|
+
lastMcpStatusNote = null;
|
|
47
82
|
/** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
|
|
48
83
|
* `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
|
|
49
84
|
* counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
|
|
@@ -63,29 +98,91 @@ export class CodexSessionForwarder {
|
|
|
63
98
|
this.unsubscribe = this.client.onNotification((method, params) => {
|
|
64
99
|
this.handle(method, params);
|
|
65
100
|
});
|
|
101
|
+
const startup = this.options.mcpStartup;
|
|
102
|
+
if (startup?.servers.length) {
|
|
103
|
+
for (const server of startup.servers)
|
|
104
|
+
this.pendingMcpServers.add(server);
|
|
105
|
+
this.mcpStartupTimer = setTimeout(() => this.settleMcpStartup(), startup.settleTimeoutMs);
|
|
106
|
+
this.mcpStartupTimer.unref?.();
|
|
107
|
+
}
|
|
66
108
|
}
|
|
67
109
|
stop() {
|
|
68
110
|
this.unsubscribe?.();
|
|
69
111
|
this.unsubscribe = null;
|
|
112
|
+
this.clearMcpStartupTimer();
|
|
70
113
|
this.flushPendingCompletion();
|
|
71
114
|
this.flushDeferredAssistantMessage();
|
|
72
115
|
if (this.turnOpen) {
|
|
73
116
|
this.turnOpen = false;
|
|
117
|
+
this.currentTurnIdValue = null;
|
|
74
118
|
this.sink.onTurnEnd();
|
|
75
119
|
}
|
|
120
|
+
else {
|
|
121
|
+
this.currentTurnIdValue = null;
|
|
122
|
+
}
|
|
76
123
|
}
|
|
77
|
-
/** True while
|
|
124
|
+
/** True while the provider owns an active turn, including the short interval
|
|
125
|
+
* between injection acceptance and observer confirmation. */
|
|
78
126
|
isTurnOpen() {
|
|
79
|
-
return this.turnOpen;
|
|
127
|
+
return this.turnOpen || this.currentTurnIdValue !== null;
|
|
80
128
|
}
|
|
81
129
|
/** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
|
|
82
130
|
currentTurnId() {
|
|
83
131
|
return this.currentTurnIdValue;
|
|
84
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Record a turn accepted by the injection connection before the independent
|
|
135
|
+
* observer receives `turn/started`. This closes the read-decide-RPC-write race:
|
|
136
|
+
* another web message arriving in that window must steer this turn, not start
|
|
137
|
+
* a second one.
|
|
138
|
+
*/
|
|
139
|
+
noteTurnAccepted(turnId) {
|
|
140
|
+
if (!turnId)
|
|
141
|
+
return;
|
|
142
|
+
if (this.pendingCompletion)
|
|
143
|
+
this.flushPendingCompletion();
|
|
144
|
+
this.currentTurnIdValue = turnId;
|
|
145
|
+
this.emitMcpStartupStatus();
|
|
146
|
+
}
|
|
147
|
+
hasPendingMcpStartup() {
|
|
148
|
+
return this.pendingMcpServers.size > 0;
|
|
149
|
+
}
|
|
150
|
+
/** Mark and return the startup servers cancelled by a web Stop. */
|
|
151
|
+
cancelMcpStartup() {
|
|
152
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
153
|
+
if (pending.length === 0)
|
|
154
|
+
return pending;
|
|
155
|
+
this.pendingMcpServers.clear();
|
|
156
|
+
this.clearMcpStartupTimer();
|
|
157
|
+
if (this.isTurnOpen()) {
|
|
158
|
+
this.emitMcpStatus(`MCP startup cancelled: ${pending.join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
return pending;
|
|
161
|
+
}
|
|
162
|
+
/** Diagnostic suffix for an injection failure during Provider startup. */
|
|
163
|
+
mcpStartupDetail() {
|
|
164
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
165
|
+
if (pending.length)
|
|
166
|
+
return `MCP startup still waiting on ${pending.join(", ")}`;
|
|
167
|
+
const failed = [...this.failedMcpServers.entries()]
|
|
168
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
169
|
+
.map(([name, error]) => error ? `${name}: ${error}` : name);
|
|
170
|
+
return failed.length ? `MCP startup failed for ${failed.join(", ")}` : null;
|
|
171
|
+
}
|
|
85
172
|
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
86
173
|
threadId() {
|
|
87
174
|
return this.currentThreadIdValue;
|
|
88
175
|
}
|
|
176
|
+
/** Seed an already-persisted/resumed thread binding. The bridge retains this
|
|
177
|
+
* state even when app-server does not rebroadcast `thread/started`, so
|
|
178
|
+
* terminal-boundary recovery must know it too. */
|
|
179
|
+
noteThreadBound(threadId) {
|
|
180
|
+
if (!threadId || this.currentThreadIdValue === threadId)
|
|
181
|
+
return;
|
|
182
|
+
if (this.currentThreadIdValue !== null || this.turnOpen)
|
|
183
|
+
return;
|
|
184
|
+
this.currentThreadIdValue = threadId;
|
|
185
|
+
}
|
|
89
186
|
/**
|
|
90
187
|
* Replay the backlog turns from a `thread/resume` response as if they were live
|
|
91
188
|
* `item/completed` notifications — the fresh-thread first-turn backfill. Each
|
|
@@ -101,16 +198,73 @@ export class CodexSessionForwarder {
|
|
|
101
198
|
this.ensureTurn();
|
|
102
199
|
for (const item of turn.items ?? [])
|
|
103
200
|
this.processCompletedItem(item);
|
|
104
|
-
if (turn
|
|
201
|
+
if (codexResumeTerminalStatus(turn) === undefined)
|
|
105
202
|
continue;
|
|
106
203
|
const mapped = mapCodexNotification("turn/completed", { turn });
|
|
107
204
|
this.turnOpen = false;
|
|
205
|
+
this.currentTurnIdValue = null;
|
|
108
206
|
if (mapped.fatalError)
|
|
109
207
|
this.sink.onTurnError(mapped.fatalError);
|
|
110
208
|
else
|
|
111
209
|
this.sink.onTurnEnd(mapped.usage);
|
|
112
210
|
}
|
|
113
211
|
}
|
|
212
|
+
/** Reconcile only the exact active turn after an observer resume. Historical
|
|
213
|
+
* items are intentionally not replayed on an existing-thread reconnect. */
|
|
214
|
+
reconcileActiveTurn(turn) {
|
|
215
|
+
if (!this.isTurnOpen() || !turn || codexResumeTerminalStatus(turn) === undefined)
|
|
216
|
+
return false;
|
|
217
|
+
const resumedTurnId = turn.id ?? turn.turnId;
|
|
218
|
+
if (!this.currentTurnIdValue || resumedTurnId !== this.currentTurnIdValue)
|
|
219
|
+
return false;
|
|
220
|
+
const mapped = mapCodexNotification("turn/completed", { turn });
|
|
221
|
+
this.ensureTurn();
|
|
222
|
+
this.settle(mapped.fatalError
|
|
223
|
+
? { kind: "error", error: mapped.fatalError }
|
|
224
|
+
: { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
/** Reconcile an observer reconnect from the explicit resume snapshot. An
|
|
228
|
+
* identified active turn must match exactly; with no active turn, only the
|
|
229
|
+
* newest explicit terminal status is published and no history is replayed. */
|
|
230
|
+
reconcileResumeTurns(turns) {
|
|
231
|
+
if (this.currentThreadIdValue === null)
|
|
232
|
+
return false;
|
|
233
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
234
|
+
const turn = turns[index];
|
|
235
|
+
if (!turn)
|
|
236
|
+
continue;
|
|
237
|
+
const turnId = turn.id ?? turn.turnId;
|
|
238
|
+
if (!turnId)
|
|
239
|
+
return false;
|
|
240
|
+
if (this.currentTurnIdValue !== null && this.currentTurnIdValue !== turnId) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const status = codexResumeTerminalStatus(turn);
|
|
244
|
+
if (!status)
|
|
245
|
+
return false;
|
|
246
|
+
const mapped = mapCodexNotification("turn/completed", { turn });
|
|
247
|
+
if (this.currentTurnIdValue !== null || this.turnOpen) {
|
|
248
|
+
this.ensureTurn();
|
|
249
|
+
this.settle(mapped.fatalError
|
|
250
|
+
? { kind: "error", error: mapped.fatalError }
|
|
251
|
+
: { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
this.sink.onRecoveredTurnStatus?.(status, turnId, mapped.fatalError);
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
261
|
+
failOpenTurn(error) {
|
|
262
|
+
if (!this.isTurnOpen())
|
|
263
|
+
return false;
|
|
264
|
+
this.ensureTurn();
|
|
265
|
+
this.settle({ kind: "error", error });
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
114
268
|
handle(method, params) {
|
|
115
269
|
if (method === "thread/started" || method === "thread.started") {
|
|
116
270
|
const tid = threadIdFrom(params);
|
|
@@ -138,6 +292,13 @@ export class CodexSessionForwarder {
|
|
|
138
292
|
notificationThreadId &&
|
|
139
293
|
notificationThreadId !== this.currentThreadIdValue)
|
|
140
294
|
return;
|
|
295
|
+
if (method === MCP_STARTUP_STATUS_METHOD) {
|
|
296
|
+
this.handleMcpStartupStatus(params);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (isThreadIdle(method, params) || isModelOutput(method, params)) {
|
|
300
|
+
this.settleMcpStartup();
|
|
301
|
+
}
|
|
141
302
|
// Release a parked resume as soon as the thread shows activity (rollout now
|
|
142
303
|
// exists). Fire once.
|
|
143
304
|
if (!this.activeSignaled && indicatesActive(method, params)) {
|
|
@@ -148,17 +309,63 @@ export class CodexSessionForwarder {
|
|
|
148
309
|
// behavior unchanged unless the Traex host explicitly opts in.
|
|
149
310
|
if (method === "queue/status" && !this.options.surfaceQueueStatus)
|
|
150
311
|
return;
|
|
312
|
+
if (method === "queue/status" && this.currentTurnIdValue === null)
|
|
313
|
+
return;
|
|
151
314
|
const carriedTurnId = turnIdFrom(params);
|
|
152
315
|
if (method === "turn/started") {
|
|
153
|
-
this.
|
|
154
|
-
this.
|
|
155
|
-
|
|
316
|
+
this.beginTurn(carriedTurnId);
|
|
317
|
+
this.sink.onTurnObserved?.(carriedTurnId);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (isTerminalTurnMethod(method)) {
|
|
321
|
+
if (!this.terminalBoundaryMatchesActiveTurn(carriedTurnId, notificationThreadId)) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const hadActiveTurn = this.currentTurnIdValue !== null;
|
|
325
|
+
const hadOpenResponse = this.turnOpen;
|
|
326
|
+
const mapped = mapCodexNotification(method, params);
|
|
327
|
+
if (!hadActiveTurn && !hadOpenResponse) {
|
|
328
|
+
const turn = params?.turn;
|
|
329
|
+
const recoveredStatus = mapped.fatalError ||
|
|
330
|
+
method === "turn/failed" ||
|
|
331
|
+
codexResumeTerminalStatus(turn) === "failed"
|
|
332
|
+
? "failed"
|
|
333
|
+
: "idle";
|
|
334
|
+
this.sink.onRecoveredTurnStatus?.(recoveredStatus, carriedTurnId, mapped.fatalError);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (carriedTurnId && this.currentTurnIdValue === null) {
|
|
156
338
|
this.currentTurnIdValue = carriedTurnId;
|
|
339
|
+
}
|
|
157
340
|
this.ensureTurn();
|
|
341
|
+
this.pendingCompletionTurnId = carriedTurnId ?? this.currentTurnIdValue;
|
|
342
|
+
// The provider lifecycle closes immediately. A configured grace keeps
|
|
343
|
+
// only the canonical response open long enough to absorb late items.
|
|
344
|
+
this.currentTurnIdValue = null;
|
|
345
|
+
for (const event of mapped.events.filter((event) => event.type !== "runtime_debug")) {
|
|
346
|
+
this.sink.onEvent(event);
|
|
347
|
+
}
|
|
348
|
+
this.scheduleCompletion(mapped.fatalError
|
|
349
|
+
? { kind: "error", error: mapped.fatalError }
|
|
350
|
+
: { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// A late event from an older turn must not replace the app-server's active
|
|
354
|
+
// turn id. That was the source of stale completions closing a newer turn.
|
|
355
|
+
if (carriedTurnId &&
|
|
356
|
+
this.currentTurnIdValue &&
|
|
357
|
+
carriedTurnId !== this.currentTurnIdValue &&
|
|
358
|
+
isTurnScopedMethod(method)) {
|
|
158
359
|
return;
|
|
159
360
|
}
|
|
160
|
-
if (carriedTurnId)
|
|
161
|
-
|
|
361
|
+
if (carriedTurnId && this.currentTurnIdValue === null) {
|
|
362
|
+
if (canRecoverMissedTurnFrom(method)) {
|
|
363
|
+
if (!this.notificationMatchesCurrentThread(notificationThreadId))
|
|
364
|
+
return;
|
|
365
|
+
this.currentTurnIdValue = carriedTurnId;
|
|
366
|
+
this.ensureTurn();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
162
369
|
// Completed items (user echo, assistant, tool, reasoning) go through the
|
|
163
370
|
// deduped path so a resume backfill and the live stream never double them.
|
|
164
371
|
if (method === "item/completed") {
|
|
@@ -238,11 +445,80 @@ export class CodexSessionForwarder {
|
|
|
238
445
|
settle(completion) {
|
|
239
446
|
this.flushDeferredAssistantMessage();
|
|
240
447
|
this.turnOpen = false;
|
|
448
|
+
this.currentTurnIdValue = null;
|
|
449
|
+
this.pendingCompletionTurnId = null;
|
|
241
450
|
if (completion.kind === "error")
|
|
242
451
|
this.sink.onTurnError(completion.error);
|
|
243
452
|
else
|
|
244
453
|
this.sink.onTurnEnd(completion.usage);
|
|
245
454
|
}
|
|
455
|
+
handleMcpStartupStatus(params) {
|
|
456
|
+
const update = params;
|
|
457
|
+
const name = typeof update?.name === "string" ? update.name : "";
|
|
458
|
+
const status = typeof update?.status === "string" ? update.status : "";
|
|
459
|
+
if (!name || (status !== "starting" && !MCP_TERMINAL_STATES.has(status)))
|
|
460
|
+
return;
|
|
461
|
+
if (status === "starting") {
|
|
462
|
+
this.pendingMcpServers.add(name);
|
|
463
|
+
this.failedMcpServers.delete(name);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
this.pendingMcpServers.delete(name);
|
|
467
|
+
if (status === "failed") {
|
|
468
|
+
this.failedMcpServers.set(name, typeof update?.error === "string" && update.error ? update.error : undefined);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
this.failedMcpServers.delete(name);
|
|
472
|
+
}
|
|
473
|
+
if (this.pendingMcpServers.size === 0)
|
|
474
|
+
this.clearMcpStartupTimer();
|
|
475
|
+
}
|
|
476
|
+
this.emitMcpStartupStatus();
|
|
477
|
+
}
|
|
478
|
+
settleMcpStartup() {
|
|
479
|
+
if (this.pendingMcpServers.size === 0) {
|
|
480
|
+
this.clearMcpStartupTimer();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.pendingMcpServers.clear();
|
|
484
|
+
this.clearMcpStartupTimer();
|
|
485
|
+
this.emitMcpStartupStatus();
|
|
486
|
+
}
|
|
487
|
+
clearMcpStartupTimer() {
|
|
488
|
+
if (this.mcpStartupTimer)
|
|
489
|
+
clearTimeout(this.mcpStartupTimer);
|
|
490
|
+
this.mcpStartupTimer = null;
|
|
491
|
+
}
|
|
492
|
+
emitMcpStartupStatus() {
|
|
493
|
+
if (!this.isTurnOpen())
|
|
494
|
+
return;
|
|
495
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
496
|
+
if (pending.length) {
|
|
497
|
+
this.emitMcpStatus(`Starting MCP servers: ${pending.join(", ")}`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const failed = [...this.failedMcpServers.keys()].sort();
|
|
501
|
+
if (failed.length) {
|
|
502
|
+
this.emitMcpStatus(`MCP startup failed: ${failed.join(", ")}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (this.lastMcpStatusNote !== null) {
|
|
506
|
+
this.lastMcpStatusNote = null;
|
|
507
|
+
if (this.sink.onStatus)
|
|
508
|
+
this.sink.onStatus(undefined);
|
|
509
|
+
else
|
|
510
|
+
this.sink.onEvent({ type: "status" });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
emitMcpStatus(note) {
|
|
514
|
+
if (this.lastMcpStatusNote === note)
|
|
515
|
+
return;
|
|
516
|
+
this.lastMcpStatusNote = note;
|
|
517
|
+
if (this.sink.onStatus)
|
|
518
|
+
this.sink.onStatus(note, "startup");
|
|
519
|
+
else
|
|
520
|
+
this.sink.onEvent({ type: "status", statusKind: "startup", note });
|
|
521
|
+
}
|
|
246
522
|
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
247
523
|
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
248
524
|
processCompletedItem(item) {
|
|
@@ -305,14 +581,15 @@ export class CodexSessionForwarder {
|
|
|
305
581
|
* {@link advanceAnonCounter}). Mirrors reference implementation `_completed_item_key`. */
|
|
306
582
|
completedItemKey(item) {
|
|
307
583
|
const threadId = this.currentThreadIdValue ?? "thread";
|
|
308
|
-
const turnId = this.currentTurnIdValue ?? "turn";
|
|
584
|
+
const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
|
|
309
585
|
if (item.id)
|
|
310
586
|
return { key: `${threadId}:${turnId}:${item.id}`, isAnon: false };
|
|
311
587
|
const n = this.anonCounters.get(`${threadId}:${turnId}`) ?? 0;
|
|
312
588
|
return { key: `${threadId}:${turnId}:anon:${n}`, isAnon: true };
|
|
313
589
|
}
|
|
314
590
|
advanceAnonCounter() {
|
|
315
|
-
const
|
|
591
|
+
const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
|
|
592
|
+
const k = `${this.currentThreadIdValue ?? "thread"}:${turnId}`;
|
|
316
593
|
this.anonCounters.set(k, (this.anonCounters.get(k) ?? 0) + 1);
|
|
317
594
|
}
|
|
318
595
|
ensureTurn() {
|
|
@@ -321,4 +598,45 @@ export class CodexSessionForwarder {
|
|
|
321
598
|
this.sink.onTurnStart(this.currentTurnIdValue ?? undefined);
|
|
322
599
|
}
|
|
323
600
|
}
|
|
601
|
+
/** Start (or confirm) the app-server's authoritative active turn. A newer
|
|
602
|
+
* start supersedes an older response whose terminal edge arrived late; a
|
|
603
|
+
* pending Traex completion is flushed first so its final item grace remains
|
|
604
|
+
* intact. */
|
|
605
|
+
beginTurn(turnId) {
|
|
606
|
+
if (this.pendingCompletion)
|
|
607
|
+
this.flushPendingCompletion();
|
|
608
|
+
const nextTurnId = turnId ?? null;
|
|
609
|
+
const changesIdentifiedTurn = this.turnOpen &&
|
|
610
|
+
this.currentTurnIdValue !== null &&
|
|
611
|
+
nextTurnId !== null &&
|
|
612
|
+
this.currentTurnIdValue !== nextTurnId;
|
|
613
|
+
if (changesIdentifiedTurn) {
|
|
614
|
+
this.flushPendingCompletion();
|
|
615
|
+
this.flushDeferredAssistantMessage();
|
|
616
|
+
if (this.turnOpen) {
|
|
617
|
+
this.turnOpen = false;
|
|
618
|
+
this.sink.onTurnEnd(undefined, "superseded");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
this.currentTurnIdValue = nextTurnId;
|
|
622
|
+
this.ensureTurn();
|
|
623
|
+
}
|
|
624
|
+
/** Active-turn clearing contract:
|
|
625
|
+
*
|
|
626
|
+
* - an identified active turn is closed only by the same id;
|
|
627
|
+
* - an id-less boundary cannot close an identified active turn;
|
|
628
|
+
* - with no observed active turn, an identified boundary may recover a
|
|
629
|
+
* missed start only when it carries the currently-bound thread id. */
|
|
630
|
+
terminalBoundaryMatchesActiveTurn(terminalTurnId, notificationThreadId) {
|
|
631
|
+
if (this.currentTurnIdValue !== null) {
|
|
632
|
+
return terminalTurnId === this.currentTurnIdValue;
|
|
633
|
+
}
|
|
634
|
+
if (terminalTurnId === undefined)
|
|
635
|
+
return true;
|
|
636
|
+
return this.notificationMatchesCurrentThread(notificationThreadId);
|
|
637
|
+
}
|
|
638
|
+
notificationMatchesCurrentThread(notificationThreadId) {
|
|
639
|
+
return this.currentThreadIdValue !== null &&
|
|
640
|
+
notificationThreadId === this.currentThreadIdValue;
|
|
641
|
+
}
|
|
324
642
|
}
|
|
@@ -13,6 +13,8 @@ export interface CodexMapResult {
|
|
|
13
13
|
turnCompleted?: boolean;
|
|
14
14
|
fatalError?: Error;
|
|
15
15
|
}
|
|
16
|
+
export declare function codexTurnStatus(turn: unknown): string | undefined;
|
|
17
|
+
export declare function codexResumeTerminalStatus(turn: unknown): "idle" | "failed" | undefined;
|
|
16
18
|
/** Map one thread item (`item/started` | `item/completed`) to events. */
|
|
17
19
|
export declare function mapCodexItem(method: string, item: ThreadItem): CodexMapResult;
|
|
18
20
|
/** Map one codex app-server notification to events (+ turn/usage/error signals). */
|
|
@@ -1,12 +1,98 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
const CODEX_AUTH_ERROR_FRAGMENTS = [
|
|
5
|
+
"401",
|
|
6
|
+
"403",
|
|
7
|
+
"unauthorized",
|
|
8
|
+
"authentication",
|
|
9
|
+
"not logged in",
|
|
10
|
+
"not authenticated",
|
|
11
|
+
"log in",
|
|
12
|
+
"login",
|
|
13
|
+
"sign in",
|
|
14
|
+
"re-authenticate",
|
|
15
|
+
"reauthenticate",
|
|
16
|
+
"credentials",
|
|
17
|
+
"access token",
|
|
18
|
+
"token expired",
|
|
19
|
+
"expired token",
|
|
20
|
+
"session expired",
|
|
21
|
+
"api key",
|
|
22
|
+
];
|
|
23
|
+
const CODEX_REAUTH_HINT = "Codex needs you to re-authenticate. Run `codex login` and retry.";
|
|
24
|
+
function codexErrorMessage(error) {
|
|
25
|
+
for (const key of ["message", "error", "text", "detail"]) {
|
|
26
|
+
const value = error[key];
|
|
27
|
+
if (typeof value === "string" && value.trim())
|
|
28
|
+
return value.trim();
|
|
29
|
+
}
|
|
30
|
+
return "Codex turn ended with an unspecified error.";
|
|
31
|
+
}
|
|
32
|
+
function codexErrorIsAuth(error, message) {
|
|
33
|
+
const info = error.codexErrorInfo;
|
|
34
|
+
let variant;
|
|
35
|
+
let httpStatusCode;
|
|
36
|
+
if (typeof info === "string") {
|
|
37
|
+
variant = info;
|
|
38
|
+
}
|
|
39
|
+
else if (isRecord(info)) {
|
|
40
|
+
variant = info.type ?? info.kind ?? info.variant;
|
|
41
|
+
httpStatusCode = info.httpStatusCode;
|
|
42
|
+
}
|
|
43
|
+
if (typeof variant === "string" && variant.toLowerCase() === "unauthorized")
|
|
44
|
+
return true;
|
|
45
|
+
if (httpStatusCode === 401 || httpStatusCode === 403)
|
|
46
|
+
return true;
|
|
47
|
+
const lowered = message.toLowerCase();
|
|
48
|
+
return CODEX_AUTH_ERROR_FRAGMENTS.some((fragment) => lowered.includes(fragment));
|
|
49
|
+
}
|
|
1
50
|
function turnError(error, fallback) {
|
|
2
|
-
if (
|
|
3
|
-
return new Error(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
51
|
+
if (!error)
|
|
52
|
+
return new Error(fallback);
|
|
53
|
+
const message = codexErrorMessage(error);
|
|
54
|
+
return new Error(codexErrorIsAuth(error, message)
|
|
55
|
+
? `${message}\n\n${CODEX_REAUTH_HINT}`
|
|
56
|
+
: message);
|
|
57
|
+
}
|
|
58
|
+
export function codexTurnStatus(turn) {
|
|
59
|
+
if (!isRecord(turn))
|
|
60
|
+
return undefined;
|
|
61
|
+
const status = turn.status;
|
|
62
|
+
if (typeof status === "string")
|
|
63
|
+
return status;
|
|
64
|
+
if (!isRecord(status))
|
|
65
|
+
return undefined;
|
|
66
|
+
const value = status.type ?? status.status;
|
|
67
|
+
return typeof value === "string" ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
export function codexResumeTerminalStatus(turn) {
|
|
70
|
+
if (terminalTurnError(turn, "turn/completed"))
|
|
71
|
+
return "failed";
|
|
72
|
+
const status = codexTurnStatus(turn);
|
|
73
|
+
if (status === "completed" || status === "interrupted" ||
|
|
74
|
+
status === "cancelled" || status === "canceled") {
|
|
75
|
+
return "idle";
|
|
8
76
|
}
|
|
9
|
-
|
|
77
|
+
if (status === "failed" || status === "errored")
|
|
78
|
+
return "failed";
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
function terminalTurnError(turn, method) {
|
|
82
|
+
if (!isRecord(turn)) {
|
|
83
|
+
return method === "turn/failed" ? new Error("Codex turn failed") : undefined;
|
|
84
|
+
}
|
|
85
|
+
let error = turn.error;
|
|
86
|
+
if (!isRecord(error) && Array.isArray(turn.items)) {
|
|
87
|
+
error = turn.items.find((item) => isRecord(item) && item.type === "error");
|
|
88
|
+
}
|
|
89
|
+
if (isRecord(error))
|
|
90
|
+
return turnError(error, "Codex turn failed");
|
|
91
|
+
const status = codexTurnStatus(turn);
|
|
92
|
+
if (method === "turn/failed" || status === "failed" || status === "errored") {
|
|
93
|
+
return turnError(undefined, "Codex turn failed");
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
10
96
|
}
|
|
11
97
|
function webSearchInput(item) {
|
|
12
98
|
const action = item.action;
|
|
@@ -151,23 +237,11 @@ export function mapCodexNotification(method, params) {
|
|
|
151
237
|
return { events };
|
|
152
238
|
case "turn/started":
|
|
153
239
|
return { events };
|
|
154
|
-
case "turn/completed":
|
|
240
|
+
case "turn/completed":
|
|
241
|
+
case "turn/failed": {
|
|
155
242
|
const turnPayload = typed.params?.turn;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
events,
|
|
159
|
-
fatalError: turnError(turnPayload.error, "Codex turn failed"),
|
|
160
|
-
turnCompleted: true,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
if (turnPayload?.status === "interrupted") {
|
|
164
|
-
return {
|
|
165
|
-
events,
|
|
166
|
-
fatalError: turnError(turnPayload.error, "Codex turn was interrupted"),
|
|
167
|
-
turnCompleted: true,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
return { events, turnCompleted: true };
|
|
243
|
+
const fatalError = terminalTurnError(turnPayload, typed.method);
|
|
244
|
+
return { events, ...(fatalError ? { fatalError } : {}), turnCompleted: true };
|
|
171
245
|
}
|
|
172
246
|
case "turn/plan/updated": {
|
|
173
247
|
const planParams = typed.params;
|
|
@@ -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
|
+
}
|