@threadbase-sh/streamer 1.46.2 → 1.47.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.
package/dist/index.cjs CHANGED
@@ -1093,12 +1093,66 @@ var PTY_COLS = 120;
1093
1093
  var PTY_ROWS = 40;
1094
1094
  var SCREEN_SCROLLBACK = 1e3;
1095
1095
  var CODEX_PROMPT_READY_TEXT = "Ready";
1096
+ var CODEX_BUSY_STATUS_RE = /\b(?:Starting|Working)\b/;
1097
+ var CODEX_MCP_BOOT_RE = /Booting MCP|Starting MCP servers/i;
1096
1098
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
1097
1099
  var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
1100
+ var CODEX_ACTIVE_WRITER_RE = /already has an active writer|-32600/;
1101
+ var CODEX_ACTIVE_WRITER_CODE = "codex_active_writer";
1102
+ var CODEX_USAGE_LIMIT_RE = /you(?:'ve| have) hit your usage limit/i;
1103
+ var CODEX_USAGE_RESET_TIP_RE = /usage limit reset available/i;
1104
+ var CODEX_RATE_LIMIT_MENU_RE = /approaching rate limits/i;
1105
+ function parseCodexNumberedOptions(lines) {
1106
+ const options = [];
1107
+ for (const line of lines) {
1108
+ const m = /^\s*›?\s*(\d+)\.\s+(.+?)\s*$/.exec(line.trimEnd());
1109
+ if (!m) continue;
1110
+ const label = m[2].replace(/\s*\(selected\)\s*$/i, "").trim();
1111
+ options.push({ index: Number(m[1]), label, answerKeys: `${m[1]}\r` });
1112
+ }
1113
+ return options;
1114
+ }
1115
+ function detectCodexBlockingPrompt(lines) {
1116
+ const screenText = lines.join("\n");
1117
+ const usageLine = lines.find((l) => CODEX_USAGE_LIMIT_RE.test(l))?.trim();
1118
+ const tipLine = lines.find((l) => CODEX_USAGE_RESET_TIP_RE.test(l))?.trim();
1119
+ const rateMenu = CODEX_RATE_LIMIT_MENU_RE.test(screenText);
1120
+ if (!usageLine && !rateMenu && !tipLine) return null;
1121
+ const soft = !usageLine && !rateMenu && Boolean(tipLine);
1122
+ const prompt = usageLine ?? lines.find((l) => CODEX_RATE_LIMIT_MENU_RE.test(l))?.trim() ?? tipLine ?? "Codex usage limit reached";
1123
+ const detail = lines.find((l) => /try again at/i.test(l))?.trim();
1124
+ const options = parseCodexNumberedOptions(lines);
1125
+ if (options.length === 0) {
1126
+ options.push({ index: 1, label: soft ? "Dismiss" : "OK", answerKeys: "\x1B" });
1127
+ }
1128
+ return { prompt, ...detail ? { detail } : {}, options, ...soft ? { soft: true } : {} };
1129
+ }
1098
1130
  var QUIET_DETECT_MS = 500;
1099
1131
  var CODEX_READY_FALLBACK_MS = 8e3;
1100
1132
  var SUBMIT_BYTES = "\r";
1101
1133
  var CODEX_SUBMIT_DELAY_MS = 16;
1134
+ var CODEX_SUBMIT_MAX_WAIT_MS = 500;
1135
+ var CODEX_SUBMIT_STALE_MS = 2e3;
1136
+ function codexStatusBarLine(lines) {
1137
+ return [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1138
+ }
1139
+ function codexScreenBlocksComposer(lines) {
1140
+ const screenText = lines.join("\n");
1141
+ if (CODEX_HOOKS_GATE_REGEX.test(screenText) || CODEX_TRUST_GATE_REGEX.test(screenText)) {
1142
+ return true;
1143
+ }
1144
+ if (CODEX_MCP_BOOT_RE.test(screenText)) return true;
1145
+ return CODEX_BUSY_STATUS_RE.test(codexStatusBarLine(lines));
1146
+ }
1147
+ function codexScreenShowsReady(lines) {
1148
+ if (codexScreenBlocksComposer(lines)) return false;
1149
+ return codexStatusBarLine(lines).includes(CODEX_PROMPT_READY_TEXT);
1150
+ }
1151
+ function codexScreenLooksIdle(lines) {
1152
+ if (codexScreenBlocksComposer(lines)) return false;
1153
+ if (codexScreenShowsReady(lines)) return true;
1154
+ return lines.some((l) => /^\s*[›>]\s/.test(l) || l.includes("\u203A"));
1155
+ }
1102
1156
  function digestBytes(s) {
1103
1157
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1104
1158
  if (escaped.length <= 200) return escaped;
@@ -1189,6 +1243,21 @@ var CodexPtyRunner = class {
1189
1243
  quietCheckers = /* @__PURE__ */ new Map();
1190
1244
  // Per-session flat backstop from spawn (CODEX_READY_FALLBACK_MS).
1191
1245
  readyFallbackTimers = /* @__PURE__ */ new Map();
1246
+ // After a user submit: if Working never appears, recover from stuck `running`.
1247
+ submitWatchTimers = /* @__PURE__ */ new Map();
1248
+ // Wall-clock of the last PTY chunk per session — writeSubmit waits until
1249
+ // this hasn't advanced for CODEX_SUBMIT_DELAY_MS before writing \r.
1250
+ lastChunkAt = /* @__PURE__ */ new Map();
1251
+ // Sessions that have shown a Working status bar since the last user submit.
1252
+ // Mid-session Ready→waiting_input only fires after this, so a still-painted
1253
+ // Ready bar immediately after sendInput cannot flip status back before the
1254
+ // turn starts (which would let grace/hold kill a live turn).
1255
+ turnBusy = /* @__PURE__ */ new Set();
1256
+ // Usage-limit / rate-limit menus — content key for deduped permission cards.
1257
+ openBlockingPrompt = /* @__PURE__ */ new Map();
1258
+ // Last codex.screen fingerprint per session — only emit when it changes so
1259
+ // MCP boot redraw storms don't flood the log.
1260
+ lastScreenLog = /* @__PURE__ */ new Map();
1192
1261
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1193
1262
  // concurrent resume for the same session (double-tap, client retry) awaits
1194
1263
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -1218,24 +1287,27 @@ var CodexPtyRunner = class {
1218
1287
  return promise;
1219
1288
  }
1220
1289
  async doStart(sessionId, options) {
1290
+ return this.launch(
1291
+ sessionId,
1292
+ ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1293
+ options
1294
+ );
1295
+ }
1296
+ // Spawn a Codex PTY under `sessionId` and wire up the shared boot machinery
1297
+ // (screen, ready fallback, output/exit handlers). The only difference between
1298
+ // resume, fresh and fork is argv.
1299
+ async launch(sessionId, args, options) {
1221
1300
  const nodePty = await loadPty();
1222
1301
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1223
1302
  let proc;
1224
1303
  try {
1225
- proc = nodePty.spawn(
1226
- resolveCodexExe(),
1227
- // `sessionId` stays the runner's map key — only argv carries the
1228
- // provider-side id, so a resumed Codex session keeps the placeholder id
1229
- // its client already navigated to.
1230
- ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1231
- {
1232
- name: "xterm-256color",
1233
- cols: PTY_COLS,
1234
- rows: PTY_ROWS,
1235
- cwd: options.projectPath,
1236
- env: process.env
1237
- }
1238
- );
1304
+ proc = nodePty.spawn(resolveCodexExe(), args, {
1305
+ name: "xterm-256color",
1306
+ cols: PTY_COLS,
1307
+ rows: PTY_ROWS,
1308
+ cwd: options.projectPath,
1309
+ env: process.env
1310
+ });
1239
1311
  } catch (err) {
1240
1312
  clearCodexExeCache();
1241
1313
  throw err;
@@ -1275,71 +1347,85 @@ var CodexPtyRunner = class {
1275
1347
  // binding logic). This runner generates a local placeholder id for the
1276
1348
  // ManagedSession handle only.
1277
1349
  async startFresh(options) {
1278
- const nodePty = await loadPty();
1279
1350
  const sessionId = (0, import_crypto2.randomUUID)();
1280
- const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1281
1351
  const args = ["--cd", options.projectPath, "--no-alt-screen"];
1282
1352
  if (options.systemPrompt) {
1283
1353
  args.push(options.systemPrompt);
1284
1354
  }
1285
- let proc;
1286
- try {
1287
- proc = nodePty.spawn(resolveCodexExe(), args, {
1288
- name: "xterm-256color",
1289
- cols: PTY_COLS,
1290
- rows: PTY_ROWS,
1291
- cwd: options.projectPath,
1292
- env: process.env
1293
- });
1294
- } catch (err) {
1295
- clearCodexExeCache();
1296
- throw err;
1297
- }
1298
- const session = {
1299
- id: sessionId,
1300
- provider: CODEX_CLI_PROVIDER,
1301
- projectPath: options.projectPath,
1302
- projectName,
1303
- branch: "",
1304
- status: "running",
1305
- statusSource: "spawn",
1306
- statusUpdatedAt: /* @__PURE__ */ new Date(),
1307
- startedAt: /* @__PURE__ */ new Date(),
1308
- completedAt: null,
1309
- promptCount: 0,
1310
- lastOutput: "",
1311
- process: proc,
1312
- outputBuffer: Buffer.alloc(0),
1313
- screen: createScreen(),
1314
- inputHistory: []
1315
- };
1316
- this.sessions.set(sessionId, session);
1317
- this.pendingReady.add(sessionId);
1318
- this.armReadyFallback(sessionId);
1319
- proc.onData((data) => {
1320
- this.handleOutput(sessionId, data);
1321
- });
1322
- proc.onExit(({ exitCode }) => {
1323
- this.pendingReady.delete(sessionId);
1324
- this.handleExit(sessionId, exitCode);
1325
- });
1326
- return toPublicSession(session);
1355
+ return this.launch(sessionId, args, options);
1327
1356
  }
1328
- // Flat backstop: if neither the "Ready" marker nor the quiet-checker settled
1329
- // the session within CODEX_READY_FALLBACK_MS of spawn, mark it ready anyway
1330
- // so start requests resolve and mobile can watch the boot live. unref() so a
1331
- // pending timer never holds the process open.
1357
+ /**
1358
+ * Fork an existing Codex conversation into a new, independently-owned one
1359
+ * (`codex fork <session-id>`).
1360
+ *
1361
+ * This is the recovery path for a rollout Codex will not let us resume: fork
1362
+ * starts a *new* rollout seeded from the source's history and never touches
1363
+ * the source's writer, so the terminal / VS Code / desktop client that owns
1364
+ * it keeps running untouched. Like a fresh start, Codex assigns the new
1365
+ * rollout id itself — the returned session is keyed by a local placeholder
1366
+ * until watchForCodexRollout binds the real id.
1367
+ */
1368
+ async startFork(options) {
1369
+ const sessionId = (0, import_crypto2.randomUUID)();
1370
+ return this.launch(
1371
+ sessionId,
1372
+ ["fork", options.forkFromId, "--cd", options.projectPath, "--no-alt-screen"],
1373
+ options
1374
+ );
1375
+ }
1376
+ // Flat backstop: if the "Ready" marker never appears within
1377
+ // CODEX_READY_FALLBACK_MS of spawn (truncated status bar), mark ready once
1378
+ // the screen is no longer Starting/Working/MCP-booting. Re-arms while the
1379
+ // boot is still busy so a slow MCP load cannot be mistaken for Ready.
1380
+ // unref() so a pending timer never holds the process open.
1332
1381
  armReadyFallback(sessionId) {
1333
1382
  const timer = setTimeout(() => {
1334
1383
  this.readyFallbackTimers.delete(sessionId);
1335
- const session = this.sessions.get(sessionId);
1336
- if (session?.status === "running" && this.pendingReady.has(sessionId)) {
1337
- this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1338
- }
1384
+ void this.tryReadyFallback(sessionId);
1339
1385
  }, CODEX_READY_FALLBACK_MS);
1340
1386
  timer.unref?.();
1341
1387
  this.readyFallbackTimers.set(sessionId, timer);
1342
1388
  }
1389
+ async tryReadyFallback(sessionId) {
1390
+ const session = this.sessions.get(sessionId);
1391
+ if (session?.status !== "running" || !this.pendingReady.has(sessionId)) return;
1392
+ if (session.outputBuffer.length === 0) {
1393
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1394
+ return;
1395
+ }
1396
+ try {
1397
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1398
+ if (!this.pendingReady.has(sessionId)) return;
1399
+ const busy = codexScreenBlocksComposer(lines);
1400
+ const bar = codexStatusBarLine(lines);
1401
+ this.log.info(
1402
+ `[codex.ready_fallback] ${sessionId.slice(0, 8)} busy=${busy} bar=${JSON.stringify(bar.slice(0, 120))}`,
1403
+ {
1404
+ event: "codex.ready_fallback",
1405
+ sessionId,
1406
+ busy,
1407
+ hasReady: codexScreenShowsReady(lines),
1408
+ statusBar: bar.slice(0, 160)
1409
+ }
1410
+ );
1411
+ if (busy) {
1412
+ this.armReadyFallback(sessionId);
1413
+ return;
1414
+ }
1415
+ if (!codexScreenShowsReady(lines) && !codexScreenLooksIdle(lines)) {
1416
+ this.armReadyFallback(sessionId);
1417
+ return;
1418
+ }
1419
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1420
+ } catch (err) {
1421
+ this.log.warn("[codex.ready_fallback] failed", {
1422
+ event: "codex.ready_fallback_failed",
1423
+ sessionId,
1424
+ err
1425
+ });
1426
+ if (this.pendingReady.has(sessionId)) this.armReadyFallback(sessionId);
1427
+ }
1428
+ }
1343
1429
  // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
1344
1430
  sendKeys(sessionId, keys) {
1345
1431
  const session = this.sessions.get(sessionId);
@@ -1400,7 +1486,7 @@ var CodexPtyRunner = class {
1400
1486
  if (session.status === "idle") {
1401
1487
  throw new Error(`Session is idle (no active PTY): ${sessionId}`);
1402
1488
  }
1403
- if (this.pendingReady.has(sessionId)) {
1489
+ if (this.pendingReady.has(sessionId) || this.openGate.has(sessionId)) {
1404
1490
  const queue = this.queuedInputs.get(sessionId) ?? [];
1405
1491
  queue.push(input);
1406
1492
  this.queuedInputs.set(sessionId, queue);
@@ -1424,14 +1510,17 @@ var CodexPtyRunner = class {
1424
1510
  session.statusUpdatedAt = /* @__PURE__ */ new Date();
1425
1511
  this.onStatusChange?.(toPublicSession(session));
1426
1512
  }
1513
+ this.turnBusy.delete(sessionId);
1427
1514
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
1428
1515
  session.lastActivityAt = /* @__PURE__ */ new Date();
1429
1516
  session.promptCount++;
1430
1517
  return session.promptCount;
1431
1518
  }
1432
1519
  // Write the input as plain bytes (no bracketed-paste wrap — Phase 0
1433
- // confirmed Codex accepts plain keystrokes), then submit \r after a short
1434
- // delay so Codex's TUI gets an event-loop tick to process the input first.
1520
+ // confirmed Codex accepts plain keystrokes), then submit \r once the PTY
1521
+ // has been quiet for CODEX_SUBMIT_DELAY_MS. A flat delay fired \r into a
1522
+ // still-repainting TUI and the Enter became a compose newline instead of a
1523
+ // turn submit (same pathology Claude's quiescence wait fixed).
1435
1524
  writeSubmit(sessionId, session, input, path, promptCount) {
1436
1525
  this.recordUserMessage(session, input);
1437
1526
  this.log.info(
@@ -1446,12 +1535,21 @@ var CodexPtyRunner = class {
1446
1535
  phase: "input"
1447
1536
  }
1448
1537
  );
1538
+ const writeAt = Date.now();
1449
1539
  session.process.write(input);
1450
- setTimeout(() => {
1540
+ const trySubmit = () => {
1451
1541
  const current = this.sessions.get(sessionId);
1452
1542
  if (!current || current !== session) return;
1543
+ const now = Date.now();
1544
+ const lastChunk = this.lastChunkAt.get(sessionId) ?? writeAt;
1545
+ const quiet = now - lastChunk >= CODEX_SUBMIT_DELAY_MS;
1546
+ const timedOut = now - writeAt >= CODEX_SUBMIT_MAX_WAIT_MS;
1547
+ if (!quiet && !timedOut) {
1548
+ setTimeout(trySubmit, CODEX_SUBMIT_DELAY_MS);
1549
+ return;
1550
+ }
1453
1551
  this.log.info(
1454
- `[codex.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
1552
+ `[codex.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r waitedMs=${now - writeAt} timedOut=${timedOut}`,
1455
1553
  {
1456
1554
  event: "codex.input_write",
1457
1555
  sessionId,
@@ -1459,11 +1557,55 @@ var CodexPtyRunner = class {
1459
1557
  byteLen: SUBMIT_BYTES.length,
1460
1558
  digest: "\\r",
1461
1559
  path,
1462
- phase: "submit"
1560
+ phase: "submit",
1561
+ waitedMs: now - writeAt,
1562
+ timedOut
1463
1563
  }
1464
1564
  );
1465
1565
  current.process.write(SUBMIT_BYTES);
1466
- }, CODEX_SUBMIT_DELAY_MS);
1566
+ this.armSubmitWatch(sessionId);
1567
+ };
1568
+ setTimeout(trySubmit, CODEX_SUBMIT_DELAY_MS);
1569
+ }
1570
+ // If Working never appears after \r, the turn did not start — recover from
1571
+ // stuck `running` even when no further PTY chunks re-arm the quiet checker
1572
+ // (session ddc67b57: one post-submit chunk, then silence forever).
1573
+ armSubmitWatch(sessionId) {
1574
+ const prev = this.submitWatchTimers.get(sessionId);
1575
+ if (prev) clearTimeout(prev);
1576
+ const timer = setTimeout(() => {
1577
+ this.submitWatchTimers.delete(sessionId);
1578
+ void this.trySubmitStaleRecovery(sessionId);
1579
+ }, CODEX_SUBMIT_STALE_MS);
1580
+ timer.unref?.();
1581
+ this.submitWatchTimers.set(sessionId, timer);
1582
+ }
1583
+ async trySubmitStaleRecovery(sessionId) {
1584
+ const session = this.sessions.get(sessionId);
1585
+ if (session?.status !== "running") return;
1586
+ if (this.turnBusy.has(sessionId)) return;
1587
+ if (session.statusSource !== "user-input") return;
1588
+ try {
1589
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1590
+ if (session.status !== "running" || this.turnBusy.has(sessionId)) return;
1591
+ if (codexScreenBlocksComposer(lines)) {
1592
+ this.armSubmitWatch(sessionId);
1593
+ return;
1594
+ }
1595
+ this.log.info(`[codex.submit_stale] ${sessionId.slice(0, 8)} recovering`, {
1596
+ event: "codex.submit_stale",
1597
+ sessionId,
1598
+ statusBar: codexStatusBarLine(lines).slice(0, 160)
1599
+ });
1600
+ this.markReady(sessionId, session, "quiet-fallback", "submit-stale");
1601
+ } catch (err) {
1602
+ this.log.warn("[codex.submit_stale] failed", {
1603
+ event: "codex.submit_stale_failed",
1604
+ sessionId,
1605
+ err
1606
+ });
1607
+ this.armSubmitWatch(sessionId);
1608
+ }
1467
1609
  }
1468
1610
  // Drain any inputs sent while the session was still pendingReady, writing
1469
1611
  // them in arrival order now that Codex is Ready. No-op while a gate dialog
@@ -1538,11 +1680,20 @@ var CodexPtyRunner = class {
1538
1680
  const timer = this.readyFallbackTimers.get(sessionId);
1539
1681
  if (timer) clearTimeout(timer);
1540
1682
  this.readyFallbackTimers.delete(sessionId);
1683
+ const submitWatch = this.submitWatchTimers.get(sessionId);
1684
+ if (submitWatch) clearTimeout(submitWatch);
1685
+ this.submitWatchTimers.delete(sessionId);
1686
+ this.lastChunkAt.delete(sessionId);
1541
1687
  if (this.openGate.delete(sessionId)) {
1542
1688
  this.onPermissionChange?.(sessionId, null);
1543
1689
  }
1544
1690
  this.gateActioned.delete(`${sessionId}:hooks`);
1545
1691
  this.gateActioned.delete(`${sessionId}:trust`);
1692
+ this.turnBusy.delete(sessionId);
1693
+ if (this.openBlockingPrompt.delete(sessionId)) {
1694
+ this.onPermissionChange?.(sessionId, null);
1695
+ }
1696
+ this.lastScreenLog.delete(sessionId);
1546
1697
  }
1547
1698
  getOutput(sessionId) {
1548
1699
  const session = this.sessions.get(sessionId);
@@ -1614,10 +1765,19 @@ var CodexPtyRunner = class {
1614
1765
  this.gateActioned.clear();
1615
1766
  this.quietCheckers.clear();
1616
1767
  this.readyFallbackTimers.clear();
1768
+ for (const timer of this.submitWatchTimers.values()) {
1769
+ clearTimeout(timer);
1770
+ }
1771
+ this.submitWatchTimers.clear();
1772
+ this.lastChunkAt.clear();
1773
+ this.turnBusy.clear();
1774
+ this.openBlockingPrompt.clear();
1775
+ this.lastScreenLog.clear();
1617
1776
  }
1618
1777
  handleOutput(sessionId, data) {
1619
1778
  const session = this.sessions.get(sessionId);
1620
1779
  if (!session) return;
1780
+ this.lastChunkAt.set(sessionId, Date.now());
1621
1781
  const chunk = Buffer.from(data, "utf-8");
1622
1782
  session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1623
1783
  if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
@@ -1654,16 +1814,24 @@ var CodexPtyRunner = class {
1654
1814
  // - Gates (directory trust, hooks review) — checked on EVERY pass,
1655
1815
  // independent of pendingReady, so a gate appearing after ready is still
1656
1816
  // surfaced and a gate leaving the screen closes its card.
1657
- // - Readiness — the "Ready" status-bar marker while pendingReady, plus the
1658
- // quiet path: after QUIET_DETECT_MS of PTY silence a still-pending
1659
- // session is marked ready anyway (`›` alone is NOT a marker — Phase 0 —
1660
- // but a quiet boot screen is more useful to the user live than a
1661
- // spinner, and "Ready" may be truncated off the 120-col status bar).
1662
- async detectScreenState(sessionId, trigger) {
1817
+ // - Readiness — boot (pendingReady) requires the "Ready" status-bar marker
1818
+ // (quiet alone never settles boot Starting shows `›` already). Mid-
1819
+ // session, running waiting_input after Working then Ready (or a stale
1820
+ // Ready recovery if the turn never started).
1821
+ async detectScreenState(sessionId, _trigger) {
1663
1822
  const session = this.sessions.get(sessionId);
1664
1823
  if (!session || session.status === "idle") return;
1665
1824
  const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1666
1825
  const screenText = lines.join("\n");
1826
+ if (this.pendingReady.has(sessionId) && CODEX_ACTIVE_WRITER_RE.test(screenText)) {
1827
+ this.failStartup(
1828
+ sessionId,
1829
+ session,
1830
+ CODEX_ACTIVE_WRITER_CODE,
1831
+ "This Codex session is already open in another client"
1832
+ );
1833
+ return;
1834
+ }
1667
1835
  const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1668
1836
  if (gate) {
1669
1837
  this.handleGate(sessionId, session, gate, lines);
@@ -1671,12 +1839,83 @@ var CodexPtyRunner = class {
1671
1839
  this.onPermissionChange?.(sessionId, null);
1672
1840
  this.flushQueuedInputs(sessionId);
1673
1841
  }
1674
- if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1675
- const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1676
- if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1677
- this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1678
- } else if (trigger === "quiet") {
1679
- this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
1842
+ const blocking = detectCodexBlockingPrompt(lines);
1843
+ if (blocking) {
1844
+ const elevateSoft = !blocking.soft || session.status === "running" && session.statusSource === "user-input" && !this.turnBusy.has(sessionId) && !this.pendingReady.has(sessionId);
1845
+ if (elevateSoft) {
1846
+ this.handleBlockingPrompt(sessionId, session, blocking);
1847
+ }
1848
+ } else if (this.openBlockingPrompt.delete(sessionId)) {
1849
+ this.onPermissionChange?.(sessionId, null);
1850
+ }
1851
+ const hasReady = codexScreenShowsReady(lines);
1852
+ const busy = codexScreenBlocksComposer(lines);
1853
+ const bar = codexStatusBarLine(lines);
1854
+ const screenFp = [
1855
+ this.pendingReady.has(sessionId) ? "1" : "0",
1856
+ session.status,
1857
+ hasReady ? "1" : "0",
1858
+ busy ? "1" : "0",
1859
+ blocking ? blocking.soft ? "soft" : "1" : "0",
1860
+ bar.slice(0, 80)
1861
+ ].join("|");
1862
+ if (this.lastScreenLog.get(sessionId) !== screenFp) {
1863
+ this.lastScreenLog.set(sessionId, screenFp);
1864
+ this.log.info(
1865
+ `[codex.screen] ${sessionId.slice(0, 8)} pending=${this.pendingReady.has(sessionId)} status=${session.status} ready=${hasReady} busy=${busy} usage=${Boolean(blocking)} bar=${JSON.stringify(bar.slice(0, 100))}`,
1866
+ {
1867
+ event: "codex.screen",
1868
+ sessionId,
1869
+ trigger: _trigger,
1870
+ pendingReady: this.pendingReady.has(sessionId),
1871
+ status: session.status,
1872
+ hasReady,
1873
+ busy,
1874
+ usageHit: Boolean(blocking),
1875
+ usageSoft: Boolean(blocking?.soft),
1876
+ statusBar: bar.slice(0, 160)
1877
+ }
1878
+ );
1879
+ }
1880
+ if (this.pendingReady.has(sessionId)) {
1881
+ if (hasReady) {
1882
+ this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1883
+ }
1884
+ return;
1885
+ }
1886
+ if (session.status === "running") {
1887
+ if (/\bWorking\b/.test(bar)) {
1888
+ this.turnBusy.add(sessionId);
1889
+ const watch = this.submitWatchTimers.get(sessionId);
1890
+ if (watch) clearTimeout(watch);
1891
+ this.submitWatchTimers.delete(sessionId);
1892
+ }
1893
+ if (hasReady && this.turnBusy.has(sessionId)) {
1894
+ this.turnBusy.delete(sessionId);
1895
+ this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1896
+ } else if (!this.turnBusy.has(sessionId) && !busy && session.statusSource === "user-input" && session.statusUpdatedAt != null && Date.now() - session.statusUpdatedAt.getTime() >= CODEX_SUBMIT_STALE_MS) {
1897
+ this.markReady(sessionId, session, "quiet-fallback", "submit-stale");
1898
+ }
1899
+ }
1900
+ }
1901
+ // Surface quota / rate-limit screens as permission cards and stop leaving
1902
+ // the session stuck in `running` while Codex waits for a menu pick.
1903
+ handleBlockingPrompt(sessionId, session, blocking) {
1904
+ const key = `${blocking.prompt}\0${blocking.detail ?? ""}\0${blocking.options.map((o) => o.index).join(",")}`;
1905
+ const prev = this.openBlockingPrompt.get(sessionId);
1906
+ if (prev !== key) {
1907
+ this.openBlockingPrompt.set(sessionId, key);
1908
+ session.failureReason = blocking.detail ? `${blocking.prompt} ${blocking.detail}` : blocking.prompt;
1909
+ this.log.info(`[codex.usage_limit] ${sessionId.slice(0, 8)}`, {
1910
+ event: "codex.usage_limit",
1911
+ sessionId,
1912
+ prompt: blocking.prompt
1913
+ });
1914
+ this.onPermissionChange?.(sessionId, blocking);
1915
+ }
1916
+ if (session.status === "running") {
1917
+ this.turnBusy.delete(sessionId);
1918
+ this.markReady(sessionId, session, "quiet-fallback", "usage-limit");
1680
1919
  }
1681
1920
  }
1682
1921
  // Answer a gate from the persisted remember-store, or surface it as a
@@ -1718,12 +1957,52 @@ var CodexPtyRunner = class {
1718
1957
  reason
1719
1958
  });
1720
1959
  this.onStatusChange?.(toPublicSession(session));
1721
- if (this.pendingReady.has(sessionId)) {
1722
- this.pendingReady.delete(sessionId);
1960
+ const wasPending = this.pendingReady.delete(sessionId);
1961
+ const submitWatch = this.submitWatchTimers.get(sessionId);
1962
+ if (submitWatch) clearTimeout(submitWatch);
1963
+ this.submitWatchTimers.delete(sessionId);
1964
+ if (wasPending) {
1965
+ const timer = this.readyFallbackTimers.get(sessionId);
1966
+ if (timer) clearTimeout(timer);
1967
+ this.readyFallbackTimers.delete(sessionId);
1723
1968
  this.flushQueuedInputs(sessionId);
1724
1969
  this.onReady?.(toPublicSession(session));
1725
1970
  }
1726
1971
  }
1972
+ /**
1973
+ * Tear down a session that failed before it ever became usable, and report
1974
+ * the reason in machine-readable form.
1975
+ *
1976
+ * Deliberately NOT markReady + exit: the caller must be able to tell a
1977
+ * never-started session from a live one, `onReady` must not fire (no
1978
+ * `session_ready` for a failed start), and every piece of per-session state —
1979
+ * queue, timers, quiet-checker, gate cards, screen — has to go, since the
1980
+ * session is removed from the map and nothing will collect it later.
1981
+ */
1982
+ failStartup(sessionId, session, code, message) {
1983
+ this.log.warn(`[codex.start_failed] ${sessionId.slice(0, 8)} ${code}`, {
1984
+ event: "codex.start_failed",
1985
+ sessionId,
1986
+ code,
1987
+ message
1988
+ });
1989
+ session.failureCode = code;
1990
+ session.failureReason = message;
1991
+ session.status = "idle";
1992
+ session.statusSource = "process-exit";
1993
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1994
+ session.completedAt = /* @__PURE__ */ new Date();
1995
+ this.pendingReady.delete(sessionId);
1996
+ this.queuedInputs.delete(sessionId);
1997
+ this.clearSessionDetectors(sessionId);
1998
+ this.sessions.delete(sessionId);
1999
+ try {
2000
+ session.process.kill("SIGINT");
2001
+ } catch {
2002
+ }
2003
+ session.screen.dispose();
2004
+ this.onStatusChange?.(toPublicSession(session));
2005
+ }
1727
2006
  handleExit(sessionId, exitCode) {
1728
2007
  const session = this.sessions.get(sessionId);
1729
2008
  if (!session) return;
@@ -1759,6 +2038,7 @@ function toPublicSession(s) {
1759
2038
  promptCount: s.promptCount,
1760
2039
  lastOutput: s.lastOutput,
1761
2040
  ...s.failureReason != null && { failureReason: s.failureReason },
2041
+ ...s.failureCode != null && { failureCode: s.failureCode },
1762
2042
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1763
2043
  ...s.statusSource != null && { statusSource: s.statusSource },
1764
2044
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
@@ -3259,6 +3539,24 @@ var LiveSessionManager = class {
3259
3539
  const runner = this.assertSupportedProvider(provider, options.projectPath);
3260
3540
  return runner.startFresh(options);
3261
3541
  }
3542
+ /**
3543
+ * Fork an existing conversation into a new session. Codex-only: `codex fork`
3544
+ * has no Claude Code equivalent, and there is no safe generic fallback — a
3545
+ * silent downgrade to resume would attach to the very writer the caller is
3546
+ * trying to leave alone.
3547
+ */
3548
+ async startFork(options) {
3549
+ const provider = options.provider ?? CODEX_CLI_PROVIDER;
3550
+ const runner = this.remoteRunner ?? this.runners.get(provider);
3551
+ if (!(runner instanceof CodexPtyRunner)) {
3552
+ const err = new Error(
3553
+ this.remoteRunner ? "Forking is not supported while sessions are hosted by the pty-host" : `Forking is not supported for ${provider} sessions`
3554
+ );
3555
+ err.statusCode = 501;
3556
+ throw err;
3557
+ }
3558
+ return runner.startFork(options);
3559
+ }
3262
3560
  sendInput(sessionId, input) {
3263
3561
  return this.runnerFor(sessionId).sendInput(sessionId, input);
3264
3562
  }
@@ -3534,7 +3832,7 @@ async function discoverWindowsViaCim() {
3534
3832
  "-NoProfile",
3535
3833
  "-NonInteractive",
3536
3834
  "-Command",
3537
- "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
3835
+ `Get-CimInstance Win32_Process -Filter "Name = 'claude.exe' OR Name = 'claude' OR Name = 'node.exe' OR Name = 'bun.exe' OR Name = 'deno.exe'" | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress`
3538
3836
  ]);
3539
3837
  } catch {
3540
3838
  return null;
@@ -5720,6 +6018,10 @@ var createSessionRoutes = (deps) => {
5720
6018
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
5721
6019
  return alreadyHandled6();
5722
6020
  });
6021
+ app.post("/:id/fork", async (c) => {
6022
+ await deps.handleFork(c.req.param("id"), c.env.incoming, c.env.outgoing);
6023
+ return alreadyHandled6();
6024
+ });
5723
6025
  app.post("/:id/stop", async (c) => {
5724
6026
  await deps.handleStopSession(c.req.param("id"), c.env.outgoing);
5725
6027
  return alreadyHandled6();
@@ -7649,7 +7951,6 @@ function rowToProject(row) {
7649
7951
  lastIndexedAt: row.last_indexed_at,
7650
7952
  latestMessageAt: row.latest_message_at,
7651
7953
  latestMessageId: row.latest_message_id,
7652
- messageCount: row.message_count,
7653
7954
  createdAt: row.created_at,
7654
7955
  updatedAt: row.updated_at
7655
7956
  };
@@ -7668,12 +7969,12 @@ var ProjectsRepository = class {
7668
7969
  INSERT INTO projects (
7669
7970
  id, path, name,
7670
7971
  last_conversation_id, last_conversation_created_at, last_indexed_at,
7671
- latest_message_at, latest_message_id, message_count,
7972
+ latest_message_at, latest_message_id,
7672
7973
  created_at, updated_at
7673
7974
  ) VALUES (
7674
7975
  @id, @path, @name,
7675
7976
  @last_conversation_id, @last_conversation_created_at, @last_indexed_at,
7676
- @latest_message_at, @latest_message_id, @message_count,
7977
+ @latest_message_at, @latest_message_id,
7677
7978
  @created_at, @updated_at
7678
7979
  )
7679
7980
  `);
@@ -7734,7 +8035,6 @@ var ProjectsRepository = class {
7734
8035
  last_indexed_at: now,
7735
8036
  latest_message_at: input.latestMessageAt ?? null,
7736
8037
  latest_message_id: input.latestMessageId ?? null,
7737
- message_count: 0,
7738
8038
  created_at: now,
7739
8039
  updated_at: now
7740
8040
  });
@@ -7831,6 +8131,36 @@ async function recordUpload(pool2, instanceId, row) {
7831
8131
  var import_fs11 = require("fs");
7832
8132
  var import_os7 = require("os");
7833
8133
  var import_path13 = require("path");
8134
+ var HEAD_BYTES = 64 * 1024;
8135
+ var MAX_FILES_PROBED = 3;
8136
+ function readRecordedCwd(dir) {
8137
+ let files;
8138
+ try {
8139
+ files = (0, import_fs11.readdirSync)(dir).filter((f) => f.endsWith(".jsonl"));
8140
+ } catch {
8141
+ return null;
8142
+ }
8143
+ for (const file of files.slice(0, MAX_FILES_PROBED)) {
8144
+ let fd;
8145
+ try {
8146
+ fd = (0, import_fs11.openSync)((0, import_path13.join)(dir, file), "r");
8147
+ const buf = Buffer.alloc(HEAD_BYTES);
8148
+ const bytes = (0, import_fs11.readSync)(fd, buf, 0, HEAD_BYTES, 0);
8149
+ for (const line of buf.subarray(0, bytes).toString("utf8").split("\n")) {
8150
+ if (!line.includes('"cwd"')) continue;
8151
+ try {
8152
+ const cwd = JSON.parse(line).cwd;
8153
+ if (typeof cwd === "string" && cwd.length > 0) return cwd;
8154
+ } catch {
8155
+ }
8156
+ }
8157
+ } catch {
8158
+ } finally {
8159
+ if (fd !== void 0) (0, import_fs11.closeSync)(fd);
8160
+ }
8161
+ }
8162
+ return null;
8163
+ }
7834
8164
  function decodeProjectPath(dirName) {
7835
8165
  return dirName.replace(/-/g, "/");
7836
8166
  }
@@ -7847,9 +8177,7 @@ function handleListProjects(url, res) {
7847
8177
  mtime = (0, import_fs11.statSync)(fullPath).mtimeMs;
7848
8178
  } catch {
7849
8179
  }
7850
- const path = decodeProjectPath(dirName);
7851
- const name = path.split("/").filter(Boolean).pop() ?? dirName;
7852
- return { name, path, dirName, mtime };
8180
+ return { dirName: String(dirName), mtime };
7853
8181
  }).sort((a, b) => b.mtime - a.mtime);
7854
8182
  } catch {
7855
8183
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -7857,7 +8185,11 @@ function handleListProjects(url, res) {
7857
8185
  return;
7858
8186
  }
7859
8187
  const total = entries.length;
7860
- const page = entries.slice(offset, offset + limit).map(({ name, path, dirName }) => ({ name, path, dirName }));
8188
+ const page = entries.slice(offset, offset + limit).map(({ dirName }) => {
8189
+ const path = readRecordedCwd((0, import_path13.join)(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
8190
+ const name = path.split(/[\\/]/).filter(Boolean).pop() ?? dirName;
8191
+ return { name, path, dirName };
8192
+ });
7861
8193
  res.writeHead(200, { "Content-Type": "application/json" });
7862
8194
  res.end(JSON.stringify({ projects: page, total }));
7863
8195
  }
@@ -8383,8 +8715,12 @@ var ConversationWatcher = class {
8383
8715
  offset = 0;
8384
8716
  }
8385
8717
  const watcher = import_chokidar.default.watch(filePath, {
8386
- ignoreInitial: true,
8387
- awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
8718
+ ignoreInitial: true
8719
+ // No awaitWriteFinish: readNewLines already coalesces bursts via the
8720
+ // reading/pending flags, and awaitWriteFinish on Linux has been observed
8721
+ // to drop the unlink when a just-created file is deleted inside the
8722
+ // stability window — leaving an external tail attached until the 5 min
8723
+ // idle sweep (#393).
8388
8724
  });
8389
8725
  watcher.on("change", () => {
8390
8726
  void this.readNewLines(key);
@@ -9612,6 +9948,69 @@ function planAutoResume(rows, opts) {
9612
9948
  };
9613
9949
  }
9614
9950
 
9951
+ // src/services/sessions/codexRolloutOwner.ts
9952
+ var import_child_process4 = require("child_process");
9953
+ var ROLLOUT_OWNER_TIMEOUT_MS = 800;
9954
+ function runLsof(rolloutPath, timeoutMs) {
9955
+ return new Promise((resolve2, reject) => {
9956
+ const child = (0, import_child_process4.execFile)(
9957
+ "lsof",
9958
+ ["-F", "pc", "-w", "--", rolloutPath],
9959
+ { windowsHide: true },
9960
+ (err, stdout) => {
9961
+ clearTimeout(timer);
9962
+ if (err && !stdout) {
9963
+ reject(err);
9964
+ return;
9965
+ }
9966
+ resolve2(stdout);
9967
+ }
9968
+ );
9969
+ const timer = setTimeout(() => {
9970
+ try {
9971
+ child.kill("SIGKILL");
9972
+ } catch {
9973
+ }
9974
+ child.stdout?.destroy();
9975
+ child.stderr?.destroy();
9976
+ reject(new Error("lsof timed out"));
9977
+ }, timeoutMs);
9978
+ timer.unref?.();
9979
+ child.unref();
9980
+ });
9981
+ }
9982
+ function parseLsofFieldOutput(stdout) {
9983
+ const owners = [];
9984
+ let pid = null;
9985
+ for (const line of stdout.split("\n")) {
9986
+ if (line.startsWith("p")) {
9987
+ const n = Number.parseInt(line.slice(1), 10);
9988
+ pid = Number.isFinite(n) ? n : null;
9989
+ } else if (line.startsWith("c") && pid != null) {
9990
+ owners.push({ pid, command: line.slice(1).trim() });
9991
+ pid = null;
9992
+ }
9993
+ }
9994
+ return owners;
9995
+ }
9996
+ async function findRolloutOwner(rolloutPath, options = {}) {
9997
+ const platform3 = options.platform ?? process.platform;
9998
+ if (platform3 === "win32") return null;
9999
+ const selfPid = options.selfPid ?? process.pid;
10000
+ const run2 = options.run ?? runLsof;
10001
+ let stdout;
10002
+ try {
10003
+ stdout = await run2(rolloutPath, options.timeoutMs ?? ROLLOUT_OWNER_TIMEOUT_MS);
10004
+ } catch {
10005
+ return null;
10006
+ }
10007
+ for (const { pid, command } of parseLsofFieldOutput(stdout)) {
10008
+ if (pid === selfPid) continue;
10009
+ return { pid, command, source: command === "codex" ? "terminal" : "unknown" };
10010
+ }
10011
+ return null;
10012
+ }
10013
+
9615
10014
  // src/services/sessions/conversationBusy.ts
9616
10015
  var import_fs18 = require("fs");
9617
10016
  var RESUME_BUSY_WINDOW_MS = 12e4;
@@ -9924,6 +10323,9 @@ function findCursorBoundary(sorted, cursor, key, order) {
9924
10323
  }
9925
10324
  return sorted.length;
9926
10325
  }
10326
+ function isLiveMultiAgent(s) {
10327
+ return s.currentTurnId !== void 0 && (s.status === "running" || s.status === "waiting_input");
10328
+ }
9927
10329
  function managedToResponse(s, ptyAttached) {
9928
10330
  return {
9929
10331
  id: s.id,
@@ -9942,8 +10344,18 @@ function managedToResponse(s, ptyAttached) {
9942
10344
  // leaves it gone, but the conversation is still resumable, not terminal —
9943
10345
  // `putOnHold` records that by leaving `statusSource: "shutdown"` (the only
9944
10346
  // place either runner sets it), so it is checked here alongside `rehydrated`.
9945
- lifecycle: ptyAttached ? "attached" : s.rehydrated || s.statusSource === "shutdown" ? "resumable" : s.failureReason != null ? "failed" : "completed",
9946
- lifecycleSource: ptyAttached ? s.reconciled ? "reconcile" : "spawn" : s.rehydrated ? "reconcile" : "exit",
10347
+ // Multi-agent sessions never have a PTY (`currentTurnId` is defined null
10348
+ // while idle between turns only on that path). While their status is
10349
+ // still live they are `attached`, not terminal (#438).
10350
+ // Otherwise, terminal requires evidence of termination — a recorded
10351
+ // `failureReason`, or the `completedAt` every exit path stamps. Without
10352
+ // either, no PTY here means the spawn has not landed, which is `starting`,
10353
+ // not `completed` (tb-mobile #508). Scoped to the PTY path only
10354
+ // (`currentTurnId === undefined`) — multi-agent never stamps `completedAt`
10355
+ // at all, so an idle multi-agent session (no pre-attach race to
10356
+ // disambiguate) stays `completed` regardless.
10357
+ lifecycle: ptyAttached || isLiveMultiAgent(s) ? "attached" : s.rehydrated || s.statusSource === "shutdown" ? "resumable" : s.failureReason != null ? "failed" : s.currentTurnId === void 0 && s.completedAt == null ? "starting" : "completed",
10358
+ lifecycleSource: ptyAttached || isLiveMultiAgent(s) ? s.reconciled ? "reconcile" : "spawn" : s.rehydrated ? "reconcile" : s.currentTurnId === void 0 && s.completedAt == null ? "spawn" : "exit",
9947
10359
  // We own its PTY, so `status` is the authoritative signal — no inferred
9948
10360
  // `activity` is attached for managed sessions.
9949
10361
  ownership: s.rehydrated ? "historical" : "managed",
@@ -9979,6 +10391,9 @@ function managedToResponse(s, ptyAttached) {
9979
10391
  ...s.resumedFromConversationId != null && {
9980
10392
  resumedFromConversationId: s.resumedFromConversationId
9981
10393
  },
10394
+ ...s.forkedFromConversationId != null && {
10395
+ forkedFromConversationId: s.forkedFromConversationId
10396
+ },
9982
10397
  ...s.boundConversationId != null && { boundConversationId: s.boundConversationId },
9983
10398
  ...s.interruptedStatus != null && { interruptedStatus: s.interruptedStatus }
9984
10399
  };
@@ -10356,6 +10771,13 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
10356
10771
  var ADOPT_KILL_POLL_MS = 100;
10357
10772
  var REFRESH_TTL_MS = 2e3;
10358
10773
  var START_READY_TIMEOUT_MS = 1e4;
10774
+ var CODEX_STARTUP_TIMEOUT_MS = 4e3;
10775
+ function resolveCodexStartupTimeoutMs(env = process.env) {
10776
+ const raw = env.THREADBASE_CODEX_STARTUP_TIMEOUT_MS;
10777
+ if (raw === void 0) return CODEX_STARTUP_TIMEOUT_MS;
10778
+ const n = Number.parseInt(raw, 10);
10779
+ return Number.isFinite(n) && n >= 0 ? n : CODEX_STARTUP_TIMEOUT_MS;
10780
+ }
10359
10781
  var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
10360
10782
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
10361
10783
  var EXTERNAL_TAIL_MAX = 32;
@@ -10366,6 +10788,22 @@ function parseIncludeAgentsEnv(raw) {
10366
10788
  const v = raw.trim().toLowerCase();
10367
10789
  return !(v === "0" || v === "false" || v === "no" || v === "off" || v === "");
10368
10790
  }
10791
+ function codexSessionActiveBody(outcome) {
10792
+ return {
10793
+ error: "This Codex session is already open in another client",
10794
+ code: "CONVERSATION_BUSY",
10795
+ reasonCode: "CODEX_SESSION_ACTIVE",
10796
+ provider: CODEX_CLI_PROVIDER,
10797
+ detectedBy: outcome.detectedBy,
10798
+ lastActivityMs: outcome.lastActivityMs,
10799
+ likelyOwner: "external",
10800
+ canForce: false,
10801
+ canTakeOver: false,
10802
+ canFork: true,
10803
+ ...outcome.ownerPid != null && { ownerPid: outcome.ownerPid },
10804
+ ...outcome.ownerSource != null && { ownerSource: outcome.ownerSource }
10805
+ };
10806
+ }
10369
10807
  var StreamerServer = class {
10370
10808
  httpServer;
10371
10809
  ptyManager;
@@ -10562,6 +11000,10 @@ var StreamerServer = class {
10562
11000
  liveActivityNotifier = null;
10563
11001
  liveActivityRenewal = null;
10564
11002
  discoveryCache = null;
11003
+ // Single-flight for process discovery. Mobile polls GET /api/sessions and
11004
+ // retries on timeout; without this, every concurrent request starts its own
11005
+ // Windows CIM scan (observed: overlapping 80–100s /api/sessions responses).
11006
+ discoveryInFlight = null;
10565
11007
  cacheDir;
10566
11008
  runtimeDbPath;
10567
11009
  tailSize;
@@ -10615,8 +11057,9 @@ var StreamerServer = class {
10615
11057
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
10616
11058
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
10617
11059
  this.markScannerStaleDebounced = debounce(() => {
10618
- if (this.scannerReady) this.scannerStale = true;
10619
- else {
11060
+ if (this.scannerReady) {
11061
+ if (this.staleFiles.size > 0) this.scannerStale = true;
11062
+ } else {
10620
11063
  this.scanner = null;
10621
11064
  this.staleFiles.clear();
10622
11065
  }
@@ -10708,6 +11151,12 @@ var StreamerServer = class {
10708
11151
  this.pendingLineSeqs.delete(filePath);
10709
11152
  },
10710
11153
  onConversationChanged: (filePath) => {
11154
+ try {
11155
+ (0, import_fs19.statSync)(filePath);
11156
+ } catch {
11157
+ this.handleJsonlDeleted(filePath);
11158
+ return;
11159
+ }
10711
11160
  const tailed = this.fileWatcher.poke(filePath);
10712
11161
  if (!tailed) this.maybeAttachExternalTail(filePath);
10713
11162
  this.sweepIdleExternalTails();
@@ -10727,21 +11176,7 @@ var StreamerServer = class {
10727
11176
  event: "tail.truncated"
10728
11177
  });
10729
11178
  },
10730
- onFileDeleted: (filePath) => {
10731
- this.detachExternalTail(canonicalizeFilePath(filePath));
10732
- if (this.cacheMonitor?.pending) {
10733
- this.cacheMonitor.deferUnlink(filePath);
10734
- return;
10735
- }
10736
- const id = this.cache?.invalidateByFilePath(filePath);
10737
- if (id)
10738
- this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
10739
- id,
10740
- filePath,
10741
- event: "cache.invalidate_on_unlink"
10742
- });
10743
- this.cacheMonitor?.recordUnlink(filePath);
10744
- },
11179
+ onFileDeleted: (filePath) => this.handleJsonlDeleted(filePath),
10745
11180
  onError: (filePath, err) => {
10746
11181
  const enospc = err.code === "ENOSPC";
10747
11182
  this.log.error(
@@ -10863,7 +11298,7 @@ var StreamerServer = class {
10863
11298
  this.wsHub.broadcast({ type: "session_update", session: resp });
10864
11299
  }
10865
11300
  void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
10866
- this.sessionStatusBus.emit(`status:${session.id}`, session.status);
11301
+ this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
10867
11302
  }
10868
11303
  });
10869
11304
  this.agentConfig = readAgentConfig();
@@ -10932,6 +11367,7 @@ var StreamerServer = class {
10932
11367
  handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
10933
11368
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
10934
11369
  handleAdopt: (id, res) => this.handleAdopt(id, res),
11370
+ handleFork: (id, req, res) => this.handleFork(id, req, res),
10935
11371
  handleResume: (req, res) => this.handleResume(req, res),
10936
11372
  handleStartSession: (req, res) => this.handleStartSession(req, res),
10937
11373
  handleListConversations: (url, res) => this.handleListConversations(url, res),
@@ -12589,6 +13025,9 @@ var StreamerServer = class {
12589
13025
  return metas.filter((m) => m !== null);
12590
13026
  }
12591
13027
  async getScanner(skipStaleRescan = false) {
13028
+ if (skipStaleRescan && this.scanner) {
13029
+ return this.scanner;
13030
+ }
12592
13031
  if (this.scannerReady) {
12593
13032
  await this.scannerReady;
12594
13033
  if (this.scanner) {
@@ -12631,29 +13070,38 @@ var StreamerServer = class {
12631
13070
  this.scannerReady = null;
12632
13071
  return this.getScanner();
12633
13072
  }
12634
- // refresh=1's scan: reuse the WARM scanner and re-run its scan with
12635
- // fullRescan:true the escape hatch that bypasses the scanner's
12636
- // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
12637
- // the "don't trust the gate, check disk for real" signal. Unlike
12638
- // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
12639
- // only ever reassigned to a live scan promise (never nulled mid-scan), so the
12640
- // getScanner() anti-infinite-loop guard is preserved.
13073
+ // refresh=1's scan: build a SHADOW scanner with fullRescan:true, then swap
13074
+ // it in atomically. The escape hatch bypasses the scanner's dir-mtime
13075
+ // discovery gate (an explicit user pull-to-refresh is exactly the "don't
13076
+ // trust the gate, check disk for real" signal).
13077
+ //
13078
+ // Scanning in place would clear the non-persistent scanner's metadataCache
13079
+ // at start, so a concurrent detail fetch that skipped the await would 404 a
13080
+ // conversation that exists — and one that awaited would pay the full scan's
13081
+ // wall clock (#368). Shadow-and-swap keeps this.scanner readable as the
13082
+ // previous generation for the whole rebuild.
12641
13083
  async rescanForRefresh(onProgress) {
12642
13084
  if (this.scannerReady) await this.scannerReady;
12643
13085
  this.takeStaleFiles();
12644
- if (!this.scanner) {
12645
- this.scanner = new import_scanner3.ConversationScanner();
12646
- this.allScanners.add(this.scanner);
12647
- }
12648
- const scanner = this.scanner;
12649
- this.scannerReady = scanner.scan({
13086
+ const previous = this.scanner;
13087
+ const statCache = this.buildStatCache(previous);
13088
+ const shadow = this.newScanner(statCache ? { persistent: false } : void 0);
13089
+ this.allScanners.add(shadow);
13090
+ this.scannerReady = shadow.scan({
12650
13091
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
12651
13092
  ...this.codexScanOpts(),
12652
13093
  fullRescan: true,
13094
+ ...statCache ? { statCache } : {},
12653
13095
  ...onProgress ? { onProgress } : {}
12654
13096
  });
12655
- await this.scannerReady;
12656
- return scanner;
13097
+ try {
13098
+ await this.scannerReady;
13099
+ } catch (err) {
13100
+ this.scannerReady = null;
13101
+ throw err;
13102
+ }
13103
+ this.scanner = shadow;
13104
+ return shadow;
12657
13105
  }
12658
13106
  /**
12659
13107
  * The projects dirs disk discovery should walk — the single source of truth
@@ -12807,6 +13255,26 @@ var StreamerServer = class {
12807
13255
  event: "external_tail.detach"
12808
13256
  });
12809
13257
  }
13258
+ /**
13259
+ * Shared unlink path for the per-file watcher and the directory watcher.
13260
+ * Detaches any external tail and drops the cache row (unless an integrity
13261
+ * alert is freezing deletes).
13262
+ */
13263
+ handleJsonlDeleted(filePath) {
13264
+ this.detachExternalTail(canonicalizeFilePath(filePath));
13265
+ if (this.cacheMonitor?.pending) {
13266
+ this.cacheMonitor.deferUnlink(filePath);
13267
+ return;
13268
+ }
13269
+ const id = this.cache?.invalidateByFilePath(filePath);
13270
+ if (id)
13271
+ this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
13272
+ id,
13273
+ filePath,
13274
+ event: "cache.invalidate_on_unlink"
13275
+ });
13276
+ this.cacheMonitor?.recordUnlink(filePath);
13277
+ }
12810
13278
  /** Make room for one more tail by evicting the least recently active ones. */
12811
13279
  evictExternalTailsIfNeeded() {
12812
13280
  while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
@@ -12951,6 +13419,24 @@ var StreamerServer = class {
12951
13419
  if (this.scanProfiles) return null;
12952
13420
  const filePath = this.findJsonlPath(lookupId) ?? this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId);
12953
13421
  if (!filePath) return null;
13422
+ if (this.scannerReady) {
13423
+ const account = this.cache?.getMetaById(lookupId)?.account ?? void 0;
13424
+ const singleFileScanner = this.scanner ?? this.newScanner();
13425
+ try {
13426
+ const page = await singleFileScanner.parseSingleFilePage(filePath, account, {
13427
+ limit: Number.MAX_SAFE_INTEGER
13428
+ });
13429
+ if (page?.conversation) return page.conversation;
13430
+ } catch (err) {
13431
+ this.log.warn("detail.single_file_parse_failed", {
13432
+ event: "detail.single_file_parse_failed",
13433
+ conversationId: lookupId,
13434
+ filePath,
13435
+ err
13436
+ });
13437
+ }
13438
+ return null;
13439
+ }
12954
13440
  this.scanner = null;
12955
13441
  this.scannerReady = null;
12956
13442
  const freshScanner = await this.getScanner();
@@ -13331,15 +13817,7 @@ var StreamerServer = class {
13331
13817
  }
13332
13818
  async handleListSessions(url, res) {
13333
13819
  if (this.rejectIfWarmingUp(res)) return;
13334
- const now = Date.now();
13335
- if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
13336
- try {
13337
- const discovered = await discoverClaudeProcesses();
13338
- this.sessionStore.setDiscovered(discovered);
13339
- this.discoveryCache = { entries: discovered, fetchedAt: now };
13340
- } catch {
13341
- }
13342
- }
13820
+ await this.refreshDiscovery();
13343
13821
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
13344
13822
  if (!hasPaginationParams) {
13345
13823
  json(
@@ -13368,6 +13846,36 @@ var StreamerServer = class {
13368
13846
  throw err;
13369
13847
  }
13370
13848
  }
13849
+ /**
13850
+ * Refresh the discovered-process list, sharing one in-flight enumeration
13851
+ * across concurrent callers and honouring the 15s TTL cache.
13852
+ */
13853
+ async refreshDiscovery() {
13854
+ const cached3 = this.discoveryCache;
13855
+ if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
13856
+ return cached3.entries;
13857
+ }
13858
+ if (this.discoveryInFlight) {
13859
+ return this.discoveryInFlight;
13860
+ }
13861
+ let flight;
13862
+ flight = (async () => {
13863
+ try {
13864
+ const discovered = await discoverClaudeProcesses();
13865
+ this.sessionStore.setDiscovered(discovered);
13866
+ this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
13867
+ return discovered;
13868
+ } catch {
13869
+ return this.discoveryCache?.entries ?? [];
13870
+ } finally {
13871
+ if (this.discoveryInFlight === flight) {
13872
+ this.discoveryInFlight = null;
13873
+ }
13874
+ }
13875
+ })();
13876
+ this.discoveryInFlight = flight;
13877
+ return flight;
13878
+ }
13371
13879
  async handleGetSession(sessionId, res) {
13372
13880
  if (this.rejectIfWarmingUp(res)) return;
13373
13881
  const base = this.sessionStore.get(sessionId, this.ptyAttachedIds());
@@ -13429,7 +13937,23 @@ var StreamerServer = class {
13429
13937
  code: "CONVERSATION_BUSY",
13430
13938
  detectedBy: outcome.detectedBy,
13431
13939
  lastActivityMs: outcome.lastActivityMs,
13432
- likelyOwner: outcome.likelyOwner
13940
+ likelyOwner: outcome.likelyOwner,
13941
+ // Additive capability hints (see docs/compatibility/tb-mobile.md).
13942
+ // Older clients ignore them and keep deriving the same actions from
13943
+ // `likelyOwner`; newer ones must honour these instead of guessing.
13944
+ canForce: true,
13945
+ canTakeOver: outcome.likelyOwner === "external",
13946
+ canFork: false
13947
+ });
13948
+ return;
13949
+ case "codex_session_active":
13950
+ json(res, 409, codexSessionActiveBody(outcome));
13951
+ return;
13952
+ case "codex_start_failed":
13953
+ json(res, 502, {
13954
+ error: outcome.failureReason,
13955
+ code: "SESSION_START_FAILED",
13956
+ provider: CODEX_CLI_PROVIDER
13433
13957
  });
13434
13958
  return;
13435
13959
  }
@@ -13441,6 +13965,116 @@ var StreamerServer = class {
13441
13965
  this.broadcastOrUnicastSessionList(req);
13442
13966
  json(res, 201, outcome.response ?? outcome.session);
13443
13967
  }
13968
+ /**
13969
+ * `POST /api/sessions/:id/fork` — continue a conversation this streamer is
13970
+ * not allowed to resume, without touching whoever owns it.
13971
+ *
13972
+ * Codex only (`codex fork <id>`): Claude Code has no equivalent, and there is
13973
+ * no safe generic fallback — quietly resuming instead would attach to the
13974
+ * exact writer the caller is trying to leave alone, which is the failure this
13975
+ * endpoint exists to avoid.
13976
+ *
13977
+ * NOT idempotent by default: every accepted call starts another Codex
13978
+ * process and another rollout. Clients that retry on timeout must send
13979
+ * `idempotencyKey`, which replays the first outcome for 10 minutes (same
13980
+ * store and semantics as `POST /:id/input`).
13981
+ */
13982
+ async handleFork(sessionId, req, res) {
13983
+ const body = await readBody2(req);
13984
+ let idempotencyKey;
13985
+ try {
13986
+ idempotencyKey = readIdempotencyKey(body);
13987
+ } catch (err) {
13988
+ json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
13989
+ return;
13990
+ }
13991
+ if (idempotencyKey) {
13992
+ const replayed = this.idempotency.get(sessionId, idempotencyKey);
13993
+ if (replayed) {
13994
+ json(res, replayed.status, replayed.body);
13995
+ return;
13996
+ }
13997
+ }
13998
+ const target = await this.resolveConversationTarget(sessionId);
13999
+ if (!target.ok) {
14000
+ if (target.reason === "history_file_missing") {
14001
+ json(res, 404, {
14002
+ error: "Conversation history file is missing; it can no longer be forked",
14003
+ code: "history_file_missing"
14004
+ });
14005
+ } else {
14006
+ json(res, 400, { error: "Could not determine project path" });
14007
+ }
14008
+ return;
14009
+ }
14010
+ if (target.provider !== CODEX_CLI_PROVIDER) {
14011
+ json(res, 501, {
14012
+ error: "Forking is only supported for Codex sessions",
14013
+ code: "UNSUPPORTED_PROVIDER",
14014
+ provider: target.provider
14015
+ });
14016
+ return;
14017
+ }
14018
+ this.discoveryCache = null;
14019
+ let session;
14020
+ try {
14021
+ session = await this.ptyManager.startFork({
14022
+ provider: CODEX_CLI_PROVIDER,
14023
+ // The rollout id, never the placeholder the client navigated to — it is
14024
+ // the only id `codex fork` accepts.
14025
+ forkFromId: target.historyId,
14026
+ projectPath: target.projectPath,
14027
+ projectName: body.projectName,
14028
+ branch: body.branch
14029
+ });
14030
+ } catch (err) {
14031
+ const message = err instanceof Error ? err.message : "Failed to fork session";
14032
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
14033
+ this.log.error(`[fork] failed to fork ${sessionId}: ${message}`, {
14034
+ event: "session.fork_failed",
14035
+ sessionId,
14036
+ error: message
14037
+ });
14038
+ json(res, statusCode, { error: message, code: "FORK_FAILED" });
14039
+ return;
14040
+ }
14041
+ session.forkedFromConversationId = target.historyId;
14042
+ this.sessionStore.addManaged(session);
14043
+ this.recordSessionSpawn(session);
14044
+ const { outcome, session: settled } = await this.waitForStartupOutcome(
14045
+ session.id,
14046
+ resolveCodexStartupTimeoutMs()
14047
+ );
14048
+ if (outcome === "failed") {
14049
+ const failed = settled ?? this.sessionStore.getManaged(session.id);
14050
+ this.abandonFailedStart(session.id);
14051
+ if (failed?.failureCode === CODEX_ACTIVE_WRITER_CODE) {
14052
+ json(res, 409, codexSessionActiveBody({ detectedBy: [], lastActivityMs: null }));
14053
+ return;
14054
+ }
14055
+ json(res, 502, {
14056
+ error: failed?.failureReason ?? "Codex exited before the fork became ready",
14057
+ code: "SESSION_START_FAILED",
14058
+ provider: CODEX_CLI_PROVIDER
14059
+ });
14060
+ return;
14061
+ }
14062
+ this.watchForCodexRollout(session.id, target.projectPath);
14063
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
14064
+ const result = {
14065
+ status: outcome === "ready" ? 201 : 202,
14066
+ body: outcome === "ready" ? response ?? session : { id: session.id, status: "pending", forkedFromConversationId: target.historyId }
14067
+ };
14068
+ if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
14069
+ this.log.info(`[fork] forked ${target.historyId} into ${session.id}`, {
14070
+ event: "session.forked",
14071
+ sessionId: session.id,
14072
+ forkedFromConversationId: target.historyId,
14073
+ outcome
14074
+ });
14075
+ this.broadcastOrUnicastSessionList(req);
14076
+ json(res, result.status, result.body);
14077
+ }
13444
14078
  /**
13445
14079
  * Resume a session, from an HTTP request or from the boot path.
13446
14080
  *
@@ -13461,43 +14095,38 @@ var StreamerServer = class {
13461
14095
  return { ok: true, alreadyRunning: true, session: null, response: resp };
13462
14096
  }
13463
14097
  }
13464
- let jsonlPath = this.findJsonlPath(sessionId);
13465
- let conv = await this.findConversationByUuid(sessionId);
13466
- let historyId = sessionId;
13467
- let registryProvider;
13468
- if (!jsonlPath && !conv) {
13469
- const row = this.managedSessionsRepo?.get(sessionId) ?? null;
13470
- const boundId = row ? resumeIdForRow(row) : null;
13471
- if (boundId != null && boundId !== sessionId) {
13472
- historyId = boundId;
13473
- registryProvider = row?.provider;
13474
- jsonlPath = this.findJsonlPath(boundId);
13475
- conv = await this.findConversationByUuid(boundId);
14098
+ const target = await this.resolveConversationTarget(sessionId);
14099
+ if (!target.ok) return target;
14100
+ const { historyId, jsonlPath, historyPath, conv, projectPath, provider } = target;
14101
+ if (provider === CODEX_CLI_PROVIDER && historyPath) {
14102
+ const owner = await findRolloutOwner(historyPath);
14103
+ if (owner) {
14104
+ this.log.info(`[resume] codex rollout held by pid ${owner.pid}`, {
14105
+ event: "session.codex_rollout_busy",
14106
+ sessionId,
14107
+ historyId,
14108
+ ownerPid: owner.pid,
14109
+ ownerCommand: owner.command
14110
+ });
14111
+ return {
14112
+ ok: false,
14113
+ reason: "codex_session_active",
14114
+ detectedBy: ["file_handle"],
14115
+ lastActivityMs: null,
14116
+ ownerPid: owner.pid,
14117
+ ownerSource: owner.source
14118
+ };
13476
14119
  }
13477
14120
  }
13478
- const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
13479
- const projectPath = jsonlCwd ?? conv?.projectPath;
13480
- if (!projectPath) {
13481
- if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
13482
- return { ok: false, reason: "no_project_path" };
13483
- }
13484
14121
  let discovered = [];
13485
- const cached3 = this.discoveryCache;
13486
- if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
13487
- discovered = cached3.entries;
13488
- } else {
13489
- try {
13490
- discovered = await Promise.race([
13491
- discoverClaudeProcesses(),
13492
- new Promise(
13493
- (resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
13494
- )
13495
- ]);
13496
- if (discovered.length > 0) {
13497
- this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
13498
- }
13499
- } catch {
13500
- }
14122
+ try {
14123
+ discovered = await Promise.race([
14124
+ this.refreshDiscovery(),
14125
+ new Promise(
14126
+ (resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
14127
+ )
14128
+ ]);
14129
+ } catch {
13501
14130
  }
13502
14131
  const busy = conversationBusy({
13503
14132
  // The id another owner's argv would actually carry — for a placeholder
@@ -13521,10 +14150,6 @@ var StreamerServer = class {
13521
14150
  if (busy.busy) {
13522
14151
  this.contendedSessions.add(sessionId);
13523
14152
  }
13524
- const cachedConvMeta = this.cache?.getMetaById(historyId);
13525
- const provider = coerceProviderForRunner(
13526
- conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
13527
- );
13528
14153
  this.discoveryCache = null;
13529
14154
  const session = await this.ptyManager.start(sessionId, {
13530
14155
  provider,
@@ -13540,11 +14165,136 @@ var StreamerServer = class {
13540
14165
  if (historyId !== sessionId) session.boundConversationId = historyId;
13541
14166
  this.sessionStore.addManaged(session);
13542
14167
  this.recordSessionSpawn(session);
14168
+ if (provider === CODEX_CLI_PROVIDER) {
14169
+ const { outcome, session: settled } = await this.waitForStartupOutcome(
14170
+ sessionId,
14171
+ resolveCodexStartupTimeoutMs()
14172
+ );
14173
+ if (outcome === "failed") {
14174
+ const failed = settled ?? this.sessionStore.getManaged(sessionId);
14175
+ this.abandonFailedStart(sessionId);
14176
+ if (failed?.failureCode === CODEX_ACTIVE_WRITER_CODE) {
14177
+ return {
14178
+ ok: false,
14179
+ reason: "codex_session_active",
14180
+ detectedBy: [],
14181
+ lastActivityMs: null
14182
+ };
14183
+ }
14184
+ return {
14185
+ ok: false,
14186
+ reason: "codex_start_failed",
14187
+ failureReason: failed?.failureReason ?? "Codex exited before becoming ready"
14188
+ };
14189
+ }
14190
+ }
13543
14191
  void this.watchConversationFile(sessionId, historyId);
13544
14192
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
13545
14193
  const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
13546
14194
  return { ok: true, alreadyRunning: false, session, response };
13547
14195
  }
14196
+ /**
14197
+ * Resolve a client-supplied session/conversation id into everything needed to
14198
+ * launch against it: the id the PROVIDER filed the history under, that
14199
+ * history's path, the project cwd, and which CLI owns it.
14200
+ *
14201
+ * Shared by resume and fork so the two can never disagree about identity —
14202
+ * which for Codex is the whole difficulty: the id a client navigated to may
14203
+ * be a local placeholder, and only the registry knows the rollout id behind
14204
+ * it.
14205
+ */
14206
+ async resolveConversationTarget(sessionId) {
14207
+ let jsonlPath = this.findJsonlPath(sessionId);
14208
+ let conv = await this.findConversationByUuid(sessionId);
14209
+ let historyId = sessionId;
14210
+ let registryProvider;
14211
+ if (!jsonlPath && !conv) {
14212
+ const row = this.managedSessionsRepo?.get(sessionId) ?? null;
14213
+ const boundId = row ? resumeIdForRow(row) : null;
14214
+ if (boundId != null && boundId !== sessionId) {
14215
+ historyId = boundId;
14216
+ registryProvider = row?.provider;
14217
+ jsonlPath = this.findJsonlPath(boundId);
14218
+ conv = await this.findConversationByUuid(boundId);
14219
+ }
14220
+ }
14221
+ const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
14222
+ const projectPath = jsonlCwd ?? conv?.projectPath;
14223
+ if (!projectPath) {
14224
+ if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
14225
+ return { ok: false, reason: "no_project_path" };
14226
+ }
14227
+ const cachedConvMeta = this.cache?.getMetaById(historyId);
14228
+ const provider = coerceProviderForRunner(
14229
+ conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
14230
+ );
14231
+ return {
14232
+ ok: true,
14233
+ historyId,
14234
+ jsonlPath,
14235
+ // findJsonlPath() only knows Claude's `<uuid>.jsonl` layout under
14236
+ // ~/.claude/projects; a Codex rollout lives in a date-nested directory
14237
+ // under a name it chose, so its path only ever comes from the indexed
14238
+ // conversation. Kept separate from `jsonlPath` deliberately: feeding it to
14239
+ // conversationBusy() would newly arm the mtime heuristic for Codex, which
14240
+ // is exactly the over-broad signal the report ruled out.
14241
+ historyPath: jsonlPath ?? conv?.filePath ?? null,
14242
+ conv,
14243
+ projectPath,
14244
+ provider
14245
+ };
14246
+ }
14247
+ /**
14248
+ * Block until a freshly spawned session reaches `waiting_input` (ready) or
14249
+ * `idle` (failed), or until `timeoutMs` elapses with the process still alive.
14250
+ *
14251
+ * "timeout" is not an error: it is the pre-existing asynchronous contract —
14252
+ * the session keeps booting and the caller answers with a pending shape.
14253
+ */
14254
+ waitForStartupOutcome(sessionId, timeoutMs) {
14255
+ return new Promise((resolve2) => {
14256
+ let timer = null;
14257
+ const handler = (status, session) => {
14258
+ if (status !== "waiting_input" && status !== "idle") return;
14259
+ this.sessionStatusBus.off(`status:${sessionId}`, handler);
14260
+ if (timer) clearTimeout(timer);
14261
+ resolve2({
14262
+ outcome: status === "waiting_input" ? "ready" : "failed",
14263
+ session: session ?? null
14264
+ });
14265
+ };
14266
+ this.sessionStatusBus.on(`status:${sessionId}`, handler);
14267
+ timer = setTimeout(() => {
14268
+ this.sessionStatusBus.off(`status:${sessionId}`, handler);
14269
+ resolve2({ outcome: "timeout", session: null });
14270
+ }, timeoutMs);
14271
+ timer.unref?.();
14272
+ });
14273
+ }
14274
+ /**
14275
+ * Drop every trace of a session that never became usable, and hand back what
14276
+ * it failed with.
14277
+ *
14278
+ * The runner has already torn itself down (failStartup / handleExit); what
14279
+ * remains is server-side bookkeeping that would otherwise leave a dead
14280
+ * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
14281
+ * marker that would suppress the mtime collision signal on the NEXT resume —
14282
+ * i.e. it would help hide the very owner we just collided with.
14283
+ */
14284
+ abandonFailedStart(sessionId) {
14285
+ this.sessionStore.removeManaged(sessionId);
14286
+ this.selfPtyEndedAt.delete(sessionId);
14287
+ this.contendedSessions.delete(sessionId);
14288
+ try {
14289
+ this.managedSessionsRepo?.delete(sessionId);
14290
+ } catch (err) {
14291
+ this.log.warn("[registry] failed to drop a failed start", {
14292
+ event: "registry.forget_failed",
14293
+ sessionId,
14294
+ err
14295
+ });
14296
+ }
14297
+ }
13548
14298
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
13549
14299
  try {
13550
14300
  if (!this.sessionStore.getManaged(sessionId)) return;
@@ -14071,19 +14821,7 @@ var StreamerServer = class {
14071
14821
  });
14072
14822
  this.sessionStore.addManaged(session);
14073
14823
  this.recordSessionSpawn(session);
14074
- const readyOrFailed = new Promise((resolve2) => {
14075
- const handler = (status) => {
14076
- if (status === "waiting_input" || status === "idle") {
14077
- this.sessionStatusBus.off(`status:${session.id}`, handler);
14078
- resolve2(status === "waiting_input" ? "ready" : "failed");
14079
- }
14080
- };
14081
- this.sessionStatusBus.on(`status:${session.id}`, handler);
14082
- });
14083
- const timeoutPromise = new Promise(
14084
- (resolve2) => setTimeout(() => resolve2("timeout"), START_READY_TIMEOUT_MS)
14085
- );
14086
- const outcome = await Promise.race([readyOrFailed, timeoutPromise]);
14824
+ const { outcome } = await this.waitForStartupOutcome(session.id, START_READY_TIMEOUT_MS);
14087
14825
  const current = this.sessionStore.get(session.id, this.ptyAttachedIds());
14088
14826
  if (outcome === "ready" && current) {
14089
14827
  json(res, 200, { session: current });
@@ -14559,7 +15297,11 @@ function conversationToResumableSession(c) {
14559
15297
  status: "on_hold",
14560
15298
  // A cached conversation with no process behind it. Distinguishes "nobody is
14561
15299
  // running this" from an external session that IS live (ownership "external").
15300
+ // Match the rehydrated branch of managedToResponse: same conceptual state
15301
+ // ("resumable, no live process") must produce the same wire shape (#438).
14562
15302
  ownership: "historical",
15303
+ lifecycle: "resumable",
15304
+ lifecycleSource: "reconcile",
14563
15305
  ptyAttached: false,
14564
15306
  projectId: c.projectId ?? void 0,
14565
15307
  projectPath: c.projectPath ?? "",