@threadbase-sh/streamer 1.46.1 → 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.js CHANGED
@@ -1040,12 +1040,66 @@ var PTY_COLS = 120;
1040
1040
  var PTY_ROWS = 40;
1041
1041
  var SCREEN_SCROLLBACK = 1e3;
1042
1042
  var CODEX_PROMPT_READY_TEXT = "Ready";
1043
+ var CODEX_BUSY_STATUS_RE = /\b(?:Starting|Working)\b/;
1044
+ var CODEX_MCP_BOOT_RE = /Booting MCP|Starting MCP servers/i;
1043
1045
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
1044
1046
  var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
1047
+ var CODEX_ACTIVE_WRITER_RE = /already has an active writer|-32600/;
1048
+ var CODEX_ACTIVE_WRITER_CODE = "codex_active_writer";
1049
+ var CODEX_USAGE_LIMIT_RE = /you(?:'ve| have) hit your usage limit/i;
1050
+ var CODEX_USAGE_RESET_TIP_RE = /usage limit reset available/i;
1051
+ var CODEX_RATE_LIMIT_MENU_RE = /approaching rate limits/i;
1052
+ function parseCodexNumberedOptions(lines) {
1053
+ const options = [];
1054
+ for (const line of lines) {
1055
+ const m = /^\s*›?\s*(\d+)\.\s+(.+?)\s*$/.exec(line.trimEnd());
1056
+ if (!m) continue;
1057
+ const label = m[2].replace(/\s*\(selected\)\s*$/i, "").trim();
1058
+ options.push({ index: Number(m[1]), label, answerKeys: `${m[1]}\r` });
1059
+ }
1060
+ return options;
1061
+ }
1062
+ function detectCodexBlockingPrompt(lines) {
1063
+ const screenText = lines.join("\n");
1064
+ const usageLine = lines.find((l) => CODEX_USAGE_LIMIT_RE.test(l))?.trim();
1065
+ const tipLine = lines.find((l) => CODEX_USAGE_RESET_TIP_RE.test(l))?.trim();
1066
+ const rateMenu = CODEX_RATE_LIMIT_MENU_RE.test(screenText);
1067
+ if (!usageLine && !rateMenu && !tipLine) return null;
1068
+ const soft = !usageLine && !rateMenu && Boolean(tipLine);
1069
+ const prompt = usageLine ?? lines.find((l) => CODEX_RATE_LIMIT_MENU_RE.test(l))?.trim() ?? tipLine ?? "Codex usage limit reached";
1070
+ const detail = lines.find((l) => /try again at/i.test(l))?.trim();
1071
+ const options = parseCodexNumberedOptions(lines);
1072
+ if (options.length === 0) {
1073
+ options.push({ index: 1, label: soft ? "Dismiss" : "OK", answerKeys: "\x1B" });
1074
+ }
1075
+ return { prompt, ...detail ? { detail } : {}, options, ...soft ? { soft: true } : {} };
1076
+ }
1045
1077
  var QUIET_DETECT_MS = 500;
1046
1078
  var CODEX_READY_FALLBACK_MS = 8e3;
1047
1079
  var SUBMIT_BYTES = "\r";
1048
1080
  var CODEX_SUBMIT_DELAY_MS = 16;
1081
+ var CODEX_SUBMIT_MAX_WAIT_MS = 500;
1082
+ var CODEX_SUBMIT_STALE_MS = 2e3;
1083
+ function codexStatusBarLine(lines) {
1084
+ return [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1085
+ }
1086
+ function codexScreenBlocksComposer(lines) {
1087
+ const screenText = lines.join("\n");
1088
+ if (CODEX_HOOKS_GATE_REGEX.test(screenText) || CODEX_TRUST_GATE_REGEX.test(screenText)) {
1089
+ return true;
1090
+ }
1091
+ if (CODEX_MCP_BOOT_RE.test(screenText)) return true;
1092
+ return CODEX_BUSY_STATUS_RE.test(codexStatusBarLine(lines));
1093
+ }
1094
+ function codexScreenShowsReady(lines) {
1095
+ if (codexScreenBlocksComposer(lines)) return false;
1096
+ return codexStatusBarLine(lines).includes(CODEX_PROMPT_READY_TEXT);
1097
+ }
1098
+ function codexScreenLooksIdle(lines) {
1099
+ if (codexScreenBlocksComposer(lines)) return false;
1100
+ if (codexScreenShowsReady(lines)) return true;
1101
+ return lines.some((l) => /^\s*[›>]\s/.test(l) || l.includes("\u203A"));
1102
+ }
1049
1103
  function digestBytes(s) {
1050
1104
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1051
1105
  if (escaped.length <= 200) return escaped;
@@ -1136,6 +1190,21 @@ var CodexPtyRunner = class {
1136
1190
  quietCheckers = /* @__PURE__ */ new Map();
1137
1191
  // Per-session flat backstop from spawn (CODEX_READY_FALLBACK_MS).
1138
1192
  readyFallbackTimers = /* @__PURE__ */ new Map();
1193
+ // After a user submit: if Working never appears, recover from stuck `running`.
1194
+ submitWatchTimers = /* @__PURE__ */ new Map();
1195
+ // Wall-clock of the last PTY chunk per session — writeSubmit waits until
1196
+ // this hasn't advanced for CODEX_SUBMIT_DELAY_MS before writing \r.
1197
+ lastChunkAt = /* @__PURE__ */ new Map();
1198
+ // Sessions that have shown a Working status bar since the last user submit.
1199
+ // Mid-session Ready→waiting_input only fires after this, so a still-painted
1200
+ // Ready bar immediately after sendInput cannot flip status back before the
1201
+ // turn starts (which would let grace/hold kill a live turn).
1202
+ turnBusy = /* @__PURE__ */ new Set();
1203
+ // Usage-limit / rate-limit menus — content key for deduped permission cards.
1204
+ openBlockingPrompt = /* @__PURE__ */ new Map();
1205
+ // Last codex.screen fingerprint per session — only emit when it changes so
1206
+ // MCP boot redraw storms don't flood the log.
1207
+ lastScreenLog = /* @__PURE__ */ new Map();
1139
1208
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1140
1209
  // concurrent resume for the same session (double-tap, client retry) awaits
1141
1210
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -1165,24 +1234,27 @@ var CodexPtyRunner = class {
1165
1234
  return promise;
1166
1235
  }
1167
1236
  async doStart(sessionId, options) {
1237
+ return this.launch(
1238
+ sessionId,
1239
+ ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1240
+ options
1241
+ );
1242
+ }
1243
+ // Spawn a Codex PTY under `sessionId` and wire up the shared boot machinery
1244
+ // (screen, ready fallback, output/exit handlers). The only difference between
1245
+ // resume, fresh and fork is argv.
1246
+ async launch(sessionId, args, options) {
1168
1247
  const nodePty = await loadPty();
1169
1248
  const projectName = options.projectName ?? basename(options.projectPath);
1170
1249
  let proc;
1171
1250
  try {
1172
- proc = nodePty.spawn(
1173
- resolveCodexExe(),
1174
- // `sessionId` stays the runner's map key — only argv carries the
1175
- // provider-side id, so a resumed Codex session keeps the placeholder id
1176
- // its client already navigated to.
1177
- ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1178
- {
1179
- name: "xterm-256color",
1180
- cols: PTY_COLS,
1181
- rows: PTY_ROWS,
1182
- cwd: options.projectPath,
1183
- env: process.env
1184
- }
1185
- );
1251
+ proc = nodePty.spawn(resolveCodexExe(), args, {
1252
+ name: "xterm-256color",
1253
+ cols: PTY_COLS,
1254
+ rows: PTY_ROWS,
1255
+ cwd: options.projectPath,
1256
+ env: process.env
1257
+ });
1186
1258
  } catch (err) {
1187
1259
  clearCodexExeCache();
1188
1260
  throw err;
@@ -1222,71 +1294,85 @@ var CodexPtyRunner = class {
1222
1294
  // binding logic). This runner generates a local placeholder id for the
1223
1295
  // ManagedSession handle only.
1224
1296
  async startFresh(options) {
1225
- const nodePty = await loadPty();
1226
1297
  const sessionId = randomUUID();
1227
- const projectName = options.projectName ?? basename(options.projectPath);
1228
1298
  const args = ["--cd", options.projectPath, "--no-alt-screen"];
1229
1299
  if (options.systemPrompt) {
1230
1300
  args.push(options.systemPrompt);
1231
1301
  }
1232
- let proc;
1233
- try {
1234
- proc = nodePty.spawn(resolveCodexExe(), args, {
1235
- name: "xterm-256color",
1236
- cols: PTY_COLS,
1237
- rows: PTY_ROWS,
1238
- cwd: options.projectPath,
1239
- env: process.env
1240
- });
1241
- } catch (err) {
1242
- clearCodexExeCache();
1243
- throw err;
1244
- }
1245
- const session = {
1246
- id: sessionId,
1247
- provider: CODEX_CLI_PROVIDER,
1248
- projectPath: options.projectPath,
1249
- projectName,
1250
- branch: "",
1251
- status: "running",
1252
- statusSource: "spawn",
1253
- statusUpdatedAt: /* @__PURE__ */ new Date(),
1254
- startedAt: /* @__PURE__ */ new Date(),
1255
- completedAt: null,
1256
- promptCount: 0,
1257
- lastOutput: "",
1258
- process: proc,
1259
- outputBuffer: Buffer.alloc(0),
1260
- screen: createScreen(),
1261
- inputHistory: []
1262
- };
1263
- this.sessions.set(sessionId, session);
1264
- this.pendingReady.add(sessionId);
1265
- this.armReadyFallback(sessionId);
1266
- proc.onData((data) => {
1267
- this.handleOutput(sessionId, data);
1268
- });
1269
- proc.onExit(({ exitCode }) => {
1270
- this.pendingReady.delete(sessionId);
1271
- this.handleExit(sessionId, exitCode);
1272
- });
1273
- return toPublicSession(session);
1302
+ return this.launch(sessionId, args, options);
1274
1303
  }
1275
- // Flat backstop: if neither the "Ready" marker nor the quiet-checker settled
1276
- // the session within CODEX_READY_FALLBACK_MS of spawn, mark it ready anyway
1277
- // so start requests resolve and mobile can watch the boot live. unref() so a
1278
- // pending timer never holds the process open.
1304
+ /**
1305
+ * Fork an existing Codex conversation into a new, independently-owned one
1306
+ * (`codex fork <session-id>`).
1307
+ *
1308
+ * This is the recovery path for a rollout Codex will not let us resume: fork
1309
+ * starts a *new* rollout seeded from the source's history and never touches
1310
+ * the source's writer, so the terminal / VS Code / desktop client that owns
1311
+ * it keeps running untouched. Like a fresh start, Codex assigns the new
1312
+ * rollout id itself — the returned session is keyed by a local placeholder
1313
+ * until watchForCodexRollout binds the real id.
1314
+ */
1315
+ async startFork(options) {
1316
+ const sessionId = randomUUID();
1317
+ return this.launch(
1318
+ sessionId,
1319
+ ["fork", options.forkFromId, "--cd", options.projectPath, "--no-alt-screen"],
1320
+ options
1321
+ );
1322
+ }
1323
+ // Flat backstop: if the "Ready" marker never appears within
1324
+ // CODEX_READY_FALLBACK_MS of spawn (truncated status bar), mark ready once
1325
+ // the screen is no longer Starting/Working/MCP-booting. Re-arms while the
1326
+ // boot is still busy so a slow MCP load cannot be mistaken for Ready.
1327
+ // unref() so a pending timer never holds the process open.
1279
1328
  armReadyFallback(sessionId) {
1280
1329
  const timer = setTimeout(() => {
1281
1330
  this.readyFallbackTimers.delete(sessionId);
1282
- const session = this.sessions.get(sessionId);
1283
- if (session?.status === "running" && this.pendingReady.has(sessionId)) {
1284
- this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1285
- }
1331
+ void this.tryReadyFallback(sessionId);
1286
1332
  }, CODEX_READY_FALLBACK_MS);
1287
1333
  timer.unref?.();
1288
1334
  this.readyFallbackTimers.set(sessionId, timer);
1289
1335
  }
1336
+ async tryReadyFallback(sessionId) {
1337
+ const session = this.sessions.get(sessionId);
1338
+ if (session?.status !== "running" || !this.pendingReady.has(sessionId)) return;
1339
+ if (session.outputBuffer.length === 0) {
1340
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1341
+ return;
1342
+ }
1343
+ try {
1344
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1345
+ if (!this.pendingReady.has(sessionId)) return;
1346
+ const busy = codexScreenBlocksComposer(lines);
1347
+ const bar = codexStatusBarLine(lines);
1348
+ this.log.info(
1349
+ `[codex.ready_fallback] ${sessionId.slice(0, 8)} busy=${busy} bar=${JSON.stringify(bar.slice(0, 120))}`,
1350
+ {
1351
+ event: "codex.ready_fallback",
1352
+ sessionId,
1353
+ busy,
1354
+ hasReady: codexScreenShowsReady(lines),
1355
+ statusBar: bar.slice(0, 160)
1356
+ }
1357
+ );
1358
+ if (busy) {
1359
+ this.armReadyFallback(sessionId);
1360
+ return;
1361
+ }
1362
+ if (!codexScreenShowsReady(lines) && !codexScreenLooksIdle(lines)) {
1363
+ this.armReadyFallback(sessionId);
1364
+ return;
1365
+ }
1366
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1367
+ } catch (err) {
1368
+ this.log.warn("[codex.ready_fallback] failed", {
1369
+ event: "codex.ready_fallback_failed",
1370
+ sessionId,
1371
+ err
1372
+ });
1373
+ if (this.pendingReady.has(sessionId)) this.armReadyFallback(sessionId);
1374
+ }
1375
+ }
1290
1376
  // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
1291
1377
  sendKeys(sessionId, keys) {
1292
1378
  const session = this.sessions.get(sessionId);
@@ -1347,7 +1433,7 @@ var CodexPtyRunner = class {
1347
1433
  if (session.status === "idle") {
1348
1434
  throw new Error(`Session is idle (no active PTY): ${sessionId}`);
1349
1435
  }
1350
- if (this.pendingReady.has(sessionId)) {
1436
+ if (this.pendingReady.has(sessionId) || this.openGate.has(sessionId)) {
1351
1437
  const queue = this.queuedInputs.get(sessionId) ?? [];
1352
1438
  queue.push(input);
1353
1439
  this.queuedInputs.set(sessionId, queue);
@@ -1371,14 +1457,17 @@ var CodexPtyRunner = class {
1371
1457
  session.statusUpdatedAt = /* @__PURE__ */ new Date();
1372
1458
  this.onStatusChange?.(toPublicSession(session));
1373
1459
  }
1460
+ this.turnBusy.delete(sessionId);
1374
1461
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
1375
1462
  session.lastActivityAt = /* @__PURE__ */ new Date();
1376
1463
  session.promptCount++;
1377
1464
  return session.promptCount;
1378
1465
  }
1379
1466
  // Write the input as plain bytes (no bracketed-paste wrap — Phase 0
1380
- // confirmed Codex accepts plain keystrokes), then submit \r after a short
1381
- // delay so Codex's TUI gets an event-loop tick to process the input first.
1467
+ // confirmed Codex accepts plain keystrokes), then submit \r once the PTY
1468
+ // has been quiet for CODEX_SUBMIT_DELAY_MS. A flat delay fired \r into a
1469
+ // still-repainting TUI and the Enter became a compose newline instead of a
1470
+ // turn submit (same pathology Claude's quiescence wait fixed).
1382
1471
  writeSubmit(sessionId, session, input, path, promptCount) {
1383
1472
  this.recordUserMessage(session, input);
1384
1473
  this.log.info(
@@ -1393,12 +1482,21 @@ var CodexPtyRunner = class {
1393
1482
  phase: "input"
1394
1483
  }
1395
1484
  );
1485
+ const writeAt = Date.now();
1396
1486
  session.process.write(input);
1397
- setTimeout(() => {
1487
+ const trySubmit = () => {
1398
1488
  const current = this.sessions.get(sessionId);
1399
1489
  if (!current || current !== session) return;
1490
+ const now = Date.now();
1491
+ const lastChunk = this.lastChunkAt.get(sessionId) ?? writeAt;
1492
+ const quiet = now - lastChunk >= CODEX_SUBMIT_DELAY_MS;
1493
+ const timedOut = now - writeAt >= CODEX_SUBMIT_MAX_WAIT_MS;
1494
+ if (!quiet && !timedOut) {
1495
+ setTimeout(trySubmit, CODEX_SUBMIT_DELAY_MS);
1496
+ return;
1497
+ }
1400
1498
  this.log.info(
1401
- `[codex.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
1499
+ `[codex.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r waitedMs=${now - writeAt} timedOut=${timedOut}`,
1402
1500
  {
1403
1501
  event: "codex.input_write",
1404
1502
  sessionId,
@@ -1406,11 +1504,55 @@ var CodexPtyRunner = class {
1406
1504
  byteLen: SUBMIT_BYTES.length,
1407
1505
  digest: "\\r",
1408
1506
  path,
1409
- phase: "submit"
1507
+ phase: "submit",
1508
+ waitedMs: now - writeAt,
1509
+ timedOut
1410
1510
  }
1411
1511
  );
1412
1512
  current.process.write(SUBMIT_BYTES);
1413
- }, CODEX_SUBMIT_DELAY_MS);
1513
+ this.armSubmitWatch(sessionId);
1514
+ };
1515
+ setTimeout(trySubmit, CODEX_SUBMIT_DELAY_MS);
1516
+ }
1517
+ // If Working never appears after \r, the turn did not start — recover from
1518
+ // stuck `running` even when no further PTY chunks re-arm the quiet checker
1519
+ // (session ddc67b57: one post-submit chunk, then silence forever).
1520
+ armSubmitWatch(sessionId) {
1521
+ const prev = this.submitWatchTimers.get(sessionId);
1522
+ if (prev) clearTimeout(prev);
1523
+ const timer = setTimeout(() => {
1524
+ this.submitWatchTimers.delete(sessionId);
1525
+ void this.trySubmitStaleRecovery(sessionId);
1526
+ }, CODEX_SUBMIT_STALE_MS);
1527
+ timer.unref?.();
1528
+ this.submitWatchTimers.set(sessionId, timer);
1529
+ }
1530
+ async trySubmitStaleRecovery(sessionId) {
1531
+ const session = this.sessions.get(sessionId);
1532
+ if (session?.status !== "running") return;
1533
+ if (this.turnBusy.has(sessionId)) return;
1534
+ if (session.statusSource !== "user-input") return;
1535
+ try {
1536
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1537
+ if (session.status !== "running" || this.turnBusy.has(sessionId)) return;
1538
+ if (codexScreenBlocksComposer(lines)) {
1539
+ this.armSubmitWatch(sessionId);
1540
+ return;
1541
+ }
1542
+ this.log.info(`[codex.submit_stale] ${sessionId.slice(0, 8)} recovering`, {
1543
+ event: "codex.submit_stale",
1544
+ sessionId,
1545
+ statusBar: codexStatusBarLine(lines).slice(0, 160)
1546
+ });
1547
+ this.markReady(sessionId, session, "quiet-fallback", "submit-stale");
1548
+ } catch (err) {
1549
+ this.log.warn("[codex.submit_stale] failed", {
1550
+ event: "codex.submit_stale_failed",
1551
+ sessionId,
1552
+ err
1553
+ });
1554
+ this.armSubmitWatch(sessionId);
1555
+ }
1414
1556
  }
1415
1557
  // Drain any inputs sent while the session was still pendingReady, writing
1416
1558
  // them in arrival order now that Codex is Ready. No-op while a gate dialog
@@ -1485,11 +1627,20 @@ var CodexPtyRunner = class {
1485
1627
  const timer = this.readyFallbackTimers.get(sessionId);
1486
1628
  if (timer) clearTimeout(timer);
1487
1629
  this.readyFallbackTimers.delete(sessionId);
1630
+ const submitWatch = this.submitWatchTimers.get(sessionId);
1631
+ if (submitWatch) clearTimeout(submitWatch);
1632
+ this.submitWatchTimers.delete(sessionId);
1633
+ this.lastChunkAt.delete(sessionId);
1488
1634
  if (this.openGate.delete(sessionId)) {
1489
1635
  this.onPermissionChange?.(sessionId, null);
1490
1636
  }
1491
1637
  this.gateActioned.delete(`${sessionId}:hooks`);
1492
1638
  this.gateActioned.delete(`${sessionId}:trust`);
1639
+ this.turnBusy.delete(sessionId);
1640
+ if (this.openBlockingPrompt.delete(sessionId)) {
1641
+ this.onPermissionChange?.(sessionId, null);
1642
+ }
1643
+ this.lastScreenLog.delete(sessionId);
1493
1644
  }
1494
1645
  getOutput(sessionId) {
1495
1646
  const session = this.sessions.get(sessionId);
@@ -1561,10 +1712,19 @@ var CodexPtyRunner = class {
1561
1712
  this.gateActioned.clear();
1562
1713
  this.quietCheckers.clear();
1563
1714
  this.readyFallbackTimers.clear();
1715
+ for (const timer of this.submitWatchTimers.values()) {
1716
+ clearTimeout(timer);
1717
+ }
1718
+ this.submitWatchTimers.clear();
1719
+ this.lastChunkAt.clear();
1720
+ this.turnBusy.clear();
1721
+ this.openBlockingPrompt.clear();
1722
+ this.lastScreenLog.clear();
1564
1723
  }
1565
1724
  handleOutput(sessionId, data) {
1566
1725
  const session = this.sessions.get(sessionId);
1567
1726
  if (!session) return;
1727
+ this.lastChunkAt.set(sessionId, Date.now());
1568
1728
  const chunk = Buffer.from(data, "utf-8");
1569
1729
  session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1570
1730
  if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
@@ -1601,16 +1761,24 @@ var CodexPtyRunner = class {
1601
1761
  // - Gates (directory trust, hooks review) — checked on EVERY pass,
1602
1762
  // independent of pendingReady, so a gate appearing after ready is still
1603
1763
  // surfaced and a gate leaving the screen closes its card.
1604
- // - Readiness — the "Ready" status-bar marker while pendingReady, plus the
1605
- // quiet path: after QUIET_DETECT_MS of PTY silence a still-pending
1606
- // session is marked ready anyway (`›` alone is NOT a marker — Phase 0 —
1607
- // but a quiet boot screen is more useful to the user live than a
1608
- // spinner, and "Ready" may be truncated off the 120-col status bar).
1609
- async detectScreenState(sessionId, trigger) {
1764
+ // - Readiness — boot (pendingReady) requires the "Ready" status-bar marker
1765
+ // (quiet alone never settles boot Starting shows `›` already). Mid-
1766
+ // session, running waiting_input after Working then Ready (or a stale
1767
+ // Ready recovery if the turn never started).
1768
+ async detectScreenState(sessionId, _trigger) {
1610
1769
  const session = this.sessions.get(sessionId);
1611
1770
  if (!session || session.status === "idle") return;
1612
1771
  const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1613
1772
  const screenText = lines.join("\n");
1773
+ if (this.pendingReady.has(sessionId) && CODEX_ACTIVE_WRITER_RE.test(screenText)) {
1774
+ this.failStartup(
1775
+ sessionId,
1776
+ session,
1777
+ CODEX_ACTIVE_WRITER_CODE,
1778
+ "This Codex session is already open in another client"
1779
+ );
1780
+ return;
1781
+ }
1614
1782
  const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1615
1783
  if (gate) {
1616
1784
  this.handleGate(sessionId, session, gate, lines);
@@ -1618,12 +1786,83 @@ var CodexPtyRunner = class {
1618
1786
  this.onPermissionChange?.(sessionId, null);
1619
1787
  this.flushQueuedInputs(sessionId);
1620
1788
  }
1621
- if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1622
- const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1623
- if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1624
- this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1625
- } else if (trigger === "quiet") {
1626
- this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
1789
+ const blocking = detectCodexBlockingPrompt(lines);
1790
+ if (blocking) {
1791
+ const elevateSoft = !blocking.soft || session.status === "running" && session.statusSource === "user-input" && !this.turnBusy.has(sessionId) && !this.pendingReady.has(sessionId);
1792
+ if (elevateSoft) {
1793
+ this.handleBlockingPrompt(sessionId, session, blocking);
1794
+ }
1795
+ } else if (this.openBlockingPrompt.delete(sessionId)) {
1796
+ this.onPermissionChange?.(sessionId, null);
1797
+ }
1798
+ const hasReady = codexScreenShowsReady(lines);
1799
+ const busy = codexScreenBlocksComposer(lines);
1800
+ const bar = codexStatusBarLine(lines);
1801
+ const screenFp = [
1802
+ this.pendingReady.has(sessionId) ? "1" : "0",
1803
+ session.status,
1804
+ hasReady ? "1" : "0",
1805
+ busy ? "1" : "0",
1806
+ blocking ? blocking.soft ? "soft" : "1" : "0",
1807
+ bar.slice(0, 80)
1808
+ ].join("|");
1809
+ if (this.lastScreenLog.get(sessionId) !== screenFp) {
1810
+ this.lastScreenLog.set(sessionId, screenFp);
1811
+ this.log.info(
1812
+ `[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))}`,
1813
+ {
1814
+ event: "codex.screen",
1815
+ sessionId,
1816
+ trigger: _trigger,
1817
+ pendingReady: this.pendingReady.has(sessionId),
1818
+ status: session.status,
1819
+ hasReady,
1820
+ busy,
1821
+ usageHit: Boolean(blocking),
1822
+ usageSoft: Boolean(blocking?.soft),
1823
+ statusBar: bar.slice(0, 160)
1824
+ }
1825
+ );
1826
+ }
1827
+ if (this.pendingReady.has(sessionId)) {
1828
+ if (hasReady) {
1829
+ this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1830
+ }
1831
+ return;
1832
+ }
1833
+ if (session.status === "running") {
1834
+ if (/\bWorking\b/.test(bar)) {
1835
+ this.turnBusy.add(sessionId);
1836
+ const watch = this.submitWatchTimers.get(sessionId);
1837
+ if (watch) clearTimeout(watch);
1838
+ this.submitWatchTimers.delete(sessionId);
1839
+ }
1840
+ if (hasReady && this.turnBusy.has(sessionId)) {
1841
+ this.turnBusy.delete(sessionId);
1842
+ this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1843
+ } else if (!this.turnBusy.has(sessionId) && !busy && session.statusSource === "user-input" && session.statusUpdatedAt != null && Date.now() - session.statusUpdatedAt.getTime() >= CODEX_SUBMIT_STALE_MS) {
1844
+ this.markReady(sessionId, session, "quiet-fallback", "submit-stale");
1845
+ }
1846
+ }
1847
+ }
1848
+ // Surface quota / rate-limit screens as permission cards and stop leaving
1849
+ // the session stuck in `running` while Codex waits for a menu pick.
1850
+ handleBlockingPrompt(sessionId, session, blocking) {
1851
+ const key = `${blocking.prompt}\0${blocking.detail ?? ""}\0${blocking.options.map((o) => o.index).join(",")}`;
1852
+ const prev = this.openBlockingPrompt.get(sessionId);
1853
+ if (prev !== key) {
1854
+ this.openBlockingPrompt.set(sessionId, key);
1855
+ session.failureReason = blocking.detail ? `${blocking.prompt} ${blocking.detail}` : blocking.prompt;
1856
+ this.log.info(`[codex.usage_limit] ${sessionId.slice(0, 8)}`, {
1857
+ event: "codex.usage_limit",
1858
+ sessionId,
1859
+ prompt: blocking.prompt
1860
+ });
1861
+ this.onPermissionChange?.(sessionId, blocking);
1862
+ }
1863
+ if (session.status === "running") {
1864
+ this.turnBusy.delete(sessionId);
1865
+ this.markReady(sessionId, session, "quiet-fallback", "usage-limit");
1627
1866
  }
1628
1867
  }
1629
1868
  // Answer a gate from the persisted remember-store, or surface it as a
@@ -1665,12 +1904,52 @@ var CodexPtyRunner = class {
1665
1904
  reason
1666
1905
  });
1667
1906
  this.onStatusChange?.(toPublicSession(session));
1668
- if (this.pendingReady.has(sessionId)) {
1669
- this.pendingReady.delete(sessionId);
1907
+ const wasPending = this.pendingReady.delete(sessionId);
1908
+ const submitWatch = this.submitWatchTimers.get(sessionId);
1909
+ if (submitWatch) clearTimeout(submitWatch);
1910
+ this.submitWatchTimers.delete(sessionId);
1911
+ if (wasPending) {
1912
+ const timer = this.readyFallbackTimers.get(sessionId);
1913
+ if (timer) clearTimeout(timer);
1914
+ this.readyFallbackTimers.delete(sessionId);
1670
1915
  this.flushQueuedInputs(sessionId);
1671
1916
  this.onReady?.(toPublicSession(session));
1672
1917
  }
1673
1918
  }
1919
+ /**
1920
+ * Tear down a session that failed before it ever became usable, and report
1921
+ * the reason in machine-readable form.
1922
+ *
1923
+ * Deliberately NOT markReady + exit: the caller must be able to tell a
1924
+ * never-started session from a live one, `onReady` must not fire (no
1925
+ * `session_ready` for a failed start), and every piece of per-session state —
1926
+ * queue, timers, quiet-checker, gate cards, screen — has to go, since the
1927
+ * session is removed from the map and nothing will collect it later.
1928
+ */
1929
+ failStartup(sessionId, session, code, message) {
1930
+ this.log.warn(`[codex.start_failed] ${sessionId.slice(0, 8)} ${code}`, {
1931
+ event: "codex.start_failed",
1932
+ sessionId,
1933
+ code,
1934
+ message
1935
+ });
1936
+ session.failureCode = code;
1937
+ session.failureReason = message;
1938
+ session.status = "idle";
1939
+ session.statusSource = "process-exit";
1940
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1941
+ session.completedAt = /* @__PURE__ */ new Date();
1942
+ this.pendingReady.delete(sessionId);
1943
+ this.queuedInputs.delete(sessionId);
1944
+ this.clearSessionDetectors(sessionId);
1945
+ this.sessions.delete(sessionId);
1946
+ try {
1947
+ session.process.kill("SIGINT");
1948
+ } catch {
1949
+ }
1950
+ session.screen.dispose();
1951
+ this.onStatusChange?.(toPublicSession(session));
1952
+ }
1674
1953
  handleExit(sessionId, exitCode) {
1675
1954
  const session = this.sessions.get(sessionId);
1676
1955
  if (!session) return;
@@ -1706,6 +1985,7 @@ function toPublicSession(s) {
1706
1985
  promptCount: s.promptCount,
1707
1986
  lastOutput: s.lastOutput,
1708
1987
  ...s.failureReason != null && { failureReason: s.failureReason },
1988
+ ...s.failureCode != null && { failureCode: s.failureCode },
1709
1989
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1710
1990
  ...s.statusSource != null && { statusSource: s.statusSource },
1711
1991
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
@@ -3206,6 +3486,24 @@ var LiveSessionManager = class {
3206
3486
  const runner = this.assertSupportedProvider(provider, options.projectPath);
3207
3487
  return runner.startFresh(options);
3208
3488
  }
3489
+ /**
3490
+ * Fork an existing conversation into a new session. Codex-only: `codex fork`
3491
+ * has no Claude Code equivalent, and there is no safe generic fallback — a
3492
+ * silent downgrade to resume would attach to the very writer the caller is
3493
+ * trying to leave alone.
3494
+ */
3495
+ async startFork(options) {
3496
+ const provider = options.provider ?? CODEX_CLI_PROVIDER;
3497
+ const runner = this.remoteRunner ?? this.runners.get(provider);
3498
+ if (!(runner instanceof CodexPtyRunner)) {
3499
+ const err = new Error(
3500
+ this.remoteRunner ? "Forking is not supported while sessions are hosted by the pty-host" : `Forking is not supported for ${provider} sessions`
3501
+ );
3502
+ err.statusCode = 501;
3503
+ throw err;
3504
+ }
3505
+ return runner.startFork(options);
3506
+ }
3209
3507
  sendInput(sessionId, input) {
3210
3508
  return this.runnerFor(sessionId).sendInput(sessionId, input);
3211
3509
  }
@@ -3481,7 +3779,7 @@ async function discoverWindowsViaCim() {
3481
3779
  "-NoProfile",
3482
3780
  "-NonInteractive",
3483
3781
  "-Command",
3484
- "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
3782
+ `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`
3485
3783
  ]);
3486
3784
  } catch {
3487
3785
  return null;
@@ -5681,6 +5979,10 @@ var createSessionRoutes = (deps) => {
5681
5979
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
5682
5980
  return alreadyHandled6();
5683
5981
  });
5982
+ app.post("/:id/fork", async (c) => {
5983
+ await deps.handleFork(c.req.param("id"), c.env.incoming, c.env.outgoing);
5984
+ return alreadyHandled6();
5985
+ });
5684
5986
  app.post("/:id/stop", async (c) => {
5685
5987
  await deps.handleStopSession(c.req.param("id"), c.env.outgoing);
5686
5988
  return alreadyHandled6();
@@ -7612,7 +7914,6 @@ function rowToProject(row) {
7612
7914
  lastIndexedAt: row.last_indexed_at,
7613
7915
  latestMessageAt: row.latest_message_at,
7614
7916
  latestMessageId: row.latest_message_id,
7615
- messageCount: row.message_count,
7616
7917
  createdAt: row.created_at,
7617
7918
  updatedAt: row.updated_at
7618
7919
  };
@@ -7631,12 +7932,12 @@ var ProjectsRepository = class {
7631
7932
  INSERT INTO projects (
7632
7933
  id, path, name,
7633
7934
  last_conversation_id, last_conversation_created_at, last_indexed_at,
7634
- latest_message_at, latest_message_id, message_count,
7935
+ latest_message_at, latest_message_id,
7635
7936
  created_at, updated_at
7636
7937
  ) VALUES (
7637
7938
  @id, @path, @name,
7638
7939
  @last_conversation_id, @last_conversation_created_at, @last_indexed_at,
7639
- @latest_message_at, @latest_message_id, @message_count,
7940
+ @latest_message_at, @latest_message_id,
7640
7941
  @created_at, @updated_at
7641
7942
  )
7642
7943
  `);
@@ -7697,7 +7998,6 @@ var ProjectsRepository = class {
7697
7998
  last_indexed_at: now,
7698
7999
  latest_message_at: input.latestMessageAt ?? null,
7699
8000
  latest_message_id: input.latestMessageId ?? null,
7700
- message_count: 0,
7701
8001
  created_at: now,
7702
8002
  updated_at: now
7703
8003
  });
@@ -7791,9 +8091,39 @@ async function recordUpload(pool2, instanceId, row) {
7791
8091
  }
7792
8092
 
7793
8093
  // src/handlers/handleListProjects.ts
7794
- import { readdirSync as readdirSync3, statSync as statSync4 } from "fs";
8094
+ import { closeSync as closeSync4, openSync as openSync4, readdirSync as readdirSync3, readSync as readSync4, statSync as statSync4 } from "fs";
7795
8095
  import { homedir as homedir6 } from "os";
7796
8096
  import { join as join13 } from "path";
8097
+ var HEAD_BYTES = 64 * 1024;
8098
+ var MAX_FILES_PROBED = 3;
8099
+ function readRecordedCwd(dir) {
8100
+ let files;
8101
+ try {
8102
+ files = readdirSync3(dir).filter((f) => f.endsWith(".jsonl"));
8103
+ } catch {
8104
+ return null;
8105
+ }
8106
+ for (const file of files.slice(0, MAX_FILES_PROBED)) {
8107
+ let fd;
8108
+ try {
8109
+ fd = openSync4(join13(dir, file), "r");
8110
+ const buf = Buffer.alloc(HEAD_BYTES);
8111
+ const bytes = readSync4(fd, buf, 0, HEAD_BYTES, 0);
8112
+ for (const line of buf.subarray(0, bytes).toString("utf8").split("\n")) {
8113
+ if (!line.includes('"cwd"')) continue;
8114
+ try {
8115
+ const cwd = JSON.parse(line).cwd;
8116
+ if (typeof cwd === "string" && cwd.length > 0) return cwd;
8117
+ } catch {
8118
+ }
8119
+ }
8120
+ } catch {
8121
+ } finally {
8122
+ if (fd !== void 0) closeSync4(fd);
8123
+ }
8124
+ }
8125
+ return null;
8126
+ }
7797
8127
  function decodeProjectPath(dirName) {
7798
8128
  return dirName.replace(/-/g, "/");
7799
8129
  }
@@ -7810,9 +8140,7 @@ function handleListProjects(url, res) {
7810
8140
  mtime = statSync4(fullPath).mtimeMs;
7811
8141
  } catch {
7812
8142
  }
7813
- const path = decodeProjectPath(dirName);
7814
- const name = path.split("/").filter(Boolean).pop() ?? dirName;
7815
- return { name, path, dirName, mtime };
8143
+ return { dirName: String(dirName), mtime };
7816
8144
  }).sort((a, b) => b.mtime - a.mtime);
7817
8145
  } catch {
7818
8146
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -7820,7 +8148,11 @@ function handleListProjects(url, res) {
7820
8148
  return;
7821
8149
  }
7822
8150
  const total = entries.length;
7823
- const page = entries.slice(offset, offset + limit).map(({ name, path, dirName }) => ({ name, path, dirName }));
8151
+ const page = entries.slice(offset, offset + limit).map(({ dirName }) => {
8152
+ const path = readRecordedCwd(join13(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
8153
+ const name = path.split(/[\\/]/).filter(Boolean).pop() ?? dirName;
8154
+ return { name, path, dirName };
8155
+ });
7824
8156
  res.writeHead(200, { "Content-Type": "application/json" });
7825
8157
  res.end(JSON.stringify({ projects: page, total }));
7826
8158
  }
@@ -8346,8 +8678,12 @@ var ConversationWatcher = class {
8346
8678
  offset = 0;
8347
8679
  }
8348
8680
  const watcher = chokidar.watch(filePath, {
8349
- ignoreInitial: true,
8350
- awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
8681
+ ignoreInitial: true
8682
+ // No awaitWriteFinish: readNewLines already coalesces bursts via the
8683
+ // reading/pending flags, and awaitWriteFinish on Linux has been observed
8684
+ // to drop the unlink when a just-created file is deleted inside the
8685
+ // stability window — leaving an external tail attached until the 5 min
8686
+ // idle sweep (#393).
8351
8687
  });
8352
8688
  watcher.on("change", () => {
8353
8689
  void this.readNewLines(key);
@@ -9575,6 +9911,69 @@ function planAutoResume(rows, opts) {
9575
9911
  };
9576
9912
  }
9577
9913
 
9914
+ // src/services/sessions/codexRolloutOwner.ts
9915
+ import { execFile as execFile3 } from "child_process";
9916
+ var ROLLOUT_OWNER_TIMEOUT_MS = 800;
9917
+ function runLsof(rolloutPath, timeoutMs) {
9918
+ return new Promise((resolve2, reject) => {
9919
+ const child = execFile3(
9920
+ "lsof",
9921
+ ["-F", "pc", "-w", "--", rolloutPath],
9922
+ { windowsHide: true },
9923
+ (err, stdout) => {
9924
+ clearTimeout(timer);
9925
+ if (err && !stdout) {
9926
+ reject(err);
9927
+ return;
9928
+ }
9929
+ resolve2(stdout);
9930
+ }
9931
+ );
9932
+ const timer = setTimeout(() => {
9933
+ try {
9934
+ child.kill("SIGKILL");
9935
+ } catch {
9936
+ }
9937
+ child.stdout?.destroy();
9938
+ child.stderr?.destroy();
9939
+ reject(new Error("lsof timed out"));
9940
+ }, timeoutMs);
9941
+ timer.unref?.();
9942
+ child.unref();
9943
+ });
9944
+ }
9945
+ function parseLsofFieldOutput(stdout) {
9946
+ const owners = [];
9947
+ let pid = null;
9948
+ for (const line of stdout.split("\n")) {
9949
+ if (line.startsWith("p")) {
9950
+ const n = Number.parseInt(line.slice(1), 10);
9951
+ pid = Number.isFinite(n) ? n : null;
9952
+ } else if (line.startsWith("c") && pid != null) {
9953
+ owners.push({ pid, command: line.slice(1).trim() });
9954
+ pid = null;
9955
+ }
9956
+ }
9957
+ return owners;
9958
+ }
9959
+ async function findRolloutOwner(rolloutPath, options = {}) {
9960
+ const platform3 = options.platform ?? process.platform;
9961
+ if (platform3 === "win32") return null;
9962
+ const selfPid = options.selfPid ?? process.pid;
9963
+ const run2 = options.run ?? runLsof;
9964
+ let stdout;
9965
+ try {
9966
+ stdout = await run2(rolloutPath, options.timeoutMs ?? ROLLOUT_OWNER_TIMEOUT_MS);
9967
+ } catch {
9968
+ return null;
9969
+ }
9970
+ for (const { pid, command } of parseLsofFieldOutput(stdout)) {
9971
+ if (pid === selfPid) continue;
9972
+ return { pid, command, source: command === "codex" ? "terminal" : "unknown" };
9973
+ }
9974
+ return null;
9975
+ }
9976
+
9578
9977
  // src/services/sessions/conversationBusy.ts
9579
9978
  import { statSync as statSync8 } from "fs";
9580
9979
  var RESUME_BUSY_WINDOW_MS = 12e4;
@@ -9887,6 +10286,9 @@ function findCursorBoundary(sorted, cursor, key, order) {
9887
10286
  }
9888
10287
  return sorted.length;
9889
10288
  }
10289
+ function isLiveMultiAgent(s) {
10290
+ return s.currentTurnId !== void 0 && (s.status === "running" || s.status === "waiting_input");
10291
+ }
9890
10292
  function managedToResponse(s, ptyAttached) {
9891
10293
  return {
9892
10294
  id: s.id,
@@ -9905,8 +10307,18 @@ function managedToResponse(s, ptyAttached) {
9905
10307
  // leaves it gone, but the conversation is still resumable, not terminal —
9906
10308
  // `putOnHold` records that by leaving `statusSource: "shutdown"` (the only
9907
10309
  // place either runner sets it), so it is checked here alongside `rehydrated`.
9908
- lifecycle: ptyAttached ? "attached" : s.rehydrated || s.statusSource === "shutdown" ? "resumable" : s.failureReason != null ? "failed" : "completed",
9909
- lifecycleSource: ptyAttached ? s.reconciled ? "reconcile" : "spawn" : s.rehydrated ? "reconcile" : "exit",
10310
+ // Multi-agent sessions never have a PTY (`currentTurnId` is defined null
10311
+ // while idle between turns only on that path). While their status is
10312
+ // still live they are `attached`, not terminal (#438).
10313
+ // Otherwise, terminal requires evidence of termination — a recorded
10314
+ // `failureReason`, or the `completedAt` every exit path stamps. Without
10315
+ // either, no PTY here means the spawn has not landed, which is `starting`,
10316
+ // not `completed` (tb-mobile #508). Scoped to the PTY path only
10317
+ // (`currentTurnId === undefined`) — multi-agent never stamps `completedAt`
10318
+ // at all, so an idle multi-agent session (no pre-attach race to
10319
+ // disambiguate) stays `completed` regardless.
10320
+ lifecycle: ptyAttached || isLiveMultiAgent(s) ? "attached" : s.rehydrated || s.statusSource === "shutdown" ? "resumable" : s.failureReason != null ? "failed" : s.currentTurnId === void 0 && s.completedAt == null ? "starting" : "completed",
10321
+ lifecycleSource: ptyAttached || isLiveMultiAgent(s) ? s.reconciled ? "reconcile" : "spawn" : s.rehydrated ? "reconcile" : s.currentTurnId === void 0 && s.completedAt == null ? "spawn" : "exit",
9910
10322
  // We own its PTY, so `status` is the authoritative signal — no inferred
9911
10323
  // `activity` is attached for managed sessions.
9912
10324
  ownership: s.rehydrated ? "historical" : "managed",
@@ -9942,6 +10354,9 @@ function managedToResponse(s, ptyAttached) {
9942
10354
  ...s.resumedFromConversationId != null && {
9943
10355
  resumedFromConversationId: s.resumedFromConversationId
9944
10356
  },
10357
+ ...s.forkedFromConversationId != null && {
10358
+ forkedFromConversationId: s.forkedFromConversationId
10359
+ },
9945
10360
  ...s.boundConversationId != null && { boundConversationId: s.boundConversationId },
9946
10361
  ...s.interruptedStatus != null && { interruptedStatus: s.interruptedStatus }
9947
10362
  };
@@ -10319,6 +10734,13 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
10319
10734
  var ADOPT_KILL_POLL_MS = 100;
10320
10735
  var REFRESH_TTL_MS = 2e3;
10321
10736
  var START_READY_TIMEOUT_MS = 1e4;
10737
+ var CODEX_STARTUP_TIMEOUT_MS = 4e3;
10738
+ function resolveCodexStartupTimeoutMs(env = process.env) {
10739
+ const raw = env.THREADBASE_CODEX_STARTUP_TIMEOUT_MS;
10740
+ if (raw === void 0) return CODEX_STARTUP_TIMEOUT_MS;
10741
+ const n = Number.parseInt(raw, 10);
10742
+ return Number.isFinite(n) && n >= 0 ? n : CODEX_STARTUP_TIMEOUT_MS;
10743
+ }
10322
10744
  var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
10323
10745
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
10324
10746
  var EXTERNAL_TAIL_MAX = 32;
@@ -10329,6 +10751,22 @@ function parseIncludeAgentsEnv(raw) {
10329
10751
  const v = raw.trim().toLowerCase();
10330
10752
  return !(v === "0" || v === "false" || v === "no" || v === "off" || v === "");
10331
10753
  }
10754
+ function codexSessionActiveBody(outcome) {
10755
+ return {
10756
+ error: "This Codex session is already open in another client",
10757
+ code: "CONVERSATION_BUSY",
10758
+ reasonCode: "CODEX_SESSION_ACTIVE",
10759
+ provider: CODEX_CLI_PROVIDER,
10760
+ detectedBy: outcome.detectedBy,
10761
+ lastActivityMs: outcome.lastActivityMs,
10762
+ likelyOwner: "external",
10763
+ canForce: false,
10764
+ canTakeOver: false,
10765
+ canFork: true,
10766
+ ...outcome.ownerPid != null && { ownerPid: outcome.ownerPid },
10767
+ ...outcome.ownerSource != null && { ownerSource: outcome.ownerSource }
10768
+ };
10769
+ }
10332
10770
  var StreamerServer = class {
10333
10771
  httpServer;
10334
10772
  ptyManager;
@@ -10525,6 +10963,10 @@ var StreamerServer = class {
10525
10963
  liveActivityNotifier = null;
10526
10964
  liveActivityRenewal = null;
10527
10965
  discoveryCache = null;
10966
+ // Single-flight for process discovery. Mobile polls GET /api/sessions and
10967
+ // retries on timeout; without this, every concurrent request starts its own
10968
+ // Windows CIM scan (observed: overlapping 80–100s /api/sessions responses).
10969
+ discoveryInFlight = null;
10528
10970
  cacheDir;
10529
10971
  runtimeDbPath;
10530
10972
  tailSize;
@@ -10578,8 +11020,9 @@ var StreamerServer = class {
10578
11020
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
10579
11021
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
10580
11022
  this.markScannerStaleDebounced = debounce(() => {
10581
- if (this.scannerReady) this.scannerStale = true;
10582
- else {
11023
+ if (this.scannerReady) {
11024
+ if (this.staleFiles.size > 0) this.scannerStale = true;
11025
+ } else {
10583
11026
  this.scanner = null;
10584
11027
  this.staleFiles.clear();
10585
11028
  }
@@ -10671,6 +11114,12 @@ var StreamerServer = class {
10671
11114
  this.pendingLineSeqs.delete(filePath);
10672
11115
  },
10673
11116
  onConversationChanged: (filePath) => {
11117
+ try {
11118
+ statSync9(filePath);
11119
+ } catch {
11120
+ this.handleJsonlDeleted(filePath);
11121
+ return;
11122
+ }
10674
11123
  const tailed = this.fileWatcher.poke(filePath);
10675
11124
  if (!tailed) this.maybeAttachExternalTail(filePath);
10676
11125
  this.sweepIdleExternalTails();
@@ -10690,21 +11139,7 @@ var StreamerServer = class {
10690
11139
  event: "tail.truncated"
10691
11140
  });
10692
11141
  },
10693
- onFileDeleted: (filePath) => {
10694
- this.detachExternalTail(canonicalizeFilePath(filePath));
10695
- if (this.cacheMonitor?.pending) {
10696
- this.cacheMonitor.deferUnlink(filePath);
10697
- return;
10698
- }
10699
- const id = this.cache?.invalidateByFilePath(filePath);
10700
- if (id)
10701
- this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
10702
- id,
10703
- filePath,
10704
- event: "cache.invalidate_on_unlink"
10705
- });
10706
- this.cacheMonitor?.recordUnlink(filePath);
10707
- },
11142
+ onFileDeleted: (filePath) => this.handleJsonlDeleted(filePath),
10708
11143
  onError: (filePath, err) => {
10709
11144
  const enospc = err.code === "ENOSPC";
10710
11145
  this.log.error(
@@ -10826,7 +11261,7 @@ var StreamerServer = class {
10826
11261
  this.wsHub.broadcast({ type: "session_update", session: resp });
10827
11262
  }
10828
11263
  void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
10829
- this.sessionStatusBus.emit(`status:${session.id}`, session.status);
11264
+ this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
10830
11265
  }
10831
11266
  });
10832
11267
  this.agentConfig = readAgentConfig();
@@ -10895,6 +11330,7 @@ var StreamerServer = class {
10895
11330
  handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
10896
11331
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
10897
11332
  handleAdopt: (id, res) => this.handleAdopt(id, res),
11333
+ handleFork: (id, req, res) => this.handleFork(id, req, res),
10898
11334
  handleResume: (req, res) => this.handleResume(req, res),
10899
11335
  handleStartSession: (req, res) => this.handleStartSession(req, res),
10900
11336
  handleListConversations: (url, res) => this.handleListConversations(url, res),
@@ -12552,6 +12988,9 @@ var StreamerServer = class {
12552
12988
  return metas.filter((m) => m !== null);
12553
12989
  }
12554
12990
  async getScanner(skipStaleRescan = false) {
12991
+ if (skipStaleRescan && this.scanner) {
12992
+ return this.scanner;
12993
+ }
12555
12994
  if (this.scannerReady) {
12556
12995
  await this.scannerReady;
12557
12996
  if (this.scanner) {
@@ -12594,29 +13033,38 @@ var StreamerServer = class {
12594
13033
  this.scannerReady = null;
12595
13034
  return this.getScanner();
12596
13035
  }
12597
- // refresh=1's scan: reuse the WARM scanner and re-run its scan with
12598
- // fullRescan:true the escape hatch that bypasses the scanner's
12599
- // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
12600
- // the "don't trust the gate, check disk for real" signal. Unlike
12601
- // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
12602
- // only ever reassigned to a live scan promise (never nulled mid-scan), so the
12603
- // getScanner() anti-infinite-loop guard is preserved.
13036
+ // refresh=1's scan: build a SHADOW scanner with fullRescan:true, then swap
13037
+ // it in atomically. The escape hatch bypasses the scanner's dir-mtime
13038
+ // discovery gate (an explicit user pull-to-refresh is exactly the "don't
13039
+ // trust the gate, check disk for real" signal).
13040
+ //
13041
+ // Scanning in place would clear the non-persistent scanner's metadataCache
13042
+ // at start, so a concurrent detail fetch that skipped the await would 404 a
13043
+ // conversation that exists — and one that awaited would pay the full scan's
13044
+ // wall clock (#368). Shadow-and-swap keeps this.scanner readable as the
13045
+ // previous generation for the whole rebuild.
12604
13046
  async rescanForRefresh(onProgress) {
12605
13047
  if (this.scannerReady) await this.scannerReady;
12606
13048
  this.takeStaleFiles();
12607
- if (!this.scanner) {
12608
- this.scanner = new ConversationScanner();
12609
- this.allScanners.add(this.scanner);
12610
- }
12611
- const scanner = this.scanner;
12612
- this.scannerReady = scanner.scan({
13049
+ const previous = this.scanner;
13050
+ const statCache = this.buildStatCache(previous);
13051
+ const shadow = this.newScanner(statCache ? { persistent: false } : void 0);
13052
+ this.allScanners.add(shadow);
13053
+ this.scannerReady = shadow.scan({
12613
13054
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
12614
13055
  ...this.codexScanOpts(),
12615
13056
  fullRescan: true,
13057
+ ...statCache ? { statCache } : {},
12616
13058
  ...onProgress ? { onProgress } : {}
12617
13059
  });
12618
- await this.scannerReady;
12619
- return scanner;
13060
+ try {
13061
+ await this.scannerReady;
13062
+ } catch (err) {
13063
+ this.scannerReady = null;
13064
+ throw err;
13065
+ }
13066
+ this.scanner = shadow;
13067
+ return shadow;
12620
13068
  }
12621
13069
  /**
12622
13070
  * The projects dirs disk discovery should walk — the single source of truth
@@ -12770,6 +13218,26 @@ var StreamerServer = class {
12770
13218
  event: "external_tail.detach"
12771
13219
  });
12772
13220
  }
13221
+ /**
13222
+ * Shared unlink path for the per-file watcher and the directory watcher.
13223
+ * Detaches any external tail and drops the cache row (unless an integrity
13224
+ * alert is freezing deletes).
13225
+ */
13226
+ handleJsonlDeleted(filePath) {
13227
+ this.detachExternalTail(canonicalizeFilePath(filePath));
13228
+ if (this.cacheMonitor?.pending) {
13229
+ this.cacheMonitor.deferUnlink(filePath);
13230
+ return;
13231
+ }
13232
+ const id = this.cache?.invalidateByFilePath(filePath);
13233
+ if (id)
13234
+ this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
13235
+ id,
13236
+ filePath,
13237
+ event: "cache.invalidate_on_unlink"
13238
+ });
13239
+ this.cacheMonitor?.recordUnlink(filePath);
13240
+ }
12773
13241
  /** Make room for one more tail by evicting the least recently active ones. */
12774
13242
  evictExternalTailsIfNeeded() {
12775
13243
  while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
@@ -12914,6 +13382,24 @@ var StreamerServer = class {
12914
13382
  if (this.scanProfiles) return null;
12915
13383
  const filePath = this.findJsonlPath(lookupId) ?? this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId);
12916
13384
  if (!filePath) return null;
13385
+ if (this.scannerReady) {
13386
+ const account = this.cache?.getMetaById(lookupId)?.account ?? void 0;
13387
+ const singleFileScanner = this.scanner ?? this.newScanner();
13388
+ try {
13389
+ const page = await singleFileScanner.parseSingleFilePage(filePath, account, {
13390
+ limit: Number.MAX_SAFE_INTEGER
13391
+ });
13392
+ if (page?.conversation) return page.conversation;
13393
+ } catch (err) {
13394
+ this.log.warn("detail.single_file_parse_failed", {
13395
+ event: "detail.single_file_parse_failed",
13396
+ conversationId: lookupId,
13397
+ filePath,
13398
+ err
13399
+ });
13400
+ }
13401
+ return null;
13402
+ }
12917
13403
  this.scanner = null;
12918
13404
  this.scannerReady = null;
12919
13405
  const freshScanner = await this.getScanner();
@@ -13294,15 +13780,7 @@ var StreamerServer = class {
13294
13780
  }
13295
13781
  async handleListSessions(url, res) {
13296
13782
  if (this.rejectIfWarmingUp(res)) return;
13297
- const now = Date.now();
13298
- if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
13299
- try {
13300
- const discovered = await discoverClaudeProcesses();
13301
- this.sessionStore.setDiscovered(discovered);
13302
- this.discoveryCache = { entries: discovered, fetchedAt: now };
13303
- } catch {
13304
- }
13305
- }
13783
+ await this.refreshDiscovery();
13306
13784
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
13307
13785
  if (!hasPaginationParams) {
13308
13786
  json(
@@ -13331,6 +13809,36 @@ var StreamerServer = class {
13331
13809
  throw err;
13332
13810
  }
13333
13811
  }
13812
+ /**
13813
+ * Refresh the discovered-process list, sharing one in-flight enumeration
13814
+ * across concurrent callers and honouring the 15s TTL cache.
13815
+ */
13816
+ async refreshDiscovery() {
13817
+ const cached3 = this.discoveryCache;
13818
+ if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
13819
+ return cached3.entries;
13820
+ }
13821
+ if (this.discoveryInFlight) {
13822
+ return this.discoveryInFlight;
13823
+ }
13824
+ let flight;
13825
+ flight = (async () => {
13826
+ try {
13827
+ const discovered = await discoverClaudeProcesses();
13828
+ this.sessionStore.setDiscovered(discovered);
13829
+ this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
13830
+ return discovered;
13831
+ } catch {
13832
+ return this.discoveryCache?.entries ?? [];
13833
+ } finally {
13834
+ if (this.discoveryInFlight === flight) {
13835
+ this.discoveryInFlight = null;
13836
+ }
13837
+ }
13838
+ })();
13839
+ this.discoveryInFlight = flight;
13840
+ return flight;
13841
+ }
13334
13842
  async handleGetSession(sessionId, res) {
13335
13843
  if (this.rejectIfWarmingUp(res)) return;
13336
13844
  const base = this.sessionStore.get(sessionId, this.ptyAttachedIds());
@@ -13392,7 +13900,23 @@ var StreamerServer = class {
13392
13900
  code: "CONVERSATION_BUSY",
13393
13901
  detectedBy: outcome.detectedBy,
13394
13902
  lastActivityMs: outcome.lastActivityMs,
13395
- likelyOwner: outcome.likelyOwner
13903
+ likelyOwner: outcome.likelyOwner,
13904
+ // Additive capability hints (see docs/compatibility/tb-mobile.md).
13905
+ // Older clients ignore them and keep deriving the same actions from
13906
+ // `likelyOwner`; newer ones must honour these instead of guessing.
13907
+ canForce: true,
13908
+ canTakeOver: outcome.likelyOwner === "external",
13909
+ canFork: false
13910
+ });
13911
+ return;
13912
+ case "codex_session_active":
13913
+ json(res, 409, codexSessionActiveBody(outcome));
13914
+ return;
13915
+ case "codex_start_failed":
13916
+ json(res, 502, {
13917
+ error: outcome.failureReason,
13918
+ code: "SESSION_START_FAILED",
13919
+ provider: CODEX_CLI_PROVIDER
13396
13920
  });
13397
13921
  return;
13398
13922
  }
@@ -13404,6 +13928,116 @@ var StreamerServer = class {
13404
13928
  this.broadcastOrUnicastSessionList(req);
13405
13929
  json(res, 201, outcome.response ?? outcome.session);
13406
13930
  }
13931
+ /**
13932
+ * `POST /api/sessions/:id/fork` — continue a conversation this streamer is
13933
+ * not allowed to resume, without touching whoever owns it.
13934
+ *
13935
+ * Codex only (`codex fork <id>`): Claude Code has no equivalent, and there is
13936
+ * no safe generic fallback — quietly resuming instead would attach to the
13937
+ * exact writer the caller is trying to leave alone, which is the failure this
13938
+ * endpoint exists to avoid.
13939
+ *
13940
+ * NOT idempotent by default: every accepted call starts another Codex
13941
+ * process and another rollout. Clients that retry on timeout must send
13942
+ * `idempotencyKey`, which replays the first outcome for 10 minutes (same
13943
+ * store and semantics as `POST /:id/input`).
13944
+ */
13945
+ async handleFork(sessionId, req, res) {
13946
+ const body = await readBody2(req);
13947
+ let idempotencyKey;
13948
+ try {
13949
+ idempotencyKey = readIdempotencyKey(body);
13950
+ } catch (err) {
13951
+ json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
13952
+ return;
13953
+ }
13954
+ if (idempotencyKey) {
13955
+ const replayed = this.idempotency.get(sessionId, idempotencyKey);
13956
+ if (replayed) {
13957
+ json(res, replayed.status, replayed.body);
13958
+ return;
13959
+ }
13960
+ }
13961
+ const target = await this.resolveConversationTarget(sessionId);
13962
+ if (!target.ok) {
13963
+ if (target.reason === "history_file_missing") {
13964
+ json(res, 404, {
13965
+ error: "Conversation history file is missing; it can no longer be forked",
13966
+ code: "history_file_missing"
13967
+ });
13968
+ } else {
13969
+ json(res, 400, { error: "Could not determine project path" });
13970
+ }
13971
+ return;
13972
+ }
13973
+ if (target.provider !== CODEX_CLI_PROVIDER) {
13974
+ json(res, 501, {
13975
+ error: "Forking is only supported for Codex sessions",
13976
+ code: "UNSUPPORTED_PROVIDER",
13977
+ provider: target.provider
13978
+ });
13979
+ return;
13980
+ }
13981
+ this.discoveryCache = null;
13982
+ let session;
13983
+ try {
13984
+ session = await this.ptyManager.startFork({
13985
+ provider: CODEX_CLI_PROVIDER,
13986
+ // The rollout id, never the placeholder the client navigated to — it is
13987
+ // the only id `codex fork` accepts.
13988
+ forkFromId: target.historyId,
13989
+ projectPath: target.projectPath,
13990
+ projectName: body.projectName,
13991
+ branch: body.branch
13992
+ });
13993
+ } catch (err) {
13994
+ const message = err instanceof Error ? err.message : "Failed to fork session";
13995
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
13996
+ this.log.error(`[fork] failed to fork ${sessionId}: ${message}`, {
13997
+ event: "session.fork_failed",
13998
+ sessionId,
13999
+ error: message
14000
+ });
14001
+ json(res, statusCode, { error: message, code: "FORK_FAILED" });
14002
+ return;
14003
+ }
14004
+ session.forkedFromConversationId = target.historyId;
14005
+ this.sessionStore.addManaged(session);
14006
+ this.recordSessionSpawn(session);
14007
+ const { outcome, session: settled } = await this.waitForStartupOutcome(
14008
+ session.id,
14009
+ resolveCodexStartupTimeoutMs()
14010
+ );
14011
+ if (outcome === "failed") {
14012
+ const failed = settled ?? this.sessionStore.getManaged(session.id);
14013
+ this.abandonFailedStart(session.id);
14014
+ if (failed?.failureCode === CODEX_ACTIVE_WRITER_CODE) {
14015
+ json(res, 409, codexSessionActiveBody({ detectedBy: [], lastActivityMs: null }));
14016
+ return;
14017
+ }
14018
+ json(res, 502, {
14019
+ error: failed?.failureReason ?? "Codex exited before the fork became ready",
14020
+ code: "SESSION_START_FAILED",
14021
+ provider: CODEX_CLI_PROVIDER
14022
+ });
14023
+ return;
14024
+ }
14025
+ this.watchForCodexRollout(session.id, target.projectPath);
14026
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
14027
+ const result = {
14028
+ status: outcome === "ready" ? 201 : 202,
14029
+ body: outcome === "ready" ? response ?? session : { id: session.id, status: "pending", forkedFromConversationId: target.historyId }
14030
+ };
14031
+ if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
14032
+ this.log.info(`[fork] forked ${target.historyId} into ${session.id}`, {
14033
+ event: "session.forked",
14034
+ sessionId: session.id,
14035
+ forkedFromConversationId: target.historyId,
14036
+ outcome
14037
+ });
14038
+ this.broadcastOrUnicastSessionList(req);
14039
+ json(res, result.status, result.body);
14040
+ }
13407
14041
  /**
13408
14042
  * Resume a session, from an HTTP request or from the boot path.
13409
14043
  *
@@ -13424,43 +14058,38 @@ var StreamerServer = class {
13424
14058
  return { ok: true, alreadyRunning: true, session: null, response: resp };
13425
14059
  }
13426
14060
  }
13427
- let jsonlPath = this.findJsonlPath(sessionId);
13428
- let conv = await this.findConversationByUuid(sessionId);
13429
- let historyId = sessionId;
13430
- let registryProvider;
13431
- if (!jsonlPath && !conv) {
13432
- const row = this.managedSessionsRepo?.get(sessionId) ?? null;
13433
- const boundId = row ? resumeIdForRow(row) : null;
13434
- if (boundId != null && boundId !== sessionId) {
13435
- historyId = boundId;
13436
- registryProvider = row?.provider;
13437
- jsonlPath = this.findJsonlPath(boundId);
13438
- conv = await this.findConversationByUuid(boundId);
14061
+ const target = await this.resolveConversationTarget(sessionId);
14062
+ if (!target.ok) return target;
14063
+ const { historyId, jsonlPath, historyPath, conv, projectPath, provider } = target;
14064
+ if (provider === CODEX_CLI_PROVIDER && historyPath) {
14065
+ const owner = await findRolloutOwner(historyPath);
14066
+ if (owner) {
14067
+ this.log.info(`[resume] codex rollout held by pid ${owner.pid}`, {
14068
+ event: "session.codex_rollout_busy",
14069
+ sessionId,
14070
+ historyId,
14071
+ ownerPid: owner.pid,
14072
+ ownerCommand: owner.command
14073
+ });
14074
+ return {
14075
+ ok: false,
14076
+ reason: "codex_session_active",
14077
+ detectedBy: ["file_handle"],
14078
+ lastActivityMs: null,
14079
+ ownerPid: owner.pid,
14080
+ ownerSource: owner.source
14081
+ };
13439
14082
  }
13440
14083
  }
13441
- const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
13442
- const projectPath = jsonlCwd ?? conv?.projectPath;
13443
- if (!projectPath) {
13444
- if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
13445
- return { ok: false, reason: "no_project_path" };
13446
- }
13447
14084
  let discovered = [];
13448
- const cached3 = this.discoveryCache;
13449
- if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
13450
- discovered = cached3.entries;
13451
- } else {
13452
- try {
13453
- discovered = await Promise.race([
13454
- discoverClaudeProcesses(),
13455
- new Promise(
13456
- (resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
13457
- )
13458
- ]);
13459
- if (discovered.length > 0) {
13460
- this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
13461
- }
13462
- } catch {
13463
- }
14085
+ try {
14086
+ discovered = await Promise.race([
14087
+ this.refreshDiscovery(),
14088
+ new Promise(
14089
+ (resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
14090
+ )
14091
+ ]);
14092
+ } catch {
13464
14093
  }
13465
14094
  const busy = conversationBusy({
13466
14095
  // The id another owner's argv would actually carry — for a placeholder
@@ -13484,10 +14113,6 @@ var StreamerServer = class {
13484
14113
  if (busy.busy) {
13485
14114
  this.contendedSessions.add(sessionId);
13486
14115
  }
13487
- const cachedConvMeta = this.cache?.getMetaById(historyId);
13488
- const provider = coerceProviderForRunner(
13489
- conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
13490
- );
13491
14116
  this.discoveryCache = null;
13492
14117
  const session = await this.ptyManager.start(sessionId, {
13493
14118
  provider,
@@ -13503,11 +14128,136 @@ var StreamerServer = class {
13503
14128
  if (historyId !== sessionId) session.boundConversationId = historyId;
13504
14129
  this.sessionStore.addManaged(session);
13505
14130
  this.recordSessionSpawn(session);
14131
+ if (provider === CODEX_CLI_PROVIDER) {
14132
+ const { outcome, session: settled } = await this.waitForStartupOutcome(
14133
+ sessionId,
14134
+ resolveCodexStartupTimeoutMs()
14135
+ );
14136
+ if (outcome === "failed") {
14137
+ const failed = settled ?? this.sessionStore.getManaged(sessionId);
14138
+ this.abandonFailedStart(sessionId);
14139
+ if (failed?.failureCode === CODEX_ACTIVE_WRITER_CODE) {
14140
+ return {
14141
+ ok: false,
14142
+ reason: "codex_session_active",
14143
+ detectedBy: [],
14144
+ lastActivityMs: null
14145
+ };
14146
+ }
14147
+ return {
14148
+ ok: false,
14149
+ reason: "codex_start_failed",
14150
+ failureReason: failed?.failureReason ?? "Codex exited before becoming ready"
14151
+ };
14152
+ }
14153
+ }
13506
14154
  void this.watchConversationFile(sessionId, historyId);
13507
14155
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
13508
14156
  const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
13509
14157
  return { ok: true, alreadyRunning: false, session, response };
13510
14158
  }
14159
+ /**
14160
+ * Resolve a client-supplied session/conversation id into everything needed to
14161
+ * launch against it: the id the PROVIDER filed the history under, that
14162
+ * history's path, the project cwd, and which CLI owns it.
14163
+ *
14164
+ * Shared by resume and fork so the two can never disagree about identity —
14165
+ * which for Codex is the whole difficulty: the id a client navigated to may
14166
+ * be a local placeholder, and only the registry knows the rollout id behind
14167
+ * it.
14168
+ */
14169
+ async resolveConversationTarget(sessionId) {
14170
+ let jsonlPath = this.findJsonlPath(sessionId);
14171
+ let conv = await this.findConversationByUuid(sessionId);
14172
+ let historyId = sessionId;
14173
+ let registryProvider;
14174
+ if (!jsonlPath && !conv) {
14175
+ const row = this.managedSessionsRepo?.get(sessionId) ?? null;
14176
+ const boundId = row ? resumeIdForRow(row) : null;
14177
+ if (boundId != null && boundId !== sessionId) {
14178
+ historyId = boundId;
14179
+ registryProvider = row?.provider;
14180
+ jsonlPath = this.findJsonlPath(boundId);
14181
+ conv = await this.findConversationByUuid(boundId);
14182
+ }
14183
+ }
14184
+ const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
14185
+ const projectPath = jsonlCwd ?? conv?.projectPath;
14186
+ if (!projectPath) {
14187
+ if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
14188
+ return { ok: false, reason: "no_project_path" };
14189
+ }
14190
+ const cachedConvMeta = this.cache?.getMetaById(historyId);
14191
+ const provider = coerceProviderForRunner(
14192
+ conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
14193
+ );
14194
+ return {
14195
+ ok: true,
14196
+ historyId,
14197
+ jsonlPath,
14198
+ // findJsonlPath() only knows Claude's `<uuid>.jsonl` layout under
14199
+ // ~/.claude/projects; a Codex rollout lives in a date-nested directory
14200
+ // under a name it chose, so its path only ever comes from the indexed
14201
+ // conversation. Kept separate from `jsonlPath` deliberately: feeding it to
14202
+ // conversationBusy() would newly arm the mtime heuristic for Codex, which
14203
+ // is exactly the over-broad signal the report ruled out.
14204
+ historyPath: jsonlPath ?? conv?.filePath ?? null,
14205
+ conv,
14206
+ projectPath,
14207
+ provider
14208
+ };
14209
+ }
14210
+ /**
14211
+ * Block until a freshly spawned session reaches `waiting_input` (ready) or
14212
+ * `idle` (failed), or until `timeoutMs` elapses with the process still alive.
14213
+ *
14214
+ * "timeout" is not an error: it is the pre-existing asynchronous contract —
14215
+ * the session keeps booting and the caller answers with a pending shape.
14216
+ */
14217
+ waitForStartupOutcome(sessionId, timeoutMs) {
14218
+ return new Promise((resolve2) => {
14219
+ let timer = null;
14220
+ const handler = (status, session) => {
14221
+ if (status !== "waiting_input" && status !== "idle") return;
14222
+ this.sessionStatusBus.off(`status:${sessionId}`, handler);
14223
+ if (timer) clearTimeout(timer);
14224
+ resolve2({
14225
+ outcome: status === "waiting_input" ? "ready" : "failed",
14226
+ session: session ?? null
14227
+ });
14228
+ };
14229
+ this.sessionStatusBus.on(`status:${sessionId}`, handler);
14230
+ timer = setTimeout(() => {
14231
+ this.sessionStatusBus.off(`status:${sessionId}`, handler);
14232
+ resolve2({ outcome: "timeout", session: null });
14233
+ }, timeoutMs);
14234
+ timer.unref?.();
14235
+ });
14236
+ }
14237
+ /**
14238
+ * Drop every trace of a session that never became usable, and hand back what
14239
+ * it failed with.
14240
+ *
14241
+ * The runner has already torn itself down (failStartup / handleExit); what
14242
+ * remains is server-side bookkeeping that would otherwise leave a dead
14243
+ * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
14244
+ * marker that would suppress the mtime collision signal on the NEXT resume —
14245
+ * i.e. it would help hide the very owner we just collided with.
14246
+ */
14247
+ abandonFailedStart(sessionId) {
14248
+ this.sessionStore.removeManaged(sessionId);
14249
+ this.selfPtyEndedAt.delete(sessionId);
14250
+ this.contendedSessions.delete(sessionId);
14251
+ try {
14252
+ this.managedSessionsRepo?.delete(sessionId);
14253
+ } catch (err) {
14254
+ this.log.warn("[registry] failed to drop a failed start", {
14255
+ event: "registry.forget_failed",
14256
+ sessionId,
14257
+ err
14258
+ });
14259
+ }
14260
+ }
13511
14261
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
13512
14262
  try {
13513
14263
  if (!this.sessionStore.getManaged(sessionId)) return;
@@ -14034,19 +14784,7 @@ var StreamerServer = class {
14034
14784
  });
14035
14785
  this.sessionStore.addManaged(session);
14036
14786
  this.recordSessionSpawn(session);
14037
- const readyOrFailed = new Promise((resolve2) => {
14038
- const handler = (status) => {
14039
- if (status === "waiting_input" || status === "idle") {
14040
- this.sessionStatusBus.off(`status:${session.id}`, handler);
14041
- resolve2(status === "waiting_input" ? "ready" : "failed");
14042
- }
14043
- };
14044
- this.sessionStatusBus.on(`status:${session.id}`, handler);
14045
- });
14046
- const timeoutPromise = new Promise(
14047
- (resolve2) => setTimeout(() => resolve2("timeout"), START_READY_TIMEOUT_MS)
14048
- );
14049
- const outcome = await Promise.race([readyOrFailed, timeoutPromise]);
14787
+ const { outcome } = await this.waitForStartupOutcome(session.id, START_READY_TIMEOUT_MS);
14050
14788
  const current = this.sessionStore.get(session.id, this.ptyAttachedIds());
14051
14789
  if (outcome === "ready" && current) {
14052
14790
  json(res, 200, { session: current });
@@ -14522,7 +15260,11 @@ function conversationToResumableSession(c) {
14522
15260
  status: "on_hold",
14523
15261
  // A cached conversation with no process behind it. Distinguishes "nobody is
14524
15262
  // running this" from an external session that IS live (ownership "external").
15263
+ // Match the rehydrated branch of managedToResponse: same conceptual state
15264
+ // ("resumable, no live process") must produce the same wire shape (#438).
14525
15265
  ownership: "historical",
15266
+ lifecycle: "resumable",
15267
+ lifecycleSource: "reconcile",
14526
15268
  ptyAttached: false,
14527
15269
  projectId: c.projectId ?? void 0,
14528
15270
  projectPath: c.projectPath ?? "",