@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.
@@ -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 a turn is open injection uses `turn/steer` then, else `turn/start`. */
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.status === "inProgress")
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.flushPendingCompletion();
154
- this.flushDeferredAssistantMessage();
155
- if (carriedTurnId)
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
- this.currentTurnIdValue = carriedTurnId;
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 k = `${this.currentThreadIdValue ?? "thread"}:${this.currentTurnIdValue ?? "turn"}`;
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 (typeof error === "string" && error.trim())
3
- return new Error(error);
4
- if (error && typeof error === "object" && "message" in error) {
5
- const message = error.message;
6
- if (typeof message === "string" && message.trim())
7
- return new Error(message);
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
- return new Error(fallback);
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
- if (turnPayload?.status === "failed") {
157
- return {
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
+ }