@makerbi/remodex 2.0.0 → 2.3.0

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.
@@ -13,11 +13,38 @@ const {
13
13
  } = require("./rollout-watch");
14
14
  const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
15
15
  const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
16
-
17
- const DEFAULT_POLL_INTERVAL_MS = 700;
16
+ const {
17
+ TERMINAL_TASK_EVENT_TYPES,
18
+ terminalEventClosesTrackedTurn,
19
+ } = require("./rollout-turn-semantics");
20
+ const {
21
+ hasVisiblePlanUpdate,
22
+ buildRemodexSourceItemKey,
23
+ visibleUserPromptFromInputEntries,
24
+ visibleUserPromptText,
25
+ responseItemMessageText,
26
+ } = require("./desktop-ipc-shared");
27
+
28
+ // The phone batches each poll tick's notifications and settles its timeline
29
+ // ~80ms after the batch ends (CodexService liveMirrorBatchFlushNanoseconds).
30
+ // Keep this interval comfortably above that settle window, or lower both
31
+ // together, so consecutive ticks never merge into one batch.
32
+ const DEFAULT_POLL_INTERVAL_MS = 250;
18
33
  const DEFAULT_LOOKUP_TIMEOUT_MS = 5_000;
19
34
  const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
20
35
  const DEFAULT_ACTIVITY_HEARTBEAT_MS = 5_000;
36
+ // Bootstrap replay must not resurrect runs whose rollout stopped growing long ago
37
+ // (aborted/killed desktop runs never write task_complete).
38
+ const DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS = 10 * 60_000;
39
+ const DEFAULT_SYNTHETIC_TERMINAL_GRACE_MS = 1_000;
40
+ // Rollouts can be tens of megabytes. They are a live-delta fallback, not the
41
+ // durable conversation history, so bootstrap must never synchronously parse
42
+ // the entire file just to discover an active turn.
43
+ const DEFAULT_BOOTSTRAP_METADATA_HEAD_BYTES = 256 * 1024;
44
+ const DEFAULT_BOOTSTRAP_TAIL_BYTES = 4 * 1024 * 1024;
45
+ // Keep a hard bound, but match the JSONL history reader's 64MB recovery window
46
+ // so a long active turn does not degrade permanently to no live context.
47
+ const DEFAULT_BOOTSTRAP_MAX_BYTES = 64 * 1024 * 1024;
21
48
  const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
22
49
 
23
50
  // Observes desktop-authored rollout files and replays the currently active run as
