@threadbase-sh/streamer 1.46.2 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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.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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
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
|
|
1381
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1605
|
-
// quiet
|
|
1606
|
-
// session
|
|
1607
|
-
//
|
|
1608
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(({
|
|
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:
|
|
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
|
-
|
|
9909
|
-
|
|
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)
|
|
10582
|
-
|
|
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:
|
|
12598
|
-
//
|
|
12599
|
-
//
|
|
12600
|
-
//
|
|
12601
|
-
//
|
|
12602
|
-
//
|
|
12603
|
-
//
|
|
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
|
-
|
|
12608
|
-
|
|
12609
|
-
|
|
12610
|
-
|
|
12611
|
-
|
|
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
|
-
|
|
12619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13428
|
-
|
|
13429
|
-
|
|
13430
|
-
|
|
13431
|
-
|
|
13432
|
-
|
|
13433
|
-
|
|
13434
|
-
|
|
13435
|
-
|
|
13436
|
-
|
|
13437
|
-
|
|
13438
|
-
|
|
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
|
-
|
|
13449
|
-
|
|
13450
|
-
|
|
13451
|
-
|
|
13452
|
-
|
|
13453
|
-
|
|
13454
|
-
|
|
13455
|
-
|
|
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
|
|
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 ?? "",
|