@rynx-ai/runtime 0.1.11-beta.20 → 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,5 @@
1
1
  import { codexUserContent } from "../input-resources.js";
2
- import { mapCodexItem, mapCodexNotification } from "./mapping.js";
2
+ import { codexResumeTerminalStatus, mapCodexItem, mapCodexNotification, } from "./mapping.js";
3
3
  const MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated";
4
4
  const MCP_TERMINAL_STATES = new Set(["ready", "failed", "cancelled"]);
5
5
  function threadIdFrom(params) {
@@ -45,6 +45,20 @@ function isModelOutput(method, params) {
45
45
  const item = params?.item;
46
46
  return Boolean(item?.type && item.type !== "userMessage");
47
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
+ }
48
62
  export class CodexSessionForwarder {
49
63
  client;
50
64
  sink;
@@ -55,6 +69,9 @@ export class CodexSessionForwarder {
55
69
  currentThreadIdValue = null;
56
70
  activeSignaled = false;
57
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;
58
75
  assistantMessageTimer = null;
59
76
  deferredAssistantMessage = null;
60
77
  pendingCompletion = null;
@@ -97,12 +114,17 @@ export class CodexSessionForwarder {
97
114
  this.flushDeferredAssistantMessage();
98
115
  if (this.turnOpen) {
99
116
  this.turnOpen = false;
117
+ this.currentTurnIdValue = null;
100
118
  this.sink.onTurnEnd();
101
119
  }
120
+ else {
121
+ this.currentTurnIdValue = null;
122
+ }
102
123
  }
103
- /** 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. */
104
126
  isTurnOpen() {
105
- return this.turnOpen;
127
+ return this.turnOpen || this.currentTurnIdValue !== null;
106
128
  }
107
129
  /** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
108
130
  currentTurnId() {
@@ -117,8 +139,9 @@ export class CodexSessionForwarder {
117
139
  noteTurnAccepted(turnId) {
118
140
  if (!turnId)
119
141
  return;
142
+ if (this.pendingCompletion)
143
+ this.flushPendingCompletion();
120
144
  this.currentTurnIdValue = turnId;
121
- this.ensureTurn();
122
145
  this.emitMcpStartupStatus();
123
146
  }
124
147
  hasPendingMcpStartup() {
@@ -131,7 +154,7 @@ export class CodexSessionForwarder {
131
154
  return pending;
132
155
  this.pendingMcpServers.clear();
133
156
  this.clearMcpStartupTimer();
134
- if (this.turnOpen) {
157
+ if (this.isTurnOpen()) {
135
158
  this.emitMcpStatus(`MCP startup cancelled: ${pending.join(", ")}`);
136
159
  }
137
160
  return pending;
@@ -150,6 +173,16 @@ export class CodexSessionForwarder {
150
173
  threadId() {
151
174
  return this.currentThreadIdValue;
152
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
+ }
153
186
  /**
154
187
  * Replay the backlog turns from a `thread/resume` response as if they were live
155
188
  * `item/completed` notifications — the fresh-thread first-turn backfill. Each
@@ -165,10 +198,11 @@ export class CodexSessionForwarder {
165
198
  this.ensureTurn();
166
199
  for (const item of turn.items ?? [])
167
200
  this.processCompletedItem(item);
168
- if (turn.status === "inProgress")
201
+ if (codexResumeTerminalStatus(turn) === undefined)
169
202
  continue;
170
203
  const mapped = mapCodexNotification("turn/completed", { turn });
171
204
  this.turnOpen = false;
205
+ this.currentTurnIdValue = null;
172
206
  if (mapped.fatalError)
173
207
  this.sink.onTurnError(mapped.fatalError);
174
208
  else
@@ -178,21 +212,56 @@ export class CodexSessionForwarder {
178
212
  /** Reconcile only the exact active turn after an observer resume. Historical
179
213
  * items are intentionally not replayed on an existing-thread reconnect. */
180
214
  reconcileActiveTurn(turn) {
181
- if (!this.turnOpen || !turn || turn.status === "inProgress")
215
+ if (!this.isTurnOpen() || !turn || codexResumeTerminalStatus(turn) === undefined)
182
216
  return false;
183
217
  const resumedTurnId = turn.id ?? turn.turnId;
184
218
  if (!this.currentTurnIdValue || resumedTurnId !== this.currentTurnIdValue)
185
219
  return false;
186
220
  const mapped = mapCodexNotification("turn/completed", { turn });
221
+ this.ensureTurn();
187
222
  this.settle(mapped.fatalError
188
223
  ? { kind: "error", error: mapped.fatalError }
189
224
  : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
190
225
  return true;
191
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
+ }
192
260
  /** Fail an open response exactly once when its observer or terminal exits. */
193
261
  failOpenTurn(error) {
194
- if (!this.turnOpen)
262
+ if (!this.isTurnOpen())
195
263
  return false;
264
+ this.ensureTurn();
196
265
  this.settle({ kind: "error", error });
197
266
  return true;
198
267
  }
@@ -240,17 +309,63 @@ export class CodexSessionForwarder {
240
309
  // behavior unchanged unless the Traex host explicitly opts in.
241
310
  if (method === "queue/status" && !this.options.surfaceQueueStatus)
242
311
  return;
312
+ if (method === "queue/status" && this.currentTurnIdValue === null)
313
+ return;
243
314
  const carriedTurnId = turnIdFrom(params);
244
315
  if (method === "turn/started") {
245
- this.flushPendingCompletion();
246
- this.flushDeferredAssistantMessage();
247
- 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) {
248
338
  this.currentTurnIdValue = carriedTurnId;
339
+ }
249
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)) {
250
359
  return;
251
360
  }
252
- if (carriedTurnId)
253
- 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
+ }
254
369
  // Completed items (user echo, assistant, tool, reasoning) go through the
255
370
  // deduped path so a resume backfill and the live stream never double them.
256
371
  if (method === "item/completed") {
@@ -330,6 +445,8 @@ export class CodexSessionForwarder {
330
445
  settle(completion) {
331
446
  this.flushDeferredAssistantMessage();
332
447
  this.turnOpen = false;
448
+ this.currentTurnIdValue = null;
449
+ this.pendingCompletionTurnId = null;
333
450
  if (completion.kind === "error")
334
451
  this.sink.onTurnError(completion.error);
335
452
  else
@@ -373,7 +490,7 @@ export class CodexSessionForwarder {
373
490
  this.mcpStartupTimer = null;
374
491
  }
375
492
  emitMcpStartupStatus() {
376
- if (!this.turnOpen)
493
+ if (!this.isTurnOpen())
377
494
  return;
378
495
  const pending = [...this.pendingMcpServers].sort();
379
496
  if (pending.length) {
@@ -464,14 +581,15 @@ export class CodexSessionForwarder {
464
581
  * {@link advanceAnonCounter}). Mirrors reference implementation `_completed_item_key`. */
465
582
  completedItemKey(item) {
466
583
  const threadId = this.currentThreadIdValue ?? "thread";
467
- const turnId = this.currentTurnIdValue ?? "turn";
584
+ const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
468
585
  if (item.id)
469
586
  return { key: `${threadId}:${turnId}:${item.id}`, isAnon: false };
470
587
  const n = this.anonCounters.get(`${threadId}:${turnId}`) ?? 0;
471
588
  return { key: `${threadId}:${turnId}:anon:${n}`, isAnon: true };
472
589
  }
473
590
  advanceAnonCounter() {
474
- const k = `${this.currentThreadIdValue ?? "thread"}:${this.currentTurnIdValue ?? "turn"}`;
591
+ const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
592
+ const k = `${this.currentThreadIdValue ?? "thread"}:${turnId}`;
475
593
  this.anonCounters.set(k, (this.anonCounters.get(k) ?? 0) + 1);
476
594
  }
477
595
  ensureTurn() {
@@ -480,4 +598,45 @@ export class CodexSessionForwarder {
480
598
  this.sink.onTurnStart(this.currentTurnIdValue ?? undefined);
481
599
  }
482
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
+ }
483
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;
@@ -172,7 +172,7 @@ export interface TurnSteerParams {
172
172
  expectedTurnId: string;
173
173
  input: UserInput[];
174
174
  }
175
- export type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress";
175
+ export type TurnStatus = "completed" | "interrupted" | "cancelled" | "canceled" | "failed" | "errored" | "inProgress";
176
176
  export interface TurnPlanStep {
177
177
  step: string;
178
178
  status: "pending" | "inProgress" | "completed";
package/dist/host.d.ts CHANGED
@@ -226,8 +226,9 @@ export declare class LocalAgentHost implements CodexCapabilities {
226
226
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
227
227
  * turn, so `thread/resume` is retried: park until the forwarder observes the
228
228
  * thread active, then retry. Resume only fetches the newest summarized Turn:
229
- * recovery reconciles the exact active Turn's terminal status and never replays
230
- * historical items. Once resume succeeds, subsequent turns arrive live.
229
+ * recovery reconciles the exact active Turn or publishes the newest explicit
230
+ * terminal status without replaying historical items. Once resume succeeds,
231
+ * subsequent turns arrive live.
231
232
  */
232
233
  private subscribeUntilReady;
233
234
  /** Restore the independent observer after an unexpected exit. The active turn