@@ -33,11 +60,17 @@ function createRolloutLiveMirrorController({
33
60
  lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS,
34
61
  idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
35
62
  activityHeartbeatMs = DEFAULT_ACTIVITY_HEARTBEAT_MS,
63
+ staleActiveRunMaxAgeMs = DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS,
64
+ syntheticTerminalGraceMs = DEFAULT_SYNTHETIC_TERMINAL_GRACE_MS,
65
+ // Rollout tailing is the fallback mirror; when another live source already
66
+ // streams a thread (IPC follower state or bridge-owned app-server stream),
67
+ // emitting from the file too would double every row on the phone.
68
+ shouldSuppressThread = null,
36
69
  } = {}) {
37
70
  const mirrorsByThreadId = new Map();
38
71
 
39
- function observeInbound(rawMessage) {
40
- const request = safeParseJSON(rawMessage);
72
+ function observeInbound(rawMessage, parsedMessage = null) {
73
+ const request = parsedMessage ?? safeParseJSON(rawMessage);
41
74
  const method = readString(request?.method);
42
75
  if (!DESKTOP_RESUME_METHODS.has(method)) {
43
76
  return;
@@ -55,9 +88,23 @@ function createRolloutLiveMirrorController({
55
88
  }
56
89
 
57
90
  let mirror;
91
+ let suppressionContext = {};
92
+ const isThreadSuppressed = () => Boolean(shouldSuppressThread?.(threadId, suppressionContext));
58
93
  mirror = createThreadRolloutLiveMirror({
59
94
  threadId,
60
- sendApplicationResponse,
95
+ sendApplicationResponse: typeof shouldSuppressThread === "function"
96
+ ? (rawNotification) => {
97
+ if (!isThreadSuppressed()) {
98
+ sendApplicationResponse(rawNotification);
99
+ }
100
+ }
101
+ : sendApplicationResponse,
102
+ isSuppressed: typeof shouldSuppressThread === "function"
103
+ ? (context) => {
104
+ suppressionContext = context || {};
105
+ return isThreadSuppressed();
106
+ }
107
+ : () => false,
61
108
  logPrefix,
62
109
  fsModule,
63
110
  now,
@@ -67,6 +114,8 @@ function createRolloutLiveMirrorController({
67
114
  lookupTimeoutMs,
68
115
  idleTimeoutMs,
69
116
  activityHeartbeatMs,
117
+ staleActiveRunMaxAgeMs,
118
+ syntheticTerminalGraceMs,
70
119
  onStop() {
71
120
  if (mirrorsByThreadId.get(threadId) === mirror) {
72
121
  mirrorsByThreadId.delete(threadId);
@@ -94,6 +143,7 @@ function createRolloutLiveMirrorController({
94
143
  function createThreadRolloutLiveMirror({
95
144
  threadId,
96
145
  sendApplicationResponse,
146
+ isSuppressed = () => false,
97
147
  logPrefix,
98
148
  fsModule,
99
149
  now,
@@ -103,6 +153,8 @@ function createThreadRolloutLiveMirror({
103
153
  lookupTimeoutMs,
104
154
  idleTimeoutMs,
105
155
  activityHeartbeatMs,
156
+ staleActiveRunMaxAgeMs,
157
+ syntheticTerminalGraceMs,
106
158
  onStop = () => {},
107
159
  }) {
108
160
  const startedAt = now();
@@ -113,8 +165,13 @@ function createThreadRolloutLiveMirror({
113
165
  let lastSize = 0;
114
166
  let partialLine = "";
115
167
  let lastActivityAt = startedAt;
168
+ // Rollout growth only: heartbeats deliberately never refresh this clock, so a
169
+ // desktop process that died mid-run (no terminal event, file frozen) cannot
170
+ // keep the mirror heartbeating "running" forever.
171
+ let lastGrowthAt = startedAt;
116
172
  let lastHeartbeatAt = 0;
117
173
  let didBootstrap = false;
174
+ let wasSuppressed = false;
118
175
 
119
176
  const intervalId = setIntervalFn(tick, pollIntervalMs);
120
177
  tick();
@@ -142,7 +199,22 @@ function createThreadRolloutLiveMirror({
142
199
  }
143
200
  }
144
201
 
145
- const fileSize = readFileSize(rolloutPath, fsModule);
202
+ const rolloutStat = fsModule.statSync(rolloutPath);
203
+ const fileSize = rolloutStat.size;
204
+ // While another live source streams this thread the tail keeps consuming
205
+ // rollout lines with its emissions muted. Compare per-thread activity so
206
+ // a quiet Desktop turn stays owned, while newer rollout growth can recover
207
+ // from a stale connected snapshot.
208
+ const suppressed = isSuppressed({
209
+ fallbackActivityAt: Number(rolloutStat.mtimeMs) || 0,
210
+ });
211
+ if (wasSuppressed && !suppressed && didBootstrap) {
212
+ lastSize = 0;
213
+ partialLine = "";
214
+ didBootstrap = false;
215
+ resetRunState(state);
216
+ }
217
+ wasSuppressed = suppressed;
146
218
  if (!didBootstrap) {
147
219
  didBootstrap = true;
148
220
  bootstrapFromExistingRollout({
@@ -151,9 +223,12 @@ function createThreadRolloutLiveMirror({
151
223
  state,
152
224
  fsModule,
153
225
  sendApplicationResponse,
226
+ nowMs: currentTime,
227
+ staleActiveRunMaxAgeMs,
154
228
  });
155
229
  lastSize = fileSize;
156
230
  lastActivityAt = currentTime;
231
+ lastGrowthAt = currentTime;
157
232
  lastHeartbeatAt = currentTime;
158
233
  if (state.isDesktopOrigin === false) {
159
234
  stop();
@@ -161,10 +236,28 @@ function createThreadRolloutLiveMirror({
161
236
  return;
162
237
  }
163
238
 
239
+ if (fileSize < lastSize) {
240
+ // Rollout files can be rewritten/truncated by desktop recovery. The
241
+ // rewritten contents are a different history, not live growth: reset the
242
+ // cursor and re-run the bootstrap path (tagged catch-up / terminal
243
+ // catch-up) instead of replaying the whole file as untagged live events.
244
+ lastSize = 0;
245
+ partialLine = "";
246
+ didBootstrap = false;
247
+ resetRunState(state);
248
+ lastGrowthAt = currentTime;
249
+ return;
250
+ }
251
+
164
252
  if (fileSize > lastSize) {
253
+ // A capped bootstrap has no verified active-turn opener. Never append
254
+ // arbitrary deltas to that unknown state: wait for growth, then retry
255
+ // a bounded coherent bootstrap. A new task_started+prompt near EOF
256
+ // recovers immediately; an old huge run remains canonical-history only.
165
257
  const chunk = readFileSlice(rolloutPath, lastSize, fileSize, fsModule);
166
258
  lastSize = fileSize;
167
259
  lastActivityAt = currentTime;
260
+ lastGrowthAt = currentTime;
168
261
  lastHeartbeatAt = currentTime;
169
262
  if (!chunk) {
170
263
  return;
@@ -179,16 +272,51 @@ function createThreadRolloutLiveMirror({
179
272
  searchStart = nlIndex + 1;
180
273
  }
181
274
  partialLine = searchStart < combined.length ? combined.substring(searchStart) : "";
182
- processRolloutLines(lines, state, sendApplicationResponse);
275
+ if (state.awaitingCoherentBoundary) {
276
+ if (processAwaitingCoherentBoundary(lines, state, sendApplicationResponse, currentTime)) {
277
+ state.awaitingCoherentBoundary = false;
278
+ }
279
+ return;
280
+ }
281
+ // Real growth proves the run is alive again; resume normal mirroring.
282
+ state.suppressLiveActivityUntilGrowth = false;
283
+ processRolloutLines(lines, state, sendApplicationResponse, { nowMs: currentTime });
284
+ return;
285
+ }
286
+
287
+ const syntheticTerminalNotifications = finalizePendingSyntheticTerminalIfReady(
288
+ state,
289
+ currentTime,
290
+ syntheticTerminalGraceMs
291
+ );
292
+ if (syntheticTerminalNotifications.length > 0) {
293
+ for (const notification of syntheticTerminalNotifications) {
294
+ sendApplicationResponse(JSON.stringify(notification));
295
+ }
296
+ lastActivityAt = currentTime;
297
+ lastHeartbeatAt = currentTime;
298
+ return;
299
+ }
300
+
301
+ // A frozen rollout with a still-open turn means the desktop process died
302
+ // mid-run (crash / kill: no terminal event will ever arrive). Stop before
303
+ // heartbeating so the phone is not kept in "running" forever.
304
+ if (state.activeTurnId && currentTime - lastGrowthAt >= staleActiveRunMaxAgeMs) {
305
+ stop();
183
306
  return;
184
307
  }
185
308
 
186
309
  if (
187
310
  state.isDesktopOrigin !== false
188
311
  && state.activeTurnId
312
+ && !state.suppressLiveActivityUntilGrowth
189
313
  && currentTime - lastHeartbeatAt >= activityHeartbeatMs
190
314
  ) {
191
315
  lastHeartbeatAt = currentTime;
316
+ // Heartbeats keep the idle timeout from killing a quiet-but-alive run
317
+ // (long thinking stretches legitimately exceed the 60s idle window);
318
+ // the growth-stale guard above still bounds crashed runs.
319
+ lastActivityAt = currentTime;
192
320
  sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
193
321
  threadId: state.threadId,
194
322
  turnId: state.activeTurnId,
@@ -214,8 +342,19 @@ function createThreadRolloutLiveMirror({
214
342
  return;
215
343
  }
216
344
 
345
+ // Mark stopped and clear the interval first: a throwing send during the
346
+ // final partial-line flush must never leak the poll interval.
217
347
  isStopped = true;
218
348
  clearIntervalFn(intervalId);
349
+ if (partialLine) {
350
+ const flushLine = partialLine;
351
+ partialLine = "";
352
+ try {
353
+ processRolloutLines([flushLine], state, sendApplicationResponse, { nowMs: now() });
354
+ } catch (error) {
355
+ console.warn(`${logPrefix} rollout live mirror final flush failed for ${threadId}: ${error.message}`);
356
+ }
357
+ }
219
358
  onStop();
220
359
  }
221
360
 
@@ -231,8 +370,55 @@ function bootstrapFromExistingRollout({
231
370
  state,
232
371
  fsModule,
233
372
  sendApplicationResponse,
373
+ nowMs = Date.now(),
374
+ staleActiveRunMaxAgeMs = DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS,
234
375
  }) {
235
- const initialContents = readFileSlice(rolloutPath, 0, fileSize, fsModule);
376
+ // Read metadata independently from the tail. session_meta is written at the
377
+ // beginning, while the active run lives at the end. This keeps reopening a
378
+ // 30MB rollout bounded and avoids treating a partial tail as history.
379
+ const metadataContents = readFileSlice(
380
+ rolloutPath,
381
+ 0,
382
+ Math.min(fileSize, DEFAULT_BOOTSTRAP_METADATA_HEAD_BYTES),
383
+ fsModule
384
+ );
385
+ for (const rawLine of metadataContents.split("\n")) {
386
+ const parsed = safeParseJSON(rawLine.trim());
387
+ if (parsed?.type === "session_meta") {
388
+ populateSessionMetaState(state, parsed.payload);
389
+ break;
390
+ }
391
+ }
392
+ if (!isDesktopRolloutOrigin(state.sessionMeta)) {
393
+ state.isDesktopOrigin = false;
394
+ return;
395
+ }
396
+ state.isDesktopOrigin = true;
397
+
398
+ const bootstrapWindow = readCoherentBootstrapWindow({
399
+ rolloutPath,
400
+ fileSize,
401
+ fsModule,
402
+ });
403
+ if (!bootstrapWindow) {
404
+ // The active run starts outside the bounded bootstrap window. Do not emit
405
+ // a plausible-looking tail: canonical history remains the baseline and
406
+ // this mirror will still consume future growth normally.
407
+ state.awaitingCoherentBoundary = true;
408
+ return;
409
+ }
410
+ const { tailStart, contents: bootstrapContents } = bootstrapWindow;
411
+ let initialContents = bootstrapContents;
412
+ if (!initialContents) {
413
+ return;
414
+ }
415
+ // The first bytes may be the end of a JSON record. Drop that fragment rather
416
+ // than guessing, because an incomplete task_started record would lose the
417
+ // user opener and recreate the exact tail-only regression we are fixing.
418
+ if (tailStart > 0) {
419
+ const firstNewline = initialContents.indexOf("\n");
420
+ initialContents = firstNewline >= 0 ? initialContents.slice(firstNewline + 1) : "";
421
+ }
236
422
  if (!initialContents) {
237
423
  return;
238
424
  }
@@ -242,6 +428,7 @@ function bootstrapFromExistingRollout({
242
428
  let insideActiveRun = false;
243
429
  let activeTurnId = null;
244
430
  let pendingUserPreludeLine = null;
431
+ let latestTerminalRun = null;
245
432
 
246
433
  for (const rawLine of lines) {
247
434
  const line = rawLine.trim();
@@ -254,14 +441,20 @@ function bootstrapFromExistingRollout({
254
441
  continue;
255
442
  }
256
443
 
257
- if (parsed.type === "session_meta") {
258
- populateSessionMetaState(state, parsed.payload);
259
- }
260
-
261
444
  const taskEventType = parsed?.type === "event_msg"
262
445
  ? readString(parsed?.payload?.type)
263
446
  : "";
264
- if (taskEventType === "user_message") {
447
+ const eventUserMessage = taskEventType === "user_message"
448
+ && Boolean(visibleUserPromptFromInputEntries(
449
+ readString(parsed?.payload?.message) || readString(parsed?.payload?.text)
450
+ ));
451
+ const responseUserMessage = parsed?.type === "response_item"
452
+ && readString(parsed?.payload?.role).toLowerCase() === "user"
453
+ && Boolean(
454
+ visibleUserPromptFromInputEntries(extractResponseItemMessageText(parsed?.payload || {}))
455
+ || responseItemHasUserImage(parsed?.payload)
456
+ );
457
+ if (eventUserMessage || responseUserMessage) {
265
458
  pendingUserPreludeLine = line;
266
459
  }
267
460
  if (taskEventType === "task_started") {
@@ -269,6 +462,7 @@ function bootstrapFromExistingRollout({
269
462
  activeTurnId = readString(parsed?.payload?.turn_id)
270
463
  || readString(parsed?.payload?.turnId)
271
464
  || "";
465
+ latestTerminalRun = null;
272
466
  activeRunLines.length = 0;
273
467
  if (pendingUserPreludeLine) {
274
468
  activeRunLines.push(pendingUserPreludeLine);
@@ -282,28 +476,290 @@ function bootstrapFromExistingRollout({
282
476
  }
283
477
 
284
478
  activeRunLines.push(line);
285
- if (taskEventType === "task_complete") {
286
- insideActiveRun = false;
287
- activeTurnId = "";
288
- activeRunLines.length = 0;
289
- pendingUserPreludeLine = null;
479
+ if (TERMINAL_TASK_EVENT_TYPES.has(taskEventType)) {
480
+ // A sibling parallel turn's terminal event must not close the newest
481
+ // run's window; its own terminal event is still honored later.
482
+ const terminalTurnId = readString(parsed?.payload?.turn_id)
483
+ || readString(parsed?.payload?.turnId);
484
+ if (terminalEventClosesTrackedTurn(terminalTurnId, activeTurnId)) {
485
+ latestTerminalRun = terminalRunFromEvent(parsed, activeTurnId);
486
+ insideActiveRun = false;
487
+ activeTurnId = "";
488
+ activeRunLines.length = 0;
489
+ pendingUserPreludeLine = null;
490
+ }
290
491
  }
291
492
  }
292
493
 
293
- if (!isDesktopRolloutOrigin(state.sessionMeta)) {
294
- state.isDesktopOrigin = false;
494
+ if (activeRunLines.length === 0 && latestTerminalRun) {
495
+ sendApplicationResponse(JSON.stringify(terminalCatchUpNotification(state.threadId, latestTerminalRun)));
295
496
  return;
296
497
  }
297
498
 
298
- state.isDesktopOrigin = true;
299
- processRolloutLines(activeRunLines, state, sendApplicationResponse);
499
+ // A run with no terminal marker whose rollout stopped growing long ago is dead
500
+ // (killed process / lost session); replaying it would fake a live stream and
501
+ // pin the reopened thread in "running" forever. Hydrate the run context
502
+ // silently instead, so heartbeats stay off but a run that resumes writing can
503
+ // still mirror its new activity live.
504
+ if (
505
+ activeRunLines.length > 0
506
+ && isRolloutFileStale(rolloutPath, fsModule, nowMs, staleActiveRunMaxAgeMs)
507
+ ) {
508
+ processRolloutLines(activeRunLines, state, () => {});
509
+ // task_started resets per-run state while hydrating. Apply the stale-run
510
+ // suppression afterwards so it survives until real file growth proves the
511
+ // desktop process is alive again.
512
+ state.suppressLiveActivityUntilGrowth = true;
513
+ return;
514
+ }
515
+
516
+ // Bootstrap replay is catch-up history, not live streaming: tag it so the
517
+ // phone can batch-apply it, then close the burst with an explicit marker so
518
+ // the run still reads as active without waiting for the next heartbeat.
519
+ processRolloutLines(activeRunLines, state, sendApplicationResponse, {
520
+ tagBootstrapReplay: true,
521
+ });
522
+ if (activeRunLines.length > 0 && state.activeTurnId) {
523
+ sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
524
+ threadId: state.threadId,
525
+ turnId: state.activeTurnId,
526
+ id: state.activeTurnId,
527
+ remodexRolloutBootstrapComplete: true,
528
+ })));
529
+ }
300
530
  }
301
531
 
302
- function processRolloutLines(lines, state, sendApplicationResponse) {
532
+ // Expands backwards only until the newest active task has its opening user
533
+ // message. Every expansion reads just the newly needed prefix, so a 30MB file
534
+ // is read at most once rather than once per retry. The hard cap keeps bootstrap
535
+ // work/memory bounded; no coherent opener means no replay.
536
+ function readCoherentBootstrapWindow({ rolloutPath, fileSize, fsModule }) {
537
+ const maxBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_MAX_BYTES);
538
+ let windowBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_TAIL_BYTES);
539
+ let tailStart = Math.max(0, fileSize - windowBytes);
540
+ let contents = readFileSlice(rolloutPath, tailStart, fileSize, fsModule);
541
+ if (!contents) {
542
+ return null;
543
+ }
544
+
545
+ while (true) {
546
+ const alignedContents = alignedBootstrapContents(contents, tailStart);
547
+ const boundary = inspectBootstrapRunBoundary(alignedContents);
548
+ // When the window already reaches byte zero it is the complete rollout:
549
+ // some legitimate system/continuation turns have no materialized user row.
550
+ // The opener requirement only protects a truncated tail.
551
+ if (!boundary.hasActiveRun || boundary.hasOpeningUser || tailStart === 0) {
552
+ return { tailStart, contents };
553
+ }
554
+ if (windowBytes >= maxBytes || tailStart === 0) {
555
+ return null;
556
+ }
557
+
558
+ const nextWindowBytes = Math.min(maxBytes, windowBytes * 2);
559
+ const nextTailStart = Math.max(0, fileSize - nextWindowBytes);
560
+ const prefix = readFileSlice(rolloutPath, nextTailStart, tailStart, fsModule);
561
+ if (!prefix) {
562
+ return null;
563
+ }
564
+ contents = `${prefix}${contents}`;
565
+ windowBytes = nextWindowBytes;
566
+ tailStart = nextTailStart;
567
+ }
568
+ }
569
+
570
+ function alignedBootstrapContents(contents, tailStart) {
571
+ if (tailStart === 0) {
572
+ return contents;
573
+ }
574
+ const firstNewline = contents.indexOf("\n");
575
+ return firstNewline >= 0 ? contents.slice(firstNewline + 1) : "";
576
+ }
577
+
578
+ function inspectBootstrapRunBoundary(contents) {
579
+ let activeTurnId = "";
580
+ let hasOpeningUser = false;
581
+ let hasTurnOutputSinceStart = false;
582
+ let pendingUserBeforeStart = false;
583
+ // A tail can begin after task_started. In that case activity without a
584
+ // closing terminal is evidence of an unknown active boundary, not permission
585
+ // to replay a partial conversation.
586
+ let unboundedActivitySinceTerminal = false;
587
+
588
+ for (const rawLine of contents.split("\n")) {
589
+ const parsed = safeParseJSON(rawLine.trim());
590
+ if (!parsed) {
591
+ continue;
592
+ }
593
+ const taskEventType = parsed?.type === "event_msg"
594
+ ? readString(parsed?.payload?.type)
595
+ : "";
596
+ const isUser = taskEventType === "user_message"
597
+ || (parsed?.type === "response_item" && readString(parsed?.payload?.role).toLowerCase() === "user");
598
+ const isResponseUser = parsed?.type === "response_item"
599
+ && readString(parsed?.payload?.role).toLowerCase() === "user";
600
+ const userText = isResponseUser
601
+ ? extractResponseItemMessageText(parsed?.payload || {})
602
+ : firstNonEmptyString([readString(parsed?.payload?.message), readString(parsed?.payload?.text)]);
603
+ const isVisibleUser = isUser && Boolean(
604
+ visibleUserPromptText(userText).trim()
605
+ || (isResponseUser && responseItemHasUserImage(parsed?.payload))
606
+ );
607
+ if (taskEventType === "task_started") {
608
+ activeTurnId = readString(parsed?.payload?.turn_id)
609
+ || readString(parsed?.payload?.turnId)
610
+ || "synthetic-active-turn";
611
+ hasOpeningUser = pendingUserBeforeStart;
612
+ hasTurnOutputSinceStart = false;
613
+ pendingUserBeforeStart = false;
614
+ continue;
615
+ }
616
+ if (!activeTurnId) {
617
+ const isNeutral = isBootstrapNeutralRecord(parsed, taskEventType)
618
+ || (isUser && !isVisibleUser);
619
+ if (TERMINAL_TASK_EVENT_TYPES.has(taskEventType)) {
620
+ unboundedActivitySinceTerminal = false;
621
+ } else if (!isNeutral) {
622
+ unboundedActivitySinceTerminal = true;
623
+ }
624
+ if (isVisibleUser) {
625
+ pendingUserBeforeStart = true;
626
+ } else if (!isNeutral) {
627
+ pendingUserBeforeStart = false;
628
+ }
629
+ continue;
630
+ }
631
+ // The only user item that can certify a truncated active run is the one
632
+ // adjacent to task_started, before any assistant/tool output. Later user
633
+ // messages are steering/follow-up input and must never turn a partial tail
634
+ // into a valid bootstrap baseline.
635
+ if (isVisibleUser && !hasTurnOutputSinceStart) {
636
+ hasOpeningUser = true;
637
+ }
638
+ if (TERMINAL_TASK_EVENT_TYPES.has(taskEventType)) {
639
+ const terminalTurnId = readString(parsed?.payload?.turn_id)
640
+ || readString(parsed?.payload?.turnId);
641
+ if (terminalEventClosesTrackedTurn(terminalTurnId, activeTurnId)) {
642
+ activeTurnId = "";
643
+ hasOpeningUser = false;
644
+ hasTurnOutputSinceStart = false;
645
+ pendingUserBeforeStart = false;
646
+ unboundedActivitySinceTerminal = false;
647
+ }
648
+ } else if (!isUser && !isBootstrapNeutralRecord(parsed, taskEventType)) {
649
+ hasTurnOutputSinceStart = true;
650
+ }
651
+ }
652
+
653
+ return {
654
+ hasActiveRun: Boolean(activeTurnId) || unboundedActivitySinceTerminal,
655
+ hasOpeningUser,
656
+ };
657
+ }
658
+
659
+ // These records describe the runtime envelope around a turn. They are neither
660
+ // visible assistant output nor tool activity, so they must not turn the first
661
+ // real user prompt into a later steer during a bounded bootstrap scan.
662
+ function isBootstrapNeutralRecord(entry, taskEventType = "") {
663
+ const entryType = readString(entry?.type).toLowerCase();
664
+ return entryType === "session_meta"
665
+ || entryType === "world_state"
666
+ || entryType === "turn_context"
667
+ || taskEventType === "context_updated";
668
+ }
669
+
670
+ // After a bounded bootstrap cannot reach the old opener, consume only new
671
+ // bytes. A later real user+task_started boundary safely starts a new live run;
672
+ // everything before it remains canonical-history territory.
673
+ function processAwaitingCoherentBoundary(lines, state, sendApplicationResponse, nowMs) {
674
+ for (let index = 0; index < lines.length; index += 1) {
675
+ const rawLine = lines[index];
676
+ const line = rawLine.trim();
677
+ const parsed = safeParseJSON(line);
678
+ if (!parsed) continue;
679
+ const eventType = parsed?.type === "event_msg" ? readString(parsed?.payload?.type) : "";
680
+ const responseUser = parsed?.type === "response_item"
681
+ && readString(parsed?.payload?.role).toLowerCase() === "user";
682
+ const visibleUser = eventType === "user_message"
683
+ ? Boolean(visibleUserPromptFromInputEntries(readString(parsed?.payload?.message) || readString(parsed?.payload?.text)))
684
+ : responseUser && Boolean(visibleUserPromptFromInputEntries(extractResponseItemMessageText(parsed?.payload || {})) || responseItemHasUserImage(parsed?.payload));
685
+ if (visibleUser) state.awaitingBoundaryPreludeLine = line;
686
+ if (eventType !== "task_started" || !state.awaitingBoundaryPreludeLine) continue;
687
+ const boundaryLines = [state.awaitingBoundaryPreludeLine, line];
688
+ state.awaitingBoundaryPreludeLine = "";
689
+ resetRunState(state);
690
+ processRolloutLines(boundaryLines, state, sendApplicationResponse, { nowMs });
691
+ // The boundary and its first output frequently land in the same filesystem
692
+ // read. Replay the remainder immediately so recovery never drops that
693
+ // assistant/tool burst while changing modes.
694
+ processRolloutLines(lines.slice(index + 1), state, sendApplicationResponse, { nowMs });
695
+ return true;
696
+ }
697
+ return false;
698
+ }
699
+
700
+ function isRolloutFileStale(rolloutPath, fsModule, nowMs, staleActiveRunMaxAgeMs) {
701
+ try {
702
+ const modifiedAtMs = fsModule.statSync(rolloutPath).mtimeMs;
703
+ return Number.isFinite(modifiedAtMs) && nowMs - modifiedAtMs >= staleActiveRunMaxAgeMs;
704
+ } catch {
705
+ return false;
706
+ }
707
+ }
708
+
709
+ function terminalRunFromEvent(entry, fallbackTurnId = "") {
710
+ const payload = entry?.payload || {};
711
+ const eventType = readString(payload.type);
712
+ if (!TERMINAL_TASK_EVENT_TYPES.has(eventType)) {
713
+ return null;
714
+ }
715
+
716
+ const turnId = readString(payload.turn_id)
717
+ || readString(payload.turnId)
718
+ || readString(fallbackTurnId);
719
+ if (!turnId) {
720
+ return null;
721
+ }
722
+
723
+ return {
724
+ eventType,
725
+ turnId,
726
+ message: readString(payload.message),
727
+ };
728
+ }
729
+
730
+ function terminalCatchUpNotification(threadId, terminalRun) {
731
+ const params = {
732
+ threadId,
733
+ turnId: terminalRun.turnId,
734
+ id: terminalRun.turnId,
735
+ remodexRolloutTerminalCatchUp: true,
736
+ };
737
+ if (terminalRun.eventType === "turn_aborted") {
738
+ params.status = "aborted";
739
+ } else if (terminalRun.eventType === "error") {
740
+ params.status = "failed";
741
+ if (terminalRun.message) {
742
+ params.error = { message: terminalRun.message };
743
+ }
744
+ }
745
+ return createNotification("turn/completed", params);
746
+ }
747
+
748
+ function processRolloutLines(lines, state, sendApplicationResponse, {
749
+ tagBootstrapReplay = false,
750
+ nowMs = Date.now(),
751
+ } = {}) {
303
752
  if (!Array.isArray(lines) || lines.length === 0) {
304
753
  return;
305
754
  }
306
755
 
756
+ const emitNotification = (notification) => {
757
+ if (tagBootstrapReplay && notification.params && typeof notification.params === "object") {
758
+ notification.params.remodexRolloutBootstrapReplay = true;
759
+ }
760
+ sendApplicationResponse(JSON.stringify(notification));
761
+ };
762
+
307
763
  for (const rawLine of lines) {
308
764
  const line = rawLine.trim();
309
765
  if (!line) {
@@ -315,14 +771,14 @@ function processRolloutLines(lines, state, sendApplicationResponse) {
315
771
  continue;
316
772
  }
317
773
 
318
- const notifications = synthesizeNotificationsFromRolloutEntry(parsed, state);
774
+ const notifications = synthesizeNotificationsFromRolloutEntry(parsed, state, { nowMs });
319
775
  for (const notification of notifications) {
320
- sendApplicationResponse(JSON.stringify(notification));
776
+ emitNotification(notification);
321
777
  }
322
778
  }
323
779
  }
324
780
 
325
- function synthesizeNotificationsFromRolloutEntry(entry, state) {
781
+ function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.now() } = {}) {
326
782
  if (entry?.type === "session_meta") {
327
783
  populateSessionMetaState(state, entry.payload);
328
784
  if (!isDesktopRolloutOrigin(state.sessionMeta)) {
@@ -344,12 +800,15 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
344
800
  const eventType = readString(payload.type);
345
801
 
346
802
  if (eventType === "task_started") {
803
+ notifications.push(...finalizePendingSyntheticTerminal(state));
347
804
  const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
348
805
  const turnId = explicitTurnId || buildSyntheticTurnId(state, entry);
349
806
  state.activeTurnId = turnId;
350
807
  state.activeTurnIdIsSynthetic = !explicitTurnId;
351
808
  state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, turnId);
352
809
  state.hasThinking = false;
810
+ state.hasReasoningContent = false;
811
+ state.emittedReasoningSummaryKeys.clear();
353
812
  state.commandCalls.clear();
354
813
  state.applyPatchCalls.clear();
355
814
  state.emittedPatchApplyEndCalls.clear();
@@ -367,44 +826,66 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
367
826
  return notifications;
368
827
  }
369
828
 
829
+ if (eventType && !TERMINAL_TASK_EVENT_TYPES.has(eventType)) {
830
+ clearPendingSyntheticTerminal(state);
831
+ }
832
+
370
833
  if (eventType === "user_message") {
371
- const message = readString(payload.message) || readString(payload.text);
372
- if (!message) {
373
- return [];
374
- }
834
+ // Rollouts persist injected context (AGENTS.md instructions, IDE prompt
835
+ // wrappers) as user_message events; only the real request is a bubble.
836
+ notifications.push(...userMessageNotifications(state, entry, payload));
837
+ return notifications;
838
+ }
375
839
 
376
- const turnId = resolveRolloutEventTurnId(state, payload);
840
+ if (eventType === "task_complete") {
841
+ const turnId = resolveRolloutEventTurnId(state, payload, { allowSyntheticPromotion: false });
377
842
  if (!turnId) {
378
- state.pendingUserMessages.push({
379
- id: readString(payload.id),
380
- message,
381
- timestamp: readUserMessageTimestamp(entry, payload),
382
- });
383
843
  return [];
384
844
  }
385
845
 
386
- notifications.push(createNotification("codex/event/user_message", {
846
+ // Desktop runs parallel turns in one rollout: a sibling turn finishing
847
+ // must not wipe the tracked state of the turn that is still streaming.
848
+ const closesActiveRun = terminalEventClosesTrackedTurn(turnId, state.activeTurnId);
849
+ if (closesActiveRun) {
850
+ notifications.push(...turnFileChangeSnapshotNotifications(state, turnId));
851
+ }
852
+ notifications.push(createNotification("turn/completed", {
387
853
  threadId: state.threadId,
388
854
  turnId,
389
- message,
390
- ...timestampParams(readUserMessageTimestamp(entry, payload)),
855
+ id: turnId,
391
856
  }));
857
+ if (closesActiveRun) {
858
+ resetRunState(state);
859
+ } else if (isSyntheticTerminalMismatch(state, turnId)) {
860
+ markPendingSyntheticTerminal(state, { status: "completed" }, nowMs);
861
+ }
392
862
  return notifications;
393
863
  }
394
864
 
395
- if (eventType === "task_complete") {
396
- const turnId = resolveRolloutEventTurnId(state, payload);
865
+ // Aborted/failed desktop runs never write task_complete; close the mirrored
866
+ // turn anyway so the phone does not keep the thread pinned as running.
867
+ if (eventType === "turn_aborted" || eventType === "error") {
868
+ const turnId = resolveRolloutEventTurnId(state, payload, { allowSyntheticPromotion: false });
397
869
  if (!turnId) {
398
870
  return [];
399
871
  }
400
872
 
401
- notifications.push(...turnFileChangeSnapshotNotifications(state, turnId));
402
- notifications.push(createNotification("turn/completed", {
873
+ const terminalParams = {
403
874
  threadId: state.threadId,
404
875
  turnId,
405
876
  id: turnId,
406
- }));
407
- resetRunState(state);
877
+ status: eventType === "error" ? "failed" : "aborted",
878
+ };
879
+ const errorMessage = readString(payload.message);
880
+ if (eventType === "error" && errorMessage) {
881
+ terminalParams.error = { message: errorMessage };
882
+ }
883
+ notifications.push(createNotification("turn/completed", terminalParams));
884
+ if (terminalEventClosesTrackedTurn(turnId, state.activeTurnId)) {
885
+ resetRunState(state);
886
+ } else if (isSyntheticTerminalMismatch(state, turnId)) {
887
+ markPendingSyntheticTerminal(state, terminalParams, nowMs);
888
+ }
408
889
  return notifications;
409
890
  }
410
891
 
@@ -423,18 +904,7 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
423
904
  }
424
905
 
425
906
  if (eventType === "agent_message") {
426
- const message = readString(payload.message) || readString(payload.text);
427
- if (!message || !shouldMirrorAgentMessage(payload)) {
428
- return [];
429
- }
430
- const turnId = resolveRolloutEventTurnId(state, payload);
431
-
432
- notifications.push(createNotification("codex/event/agent_message", {
433
- threadId: state.threadId,
434
- turnId,
435
- itemId: buildAgentMessageItemId(state.threadId, turnId, entry, message),
436
- message,
437
- }));
907
+ notifications.push(...agentMessageNotifications(state, entry, payload));
438
908
  return notifications;
439
909
  }
440
910
 
@@ -457,9 +927,16 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
457
927
  return [];
458
928
  }
459
929
 
930
+ clearPendingSyntheticTerminal(state);
931
+
460
932
  const payload = entry.payload || {};
461
933
  const itemType = normalizeRolloutItemType(payload.type);
462
934
 
935
+ if (itemType === "message") {
936
+ notifications.push(...responseItemMessageNotifications(state, entry, payload));
937
+ return notifications;
938
+ }
939
+
463
940
  if (itemType === "reasoning") {
464
941
  notifications.push(...reasoningNotifications(state, extractReasoningText(payload)));
465
942
  return notifications;
@@ -493,12 +970,32 @@ function reasoningNotifications(state, text) {
493
970
  return [];
494
971
  }
495
972
 
496
- const delta = readString(text);
497
- if (!delta) {
973
+ const rawText = readString(text);
974
+ if (!rawText) {
498
975
  return ensureThinkingNotifications(state);
499
976
  }
500
977
 
978
+ const summaryEntries = summaryOnlyReasoningEntries(rawText);
979
+ let visibleText = rawText;
980
+ if (summaryEntries) {
981
+ const unseenEntries = summaryEntries.filter((entry) => {
982
+ if (state.emittedReasoningSummaryKeys.has(entry.key)) {
983
+ return false;
984
+ }
985
+ state.emittedReasoningSummaryKeys.add(entry.key);
986
+ return true;
987
+ });
988
+ if (unseenEntries.length === 0) {
989
+ return [];
990
+ }
991
+ visibleText = unseenEntries
992
+ .map((entry) => `**${entry.title}**\n\n<!-- -->`)
993
+ .join("\n\n");
994
+ }
995
+
501
996
  state.hasThinking = true;
997
+ const delta = `${state.hasReasoningContent ? "\n\n" : ""}${visibleText}`;
998
+ state.hasReasoningContent = true;
502
999
  return [
503
1000
  createNotification("item/reasoning/textDelta", {
504
1001
  threadId: state.threadId,
@@ -509,6 +1006,188 @@ function reasoningNotifications(state, text) {
509
1006
  ];
510
1007
  }
511
1008
 
1009
+ // Newer Codex rollouts write the same cumulative reasoning summaries through
1010
+ // both event_msg and response_item records. Recognize only title/comment-only
1011
+ // payloads here; detailed reasoning remains a separate opaque stream.
1012
+ function summaryOnlyReasoningEntries(text) {
1013
+ const entries = [];
1014
+ for (const rawLine of text.split(/\r?\n/)) {
1015
+ const line = rawLine.trim();
1016
+ if (!line || /^<!--.*-->$/.test(line)) {
1017
+ continue;
1018
+ }
1019
+ const match = /^\*\*(.+?)\*\*$/.exec(line);
1020
+ if (!match) {
1021
+ return null;
1022
+ }
1023
+ const title = match[1].trim();
1024
+ if (!title) {
1025
+ return null;
1026
+ }
1027
+ entries.push({
1028
+ title,
1029
+ key: title.replace(/\s+/g, " ").toLowerCase(),
1030
+ });
1031
+ }
1032
+ return entries.length > 0 ? entries : null;
1033
+ }
1034
+
1035
+ function responseItemMessageNotifications(state, entry, payload) {
1036
+ const role = readString(payload?.role).toLowerCase();
1037
+ if (role === "user") {
1038
+ return userMessageNotifications(state, entry, payload, {
1039
+ rawMessage: extractResponseItemMessageText(payload),
1040
+ isResponseItem: true,
1041
+ });
1042
+ }
1043
+ if (role && role !== "assistant") {
1044
+ return [];
1045
+ }
1046
+
1047
+ const message = extractResponseItemMessageText(payload);
1048
+ if (!message) {
1049
+ return [];
1050
+ }
1051
+
1052
+ return agentMessageNotifications(state, entry, {
1053
+ message,
1054
+ phase: payload?.phase,
1055
+ itemId: readString(payload?.id),
1056
+ turn_id: readString(payload?.turn_id) || readString(payload?.internal_chat_message_metadata_passthrough?.turn_id),
1057
+ turnId: readString(payload?.turnId) || readString(payload?.internal_chat_message_metadata_passthrough?.turnId),
1058
+ });
1059
+ }
1060
+
1061
+ function userMessageNotifications(state, entry, payload, {
1062
+ rawMessage = "",
1063
+ isResponseItem = false,
1064
+ } = {}) {
1065
+ const imagePlaceholder = responseItemHasUserImage(payload) ? "Image attachment" : "";
1066
+ const message = visibleUserPromptFromInputEntries(
1067
+ rawMessage || readString(payload?.message) || readString(payload?.text) || imagePlaceholder
1068
+ );
1069
+ if (!message) {
1070
+ return [];
1071
+ }
1072
+ const turnId = resolveRolloutEventTurnId(state, payload);
1073
+ const itemId = readString(payload?.id) || readString(payload?.itemId) || readString(payload?.item_id);
1074
+ const timestamp = readUserMessageTimestamp(entry, payload);
1075
+ if (!turnId) {
1076
+ // response_item(user) can precede task_started. Hold it exactly like the
1077
+ // event_msg form so task_started flushes the opener before thinking/output.
1078
+ const pendingKey = `${itemId || ""}:${message}`;
1079
+ if (!state.pendingUserMessages.some((pending) => `${pending.id || ""}:${pending.message}` === pendingKey)) {
1080
+ state.pendingUserMessages.push({ id: itemId, message, timestamp, isResponseItem });
1081
+ }
1082
+ return [];
1083
+ }
1084
+
1085
+ const dedupeKey = userMessageOccurrenceKey(state, turnId, message, { isResponseItem });
1086
+ if (state.emittedUserMessageKeys.has(dedupeKey)) {
1087
+ return [];
1088
+ }
1089
+ state.emittedUserMessageKeys.add(dedupeKey);
1090
+ return [createNotification("codex/event/user_message", {
1091
+ threadId: state.threadId,
1092
+ turnId,
1093
+ message,
1094
+ ...(itemId ? { id: itemId } : {}),
1095
+ ...timestampParams(timestamp),
1096
+ })];
1097
+ }
1098
+
1099
+ // Rollouts commonly persist one user message twice as an event_msg and a
1100
+ // response_item pair, in either order: desktop-started turns log event_msg
1101
+ // first, phone/app-server-started turns log response_item first. Pair the two
1102
+ // shapes by occurrence in both directions instead of collapsing every
1103
+ // identical text in the turn, because repeated steers are legitimate.
1104
+ function userMessageOccurrenceKey(state, turnId, message, { isResponseItem = false } = {}) {
1105
+ const baseKey = buildRemodexSourceItemKey(turnId, message);
1106
+ const unpairedMap = isResponseItem
1107
+ ? state.pendingEventUserMessageOccurrencesByBaseKey
1108
+ : state.pendingResponseItemUserMessageOccurrencesByBaseKey;
1109
+ const ownPendingMap = isResponseItem
1110
+ ? state.pendingResponseItemUserMessageOccurrencesByBaseKey
1111
+ : state.pendingEventUserMessageOccurrencesByBaseKey;
1112
+
1113
+ const unpaired = unpairedMap.get(baseKey) || [];
1114
+ if (unpaired.length > 0) {
1115
+ const occurrence = unpaired.shift();
1116
+ if (unpaired.length === 0) {
1117
+ unpairedMap.delete(baseKey);
1118
+ } else {
1119
+ unpairedMap.set(baseKey, unpaired);
1120
+ }
1121
+ return `user:${baseKey}:${occurrence}`;
1122
+ }
1123
+
1124
+ const occurrence = (state.userMessageOccurrencesByBaseKey.get(baseKey) || 0) + 1;
1125
+ state.userMessageOccurrencesByBaseKey.set(baseKey, occurrence);
1126
+ const ownPending = ownPendingMap.get(baseKey) || [];
1127
+ ownPending.push(occurrence);
1128
+ ownPendingMap.set(baseKey, ownPending);
1129
+ return `user:${baseKey}:${occurrence}`;
1130
+ }
1131
+
1132
+ function responseItemHasUserImage(payload) {
1133
+ return Array.isArray(payload?.content) && payload.content.some((part) => {
1134
+ const type = readString(part?.type).toLowerCase();
1135
+ return type === "input_image" || type === "image" || type === "image_url";
1136
+ });
1137
+ }
1138
+
1139
+ function agentMessageNotifications(state, entry, payload) {
1140
+ const message = readString(payload?.message) || readString(payload?.text);
1141
+ if (!message) {
1142
+ return [];
1143
+ }
1144
+
1145
+ const turnId = resolveRolloutEventTurnId(state, payload);
1146
+ const baseKey = agentMessageDedupeKey(turnId, message);
1147
+ const providerItemId = readString(payload?.itemId);
1148
+ const nextOccurrence = (state.agentMessageOccurrencesByBaseKey.get(baseKey) || 0) + 1;
1149
+ const occurrence = providerItemId && state.pendingEventAgentMessageOccurrencesByBaseKey.has(baseKey)
1150
+ ? state.pendingEventAgentMessageOccurrencesByBaseKey.get(baseKey)
1151
+ : nextOccurrence;
1152
+ state.agentMessageOccurrencesByBaseKey.set(baseKey, Math.max(nextOccurrence, occurrence));
1153
+ if (providerItemId) {
1154
+ state.pendingEventAgentMessageOccurrencesByBaseKey.delete(baseKey);
1155
+ } else {
1156
+ state.pendingEventAgentMessageOccurrencesByBaseKey.set(baseKey, occurrence);
1157
+ }
1158
+ const dedupeKey = `${baseKey}:${occurrence}`;
1159
+ if (state.emittedAgentMessageKeys.has(dedupeKey)) {
1160
+ return [];
1161
+ }
1162
+ state.emittedAgentMessageKeys.add(dedupeKey);
1163
+
1164
+ // Commentary (interleaved progress prose) is mirrored too: desktop renders it
1165
+ // between tool calls, and dropping it would glue every tool row into one burst
1166
+ // on the phone. The phase rides along so the app can keep commentary rows
1167
+ // distinct from the final answer.
1168
+ const params = {
1169
+ threadId: state.threadId,
1170
+ turnId,
1171
+ itemId: providerItemId || buildAgentMessageItemId(state.threadId, turnId, entry, message),
1172
+ // The same assistant item may first arrive as event_msg (without Codex's
1173
+ // item id) and later as response_item/history (with one). Preserve a
1174
+ // stable source alias across bootstrap/reconnect so the phone can merge
1175
+ // those representations without using unsafe global text deduplication.
1176
+ ...(occurrence === 1 ? { remodexSourceItemKey: baseKey } : {}),
1177
+ message,
1178
+ };
1179
+ const phase = readString(payload?.phase);
1180
+ if (phase) {
1181
+ params.phase = phase;
1182
+ }
1183
+
1184
+ return [createNotification("codex/event/agent_message", params)];
1185
+ }
1186
+
1187
+ function extractResponseItemMessageText(payload) {
1188
+ return responseItemMessageText(payload);
1189
+ }
1190
+
512
1191
  function toolStartNotifications(state, payload) {
513
1192
  if (!state.activeTurnId) {
514
1193
  return [];
@@ -528,6 +1207,40 @@ function toolStartNotifications(state, payload) {
528
1207
  ];
529
1208
  }
530
1209
 
1210
+ if (readString(toolName).toLowerCase() === "apply_patch") {
1211
+ const item = buildApplyPatchFileChangeItem({
1212
+ callId,
1213
+ patch: readString(argumentsObject.patch) || readString(argumentsObject.input) || readString(payload.input),
1214
+ status: readString(payload.status) || "completed",
1215
+ idFallback: buildSyntheticItemId("file-change", state.threadId, state.activeTurnId, callId),
1216
+ });
1217
+ const notifications = [...ensureThinkingNotifications(state)];
1218
+ if (!item) {
1219
+ return [
1220
+ ...notifications,
1221
+ createNotification("codex/event/background_event", {
1222
+ threadId: state.threadId,
1223
+ turnId: state.activeTurnId,
1224
+ call_id: callId,
1225
+ message: genericToolActivityMessage(toolName),
1226
+ }),
1227
+ ];
1228
+ }
1229
+ state.applyPatchCalls.set(callId, item);
1230
+ return [
1231
+ ...notifications,
1232
+ createNotification("codex/event/patch_apply_begin", {
1233
+ threadId: state.threadId,
1234
+ turnId: state.activeTurnId,
1235
+ id: state.activeTurnId,
1236
+ call_id: callId,
1237
+ itemId: item.id,
1238
+ status: "inProgress",
1239
+ changes: item.changes,
1240
+ }),
1241
+ ];
1242
+ }
1243
+
531
1244
  state.commandCalls.set(callId, {
532
1245
  toolName,
533
1246
  command: resolveToolCommand(toolName, argumentsObject),
@@ -689,8 +1402,15 @@ function toolOutputNotifications(state, payload) {
689
1402
  }
690
1403
 
691
1404
  if (!isCommandToolName(toolCall.toolName)) {
1405
+ const notifications = [...ensureThinkingNotifications(state)];
1406
+ notifications.push(createNotification("codex/event/background_event", {
1407
+ threadId: state.threadId,
1408
+ turnId: state.activeTurnId,
1409
+ call_id: callId,
1410
+ message: genericToolCompletionMessage(toolCall.toolName),
1411
+ }));
692
1412
  state.commandCalls.delete(callId);
693
- return [];
1413
+ return notifications;
694
1414
  }
695
1415
 
696
1416
  const output = readString(payload.output);
@@ -789,6 +1509,75 @@ function itemCompletedNotifications(state, payload) {
789
1509
  ];
790
1510
  }
791
1511
 
1512
+ // Synthetic turn ids are a temporary stand-in; close them if a terminal event
1513
+ // had no later activity proving it belonged to a sibling parallel run.
1514
+ function markPendingSyntheticTerminal(state, terminalParams = {}, nowMs = Date.now()) {
1515
+ if (state.activeTurnIdIsSynthetic && state.activeTurnId) {
1516
+ state.pendingSyntheticTerminalTurnId = state.activeTurnId;
1517
+ state.pendingSyntheticTerminalStartedAt = nowMs;
1518
+ state.pendingSyntheticTerminalStatus = readString(terminalParams.status) || "";
1519
+ state.pendingSyntheticTerminalErrorMessage = readString(terminalParams.error?.message) || "";
1520
+ }
1521
+ }
1522
+
1523
+ function clearPendingSyntheticTerminal(state) {
1524
+ state.pendingSyntheticTerminalTurnId = null;
1525
+ state.pendingSyntheticTerminalStartedAt = 0;
1526
+ state.pendingSyntheticTerminalStatus = "";
1527
+ state.pendingSyntheticTerminalErrorMessage = "";
1528
+ }
1529
+
1530
+ function isSyntheticTerminalMismatch(state, terminalTurnId) {
1531
+ return Boolean(
1532
+ state.activeTurnIdIsSynthetic
1533
+ && state.activeTurnId
1534
+ && terminalTurnId
1535
+ && terminalTurnId !== state.activeTurnId
1536
+ );
1537
+ }
1538
+
1539
+ function finalizePendingSyntheticTerminal(state) {
1540
+ const turnId = state.pendingSyntheticTerminalTurnId;
1541
+ if (!turnId) {
1542
+ return [];
1543
+ }
1544
+
1545
+ const terminalParams = {
1546
+ threadId: state.threadId,
1547
+ turnId,
1548
+ id: turnId,
1549
+ };
1550
+ if (state.pendingSyntheticTerminalStatus) {
1551
+ terminalParams.status = state.pendingSyntheticTerminalStatus;
1552
+ }
1553
+ if (state.pendingSyntheticTerminalErrorMessage) {
1554
+ terminalParams.error = { message: state.pendingSyntheticTerminalErrorMessage };
1555
+ }
1556
+
1557
+ const notifications = [
1558
+ ...turnFileChangeSnapshotNotifications(state, turnId),
1559
+ createNotification("turn/completed", terminalParams),
1560
+ ];
1561
+ resetRunState(state);
1562
+ return notifications;
1563
+ }
1564
+
1565
+ function finalizePendingSyntheticTerminalIfReady(state, nowMs, graceMs) {
1566
+ if (!state.pendingSyntheticTerminalTurnId) {
1567
+ return [];
1568
+ }
1569
+ const startedAt = Number.isFinite(state.pendingSyntheticTerminalStartedAt)
1570
+ ? state.pendingSyntheticTerminalStartedAt
1571
+ : nowMs;
1572
+ const resolvedGraceMs = Number.isFinite(graceMs)
1573
+ ? Math.max(0, graceMs)
1574
+ : DEFAULT_SYNTHETIC_TERMINAL_GRACE_MS;
1575
+ if (nowMs - startedAt < resolvedGraceMs) {
1576
+ return [];
1577
+ }
1578
+ return finalizePendingSyntheticTerminal(state);
1579
+ }
1580
+
792
1581
  function ensureThinkingNotifications(state) {
793
1582
  if (!state.activeTurnId || state.hasThinking) {
794
1583
  return [];
@@ -817,11 +1606,29 @@ function createMirrorState(threadId) {
817
1606
  activeTurnId: null,
818
1607
  reasoningItemId: null,
819
1608
  hasThinking: false,
1609
+ hasReasoningContent: false,
1610
+ emittedReasoningSummaryKeys: new Set(),
820
1611
  commandCalls: new Map(),
821
1612
  applyPatchCalls: new Map(),
822
1613
  emittedPatchApplyEndCalls: new Set(),
1614
+ emittedAgentMessageKeys: new Set(),
1615
+ agentMessageOccurrencesByBaseKey: new Map(),
1616
+ pendingEventAgentMessageOccurrencesByBaseKey: new Map(),
1617
+ emittedUserMessageKeys: new Set(),
1618
+ userMessageOccurrencesByBaseKey: new Map(),
1619
+ pendingEventUserMessageOccurrencesByBaseKey: new Map(),
1620
+ pendingResponseItemUserMessageOccurrencesByBaseKey: new Map(),
823
1621
  pendingUserMessages: [],
1622
+ pendingSyntheticTerminalTurnId: null,
1623
+ pendingSyntheticTerminalStartedAt: 0,
1624
+ pendingSyntheticTerminalStatus: "",
1625
+ pendingSyntheticTerminalErrorMessage: "",
824
1626
  activeTurnIdIsSynthetic: false,
1627
+ // True after a stale bootstrap: run context is hydrated but nothing is
1628
+ // emitted (including heartbeats) until the rollout file grows again.
1629
+ suppressLiveActivityUntilGrowth: false,
1630
+ awaitingCoherentBoundary: false,
1631
+ awaitingBoundaryPreludeLine: "",
825
1632
  };
826
1633
  }
827
1634
 
@@ -875,7 +1682,8 @@ function parseToolArguments(rawArguments) {
875
1682
 
876
1683
  function planUpdateNotifications(state, argumentsObject) {
877
1684
  const plan = normalizeProgressPlanSteps(argumentsObject.plan);
878
- if (plan.length === 0) {
1685
+ const explanation = readString(argumentsObject.explanation);
1686
+ if (!hasVisiblePlanUpdate(explanation, plan)) {
879
1687
  return [];
880
1688
  }
881
1689
 
@@ -884,7 +1692,6 @@ function planUpdateNotifications(state, argumentsObject) {
884
1692
  turnId: state.activeTurnId,
885
1693
  plan,
886
1694
  };
887
- const explanation = readString(argumentsObject.explanation);
888
1695
  if (explanation) {
889
1696
  params.explanation = explanation;
890
1697
  }
@@ -969,9 +1776,8 @@ function genericToolActivityMessage(toolName) {
969
1776
  }
970
1777
  }
971
1778
 
972
- function shouldMirrorAgentMessage(payload) {
973
- const phase = readString(payload?.phase).toLowerCase();
974
- return phase !== "commentary";
1779
+ function genericToolCompletionMessage(toolName) {
1780
+ return `Completed ${readString(toolName)}`;
975
1781
  }
976
1782
 
977
1783
  function createNotification(method, params = {}) {
@@ -995,13 +1801,29 @@ function flushPendingUserMessageNotifications(state, turnId) {
995
1801
  return [];
996
1802
  }
997
1803
 
998
- return messages.map((pending) => createNotification("codex/event/user_message", {
999
- threadId: state.threadId,
1000
- turnId: turnId || state.activeTurnId || "",
1001
- message: pending.message,
1002
- ...(pending.id ? { id: pending.id } : {}),
1003
- ...timestampParams(pending.timestamp),
1004
- }));
1804
+ const resolvedTurnId = readString(turnId) || readString(state.activeTurnId);
1805
+ return messages
1806
+ .map((pending) => ({ ...pending, message: visibleUserPromptFromInputEntries(pending.message) }))
1807
+ .filter((pending) => pending.message)
1808
+ .filter((pending) => {
1809
+ const dedupeKey = userMessageOccurrenceKey(state, resolvedTurnId, pending.message, {
1810
+ isResponseItem: pending.isResponseItem === true,
1811
+ });
1812
+ if (state.emittedUserMessageKeys.has(dedupeKey)) {
1813
+ return false;
1814
+ }
1815
+ state.emittedUserMessageKeys.add(dedupeKey);
1816
+ return true;
1817
+ })
1818
+ .map((pending) => createNotification("codex/event/user_message", {
1819
+ threadId: state.threadId,
1820
+ // An empty turnId reads as "no turn identity" on the phone and blocks
1821
+ // dedup against the turn-bound row of the same prompt; omit it instead.
1822
+ ...(resolvedTurnId ? { turnId: resolvedTurnId } : {}),
1823
+ message: pending.message,
1824
+ ...(pending.id ? { id: pending.id } : {}),
1825
+ ...timestampParams(pending.timestamp),
1826
+ }));
1005
1827
  }
1006
1828
 
1007
1829
  function readUserMessageTimestamp(entry, payload = {}) {
@@ -1031,11 +1853,36 @@ function buildSyntheticTurnId(state, entry) {
1031
1853
  return `rollout-turn:${state.threadId}:${timestamp}`;
1032
1854
  }
1033
1855
 
1034
- function resolveRolloutEventTurnId(state, payload = {}) {
1856
+ function resolveRolloutEventTurnId(state, payload = {}, { allowSyntheticPromotion = true } = {}) {
1857
+ const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
1035
1858
  if (state.activeTurnIdIsSynthetic && state.activeTurnId) {
1859
+ if (explicitTurnId) {
1860
+ // Terminal events must not promote: with parallel turns, a sibling's
1861
+ // terminal explicit id would hijack the synthetic run and wipe it. The
1862
+ // active run's real id is adopted from its own non-terminal events.
1863
+ if (allowSyntheticPromotion) {
1864
+ promoteSyntheticTurnId(state, explicitTurnId);
1865
+ }
1866
+ return explicitTurnId;
1867
+ }
1036
1868
  return state.activeTurnId;
1037
1869
  }
1038
- return readString(payload.turn_id) || readString(payload.turnId) || state.activeTurnId || "";
1870
+ return explicitTurnId || state.activeTurnId || "";
1871
+ }
1872
+
1873
+ function promoteSyntheticTurnId(state, explicitTurnId) {
1874
+ const oldTurnId = state.activeTurnId;
1875
+ if (!oldTurnId || oldTurnId === explicitTurnId) {
1876
+ state.activeTurnId = explicitTurnId;
1877
+ state.activeTurnIdIsSynthetic = false;
1878
+ return;
1879
+ }
1880
+
1881
+ state.activeTurnId = explicitTurnId;
1882
+ state.activeTurnIdIsSynthetic = false;
1883
+ if (state.reasoningItemId === buildSyntheticItemId("thinking", state.threadId, oldTurnId)) {
1884
+ state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, explicitTurnId);
1885
+ }
1039
1886
  }
1040
1887
 
1041
1888
  function buildAgentMessageItemId(threadId, turnId, entry, message) {
@@ -1053,6 +1900,15 @@ function buildAgentMessageItemId(threadId, turnId, entry, message) {
1053
1900
  );
1054
1901
  }
1055
1902
 
1903
+ // Keyed on turn + text only: the same assistant text often arrives twice per
1904
+ // turn (event_msg agent_message and response_item message), and only one side
1905
+ // carries `phase`, so phase must stay out of the key for them to collide.
1906
+ // Legitimately repeated identical prose in one turn is rare and the phone's
1907
+ // item-scoped dedup covers the remainder.
1908
+ function agentMessageDedupeKey(turnId, message) {
1909
+ return buildRemodexSourceItemKey(turnId, message);
1910
+ }
1911
+
1056
1912
  function generatedImagePathForRolloutItem(threadId, callId) {
1057
1913
  const resolvedThreadId = readString(threadId);
1058
1914
  const resolvedCallId = readString(callId);
@@ -1071,11 +1927,27 @@ function resetRunState(state) {
1071
1927
  state.activeTurnId = null;
1072
1928
  state.reasoningItemId = null;
1073
1929
  state.hasThinking = false;
1930
+ state.hasReasoningContent = false;
1931
+ state.emittedReasoningSummaryKeys.clear();
1074
1932
  state.commandCalls.clear();
1075
1933
  state.applyPatchCalls.clear();
1076
1934
  state.emittedPatchApplyEndCalls.clear();
1935
+ state.emittedAgentMessageKeys.clear();
1936
+ state.agentMessageOccurrencesByBaseKey.clear();
1937
+ state.pendingEventAgentMessageOccurrencesByBaseKey.clear();
1938
+ state.emittedUserMessageKeys.clear();
1939
+ state.userMessageOccurrencesByBaseKey.clear();
1940
+ state.pendingEventUserMessageOccurrencesByBaseKey.clear();
1941
+ state.pendingResponseItemUserMessageOccurrencesByBaseKey.clear();
1077
1942
  state.pendingUserMessages.length = 0;
1943
+ state.pendingSyntheticTerminalTurnId = null;
1944
+ state.pendingSyntheticTerminalStartedAt = 0;
1945
+ state.pendingSyntheticTerminalStatus = "";
1946
+ state.pendingSyntheticTerminalErrorMessage = "";
1078
1947
  state.activeTurnIdIsSynthetic = false;
1948
+ state.suppressLiveActivityUntilGrowth = false;
1949
+ state.awaitingCoherentBoundary = false;
1950
+ state.awaitingBoundaryPreludeLine = "";
1079
1951
  }
1080
1952
 
1081
1953
  function readThreadId(params) {
@@ -1085,10 +1957,6 @@ function readThreadId(params) {
1085
1957
  ]) || "";
1086
1958
  }
1087
1959
 
1088
- function readFileSize(filePath, fsModule) {
1089
- return fsModule.statSync(filePath).size;
1090
- }
1091
-
1092
1960
  function readFileSlice(filePath, start, endExclusive, fsModule) {
1093
1961
  const length = Math.max(0, endExclusive - start);
1094
1962
  if (length === 0) {