@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.50

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.
Files changed (65) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +103 -1
  6. package/dist/claude/native-bridge.js +460 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1571 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +183 -46
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +96 -3
  20. package/dist/codex-app-server/forwarder.js +542 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +228 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +68 -7
  28. package/dist/codex-app-server/transport.d.ts +2 -2
  29. package/dist/codex-app-server/transport.js +4 -0
  30. package/dist/codex-app-server/ws-channel.d.ts +8 -1
  31. package/dist/codex-app-server/ws-channel.js +113 -28
  32. package/dist/codex-child-env.js +3 -32
  33. package/dist/codex-home.d.ts +35 -3
  34. package/dist/codex-home.js +323 -18
  35. package/dist/codex-session-store.d.ts +23 -0
  36. package/dist/codex-session-store.js +21 -0
  37. package/dist/host.d.ts +106 -48
  38. package/dist/host.js +2024 -640
  39. package/dist/index.d.ts +3 -3
  40. package/dist/index.js +1 -1
  41. package/dist/input-resources.d.ts +4 -0
  42. package/dist/input-resources.js +21 -5
  43. package/dist/models-catalog.js +12 -3
  44. package/dist/runner/child.d.ts +105 -28
  45. package/dist/runner/child.js +1625 -100
  46. package/dist/runner/manager.d.ts +114 -46
  47. package/dist/runner/manager.js +1523 -446
  48. package/dist/runner/protocol.d.ts +212 -24
  49. package/dist/runner/protocol.js +5 -0
  50. package/dist/runner/startup-policy.d.ts +7 -0
  51. package/dist/runner/startup-policy.js +10 -0
  52. package/dist/runner/transport.d.ts +18 -2
  53. package/dist/runner/transport.js +82 -3
  54. package/dist/runner-main.js +8 -3
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -1,9 +1,23 @@
1
- import { codexUserContent } from "../input-resources.js";
2
- import { mapCodexItem, mapCodexNotification } from "./mapping.js";
1
+ import { codexUserEchoContent } from "../input-resources.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;
6
8
  }
