@makerbi/remodex 2.0.1 → 2.3.1

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,15 +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;
21
- const DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS = 0;
22
- const BOOTSTRAP_REPLAY_CHUNK_SIZE = 50;
23
- const BOOTSTRAP_REPLAY_CHUNK_MAX_BYTES = 128 * 1024;
24
- const BOOTSTRAP_REPLAY_CACHE_LIMIT = 32;
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;
25
48
  const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
26
49
 
27
50
  // Observes desktop-authored rollout files and replays the currently active run as
@@ -33,19 +56,21 @@ function createRolloutLiveMirrorController({
33
56
  now = () => Date.now(),
34
57
  setIntervalFn = setInterval,
35
58
  clearIntervalFn = clearInterval,
36
- setTimeoutFn = setTimeout,
37
- clearTimeoutFn = clearTimeout,
38
59
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
39
60
  lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS,
40
61
  idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
41
62
  activityHeartbeatMs = DEFAULT_ACTIVITY_HEARTBEAT_MS,
42
- bootstrapReplayBatchIntervalMs = DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_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,
43
69
  } = {}) {
44
70
  const mirrorsByThreadId = new Map();
45
- const bootstrapReplayCache = new Map();
46
71
 
47
- function observeInbound(rawMessage) {
48
- const request = safeParseJSON(rawMessage);
72
+ function observeInbound(rawMessage, parsedMessage = null) {
73
+ const request = parsedMessage ?? safeParseJSON(rawMessage);
49
74
  const method = readString(request?.method);
50
75
  if (!DESKTOP_RESUME_METHODS.has(method)) {
51
76
  return;
@@ -63,22 +88,34 @@ function createRolloutLiveMirrorController({
63
88
  }
64
89
 
65
90
  let mirror;
91
+ let suppressionContext = {};
92
+ const isThreadSuppressed = () => Boolean(shouldSuppressThread?.(threadId, suppressionContext));
66
93
  mirror = createThreadRolloutLiveMirror({
67
94
  threadId,
68
- 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,
69
108
  logPrefix,
70
109
  fsModule,
71
110
  now,
72
111
  setIntervalFn,
73
112
  clearIntervalFn,
74
- setTimeoutFn,
75
- clearTimeoutFn,
76
113
  pollIntervalMs,
77
114
  lookupTimeoutMs,
78
115
  idleTimeoutMs,
79
116
  activityHeartbeatMs,
80
- bootstrapReplayBatchIntervalMs,
81
- bootstrapReplayCache,
117
+ staleActiveRunMaxAgeMs,
118
+ syntheticTerminalGraceMs,
82
119
  onStop() {
83
120
  if (mirrorsByThreadId.get(threadId) === mirror) {
84
121
  mirrorsByThreadId.delete(threadId);
@@ -106,32 +143,35 @@ function createRolloutLiveMirrorController({
106
143
  function createThreadRolloutLiveMirror({
107
144
  threadId,
108
145
  sendApplicationResponse,
146
+ isSuppressed = () => false,
109
147
  logPrefix,
110
148
  fsModule,
111
149
  now,
112
150
  setIntervalFn,
113
151
  clearIntervalFn,
114
- setTimeoutFn,
115
- clearTimeoutFn,
116
152
  pollIntervalMs,
117
153
  lookupTimeoutMs,
118
154
  idleTimeoutMs,
119
155
  activityHeartbeatMs,
120
- bootstrapReplayBatchIntervalMs,
121
- bootstrapReplayCache,
156
+ staleActiveRunMaxAgeMs,
157
+ syntheticTerminalGraceMs,
122
158
  onStop = () => {},
123
159
  }) {
124
160
  const startedAt = now();
125
161
  const state = createMirrorState(threadId);
126
- const bootstrapReplayTimeouts = new Set();
127
162
 
128
163
  let isStopped = false;
129
164
  let rolloutPath = null;
130
165
  let lastSize = 0;
131
166
  let partialLine = "";
132
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;
133
172
  let lastHeartbeatAt = 0;
134
173
  let didBootstrap = false;
174
+ let wasSuppressed = false;
135
175
 
136
176
  const intervalId = setIntervalFn(tick, pollIntervalMs);
137
177
  tick();
@@ -159,7 +199,22 @@ function createThreadRolloutLiveMirror({
159
199
  }
160
200
  }
161
201
 
162
- 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;
163
218
  if (!didBootstrap) {
164
219
  didBootstrap = true;
165
220
  bootstrapFromExistingRollout({
@@ -168,13 +223,12 @@ function createThreadRolloutLiveMirror({
168
223
  state,
169
224
  fsModule,
170
225
  sendApplicationResponse,
171
- bootstrapReplayCache,
172
- setTimeoutFn,
173
- bootstrapReplayBatchIntervalMs,
174
- pendingTimeouts: bootstrapReplayTimeouts,
226
+ nowMs: currentTime,
227
+ staleActiveRunMaxAgeMs,
175
228
  });
176
229
  lastSize = fileSize;
177
230
  lastActivityAt = currentTime;
231
+ lastGrowthAt = currentTime;
178
232
  lastHeartbeatAt = currentTime;
179
233
  if (state.isDesktopOrigin === false) {
180
234
  stop();
@@ -182,10 +236,28 @@ function createThreadRolloutLiveMirror({
182
236
  return;
183
237
  }
184
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
+
185
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.
186
257
  const chunk = readFileSlice(rolloutPath, lastSize, fileSize, fsModule);
187
258
  lastSize = fileSize;
188
259
  lastActivityAt = currentTime;
260
+ lastGrowthAt = currentTime;
189
261
  lastHeartbeatAt = currentTime;
190
262
  if (!chunk) {
191
263
  return;
@@ -200,16 +272,51 @@ function createThreadRolloutLiveMirror({
200
272
  searchStart = nlIndex + 1;
201
273
  }
202
274
  partialLine = searchStart < combined.length ? combined.substring(searchStart) : "";
203
- 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();
204
306
  return;
205
307
  }
206
308
 
207
309
  if (
208
310
  state.isDesktopOrigin !== false
209
311
  && state.activeTurnId
312
+ && !state.suppressLiveActivityUntilGrowth
210
313
  && currentTime - lastHeartbeatAt >= activityHeartbeatMs
211
314
  ) {
212
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;
213
320
  sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
214
321
  threadId: state.threadId,
215
322
  turnId: state.activeTurnId,
@@ -235,12 +342,19 @@ function createThreadRolloutLiveMirror({
235
342
  return;
236
343
  }
237
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.
238
347
  isStopped = true;
239
348
  clearIntervalFn(intervalId);
240
- for (const timeout of bootstrapReplayTimeouts) {
241
- clearTimeoutFn(timeout);
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
+ }
242
357
  }
243
- bootstrapReplayTimeouts.clear();
244
358
  onStop();
245
359
  }
246
360
 
@@ -256,12 +370,55 @@ function bootstrapFromExistingRollout({
256
370
  state,
257
371
  fsModule,
258
372
  sendApplicationResponse,
259
- bootstrapReplayCache,
260
- setTimeoutFn = setTimeout,
261
- bootstrapReplayBatchIntervalMs = DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS,
262
- pendingTimeouts,
373
+ nowMs = Date.now(),
374
+ staleActiveRunMaxAgeMs = DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS,
263
375
  }) {
264
- 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
+ }
265
422
  if (!initialContents) {
266
423
  return;
267
424
  }
@@ -271,6 +428,7 @@ function bootstrapFromExistingRollout({
271
428
  let insideActiveRun = false;
272
429
  let activeTurnId = null;
273
430
  let pendingUserPreludeLine = null;
431
+ let latestTerminalRun = null;
274
432
 
275
433
  for (const rawLine of lines) {
276
434
  const line = rawLine.trim();
@@ -283,14 +441,20 @@ function bootstrapFromExistingRollout({
283
441
  continue;
284
442
  }
285
443
 
286
- if (parsed.type === "session_meta") {
287
- populateSessionMetaState(state, parsed.payload);
288
- }
289
-
290
444
  const taskEventType = parsed?.type === "event_msg"
291
445
  ? readString(parsed?.payload?.type)
292
446
  : "";
293
- if (taskEventType === "user_message" && !insideActiveRun) {
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) {
294
458
  pendingUserPreludeLine = line;
295
459
  }
296
460
  if (taskEventType === "task_started") {
@@ -298,11 +462,11 @@ function bootstrapFromExistingRollout({
298
462
  activeTurnId = readString(parsed?.payload?.turn_id)
299
463
  || readString(parsed?.payload?.turnId)
300
464
  || "";
465
+ latestTerminalRun = null;
301
466
  activeRunLines.length = 0;
302
467
  if (pendingUserPreludeLine) {
303
468
  activeRunLines.push(pendingUserPreludeLine);
304
469
  }
305
- pendingUserPreludeLine = null;
306
470
  activeRunLines.push(line);
307
471
  continue;
308
472
  }
@@ -312,173 +476,309 @@ function bootstrapFromExistingRollout({
312
476
  }
313
477
 
314
478
  activeRunLines.push(line);
315
- if (isRolloutTerminalTaskEvent(taskEventType)) {
316
- insideActiveRun = false;
317
- activeTurnId = "";
318
- activeRunLines.length = 0;
319
- 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
+ }
320
491
  }
321
492
  }
322
493
 
323
- if (!isDesktopRolloutOrigin(state.sessionMeta)) {
324
- state.isDesktopOrigin = false;
494
+ if (activeRunLines.length === 0 && latestTerminalRun) {
495
+ sendApplicationResponse(JSON.stringify(terminalCatchUpNotification(state.threadId, latestTerminalRun)));
325
496
  return;
326
497
  }
327
498
 
328
- state.isDesktopOrigin = true;
329
- const replayId = bootstrapReplayId(state.threadId, rolloutPath, initialContents);
330
- const cachedBatches = bootstrapReplayCache?.get(replayId);
331
- const replayNotifications = cachedBatches ? null : [];
332
- // Rebuild local mirror state from the already-written run while collecting
333
- // every notification into bounded catch-up batches for the phone.
334
- processRolloutLines(activeRunLines, state, (rawNotification) => {
335
- if (cachedBatches) {
336
- return;
337
- }
338
- const notification = safeParseJSON(rawNotification);
339
- if (notification) {
340
- replayNotifications.push(notification);
341
- }
342
- });
343
- const batches = cachedBatches || chunkBootstrapReplayNotifications(replayNotifications);
344
- rememberBootstrapReplayBatches(bootstrapReplayCache, replayId, batches);
345
- emitBootstrapReplayBatches(state, replayId, batches, sendApplicationResponse, {
346
- setTimeoutFn,
347
- batchIntervalMs: bootstrapReplayBatchIntervalMs,
348
- pendingTimeouts,
349
- });
350
- }
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
+ }
351
515
 
352
- function isRolloutTerminalTaskEvent(eventType) {
353
- return eventType === "task_complete"
354
- || eventType === "turn_aborted"
355
- || eventType === "task_aborted";
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
+ }
356
530
  }
357
531
 
358
- function processRolloutLines(lines, state, sendApplicationResponse) {
359
- if (!Array.isArray(lines) || lines.length === 0) {
360
- return;
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;
361
543
  }
362
544
 
363
- for (const rawLine of lines) {
364
- const line = rawLine.trim();
365
- if (!line) {
366
- continue;
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 };
367
553
  }
368
-
369
- const parsed = safeParseJSON(line);
370
- if (!parsed) {
371
- continue;
554
+ if (windowBytes >= maxBytes || tailStart === 0) {
555
+ return null;
372
556
  }
373
557
 
374
- const notifications = synthesizeNotificationsFromRolloutEntry(parsed, state);
375
- for (const notification of notifications) {
376
- sendApplicationResponse(JSON.stringify(notification));
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;
377
563
  }
564
+ contents = `${prefix}${contents}`;
565
+ windowBytes = nextWindowBytes;
566
+ tailStart = nextTailStart;
378
567
  }
379
568
  }
380
569
 
381
- function emitBootstrapReplayBatches(
382
- state,
383
- replayId,
384
- batches,
385
- sendApplicationResponse,
386
- {
387
- setTimeoutFn = setTimeout,
388
- batchIntervalMs = DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS,
389
- pendingTimeouts,
390
- } = {}
391
- ) {
392
- if (!Array.isArray(batches) || batches.length === 0) {
393
- return;
570
+ function alignedBootstrapContents(contents, tailStart) {
571
+ if (tailStart === 0) {
572
+ return contents;
394
573
  }
574
+ const firstNewline = contents.indexOf("\n");
575
+ return firstNewline >= 0 ? contents.slice(firstNewline + 1) : "";
576
+ }
395
577
 
396
- const sendBatch = (batch, batchIndex) => {
397
- sendApplicationResponse(JSON.stringify(createNotification("remodex/rollout/bootstrapReplay", {
398
- threadId: state.threadId,
399
- replayId,
400
- batchIndex,
401
- batchCount: batches.length,
402
- notifications: batch,
403
- })));
404
- };
405
-
406
- for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
407
- const batch = batches[batchIndex];
408
- if (batchIndex === 0) {
409
- sendBatch(batch, batchIndex);
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) {
410
591
  continue;
411
592
  }
412
-
413
- if (batchIntervalMs <= 0) {
414
- sendBatch(batch, batchIndex);
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;
415
614
  continue;
416
615
  }
417
-
418
- const timeout = setTimeoutFn(() => {
419
- pendingTimeouts?.delete(timeout);
420
- sendBatch(batch, batchIndex);
421
- }, batchIndex * batchIntervalMs);
422
- pendingTimeouts?.add(timeout);
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
+ }
423
651
  }
652
+
653
+ return {
654
+ hasActiveRun: Boolean(activeTurnId) || unboundedActivitySinceTerminal,
655
+ hasOpeningUser,
656
+ };
424
657
  }
425
658
 
426
- function chunkBootstrapReplayNotifications(notifications) {
427
- const batches = [];
428
- let batch = [];
429
- let batchBytes = 0;
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
+ }
430
669
 
431
- for (const notification of notifications) {
432
- const notificationBytes = Buffer.byteLength(JSON.stringify(notification), "utf8");
433
- const shouldStartNextBatch = batch.length > 0
434
- && (
435
- batch.length >= BOOTSTRAP_REPLAY_CHUNK_SIZE
436
- || batchBytes + notificationBytes > BOOTSTRAP_REPLAY_CHUNK_MAX_BYTES
437
- );
438
- if (shouldStartNextBatch) {
439
- batches.push(batch);
440
- batch = [];
441
- batchBytes = 0;
442
- }
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
+ }
443
699
 
444
- batch.push(notification);
445
- batchBytes += notificationBytes;
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;
446
706
  }
707
+ }
447
708
 
448
- if (batch.length > 0) {
449
- batches.push(batch);
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;
450
721
  }
451
- return batches;
722
+
723
+ return {
724
+ eventType,
725
+ turnId,
726
+ message: readString(payload.message),
727
+ };
452
728
  }
453
729
 
454
- function bootstrapReplayId(threadId, rolloutPath, contents) {
455
- return crypto
456
- .createHash("sha256")
457
- .update(readString(threadId))
458
- .update("\0")
459
- .update(readString(rolloutPath))
460
- .update("\0")
461
- .update(String(contents || ""))
462
- .digest("hex")
463
- .slice(0, 24);
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);
464
746
  }
465
747
 
466
- function rememberBootstrapReplayBatches(cache, replayId, batches) {
467
- if (!cache || cache.has(replayId)) {
748
+ function processRolloutLines(lines, state, sendApplicationResponse, {
749
+ tagBootstrapReplay = false,
750
+ nowMs = Date.now(),
751
+ } = {}) {
752
+ if (!Array.isArray(lines) || lines.length === 0) {
468
753
  return;
469
754
  }
470
755
 
471
- cache.set(replayId, batches);
472
- while (cache.size > BOOTSTRAP_REPLAY_CACHE_LIMIT) {
473
- const oldestKey = cache.keys().next().value;
474
- if (!oldestKey) {
475
- break;
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
+
763
+ for (const rawLine of lines) {
764
+ const line = rawLine.trim();
765
+ if (!line) {
766
+ continue;
767
+ }
768
+
769
+ const parsed = safeParseJSON(line);
770
+ if (!parsed) {
771
+ continue;
772
+ }
773
+
774
+ const notifications = synthesizeNotificationsFromRolloutEntry(parsed, state, { nowMs });
775
+ for (const notification of notifications) {
776
+ emitNotification(notification);
476
777
  }
477
- cache.delete(oldestKey);
478
778
  }
479
779
  }
480
780
 
481
- function synthesizeNotificationsFromRolloutEntry(entry, state) {
781
+ function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.now() } = {}) {
482
782
  if (entry?.type === "session_meta") {
483
783
  populateSessionMetaState(state, entry.payload);
484
784
  if (!isDesktopRolloutOrigin(state.sessionMeta)) {
@@ -500,12 +800,15 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
500
800
  const eventType = readString(payload.type);
501
801
 
502
802
  if (eventType === "task_started") {
803
+ notifications.push(...finalizePendingSyntheticTerminal(state));
503
804
  const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
504
805
  const turnId = explicitTurnId || buildSyntheticTurnId(state, entry);
505
806
  state.activeTurnId = turnId;
506
807
  state.activeTurnIdIsSynthetic = !explicitTurnId;
507
808
  state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, turnId);
508
809
  state.hasThinking = false;
810
+ state.hasReasoningContent = false;
811
+ state.emittedReasoningSummaryKeys.clear();
509
812
  state.commandCalls.clear();
510
813
  state.applyPatchCalls.clear();
511
814
  state.emittedPatchApplyEndCalls.clear();
@@ -523,44 +826,66 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
523
826
  return notifications;
524
827
  }
525
828
 
829
+ if (eventType && !TERMINAL_TASK_EVENT_TYPES.has(eventType)) {
830
+ clearPendingSyntheticTerminal(state);
831
+ }
832
+
526
833
  if (eventType === "user_message") {
527
- const message = readString(payload.message) || readString(payload.text);
528
- if (!message) {
529
- return [];
530
- }
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
+ }
531
839
 
532
- const turnId = resolveRolloutEventTurnId(state, payload);
840
+ if (eventType === "task_complete") {
841
+ const turnId = resolveRolloutEventTurnId(state, payload, { allowSyntheticPromotion: false });
533
842
  if (!turnId) {
534
- state.pendingUserMessages.push({
535
- id: readString(payload.id),
536
- message,
537
- timestamp: readUserMessageTimestamp(entry, payload),
538
- });
539
843
  return [];
540
844
  }
541
845
 
542
- 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", {
543
853
  threadId: state.threadId,
544
854
  turnId,
545
- message,
546
- ...timestampParams(readUserMessageTimestamp(entry, payload)),
855
+ id: turnId,
547
856
  }));
857
+ if (closesActiveRun) {
858
+ resetRunState(state);
859
+ } else if (isSyntheticTerminalMismatch(state, turnId)) {
860
+ markPendingSyntheticTerminal(state, { status: "completed" }, nowMs);
861
+ }
548
862
  return notifications;
549
863
  }
550
864
 
551
- if (eventType === "task_complete") {
552
- 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 });
553
869
  if (!turnId) {
554
870
  return [];
555
871
  }
556
872
 
557
- notifications.push(...turnFileChangeSnapshotNotifications(state, turnId));
558
- notifications.push(createNotification("turn/completed", {
873
+ const terminalParams = {
559
874
  threadId: state.threadId,
560
875
  turnId,
561
876
  id: turnId,
562
- }));
563
- 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
+ }
564
889
  return notifications;
565
890
  }
566
891
 
@@ -579,18 +904,7 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
579
904
  }
580
905
 
581
906
  if (eventType === "agent_message") {
582
- const message = readString(payload.message) || readString(payload.text);
583
- if (!message || !shouldMirrorAgentMessage(payload)) {
584
- return [];
585
- }
586
- const turnId = resolveRolloutEventTurnId(state, payload);
587
-
588
- notifications.push(createNotification("codex/event/agent_message", {
589
- threadId: state.threadId,
590
- turnId,
591
- itemId: buildAgentMessageItemId(state.threadId, turnId, entry, message),
592
- message,
593
- }));
907
+ notifications.push(...agentMessageNotifications(state, entry, payload));
594
908
  return notifications;
595
909
  }
596
910
 
@@ -613,9 +927,16 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
613
927
  return [];
614
928
  }
615
929
 
930
+ clearPendingSyntheticTerminal(state);
931
+
616
932
  const payload = entry.payload || {};
617
933
  const itemType = normalizeRolloutItemType(payload.type);
618
934
 
935
+ if (itemType === "message") {
936
+ notifications.push(...responseItemMessageNotifications(state, entry, payload));
937
+ return notifications;
938
+ }
939
+
619
940
  if (itemType === "reasoning") {
620
941
  notifications.push(...reasoningNotifications(state, extractReasoningText(payload)));
621
942
  return notifications;
@@ -649,12 +970,32 @@ function reasoningNotifications(state, text) {
649
970
  return [];
650
971
  }
651
972
 
652
- const delta = readString(text);
653
- if (!delta) {
973
+ const rawText = readString(text);
974
+ if (!rawText) {
654
975
  return ensureThinkingNotifications(state);
655
976
  }
656
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
+
657
996
  state.hasThinking = true;
997
+ const delta = `${state.hasReasoningContent ? "\n\n" : ""}${visibleText}`;
998
+ state.hasReasoningContent = true;
658
999
  return [
659
1000
  createNotification("item/reasoning/textDelta", {
660
1001
  threadId: state.threadId,
@@ -665,6 +1006,188 @@ function reasoningNotifications(state, text) {
665
1006
  ];
666
1007
  }
667
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
+
668
1191
  function toolStartNotifications(state, payload) {
669
1192
  if (!state.activeTurnId) {
670
1193
  return [];
@@ -684,6 +1207,40 @@ function toolStartNotifications(state, payload) {
684
1207
  ];
685
1208
  }
686
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
+
687
1244
  state.commandCalls.set(callId, {
688
1245
  toolName,
689
1246
  command: resolveToolCommand(toolName, argumentsObject),
@@ -845,8 +1402,15 @@ function toolOutputNotifications(state, payload) {
845
1402
  }
846
1403
 
847
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
+ }));
848
1412
  state.commandCalls.delete(callId);
849
- return [];
1413
+ return notifications;
850
1414
  }
851
1415
 
852
1416
  const output = readString(payload.output);
@@ -945,6 +1509,75 @@ function itemCompletedNotifications(state, payload) {
945
1509
  ];
946
1510
  }
947
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
+
948
1581
  function ensureThinkingNotifications(state) {
949
1582
  if (!state.activeTurnId || state.hasThinking) {
950
1583
  return [];
@@ -973,11 +1606,29 @@ function createMirrorState(threadId) {
973
1606
  activeTurnId: null,
974
1607
  reasoningItemId: null,
975
1608
  hasThinking: false,
1609
+ hasReasoningContent: false,
1610
+ emittedReasoningSummaryKeys: new Set(),
976
1611
  commandCalls: new Map(),
977
1612
  applyPatchCalls: new Map(),
978
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(),
979
1621
  pendingUserMessages: [],
1622
+ pendingSyntheticTerminalTurnId: null,
1623
+ pendingSyntheticTerminalStartedAt: 0,
1624
+ pendingSyntheticTerminalStatus: "",
1625
+ pendingSyntheticTerminalErrorMessage: "",
980
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: "",
981
1632
  };
982
1633
  }
983
1634
 
@@ -1031,7 +1682,8 @@ function parseToolArguments(rawArguments) {
1031
1682
 
1032
1683
  function planUpdateNotifications(state, argumentsObject) {
1033
1684
  const plan = normalizeProgressPlanSteps(argumentsObject.plan);
1034
- if (plan.length === 0) {
1685
+ const explanation = readString(argumentsObject.explanation);
1686
+ if (!hasVisiblePlanUpdate(explanation, plan)) {
1035
1687
  return [];
1036
1688
  }
1037
1689
 
@@ -1040,7 +1692,6 @@ function planUpdateNotifications(state, argumentsObject) {
1040
1692
  turnId: state.activeTurnId,
1041
1693
  plan,
1042
1694
  };
1043
- const explanation = readString(argumentsObject.explanation);
1044
1695
  if (explanation) {
1045
1696
  params.explanation = explanation;
1046
1697
  }
@@ -1125,9 +1776,8 @@ function genericToolActivityMessage(toolName) {
1125
1776
  }
1126
1777
  }
1127
1778
 
1128
- function shouldMirrorAgentMessage(payload) {
1129
- const phase = readString(payload?.phase).toLowerCase();
1130
- return phase !== "commentary";
1779
+ function genericToolCompletionMessage(toolName) {
1780
+ return `Completed ${readString(toolName)}`;
1131
1781
  }
1132
1782
 
1133
1783
  function createNotification(method, params = {}) {
@@ -1151,13 +1801,29 @@ function flushPendingUserMessageNotifications(state, turnId) {
1151
1801
  return [];
1152
1802
  }
1153
1803
 
1154
- return messages.map((pending) => createNotification("codex/event/user_message", {
1155
- threadId: state.threadId,
1156
- turnId: turnId || state.activeTurnId || "",
1157
- message: pending.message,
1158
- ...(pending.id ? { id: pending.id } : {}),
1159
- ...timestampParams(pending.timestamp),
1160
- }));
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
+ }));
1161
1827
  }
1162
1828
 
1163
1829
  function readUserMessageTimestamp(entry, payload = {}) {
@@ -1187,11 +1853,36 @@ function buildSyntheticTurnId(state, entry) {
1187
1853
  return `rollout-turn:${state.threadId}:${timestamp}`;
1188
1854
  }
1189
1855
 
1190
- function resolveRolloutEventTurnId(state, payload = {}) {
1856
+ function resolveRolloutEventTurnId(state, payload = {}, { allowSyntheticPromotion = true } = {}) {
1857
+ const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
1191
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
+ }
1192
1868
  return state.activeTurnId;
1193
1869
  }
1194
- 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
+ }
1195
1886
  }
1196
1887
 
1197
1888
  function buildAgentMessageItemId(threadId, turnId, entry, message) {
@@ -1209,6 +1900,15 @@ function buildAgentMessageItemId(threadId, turnId, entry, message) {
1209
1900
  );
1210
1901
  }
1211
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
+
1212
1912
  function generatedImagePathForRolloutItem(threadId, callId) {
1213
1913
  const resolvedThreadId = readString(threadId);
1214
1914
  const resolvedCallId = readString(callId);
@@ -1227,11 +1927,27 @@ function resetRunState(state) {
1227
1927
  state.activeTurnId = null;
1228
1928
  state.reasoningItemId = null;
1229
1929
  state.hasThinking = false;
1930
+ state.hasReasoningContent = false;
1931
+ state.emittedReasoningSummaryKeys.clear();
1230
1932
  state.commandCalls.clear();
1231
1933
  state.applyPatchCalls.clear();
1232
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();
1233
1942
  state.pendingUserMessages.length = 0;
1943
+ state.pendingSyntheticTerminalTurnId = null;
1944
+ state.pendingSyntheticTerminalStartedAt = 0;
1945
+ state.pendingSyntheticTerminalStatus = "";
1946
+ state.pendingSyntheticTerminalErrorMessage = "";
1234
1947
  state.activeTurnIdIsSynthetic = false;
1948
+ state.suppressLiveActivityUntilGrowth = false;
1949
+ state.awaitingCoherentBoundary = false;
1950
+ state.awaitingBoundaryPreludeLine = "";
1235
1951
  }
1236
1952
 
1237
1953
  function readThreadId(params) {
@@ -1241,10 +1957,6 @@ function readThreadId(params) {
1241
1957
  ]) || "";
1242
1958
  }
1243
1959
 
1244
- function readFileSize(filePath, fsModule) {
1245
- return fsModule.statSync(filePath).size;
1246
- }
1247
-
1248
1960
  function readFileSlice(filePath, start, endExclusive, fsModule) {
1249
1961
  const length = Math.max(0, endExclusive - start);
1250
1962
  if (length === 0) {