@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.30

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