9
+ /** Agent-control children announce their own thread on the shared app-server.
10
+ * They do not replace the parent TUI thread: only a top-level thread switch is
11
+ * a Session rotation boundary. */
12
+ function isSubagentThreadStarted(params) {
13
+ const source = params?.thread?.source?.subAgent?.thread_spawn;
14
+ return source !== null && typeof source === "object" && !Array.isArray(source);
15
+ }
16
+ /** Internal system threads are non-persistable and never replace the parent
17
+ * TUI thread, even though the app-server broadcasts `thread/started`. */
18
+ function isEphemeralThreadStarted(params) {
19
+ return params?.thread?.ephemeral === true;
20
+ }
7
21
  function turnIdFrom(params) {
8
22
  const p = params;
9
23
  return p?.turnId ?? p?.turn?.id;
@@ -11,14 +25,7 @@ function turnIdFrom(params) {
11
25
  function userMessageContent(item) {
12
26
  if (item.type !== "userMessage")
13
27
  return undefined;
14
- const parts = codexUserContent(item.content);
15
- if (parts.length === 0)
16
- return undefined;
17
- if (parts.every((part) => part.type === "input_text")) {
18
- const text = parts.map((part) => part.text).join("").trim();
19
- return text || undefined;
20
- }
21
- return parts;
28
+ return codexUserEchoContent(item.content);
22
29
  }
23
30
  /** Whether a notification implies the thread is now active (its first turn has
24
31
  * begun, so the rollout exists). Mirrors reference implementation's `_event_indicates_thread_active`. */
@@ -31,6 +38,46 @@ function indicatesActive(method, params) {
31
38
  }
32
39
  return false;
33
40
  }
41
+ function isThreadIdle(method, params) {
42
+ if (method !== "thread/status/changed")
43
+ return false;
44
+ const status = params?.status;
45
+ return status === "idle" || (typeof status === "object" && status?.type === "idle");
46
+ }
47
+ function isModelOutput(method, params) {
48
+ if (method !== "item/started" && method !== "item/completed")
49
+ return false;
50
+ const item = params?.item;
51
+ return Boolean(item?.type && item.type !== "userMessage");
52
+ }
53
+ function isTerminalTurnMethod(method) {
54
+ return method === "turn/completed" || method === "turn/failed";
55
+ }
56
+ /** Turn-scoped notifications must never be reassigned to whichever newer turn
57
+ * happens to be current when an app-server frame arrives late. */
58
+ function isTurnScopedMethod(method) {
59
+ return method.startsWith("turn/") || method.startsWith("item/") ||
60
+ method === "queue/status" || method === "error";
61
+ }
62
+ /** Recover a missed turn/started only from visible, fully-scoped assistant/plan
63
+ * deltas. Other deltas require an already matching active turn. */
64
+ function canRecoverMissedTurnFrom(method) {
65
+ return method === "item/agentMessage/delta" || method === "item/plan/delta";
66
+ }
67
+ /** Live deltas require the bridge's exact active Codex turn. Assistant/plan
68
+ * deltas may recover a missed `turn/started` when
69
+ * fully scoped; command output never opens or recovers a Turn. */
70
+ function requiresMatchingActiveTurn(method) {
71
+ return canRecoverMissedTurnFrom(method) || method === "item/commandExecution/outputDelta";
72
+ }
73
+ /** Turn content that remains valid independently of lifecycle status. */
74
+ function isIndependentTurnContent(method) {
75
+ return method === "item/started" ||
76
+ method === "item/reasoning/textDelta" ||
77
+ method === "item/reasoning/summaryTextDelta" ||
78
+ method === "item/fileChange/patchUpdated" ||
79
+ method === "turn/plan/updated";
80
+ }
34
81
  export class CodexSessionForwarder {
35
82
  client;
36
83
  sink;
@@ -41,9 +88,16 @@ export class CodexSessionForwarder {
41
88
  currentThreadIdValue = null;
42
89
  activeSignaled = false;
43
90
  completionTimer = null;
91
+ /** Turn id retained only for late-item dedup while a terminal response waits
92
+ * for its bounded output-ordering grace. It is not an active provider turn. */
93
+ pendingCompletionTurnId = null;
44
94
  assistantMessageTimer = null;
45
95
  deferredAssistantMessage = null;
46
96
  pendingCompletion = null;
97
+ pendingMcpServers = new Set();
98
+ failedMcpServers = new Map();
99
+ mcpStartupTimer = null;
100
+ lastMcpStatusNote = null;
47
101
  /** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
48
102
  * `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
49
103
  * counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
@@ -51,6 +105,11 @@ export class CodexSessionForwarder {
51
105
  /** Per-(thread,turn) position counter for items lacking a stable codex id
52
106
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
53
107
  anonCounters = new Map();
108
+ pendingPlanImplementation = null;
109
+ /** Latest aggregate diff per Turn. Codex republishes the complete diff after
110
+ * every edit; only the terminal snapshot belongs in transcript history. */
111
+ turnDiffByTurn = new Map();
112
+ replayingBackfill = false;
54
113
  constructor(client, sink, options = {}) {
55
114
  this.client = client;
56
115
  this.sink = sink;
@@ -61,31 +120,111 @@ export class CodexSessionForwarder {
61
120
  if (this.unsubscribe)
62
121
  return;
63
122
  this.unsubscribe = this.client.onNotification((method, params) => {
64
- this.handle(method, params);
123
+ try {
124
+ this.handle(method, params);
125
+ }
126
+ catch (error) {
127
+ // One malformed or unsupported notification must not detach the
128
+ // long-lived observer from every later event in this session.
129
+ console.error(`[codex-forwarder] notification handler failed for ${method}; continuing:`, error);
130
+ }
65
131
  });
132
+ const startup = this.options.mcpStartup;
133
+ if (startup?.servers.length) {
134
+ for (const server of startup.servers)
135
+ this.pendingMcpServers.add(server);
136
+ this.mcpStartupTimer = setTimeout(() => this.settleMcpStartup(), startup.settleTimeoutMs);
137
+ this.mcpStartupTimer.unref?.();
138
+ }
66
139
  }
67
140
  stop() {
68
141
  this.unsubscribe?.();
69
142
  this.unsubscribe = null;
143
+ this.clearMcpStartupTimer();
70
144
  this.flushPendingCompletion();
71
145
  this.flushDeferredAssistantMessage();
72
146
  if (this.turnOpen) {
73
147
  this.turnOpen = false;
148
+ this.currentTurnIdValue = null;
74
149
  this.sink.onTurnEnd();
75
150
  }
151
+ else {
152
+ this.currentTurnIdValue = null;
153
+ }
154
+ this.turnDiffByTurn.clear();
76
155
  }
77
- /** True while a turn is open — injection uses `turn/steer` then, else `turn/start`. */
156
+ /** True while the provider owns an active turn, including the short interval
157
+ * between injection acceptance and observer confirmation. */
78
158
  isTurnOpen() {
79
- return this.turnOpen;
159
+ return this.turnOpen || this.currentTurnIdValue !== null;
80
160
  }
81
161
  /** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
82
162
  currentTurnId() {
83
163
  return this.currentTurnIdValue;
84
164
  }
165
+ /** Reconcile a proven stale steer without clearing a newer observed turn. */
166
+ clearActiveTurnIfMatches(turnId) {
167
+ if (this.currentTurnIdValue !== turnId)
168
+ return;
169
+ this.flushPendingCompletion();
170
+ this.flushDeferredAssistantMessage();
171
+ this.currentTurnIdValue = null;
172
+ this.turnOpen = false;
173
+ this.sink.onTurnInactive?.(turnId);
174
+ }
175
+ /**
176
+ * Record a turn accepted by the injection connection before the independent
177
+ * observer receives `turn/started`. This closes the read-decide-RPC-write race:
178
+ * another web message arriving in that window must steer this turn, not start
179
+ * a second one.
180
+ */
181
+ noteTurnAccepted(turnId) {
182
+ if (!turnId)
183
+ return;
184
+ if (this.pendingCompletion)
185
+ this.flushPendingCompletion();
186
+ this.currentTurnIdValue = turnId;
187
+ this.emitMcpStartupStatus();
188
+ }
189
+ hasPendingMcpStartup() {
190
+ return this.pendingMcpServers.size > 0;
191
+ }
192
+ /** Mark and return the startup servers cancelled by a web Stop. */
193
+ cancelMcpStartup() {
194
+ const pending = [...this.pendingMcpServers].sort();
195
+ if (pending.length === 0)
196
+ return pending;
197
+ this.pendingMcpServers.clear();
198
+ this.clearMcpStartupTimer();
199
+ if (this.isTurnOpen()) {
200
+ this.emitMcpStatus(`MCP startup cancelled: ${pending.join(", ")}`);
201
+ }
202
+ return pending;
203
+ }
204
+ /** Diagnostic suffix for an injection failure during Provider startup. */
205
+ mcpStartupDetail() {
206
+ const pending = [...this.pendingMcpServers].sort();
207
+ if (pending.length)
208
+ return `MCP startup still waiting on ${pending.join(", ")}`;
209
+ const failed = [...this.failedMcpServers.entries()]
210
+ .sort(([left], [right]) => left.localeCompare(right))
211
+ .map(([name, error]) => error ? `${name}: ${error}` : name);
212
+ return failed.length ? `MCP startup failed for ${failed.join(", ")}` : null;
213
+ }
85
214
  /** The bound codex thread id captured from `thread/started` (null until then). */
86
215
  threadId() {
87
216
  return this.currentThreadIdValue;
88
217
  }
218
+ /** Seed an already-persisted/resumed thread binding. The bridge retains this
219
+ * state even when app-server does not rebroadcast `thread/started`, so
220
+ * terminal-boundary recovery must know it too. */
221
+ noteThreadBound(threadId) {
222
+ if (!threadId || this.currentThreadIdValue === threadId)
223
+ return;
224
+ if (this.currentThreadIdValue !== null || this.turnOpen)
225
+ return;
226
+ this.currentThreadIdValue = threadId;
227
+ }
89
228
  /**
90
229
  * Replay the backlog turns from a `thread/resume` response as if they were live
91
230
  * `item/completed` notifications — the fresh-thread first-turn backfill. Each
@@ -94,25 +233,52 @@ export class CodexSessionForwarder {
94
233
  * not doubled.
95
234
  */
96
235
  replayBackfill(turns) {
97
- for (const turn of turns) {
98
- const turnId = turn.id ?? turn.turnId;
99
- if (turnId)
100
- this.currentTurnIdValue = turnId;
101
- this.ensureTurn();
102
- for (const item of turn.items ?? [])
103
- this.processCompletedItem(item);
104
- if (turn.status === "inProgress")
105
- continue;
106
- const mapped = mapCodexNotification("turn/completed", { turn });
107
- this.turnOpen = false;
108
- if (mapped.fatalError)
109
- this.sink.onTurnError(mapped.fatalError);
110
- else
111
- this.sink.onTurnEnd(mapped.usage);
236
+ this.replayingBackfill = true;
237
+ try {
238
+ for (const turn of turns) {
239
+ const turnId = turn.id ?? turn.turnId;
240
+ if (turnId)
241
+ this.currentTurnIdValue = turnId;
242
+ this.ensureTurn();
243
+ for (const item of turn.items ?? [])
244
+ this.processCompletedItem(item);
245
+ if (codexResumeTerminalStatus(turn) === undefined)
246
+ continue;
247
+ const mapped = mapCodexNotification("turn/completed", { turn });
248
+ this.turnOpen = false;
249
+ this.currentTurnIdValue = null;
250
+ if (mapped.fatalError)
251
+ this.sink.onTurnError(mapped.fatalError);
252
+ else
253
+ this.sink.onTurnEnd(mapped.usage);
254
+ }
255
+ }
256
+ finally {
257
+ this.replayingBackfill = false;
112
258
  }
113
259
  }
260
+ /** Suppress our synthesized picker when a future app-server emits the native
261
+ * `plan_implementation` request itself. */
262
+ noteNativePlanImplementationPrompt(turnId) {
263
+ if (!this.pendingPlanImplementation)
264
+ return;
265
+ if (!turnId || this.pendingPlanImplementation.turnId === turnId) {
266
+ this.pendingPlanImplementation = null;
267
+ }
268
+ }
269
+ /** Fail an open response exactly once when its observer or terminal exits. */
270
+ failOpenTurn(error) {
271
+ if (!this.isTurnOpen())
272
+ return false;
273
+ this.ensureTurn();
274
+ this.settle({ kind: "error", error });
275
+ return true;
276
+ }
114
277
  handle(method, params) {
115
278
  if (method === "thread/started" || method === "thread.started") {
279
+ if (method === "thread/started" &&
280
+ (isSubagentThreadStarted(params) || isEphemeralThreadStarted(params)))
281
+ return;
116
282
  const tid = threadIdFrom(params);
117
283
  if (tid && tid !== this.currentThreadIdValue) {
118
284
  const forkedFromId = params?.thread?.forkedFromId;
@@ -128,6 +294,7 @@ export class CodexSessionForwarder {
128
294
  }
129
295
  this.currentTurnIdValue = null;
130
296
  this.activeSignaled = false;
297
+ this.turnDiffByTurn.clear();
131
298
  this.currentThreadIdValue = tid;
132
299
  this.sink.onThreadStarted?.(tid, normalizedForkedFromId);
133
300
  }
@@ -138,6 +305,33 @@ export class CodexSessionForwarder {
138
305
  notificationThreadId &&
139
306
  notificationThreadId !== this.currentThreadIdValue)
140
307
  return;
308
+ if (method === MCP_STARTUP_STATUS_METHOD) {
309
+ this.handleMcpStartupStatus(params);
310
+ return;
311
+ }
312
+ if (method === "thread/settings/updated") {
313
+ const threadSettings = params?.threadSettings;
314
+ const model = typeof threadSettings?.model === "string" && threadSettings.model.trim()
315
+ ? threadSettings.model.trim()
316
+ : undefined;
317
+ const effort = threadSettings?.effort;
318
+ if (model !== undefined || effort === null || typeof effort === "string") {
319
+ this.sink.onThreadSettingsChanged?.({
320
+ ...(model === undefined ? {} : { model }),
321
+ ...(effort === null || typeof effort === "string"
322
+ ? { reasoningEffort: effort }
323
+ : {}),
324
+ });
325
+ }
326
+ const mode = (threadSettings?.collaborationMode ?? threadSettings?.collaboration_mode)?.mode;
327
+ if (mode === "plan" || mode === "default") {
328
+ this.sink.onCollaborationModeChanged?.(mode);
329
+ }
330
+ return;
331
+ }
332
+ if (isThreadIdle(method, params) || isModelOutput(method, params)) {
333
+ this.settleMcpStartup();
334
+ }
141
335
  // Release a parked resume as soon as the thread shows activity (rollout now
142
336
  // exists). Fire once.
143
337
  if (!this.activeSignaled && indicatesActive(method, params)) {
@@ -148,33 +342,147 @@ export class CodexSessionForwarder {
148
342
  // behavior unchanged unless the Traex host explicitly opts in.
149
343
  if (method === "queue/status" && !this.options.surfaceQueueStatus)
150
344
  return;
345
+ if (method === "queue/status" && this.currentTurnIdValue === null)
346
+ return;
151
347
  const carriedTurnId = turnIdFrom(params);
348
+ if (method === "turn/diff/updated") {
349
+ if (!carriedTurnId)
350
+ return;
351
+ const update = params;
352
+ const diff = typeof update?.diff === "string"
353
+ ? update.diff
354
+ : typeof update?.unifiedDiff === "string"
355
+ ? update.unifiedDiff
356
+ : "";
357
+ if (diff)
358
+ this.turnDiffByTurn.set(carriedTurnId, diff);
359
+ else
360
+ this.turnDiffByTurn.delete(carriedTurnId);
361
+ return;
362
+ }
152
363
  if (method === "turn/started") {
153
- this.flushPendingCompletion();
154
- this.flushDeferredAssistantMessage();
155
- if (carriedTurnId)
364
+ this.beginTurn(carriedTurnId);
365
+ this.sink.onTurnObserved?.(carriedTurnId);
366
+ return;
367
+ }
368
+ if (isTerminalTurnMethod(method)) {
369
+ if (!this.terminalBoundaryMatchesActiveTurn(carriedTurnId, notificationThreadId)) {
370
+ const staleDiff = this.consumeTurnDiff(carriedTurnId);
371
+ if (staleDiff && carriedTurnId) {
372
+ this.sink.onTurnContentEvent?.(carriedTurnId, staleDiff);
373
+ }
374
+ return;
375
+ }
376
+ const hadActiveTurn = this.currentTurnIdValue !== null;
377
+ const hadOpenResponse = this.turnOpen;
378
+ const mapped = mapCodexNotification(method, params);
379
+ const terminalTurnId = carriedTurnId ?? this.currentTurnIdValue;
380
+ const turnDiff = this.consumeTurnDiff(terminalTurnId);
381
+ if (!hadActiveTurn && !hadOpenResponse) {
382
+ if (turnDiff && terminalTurnId) {
383
+ this.sink.onTurnContentEvent?.(terminalTurnId, turnDiff);
384
+ }
385
+ const turn = params?.turn;
386
+ const recoveredStatus = mapped.fatalError ||
387
+ method === "turn/failed" ||
388
+ codexResumeTerminalStatus(turn) === "failed"
389
+ ? "failed"
390
+ : "idle";
391
+ this.sink.onRecoveredTurnStatus?.(recoveredStatus, carriedTurnId, mapped.fatalError);
392
+ return;
393
+ }
394
+ if (carriedTurnId && this.currentTurnIdValue === null) {
156
395
  this.currentTurnIdValue = carriedTurnId;
396
+ }
157
397
  this.ensureTurn();
398
+ this.pendingCompletionTurnId = carriedTurnId ?? this.currentTurnIdValue;
399
+ // The provider lifecycle closes immediately. A configured grace keeps
400
+ // only the canonical response open long enough to absorb late items.
401
+ this.currentTurnIdValue = null;
402
+ for (const event of mapped.events.filter((event) => event.type !== "runtime_debug")) {
403
+ this.sink.onEvent(event);
404
+ }
405
+ this.scheduleCompletion(mapped.fatalError
406
+ ? { kind: "error", error: mapped.fatalError, ...(turnDiff ? { turnDiff } : {}) }
407
+ : mapped.turnInterrupted
408
+ ? {
409
+ kind: "interrupted",
410
+ ...(mapped.usage ? { usage: mapped.usage } : {}),
411
+ ...(turnDiff ? { turnDiff } : {}),
412
+ }
413
+ : {
414
+ kind: "end",
415
+ ...(mapped.usage ? { usage: mapped.usage } : {}),
416
+ ...(turnDiff ? { turnDiff } : {}),
417
+ });
158
418
  return;
159
419
  }
160
- if (carriedTurnId)
161
- this.currentTurnIdValue = carriedTurnId;
420
+ // A late event from an older turn must not replace the app-server's active
421
+ // turn id. That was the source of stale completions closing a newer turn.
422
+ if (carriedTurnId &&
423
+ this.currentTurnIdValue &&
424
+ carriedTurnId !== this.currentTurnIdValue &&
425
+ isTurnScopedMethod(method)) {
426
+ // Durable completed items and passive content belong to their carried
427
+ // response even if a newer Turn is active. Only active-turn deltas and
428
+ // lifecycle edges are stale with respect to the newer Turn.
429
+ if (method === "item/completed") {
430
+ // Continue into the completed-item dedup/order path below.
431
+ }
432
+ else if (isIndependentTurnContent(method) && this.sink.onTurnContentEvent) {
433
+ const mapped = mapCodexNotification(method, params);
434
+ for (const event of mapped.events) {
435
+ if (event.type !== "runtime_debug") {
436
+ this.sink.onTurnContentEvent(carriedTurnId, event);
437
+ }
438
+ }
439
+ return;
440
+ }
441
+ else {
442
+ return;
443
+ }
444
+ }
445
+ if (requiresMatchingActiveTurn(method)) {
446
+ if (this.currentTurnIdValue !== null) {
447
+ // An id-less delta is not attributable to the active Turn. Require
448
+ // the exact active Turn id before forwarding it.
449
+ if (carriedTurnId !== this.currentTurnIdValue)
450
+ return;
451
+ }
452
+ else if (carriedTurnId &&
453
+ canRecoverMissedTurnFrom(method) &&
454
+ this.notificationMatchesCurrentThread(notificationThreadId)) {
455
+ this.currentTurnIdValue = carriedTurnId;
456
+ this.ensureTurn();
457
+ }
458
+ else {
459
+ return;
460
+ }
461
+ }
162
462
  // Completed items (user echo, assistant, tool, reasoning) go through the
163
463
  // deduped path so a resume backfill and the live stream never double them.
164
464
  if (method === "item/completed") {
165
465
  const item = params?.item;
166
466
  if (item) {
467
+ if (carriedTurnId &&
468
+ this.currentTurnIdValue &&
469
+ carriedTurnId !== this.currentTurnIdValue) {
470
+ // This is durable content for an older response, not an ordering
471
+ // signal for the newer Turn's deferred assistant message.
472
+ this.processCompletedItem(item, carriedTurnId);
473
+ return;
474
+ }
167
475
  if (this.shouldDeferAssistantMessage(item)) {
168
- this.deferAssistantMessage(item);
476
+ this.deferAssistantMessage(item, carriedTurnId);
169
477
  this.refreshCompletionGrace();
170
478
  return;
171
479
  }
172
- // A late reasoning item belongs before the held final answer. Any other
173
- // completed item establishes that the held message was not final and
174
- // must retain its original position.
480
+ // Within one Turn, a late reasoning item belongs before the held final
481
+ // answer. Any other completed item establishes that the held message
482
+ // was not final and must retain its original position.
175
483
  if (item.type !== "reasoning")
176
484
  this.flushDeferredAssistantMessage();
177
- this.processCompletedItem(item);
485
+ this.processCompletedItem(item, carriedTurnId);
178
486
  this.refreshCompletionGrace();
179
487
  return;
180
488
  }
@@ -189,6 +497,19 @@ export class CodexSessionForwarder {
189
497
  if (canonicalEvents.some((event) => event.type !== "reasoning_delta" && event.type !== "reasoning_completed")) {
190
498
  this.flushDeferredAssistantMessage();
191
499
  }
500
+ // Item/content delivery is independent from turn lifecycle status. A
501
+ // scoped item or reasoning delta that arrives after the terminal
502
+ // edge remains visible, but must not synthesize another running response.
503
+ if (!this.turnOpen &&
504
+ this.currentTurnIdValue === null &&
505
+ !mapped.fatalError &&
506
+ canonicalEvents.length > 0 &&
507
+ this.sink.onTurnContentEvent) {
508
+ for (const event of canonicalEvents) {
509
+ this.sink.onTurnContentEvent(carriedTurnId, event);
510
+ }
511
+ return;
512
+ }
192
513
  if (canonicalEvents.length || mapped.turnCompleted || mapped.fatalError) {
193
514
  this.ensureTurn();
194
515
  }
@@ -200,7 +521,9 @@ export class CodexSessionForwarder {
200
521
  return;
201
522
  }
202
523
  if (mapped.turnCompleted) {
203
- this.scheduleCompletion({ kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
524
+ this.scheduleCompletion(mapped.turnInterrupted
525
+ ? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
526
+ : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
204
527
  }
205
528
  else {
206
529
  this.refreshCompletionGrace();
@@ -237,39 +560,150 @@ export class CodexSessionForwarder {
237
560
  }
238
561
  settle(completion) {
239
562
  this.flushDeferredAssistantMessage();
563
+ const completedTurnId = this.pendingCompletionTurnId ?? this.currentTurnIdValue;
564
+ const planPrompt = completion.kind === "end" &&
565
+ this.pendingPlanImplementation?.turnId === completedTurnId
566
+ ? this.pendingPlanImplementation
567
+ : null;
568
+ if (this.pendingPlanImplementation?.turnId === completedTurnId) {
569
+ this.pendingPlanImplementation = null;
570
+ }
571
+ if (completion.turnDiff)
572
+ this.sink.onEvent(completion.turnDiff);
573
+ if (completedTurnId)
574
+ this.turnDiffByTurn.delete(completedTurnId);
240
575
  this.turnOpen = false;
576
+ this.currentTurnIdValue = null;
577
+ this.pendingCompletionTurnId = null;
241
578
  if (completion.kind === "error")
242
579
  this.sink.onTurnError(completion.error);
243
- else
580
+ else if (completion.kind === "interrupted") {
581
+ if (this.sink.onTurnInterrupted)
582
+ this.sink.onTurnInterrupted(completion.usage);
583
+ else
584
+ this.sink.onTurnEnd(completion.usage);
585
+ }
586
+ else {
244
587
  this.sink.onTurnEnd(completion.usage);
588
+ if (planPrompt)
589
+ this.sink.onPlanImplementationPrompt?.(planPrompt);
590
+ }
591
+ }
592
+ handleMcpStartupStatus(params) {
593
+ const update = params;
594
+ const name = typeof update?.name === "string" ? update.name : "";
595
+ const status = typeof update?.status === "string" ? update.status : "";
596
+ if (!name || (status !== "starting" && !MCP_TERMINAL_STATES.has(status)))
597
+ return;
598
+ if (status === "starting") {
599
+ this.pendingMcpServers.add(name);
600
+ this.failedMcpServers.delete(name);
601
+ }
602
+ else {
603
+ this.pendingMcpServers.delete(name);
604
+ if (status === "failed") {
605
+ this.failedMcpServers.set(name, typeof update?.error === "string" && update.error ? update.error : undefined);
606
+ }
607
+ else {
608
+ this.failedMcpServers.delete(name);
609
+ }
610
+ if (this.pendingMcpServers.size === 0)
611
+ this.clearMcpStartupTimer();
612
+ }
613
+ this.emitMcpStartupStatus();
614
+ }
615
+ settleMcpStartup() {
616
+ if (this.pendingMcpServers.size === 0) {
617
+ this.clearMcpStartupTimer();
618
+ return;
619
+ }
620
+ this.pendingMcpServers.clear();
621
+ this.clearMcpStartupTimer();
622
+ this.emitMcpStartupStatus();
623
+ }
624
+ clearMcpStartupTimer() {
625
+ if (this.mcpStartupTimer)
626
+ clearTimeout(this.mcpStartupTimer);
627
+ this.mcpStartupTimer = null;
628
+ }
629
+ emitMcpStartupStatus() {
630
+ if (!this.isTurnOpen())
631
+ return;
632
+ const pending = [...this.pendingMcpServers].sort();
633
+ if (pending.length) {
634
+ this.emitMcpStatus(`Starting MCP servers: ${pending.join(", ")}`);
635
+ return;
636
+ }
637
+ const failed = [...this.failedMcpServers.keys()].sort();
638
+ if (failed.length) {
639
+ this.emitMcpStatus(`MCP startup failed: ${failed.join(", ")}`);
640
+ return;
641
+ }
642
+ if (this.lastMcpStatusNote !== null) {
643
+ this.lastMcpStatusNote = null;
644
+ if (this.sink.onStatus)
645
+ this.sink.onStatus(undefined);
646
+ else
647
+ this.sink.onEvent({ type: "status" });
648
+ }
649
+ }
650
+ emitMcpStatus(note) {
651
+ if (this.lastMcpStatusNote === note)
652
+ return;
653
+ this.lastMcpStatusNote = note;
654
+ if (this.sink.onStatus)
655
+ this.sink.onStatus(note, "startup");
656
+ else
657
+ this.sink.onEvent({ type: "status", statusKind: "startup", note });
245
658
  }
246
659
  /** Map + emit one completed codex item, deduped by a TOTAL key and routing the
247
660
  * user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
248
- processCompletedItem(item) {
249
- if (!this.claimCompletedItem(item))
661
+ processCompletedItem(item, completedTurnId) {
662
+ if (!this.claimCompletedItem(item, completedTurnId))
250
663
  return;
251
- this.emitCompletedItem(item);
664
+ this.emitCompletedItem(item, completedTurnId);
252
665
  }
253
- claimCompletedItem(item) {
254
- const { key, isAnon } = this.completedItemKey(item);
666
+ claimCompletedItem(item, completedTurnId) {
667
+ const { key, isAnon } = this.completedItemKey(item, completedTurnId);
255
668
  if (this.seenCompletedItems.has(key))
256
669
  return false;
257
670
  this.seenCompletedItems.add(key);
258
671
  if (isAnon)
259
- this.advanceAnonCounter();
672
+ this.advanceAnonCounter(completedTurnId);
260
673
  return true;
261
674
  }
262
- emitCompletedItem(item) {
675
+ emitCompletedItem(item, completedTurnId) {
676
+ const outsideTurnLifecycle = !this.turnOpen || Boolean(completedTurnId &&
677
+ this.currentTurnIdValue &&
678
+ completedTurnId !== this.currentTurnIdValue);
263
679
  const userContent = userMessageContent(item);
264
680
  if (userContent !== undefined) {
681
+ if (outsideTurnLifecycle && this.sink.onTurnContentUserMessage) {
682
+ this.sink.onTurnContentUserMessage(completedTurnId, userContent);
683
+ return;
684
+ }
265
685
  this.ensureTurn();
266
686
  this.sink.onUserMessage?.(userContent);
267
687
  return;
268
688
  }
689
+ if (item.type === "plan" && !this.replayingBackfill) {
690
+ const text = item.text?.trim();
691
+ const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId;
692
+ const threadId = this.currentThreadIdValue;
693
+ if (text && turnId && threadId) {
694
+ this.pendingPlanImplementation = { threadId, turnId, text };
695
+ }
696
+ }
269
697
  const mapped = mapCodexItem("item/completed", item);
270
698
  const canonicalEvents = mapped.events.filter((event) => event.type !== "runtime_debug");
271
699
  if (canonicalEvents.length === 0)
272
700
  return;
701
+ if (outsideTurnLifecycle && this.sink.onTurnContentEvent) {
702
+ for (const event of canonicalEvents) {
703
+ this.sink.onTurnContentEvent(completedTurnId, event);
704
+ }
705
+ return;
706
+ }
273
707
  this.ensureTurn();
274
708
  for (const event of canonicalEvents)
275
709
  this.sink.onEvent(event);
@@ -280,11 +714,11 @@ export class CodexSessionForwarder {
280
714
  }
281
715
  return Boolean(item.text?.trim());
282
716
  }
283
- deferAssistantMessage(item) {
284
- if (!this.claimCompletedItem(item))
717
+ deferAssistantMessage(item, turnId) {
718
+ if (!this.claimCompletedItem(item, turnId))
285
719
  return;
286
720
  this.flushDeferredAssistantMessage();
287
- this.deferredAssistantMessage = item;
721
+ this.deferredAssistantMessage = { item, ...(turnId ? { turnId } : {}) };
288
722
  const graceMs = this.options.assistantMessageGraceMs ?? 0;
289
723
  this.assistantMessageTimer = setTimeout(() => this.flushDeferredAssistantMessage(), graceMs);
290
724
  this.assistantMessageTimer.unref?.();
@@ -293,26 +727,27 @@ export class CodexSessionForwarder {
293
727
  if (this.assistantMessageTimer)
294
728
  clearTimeout(this.assistantMessageTimer);
295
729
  this.assistantMessageTimer = null;
296
- const item = this.deferredAssistantMessage;
730
+ const deferred = this.deferredAssistantMessage;
297
731
  this.deferredAssistantMessage = null;
298
- if (item)
299
- this.emitCompletedItem(item);
732
+ if (deferred)
733
+ this.emitCompletedItem(deferred.item, deferred.turnId);
300
734
  }
301
735
  /** Build a total dedup key for one completed item. Stable-id items use
302
736
  * `threadId:turnId:item.id` — identical across replay + live, so the second
303
737
  * delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
304
738
  * position counter (peeked here; advanced only on a successful claim, via
305
739
  * {@link advanceAnonCounter}). Mirrors reference implementation `_completed_item_key`. */
306
- completedItemKey(item) {
740
+ completedItemKey(item, completedTurnId) {
307
741
  const threadId = this.currentThreadIdValue ?? "thread";
308
- const turnId = this.currentTurnIdValue ?? "turn";
742
+ const turnId = completedTurnId ?? this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
309
743
  if (item.id)
310
744
  return { key: `${threadId}:${turnId}:${item.id}`, isAnon: false };
311
745
  const n = this.anonCounters.get(`${threadId}:${turnId}`) ?? 0;
312
746
  return { key: `${threadId}:${turnId}:anon:${n}`, isAnon: true };
313
747
  }
314
- advanceAnonCounter() {
315
- const k = `${this.currentThreadIdValue ?? "thread"}:${this.currentTurnIdValue ?? "turn"}`;
748
+ advanceAnonCounter(completedTurnId) {
749
+ const turnId = completedTurnId ?? this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
750
+ const k = `${this.currentThreadIdValue ?? "thread"}:${turnId}`;
316
751
  this.anonCounters.set(k, (this.anonCounters.get(k) ?? 0) + 1);
317
752
  }
318
753
  ensureTurn() {
@@ -321,4 +756,54 @@ export class CodexSessionForwarder {
321
756
  this.sink.onTurnStart(this.currentTurnIdValue ?? undefined);
322
757
  }
323
758
  }
759
+ consumeTurnDiff(turnId) {
760
+ if (!turnId)
761
+ return undefined;
762
+ const diff = this.turnDiffByTurn.get(turnId);
763
+ this.turnDiffByTurn.delete(turnId);
764
+ return diff
765
+ ? { type: "turn_diff", callId: `codex_turn_diff_${turnId}`, diff }
766
+ : undefined;
767
+ }
768
+ /** Start (or confirm) the app-server's authoritative active turn. A newer
769
+ * start supersedes an older response whose terminal edge arrived late; a
770
+ * pending Traex completion is flushed first so its final item grace remains
771
+ * intact. */
772
+ beginTurn(turnId) {
773
+ if (this.pendingCompletion)
774
+ this.flushPendingCompletion();
775
+ const nextTurnId = turnId ?? null;
776
+ const changesIdentifiedTurn = this.turnOpen &&
777
+ this.currentTurnIdValue !== null &&
778
+ nextTurnId !== null &&
779
+ this.currentTurnIdValue !== nextTurnId;
780
+ if (changesIdentifiedTurn) {
781
+ this.flushPendingCompletion();
782
+ this.flushDeferredAssistantMessage();
783
+ if (this.turnOpen) {
784
+ this.turnOpen = false;
785
+ this.sink.onTurnEnd(undefined, "superseded");
786
+ }
787
+ }
788
+ this.currentTurnIdValue = nextTurnId;
789
+ this.ensureTurn();
790
+ }
791
+ /** Active-turn clearing contract:
792
+ *
793
+ * - an identified active turn is closed only by the same id;
794
+ * - an id-less boundary cannot close an identified active turn;
795
+ * - with no observed active turn, an identified boundary may recover a
796
+ * missed start only when it carries the currently-bound thread id. */
797
+ terminalBoundaryMatchesActiveTurn(terminalTurnId, notificationThreadId) {
798
+ if (this.currentTurnIdValue !== null) {
799
+ return terminalTurnId === this.currentTurnIdValue;
800
+ }
801
+ if (terminalTurnId === undefined)
802
+ return true;
803
+ return this.notificationMatchesCurrentThread(notificationThreadId);
804
+ }
805
+ notificationMatchesCurrentThread(notificationThreadId) {
806
+ return this.currentThreadIdValue !== null &&
807
+ notificationThreadId === this.currentThreadIdValue;
808
+ }
324
809
  }