@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/cli.cjs +11410 -10660
- package/dist/cli.cjs.map +1 -1
- package/dist/ensure-demo-project-dirs.cjs +105 -0
- package/dist/ensure-demo-project-dirs.cjs.map +1 -0
- package/dist/index.cjs +936 -194
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +100 -4
- package/dist/index.d.ts +100 -4
- package/dist/index.js +937 -195
- package/dist/index.js.map +1 -1
- package/dist/merge-server-yaml.cjs +97 -0
- package/dist/merge-server-yaml.cjs.map +1 -0
- package/dist/migrations/015_drop_projects_message_count.sql +8 -0
- package/package.json +1 -1
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
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
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
|
|
1434
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1658
|
-
// quiet
|
|
1659
|
-
// session
|
|
1660
|
-
//
|
|
1661
|
-
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(({
|
|
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:
|
|
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
|
-
|
|
9946
|
-
|
|
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)
|
|
10619
|
-
|
|
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:
|
|
12635
|
-
//
|
|
12636
|
-
//
|
|
12637
|
-
//
|
|
12638
|
-
//
|
|
12639
|
-
//
|
|
12640
|
-
//
|
|
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
|
-
|
|
12645
|
-
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
|
|
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
|
-
|
|
12656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13465
|
-
|
|
13466
|
-
|
|
13467
|
-
|
|
13468
|
-
|
|
13469
|
-
|
|
13470
|
-
|
|
13471
|
-
|
|
13472
|
-
|
|
13473
|
-
|
|
13474
|
-
|
|
13475
|
-
|
|
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
|
-
|
|
13486
|
-
|
|
13487
|
-
|
|
13488
|
-
|
|
13489
|
-
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
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
|
|
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 ?? "",
|