@threadbase-sh/streamer 1.36.4 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  SessionStore: () => SessionStore,
39
39
  StreamerServer: () => StreamerServer,
40
40
  WSHub: () => WSHub,
41
+ confidenceForSource: () => confidenceForSource,
41
42
  createAgentClient: () => createAgentClient,
42
43
  createConversationWriter: () => createConversationWriter,
43
44
  createPool: () => createPool,
@@ -329,6 +330,165 @@ var import_crypto = require("crypto");
329
330
  var import_fs = require("fs");
330
331
  var import_os = require("os");
331
332
  var import_path = require("path");
333
+
334
+ // src/claude-flags.ts
335
+ var PERMISSION_MODES = [
336
+ "acceptEdits",
337
+ "auto",
338
+ "bypassPermissions",
339
+ "manual",
340
+ "dontAsk",
341
+ "plan"
342
+ ];
343
+ function isPermissionMode(value) {
344
+ return typeof value === "string" && PERMISSION_MODES.includes(value);
345
+ }
346
+ var DANGEROUS_PERMISSION_MODES = [
347
+ "bypassPermissions",
348
+ "dontAsk"
349
+ ];
350
+ function isDangerousPermissionMode(mode) {
351
+ return DANGEROUS_PERMISSION_MODES.includes(mode);
352
+ }
353
+ var CLAUDE_FLAGS = [
354
+ {
355
+ id: "permissionMode",
356
+ flag: "--permission-mode",
357
+ valueType: "enum",
358
+ enumValues: PERMISSION_MODES,
359
+ risk: "low"
360
+ },
361
+ { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
362
+ { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
363
+ { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
364
+ { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
365
+ { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
366
+ ];
367
+ function findFlag(id) {
368
+ return CLAUDE_FLAGS.find((f) => f.id === id);
369
+ }
370
+ function validateFlagValues(raw) {
371
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
372
+ const out = {};
373
+ for (const [id, value] of Object.entries(raw)) {
374
+ const def = findFlag(id);
375
+ if (!def) continue;
376
+ switch (def.valueType) {
377
+ case "boolean":
378
+ if (typeof value === "boolean") out[id] = value;
379
+ break;
380
+ case "enum":
381
+ if (typeof value === "string" && def.enumValues?.includes(value)) out[id] = value;
382
+ break;
383
+ case "string":
384
+ if (typeof value === "string" && value.trim().length > 0) out[id] = value.trim();
385
+ break;
386
+ case "list": {
387
+ if (!Array.isArray(value)) break;
388
+ const items = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
389
+ if (items.length > 0) out[id] = items;
390
+ break;
391
+ }
392
+ }
393
+ }
394
+ return out;
395
+ }
396
+ function tokenizeExtraArgs(input) {
397
+ if (!input) return [];
398
+ const tokens = [];
399
+ let current = "";
400
+ let quote = null;
401
+ let started = false;
402
+ for (const ch of input) {
403
+ if (quote) {
404
+ if (ch === quote) quote = null;
405
+ else current += ch;
406
+ continue;
407
+ }
408
+ if (ch === '"' || ch === "'") {
409
+ quote = ch;
410
+ started = true;
411
+ continue;
412
+ }
413
+ if (/\s/.test(ch)) {
414
+ if (started) {
415
+ tokens.push(current);
416
+ current = "";
417
+ started = false;
418
+ }
419
+ continue;
420
+ }
421
+ current += ch;
422
+ started = true;
423
+ }
424
+ if (started) tokens.push(current);
425
+ return tokens;
426
+ }
427
+ function buildFlagArgs(values, extraArgs) {
428
+ const args = [];
429
+ const safe = validateFlagValues(values ?? {});
430
+ for (const def of CLAUDE_FLAGS) {
431
+ if (def.id === "permissionMode") continue;
432
+ const value = safe[def.id];
433
+ if (value === void 0) continue;
434
+ if (def.valueType === "boolean") {
435
+ if (value === true) args.push(def.flag);
436
+ continue;
437
+ }
438
+ if (Array.isArray(value)) {
439
+ args.push(def.flag, ...value);
440
+ continue;
441
+ }
442
+ args.push(def.flag, String(value));
443
+ }
444
+ args.push(...tokenizeExtraArgs(extraArgs));
445
+ return args;
446
+ }
447
+ function buildSettingsJson(permissionMode) {
448
+ const settings = { spinnerTipsEnabled: false };
449
+ if (isDangerousPermissionMode(permissionMode)) {
450
+ settings.skipDangerousModePermissionPrompt = true;
451
+ }
452
+ return JSON.stringify(settings);
453
+ }
454
+
455
+ // src/logger.ts
456
+ var import_pino = __toESM(require("pino"), 1);
457
+ var baseLogger = (0, import_pino.default)({
458
+ level: process.env.LOG_LEVEL ?? "info",
459
+ base: { service: "tb-streamer" },
460
+ timestamp: import_pino.default.stdTimeFunctions.isoTime,
461
+ redact: {
462
+ paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
463
+ censor: "[redacted]"
464
+ }
465
+ });
466
+ function emit(pinoChild, level, msg, fields, dest) {
467
+ if (dest === "pino" || dest === "both") {
468
+ if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
469
+ else pinoChild[level](msg);
470
+ }
471
+ if (dest === "console" || dest === "both") {
472
+ const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
473
+ console[consoleMethod](msg);
474
+ }
475
+ }
476
+ function build(pinoChild) {
477
+ return {
478
+ debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
479
+ info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
480
+ warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
481
+ error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
482
+ log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
483
+ pino: pinoChild
484
+ };
485
+ }
486
+ function getLogger(component) {
487
+ return build(component ? baseLogger.child({ component }) : baseLogger);
488
+ }
489
+ var logger = build(baseLogger);
490
+
491
+ // src/auth.ts
332
492
  function configDir() {
333
493
  return process.env.THREADBASE_CONFIG_DIR ?? (0, import_path.join)((0, import_os.homedir)(), ".threadbase");
334
494
  }
@@ -407,11 +567,79 @@ function loadDefaultPermissionMode() {
407
567
  const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
408
568
  const match = content.match(/default_permission_mode:\s*(\S+)/);
409
569
  const value = match?.[1]?.trim();
410
- if (value === "acceptEdits" || value === "manual") return value;
570
+ if (isPermissionMode(value)) return value;
411
571
  } catch {
412
572
  }
413
573
  return void 0;
414
574
  }
575
+ function setConfigValue(key, value) {
576
+ const file = configFile();
577
+ (0, import_fs.mkdirSync)(configDir(), { recursive: true });
578
+ let content = "";
579
+ try {
580
+ content = (0, import_fs.readFileSync)(file, "utf-8");
581
+ } catch (err) {
582
+ if (err.code !== "ENOENT") throw err;
583
+ }
584
+ const lineRe = new RegExp(`^${key}:\\s*.*$\\n?`, "m");
585
+ let updated;
586
+ if (value === void 0) {
587
+ updated = content.replace(lineRe, "");
588
+ } else {
589
+ const line = `${key}: ${value}`;
590
+ if (lineRe.test(content)) {
591
+ updated = content.replace(lineRe, `${line}
592
+ `);
593
+ } else if (content.length === 0 || content.endsWith("\n")) {
594
+ updated = `${content}${line}
595
+ `;
596
+ } else {
597
+ updated = `${content}
598
+ ${line}
599
+ `;
600
+ }
601
+ }
602
+ const tmpFile = `${file}.tmp`;
603
+ (0, import_fs.writeFileSync)(tmpFile, updated, { encoding: "utf-8", mode: 384 });
604
+ (0, import_fs.chmodSync)(tmpFile, 384);
605
+ (0, import_fs.renameSync)(tmpFile, file);
606
+ }
607
+ function loadClaudeFlags() {
608
+ try {
609
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
610
+ const match = content.match(/^claude_flags:\s*(.+)$/m);
611
+ if (!match?.[1]) return {};
612
+ return validateFlagValues(JSON.parse(match[1].trim()));
613
+ } catch (err) {
614
+ if (err.code !== "ENOENT") {
615
+ getLogger("auth").warn(`Ignoring unreadable claude_flags in server.yaml: ${String(err)}`, {
616
+ event: "config.claude_flags_parse_failed"
617
+ });
618
+ }
619
+ return {};
620
+ }
621
+ }
622
+ function setClaudeFlags(values) {
623
+ const safe = validateFlagValues(values);
624
+ setConfigValue("claude_flags", Object.keys(safe).length === 0 ? void 0 : JSON.stringify(safe));
625
+ }
626
+ function loadClaudeExtraArgs() {
627
+ try {
628
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
629
+ const match = content.match(/^claude_extra_args:\s*(.+)$/m);
630
+ const value = match?.[1]?.trim();
631
+ return value && value.length > 0 ? value : void 0;
632
+ } catch {
633
+ }
634
+ return void 0;
635
+ }
636
+ function setClaudeExtraArgs(text) {
637
+ const trimmed = text?.trim();
638
+ if (trimmed && /[\r\n]/.test(trimmed)) {
639
+ throw new Error("claude_extra_args must not contain newlines");
640
+ }
641
+ setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
642
+ }
415
643
  function validatePublicUrl(raw) {
416
644
  let parsed;
417
645
  try {
@@ -560,42 +788,6 @@ var import_crypto2 = require("crypto");
560
788
  var import_fs5 = require("fs");
561
789
  var import_path5 = require("path");
562
790
 
563
- // src/logger.ts
564
- var import_pino = __toESM(require("pino"), 1);
565
- var baseLogger = (0, import_pino.default)({
566
- level: process.env.LOG_LEVEL ?? "info",
567
- base: { service: "tb-streamer" },
568
- timestamp: import_pino.default.stdTimeFunctions.isoTime,
569
- redact: {
570
- paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
571
- censor: "[redacted]"
572
- }
573
- });
574
- function emit(pinoChild, level, msg, fields, dest) {
575
- if (dest === "pino" || dest === "both") {
576
- if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
577
- else pinoChild[level](msg);
578
- }
579
- if (dest === "console" || dest === "both") {
580
- const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
581
- console[consoleMethod](msg);
582
- }
583
- }
584
- function build(pinoChild) {
585
- return {
586
- debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
587
- info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
588
- warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
589
- error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
590
- log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
591
- pino: pinoChild
592
- };
593
- }
594
- function getLogger(component) {
595
- return build(component ? baseLogger.child({ component }) : baseLogger);
596
- }
597
- var logger = build(baseLogger);
598
-
599
791
  // src/platform.ts
600
792
  var import_child_process = require("child_process");
601
793
  var import_fs3 = require("fs");
@@ -947,6 +1139,8 @@ var CodexPtyRunner = class {
947
1139
  projectName,
948
1140
  branch: options.branch ?? "",
949
1141
  status: "running",
1142
+ statusSource: "spawn",
1143
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
950
1144
  startedAt: /* @__PURE__ */ new Date(),
951
1145
  completedAt: null,
952
1146
  promptCount: 0,
@@ -994,6 +1188,8 @@ var CodexPtyRunner = class {
994
1188
  projectName,
995
1189
  branch: "",
996
1190
  status: "running",
1191
+ statusSource: "spawn",
1192
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
997
1193
  startedAt: /* @__PURE__ */ new Date(),
998
1194
  completedAt: null,
999
1195
  promptCount: 0,
@@ -1024,7 +1220,7 @@ var CodexPtyRunner = class {
1024
1220
  this.readyFallbackTimers.delete(sessionId);
1025
1221
  const session = this.sessions.get(sessionId);
1026
1222
  if (session?.status === "running" && this.pendingReady.has(sessionId)) {
1027
- this.markReady(sessionId, session, "fallback:timeout");
1223
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
1028
1224
  }
1029
1225
  }, CODEX_READY_FALLBACK_MS);
1030
1226
  timer.unref?.();
@@ -1039,6 +1235,8 @@ var CodexPtyRunner = class {
1039
1235
  }
1040
1236
  if (session.status === "waiting_input") {
1041
1237
  session.status = "running";
1238
+ session.statusSource = "user-input";
1239
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1042
1240
  this.onStatusChange?.(toPublicSession(session));
1043
1241
  }
1044
1242
  const gate = this.openGate.get(sessionId);
@@ -1108,6 +1306,8 @@ var CodexPtyRunner = class {
1108
1306
  }
1109
1307
  if (session.status === "waiting_input") {
1110
1308
  session.status = "running";
1309
+ session.statusSource = "user-input";
1310
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1111
1311
  this.onStatusChange?.(toPublicSession(session));
1112
1312
  }
1113
1313
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
@@ -1208,6 +1408,8 @@ var CodexPtyRunner = class {
1208
1408
  } catch {
1209
1409
  }
1210
1410
  session.status = "idle";
1411
+ session.statusSource = "shutdown";
1412
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1211
1413
  session.completedAt = /* @__PURE__ */ new Date();
1212
1414
  session.screen.dispose();
1213
1415
  this.sessions.delete(sessionId);
@@ -1252,6 +1454,11 @@ var CodexPtyRunner = class {
1252
1454
  getInputHistory(sessionId) {
1253
1455
  return this.sessions.get(sessionId)?.inputHistory ?? [];
1254
1456
  }
1457
+ // OS pid of the spawned agent, or null if the session isn't live here.
1458
+ // Mirrors PTYManager.getPid — see there for why the registry records it.
1459
+ getPid(sessionId) {
1460
+ return this.sessions.get(sessionId)?.process?.pid ?? null;
1461
+ }
1255
1462
  // Record a submitted user message as ground truth and fire onUserMessage.
1256
1463
  // Called from writeSubmit (direct and flush paths) — never from sendKeys.
1257
1464
  recordUserMessage(session, text) {
@@ -1353,9 +1560,9 @@ var CodexPtyRunner = class {
1353
1560
  if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1354
1561
  const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1355
1562
  if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1356
- this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
1563
+ this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1357
1564
  } else if (trigger === "quiet") {
1358
- this.markReady(sessionId, session, "quiet:timeout");
1565
+ this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
1359
1566
  }
1360
1567
  }
1361
1568
  // Answer a gate from the persisted remember-store, or surface it as a
@@ -1386,9 +1593,11 @@ var CodexPtyRunner = class {
1386
1593
  });
1387
1594
  this.onPermissionChange?.(sessionId, card);
1388
1595
  }
1389
- markReady(sessionId, session, reason) {
1596
+ markReady(sessionId, session, source, reason) {
1390
1597
  session.lastActivityAt = /* @__PURE__ */ new Date();
1391
1598
  session.status = "waiting_input";
1599
+ session.statusSource = source;
1600
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1392
1601
  this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1393
1602
  event: "codex.ready",
1394
1603
  sessionId,
@@ -1406,6 +1615,8 @@ var CodexPtyRunner = class {
1406
1615
  if (!session) return;
1407
1616
  session.completedAt = /* @__PURE__ */ new Date();
1408
1617
  session.status = "idle";
1618
+ session.statusSource = "process-exit";
1619
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1409
1620
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1410
1621
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1411
1622
  if (!(0, import_fs5.existsSync)(session.projectPath)) {
@@ -1435,6 +1646,8 @@ function toPublicSession(s) {
1435
1646
  lastOutput: s.lastOutput,
1436
1647
  ...s.failureReason != null && { failureReason: s.failureReason },
1437
1648
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1649
+ ...s.statusSource != null && { statusSource: s.statusSource },
1650
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
1438
1651
  ...s.filePath != null && { filePath: s.filePath }
1439
1652
  };
1440
1653
  }
@@ -1763,16 +1976,17 @@ var PTYManager = class {
1763
1976
  }
1764
1977
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1765
1978
  //
1766
- // options.permissionMode defaults to `acceptEdits` rather than
1767
- // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1768
- // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1769
- // flag renders a blocking "Bypass Permissions mode" warning menu on every
1770
- // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1771
- // v2.1.x) the session never reaches a usable prompt, so the mobile app
1772
- // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1773
- // without that warning gate, while still prompting for shell commands.
1774
- // `manual` (prompt for everything) is the only other mode callers may pass;
1775
- // `bypassPermissions`/`plan`/`dontAsk`/`auto` are not supported here.
1979
+ // options.permissionMode defaults to `acceptEdits` the safe default that
1980
+ // auto-approves file edits while still prompting for shell commands. All six
1981
+ // Claude CLI modes are accepted (see PERMISSION_MODES in claude-flags.ts).
1982
+ //
1983
+ // On the bypass modes: `bypassPermissions`/`dontAsk` DO trigger a blocking
1984
+ // "Bypass Permissions mode" warning menu at boot ("1. No, exit" /
1985
+ // "2. Yes, I accept") which would strand the PTY and leave mobile on an empty
1986
+ // screen. buildSettingsJson() suppresses it by adding
1987
+ // `skipDangerousModePermissionPrompt` to the `--settings` blob for exactly
1988
+ // those modes probe-verified on Claude Code v2.1.218. We never pass
1989
+ // `--dangerously-skip-permissions`; bypass is requested via --permission-mode.
1776
1990
  // (The other first-run gates — onboarding/theme, workspace trust,
1777
1991
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1778
1992
  // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
@@ -1790,28 +2004,27 @@ var PTYManager = class {
1790
2004
  async doStart(sessionId, options) {
1791
2005
  const nodePty = await loadPty2();
1792
2006
  const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
1793
- const proc = nodePty.spawn(
1794
- resolveClaudeExe(),
1795
- [
1796
- "--permission-mode",
1797
- options.permissionMode ?? "acceptEdits",
1798
- "--settings",
1799
- '{"spinnerTipsEnabled":false}',
1800
- "--model",
1801
- options.model ?? "sonnet",
1802
- "--effort",
1803
- options.effort ?? "low",
1804
- "--resume",
1805
- sessionId
1806
- ],
1807
- {
1808
- name: "xterm-256color",
1809
- cols: 120,
1810
- rows: 40,
1811
- cwd: options.projectPath,
1812
- env: buildSpawnEnv()
1813
- }
1814
- );
2007
+ const permissionMode = options.permissionMode ?? "acceptEdits";
2008
+ const args = [
2009
+ "--permission-mode",
2010
+ permissionMode,
2011
+ "--settings",
2012
+ buildSettingsJson(permissionMode),
2013
+ "--model",
2014
+ options.model ?? "sonnet",
2015
+ "--effort",
2016
+ options.effort ?? "low",
2017
+ "--resume",
2018
+ sessionId
2019
+ ];
2020
+ args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
2021
+ const proc = nodePty.spawn(resolveClaudeExe(), args, {
2022
+ name: "xterm-256color",
2023
+ cols: 120,
2024
+ rows: 40,
2025
+ cwd: options.projectPath,
2026
+ env: buildSpawnEnv()
2027
+ });
1815
2028
  const session = {
1816
2029
  id: sessionId,
1817
2030
  provider: CLAUDE_CODE_PROVIDER,
@@ -1819,6 +2032,8 @@ var PTYManager = class {
1819
2032
  projectName,
1820
2033
  branch: options.branch ?? "",
1821
2034
  status: "running",
2035
+ statusSource: "spawn",
2036
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
1822
2037
  startedAt: /* @__PURE__ */ new Date(),
1823
2038
  completedAt: null,
1824
2039
  promptCount: 0,
@@ -1846,11 +2061,12 @@ var PTYManager = class {
1846
2061
  const nodePty = await loadPty2();
1847
2062
  const sessionId = (0, import_crypto3.randomUUID)();
1848
2063
  const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
2064
+ const permissionMode = options.permissionMode ?? "acceptEdits";
1849
2065
  const args = [
1850
2066
  "--permission-mode",
1851
- options.permissionMode ?? "acceptEdits",
2067
+ permissionMode,
1852
2068
  "--settings",
1853
- '{"spinnerTipsEnabled":false}',
2069
+ buildSettingsJson(permissionMode),
1854
2070
  "--model",
1855
2071
  options.model ?? "sonnet",
1856
2072
  "--effort",
@@ -1861,6 +2077,7 @@ var PTYManager = class {
1861
2077
  if (options.systemPrompt) {
1862
2078
  args.push("--system-prompt", options.systemPrompt);
1863
2079
  }
2080
+ args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
1864
2081
  const proc = nodePty.spawn(resolveClaudeExe(), args, {
1865
2082
  name: "xterm-256color",
1866
2083
  cols: 120,
@@ -1875,6 +2092,8 @@ var PTYManager = class {
1875
2092
  projectName,
1876
2093
  branch: "",
1877
2094
  status: "running",
2095
+ statusSource: "spawn",
2096
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
1878
2097
  startedAt: /* @__PURE__ */ new Date(),
1879
2098
  completedAt: null,
1880
2099
  promptCount: 0,
@@ -1905,6 +2124,8 @@ var PTYManager = class {
1905
2124
  }
1906
2125
  if (session.status === "waiting_input") {
1907
2126
  session.status = "running";
2127
+ session.statusSource = "user-input";
2128
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1908
2129
  this.onStatusChange?.(toPublicSession2(session));
1909
2130
  }
1910
2131
  this.log.info(
@@ -1940,6 +2161,8 @@ var PTYManager = class {
1940
2161
  }
1941
2162
  if (session.status === "waiting_input") {
1942
2163
  session.status = "running";
2164
+ session.statusSource = "user-input";
2165
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1943
2166
  this.onStatusChange?.(toPublicSession2(session));
1944
2167
  }
1945
2168
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
@@ -2061,6 +2284,8 @@ var PTYManager = class {
2061
2284
  } catch {
2062
2285
  }
2063
2286
  session.status = "idle";
2287
+ session.statusSource = "shutdown";
2288
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2064
2289
  session.completedAt = /* @__PURE__ */ new Date();
2065
2290
  session.screen.dispose();
2066
2291
  this.sessions.delete(sessionId);
@@ -2096,6 +2321,13 @@ var PTYManager = class {
2096
2321
  getInputHistory(sessionId) {
2097
2322
  return this.sessions.get(sessionId)?.inputHistory ?? [];
2098
2323
  }
2324
+ // OS pid of the spawned agent, or null if the session isn't live here. The
2325
+ // durable registry records this so a later streamer run can probe whether the
2326
+ // process outlived it. Liveness alone is never identity — a recycled pid is
2327
+ // why the registry stores a cmdline alongside it.
2328
+ getPid(sessionId) {
2329
+ return this.sessions.get(sessionId)?.process?.pid ?? null;
2330
+ }
2099
2331
  // Record a submitted user message as ground truth and fire onUserMessage.
2100
2332
  // Called from writeSubmit (both direct and flush paths) — never from
2101
2333
  // sendKeys, so raw keystrokes aren't logged as messages.
@@ -2172,9 +2404,9 @@ var PTYManager = class {
2172
2404
  session.lastOutput = stripped;
2173
2405
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
2174
2406
  if (session.status === "running" && matchedMarker) {
2175
- this.markReady(sessionId, session, `marker:${matchedMarker}`);
2407
+ this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
2176
2408
  } else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
2177
- this.markReady(sessionId, session, "fallback:timeout");
2409
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2178
2410
  }
2179
2411
  this.onOutput?.(sessionId, data);
2180
2412
  this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
@@ -2274,7 +2506,7 @@ var PTYManager = class {
2274
2506
  const session = this.sessions.get(sessionId);
2275
2507
  if (session?.status !== "running") return;
2276
2508
  if (this.pendingReady.has(sessionId)) {
2277
- this.markReady(sessionId, session, "quiet:timeout");
2509
+ this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
2278
2510
  } else {
2279
2511
  this.recheckReadyFromScreen(sessionId).catch((err) => {
2280
2512
  this.log.warn("[pty.ready] screen recheck failed", {
@@ -2303,14 +2535,16 @@ var PTYManager = class {
2303
2535
  const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
2304
2536
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
2305
2537
  if (matchedMarker && session.status === "running") {
2306
- this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
2538
+ this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
2307
2539
  }
2308
2540
  }
2309
2541
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2310
2542
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2311
- markReady(sessionId, session, reason) {
2543
+ markReady(sessionId, session, source, reason) {
2312
2544
  session.lastActivityAt = /* @__PURE__ */ new Date();
2313
2545
  session.status = "waiting_input";
2546
+ session.statusSource = source;
2547
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2314
2548
  const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
2315
2549
  this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
2316
2550
  event: "pty.ready",
@@ -2330,6 +2564,8 @@ var PTYManager = class {
2330
2564
  if (!session) return;
2331
2565
  session.completedAt = /* @__PURE__ */ new Date();
2332
2566
  session.status = "idle";
2567
+ session.statusSource = "process-exit";
2568
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2333
2569
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
2334
2570
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
2335
2571
  if (!(0, import_fs6.existsSync)(session.projectPath)) {
@@ -2364,6 +2600,8 @@ function toPublicSession2(s) {
2364
2600
  lastOutput: s.lastOutput,
2365
2601
  ...s.failureReason != null && { failureReason: s.failureReason },
2366
2602
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2603
+ ...s.statusSource != null && { statusSource: s.statusSource },
2604
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2367
2605
  ...s.filePath != null && { filePath: s.filePath }
2368
2606
  };
2369
2607
  }
@@ -2436,6 +2674,16 @@ var LiveSessionManager = class {
2436
2674
  }
2437
2675
  return null;
2438
2676
  }
2677
+ // Scans rather than using runnerFor(): the registry records a pid on a
2678
+ // best-effort basis, so an unknown session must return null rather than
2679
+ // throw the way the input-routing methods do.
2680
+ getPid(sessionId) {
2681
+ for (const runner of this.runners.values()) {
2682
+ const pid = runner.getPid(sessionId);
2683
+ if (pid != null) return pid;
2684
+ }
2685
+ return null;
2686
+ }
2439
2687
  hasSession(sessionId) {
2440
2688
  for (const runner of this.runners.values()) {
2441
2689
  if (runner.hasSession(sessionId)) return true;
@@ -2612,6 +2860,18 @@ async function getProcessCwdUnix(pid) {
2612
2860
  async function getProcessArgsUnix(pid) {
2613
2861
  return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2614
2862
  }
2863
+ async function getProcessArgs(pid) {
2864
+ if (!Number.isInteger(pid) || pid < 1) return "";
2865
+ try {
2866
+ if ((0, import_os4.platform)() === "win32") {
2867
+ const info = await getProcessInfoWindows(pid);
2868
+ return info?.args ?? "";
2869
+ }
2870
+ return await getProcessArgsUnix(pid);
2871
+ } catch {
2872
+ return "";
2873
+ }
2874
+ }
2615
2875
  async function getProcessStartTimeUnix(pid) {
2616
2876
  const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2617
2877
  const d = new Date(raw);
@@ -2727,6 +2987,7 @@ async function readGitBranch(dir) {
2727
2987
  var import_node_ws = require("@hono/node-ws");
2728
2988
  var import_client = require("@temporalio/client");
2729
2989
  var import_scanner3 = require("@threadbase-sh/scanner");
2990
+ var import_crypto11 = require("crypto");
2730
2991
  var import_events = require("events");
2731
2992
  var import_fs18 = require("fs");
2732
2993
  var import_promises7 = require("fs/promises");
@@ -2991,7 +3252,202 @@ async function handleStartAgentSession(body, deps) {
2991
3252
  }
2992
3253
 
2993
3254
  // src/api/app.ts
2994
- var import_hono13 = require("hono");
3255
+ var import_hono16 = require("hono");
3256
+
3257
+ // src/db/repositories/devices.repository.ts
3258
+ var import_crypto5 = require("crypto");
3259
+
3260
+ // src/services/security/capabilities.ts
3261
+ var CAPABILITIES = [
3262
+ "history:read",
3263
+ // read conversations, search
3264
+ "session:control",
3265
+ // start, resume, send input, interrupt
3266
+ "fs:browse",
3267
+ // browse the project tree
3268
+ "fs:upload",
3269
+ // upload files into a project
3270
+ "notifications",
3271
+ // register for push
3272
+ "admin"
3273
+ // rotate keys, manage devices
3274
+ ];
3275
+ function isCapability(value) {
3276
+ return typeof value === "string" && CAPABILITIES.includes(value);
3277
+ }
3278
+ var FULL_CAPABILITIES = [
3279
+ "history:read",
3280
+ "session:control",
3281
+ "fs:browse",
3282
+ "fs:upload",
3283
+ "notifications"
3284
+ ];
3285
+ var READ_ONLY_CAPABILITIES = ["history:read"];
3286
+ function capabilitiesForPreset(preset) {
3287
+ return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
3288
+ }
3289
+ function legacyPrincipal() {
3290
+ return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
3291
+ }
3292
+ function hasCapability(principal, required) {
3293
+ return principal.capabilities.includes(required);
3294
+ }
3295
+ var ROUTE_CAPABILITIES = [
3296
+ // Most specific first for readability; matching sorts by length anyway.
3297
+ ["/api/sessions/", "session:control"],
3298
+ ["/api/sessions", "history:read"],
3299
+ // listing sessions is a read
3300
+ ["/api/conversations", "history:read"],
3301
+ ["/api/projects", "history:read"],
3302
+ ["/api/search", "history:read"],
3303
+ ["/api/providers", "history:read"],
3304
+ ["/api/browse", "fs:browse"],
3305
+ ["/api/upload", "fs:upload"],
3306
+ ["/api/push", "notifications"],
3307
+ ["/api/devices", "admin"],
3308
+ ["/api/config", "admin"],
3309
+ ["/api/auth/rotate", "admin"],
3310
+ ["/api/backup", "admin"],
3311
+ // Server identity and capability discovery. A read-only device must be able
3312
+ // to see WHICH server it is talking to and what it supports, or it cannot
3313
+ // render anything at all.
3314
+ ["/api/info", "history:read"],
3315
+ ["/api/profiles", "history:read"],
3316
+ ["/api/diagnostics", "history:read"],
3317
+ ["/api/cache/alert", "history:read"],
3318
+ // Client log shipping: any authenticated client may report its own errors.
3319
+ // Gating this behind a capability would silence diagnostics from exactly the
3320
+ // devices most likely to be misbehaving.
3321
+ ["/api/__client-log", "history:read"],
3322
+ // Logs viewer is localhost-only and already bypasses this middleware; the
3323
+ // mapping exists so a remote request is classified rather than denied as
3324
+ // unclassified.
3325
+ ["/api/logs", "admin"],
3326
+ // Pairing routes other than the public exchange (e.g. minting a token).
3327
+ ["/api/pair", "admin"],
3328
+ // The live WebSocket. Subscribing is a read — terminal output, session
3329
+ // updates, conversation events. Control still flows through the HTTP input
3330
+ // routes, which carry their own capability check, so a read-only device can
3331
+ // watch a session stream without being able to drive it.
3332
+ ["/ws", "history:read"],
3333
+ // Progress webhook (multi-agent). Authenticated by HMAC in the handler and
3334
+ // already skipped by the middleware; classified so a stray request is denied
3335
+ // by rule rather than as "unclassified".
3336
+ ["/internal/sessions", "admin"]
3337
+ ];
3338
+ function requiredCapability(path, method) {
3339
+ if (path.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
3340
+ return "history:read";
3341
+ }
3342
+ let best = null;
3343
+ for (const [prefix, cap] of ROUTE_CAPABILITIES) {
3344
+ if (path.startsWith(prefix) && (best === null || prefix.length > best.len)) {
3345
+ best = { len: prefix.length, cap };
3346
+ }
3347
+ }
3348
+ return best?.cap ?? null;
3349
+ }
3350
+
3351
+ // src/db/repositories/devices.repository.ts
3352
+ function generateDeviceToken() {
3353
+ return (0, import_crypto5.randomBytes)(32).toString("base64url");
3354
+ }
3355
+ function hashDeviceToken(token) {
3356
+ return (0, import_crypto5.createHash)("sha256").update(token).digest("hex");
3357
+ }
3358
+ function safeHashEquals(a, b) {
3359
+ if (a.length !== b.length) return false;
3360
+ return (0, import_crypto5.timingSafeEqual)(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
3361
+ }
3362
+ function parseCapabilities(raw) {
3363
+ try {
3364
+ const parsed = JSON.parse(raw);
3365
+ if (!Array.isArray(parsed)) return [];
3366
+ return parsed.filter(isCapability);
3367
+ } catch {
3368
+ return [];
3369
+ }
3370
+ }
3371
+ function toDeviceView(row) {
3372
+ return {
3373
+ deviceId: row.device_id,
3374
+ name: row.name,
3375
+ capabilities: parseCapabilities(row.capabilities),
3376
+ createdAt: row.created_at,
3377
+ lastSeenAt: row.last_seen_at,
3378
+ revokedAt: row.revoked_at
3379
+ };
3380
+ }
3381
+ var DevicesRepository = class {
3382
+ insertStmt;
3383
+ byTokenHashStmt;
3384
+ byIdStmt;
3385
+ listStmt;
3386
+ revokeStmt;
3387
+ touchStmt;
3388
+ constructor(db) {
3389
+ this.insertStmt = db.prepare(`
3390
+ INSERT INTO devices (
3391
+ device_id, public_key, token_hash, name, capabilities, created_at
3392
+ ) VALUES (
3393
+ @device_id, @public_key, @token_hash, @name, @capabilities, @created_at
3394
+ )
3395
+ `);
3396
+ this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
3397
+ this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
3398
+ this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
3399
+ this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
3400
+ this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
3401
+ }
3402
+ /**
3403
+ * Record a newly paired device and mint its token.
3404
+ *
3405
+ * The raw token is returned to the caller and never stored — this is the only
3406
+ * moment it exists outside the client.
3407
+ */
3408
+ register(args) {
3409
+ const deviceId = (0, import_crypto5.randomUUID)();
3410
+ const deviceToken = generateDeviceToken();
3411
+ const capabilities = capabilitiesForPreset(args.preset ?? "full");
3412
+ this.insertStmt.run({
3413
+ device_id: deviceId,
3414
+ public_key: args.publicKey,
3415
+ token_hash: hashDeviceToken(deviceToken),
3416
+ name: args.name ?? null,
3417
+ capabilities: JSON.stringify(capabilities),
3418
+ created_at: args.now ?? Date.now()
3419
+ });
3420
+ return { deviceId, deviceToken, capabilities };
3421
+ }
3422
+ /**
3423
+ * Resolve a presented token to a device, or null.
3424
+ *
3425
+ * Returns null for a revoked device, so revocation takes effect on the very
3426
+ * next request with no cache to go stale.
3427
+ */
3428
+ authenticate(token) {
3429
+ const hash = hashDeviceToken(token);
3430
+ const row = this.byTokenHashStmt.get(hash);
3431
+ if (!row) return null;
3432
+ if (!safeHashEquals(row.token_hash, hash)) return null;
3433
+ if (row.revoked_at != null) return null;
3434
+ return row;
3435
+ }
3436
+ get(deviceId) {
3437
+ return this.byIdStmt.get(deviceId) ?? null;
3438
+ }
3439
+ /** All devices, including revoked ones — an audit surface needs the history. */
3440
+ list() {
3441
+ return this.listStmt.all().map(toDeviceView);
3442
+ }
3443
+ /** Revoke one device. Others are untouched — no key rotation, no collateral. */
3444
+ revoke(deviceId, now = Date.now()) {
3445
+ return this.revokeStmt.run(now, deviceId).changes > 0;
3446
+ }
3447
+ touch(deviceId, now = Date.now()) {
3448
+ this.touchStmt.run(now, deviceId);
3449
+ }
3450
+ };
2995
3451
 
2996
3452
  // src/api/middleware/auth.middleware.ts
2997
3453
  function isLocalRequest(remoteAddr) {
@@ -3022,19 +3478,40 @@ var authMiddleware = (deps) => async (c, next) => {
3022
3478
  }
3023
3479
  }
3024
3480
  const authorization = c.req.header("authorization");
3025
- if (authorization?.startsWith("Bearer ")) {
3026
- const token = authorization.slice(7);
3027
- if (validateApiKey(token, deps.apiKey)) {
3028
- await next();
3029
- return;
3481
+ const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : void 0;
3482
+ const queryKey = c.req.query("key") ?? void 0;
3483
+ const presented = bearer ?? queryKey;
3484
+ if (!presented) {
3485
+ return c.json({ error: "Unauthorized" }, 401);
3486
+ }
3487
+ let principal = null;
3488
+ const device = deps.devicesRepo()?.authenticate(presented) ?? null;
3489
+ if (device) {
3490
+ principal = {
3491
+ kind: "device",
3492
+ deviceId: device.device_id,
3493
+ capabilities: parseCapabilities(device.capabilities)
3494
+ };
3495
+ try {
3496
+ deps.devicesRepo()?.touch(device.device_id);
3497
+ } catch {
3030
3498
  }
3499
+ } else if (validateApiKey(presented, deps.apiKey)) {
3500
+ principal = legacyPrincipal();
3501
+ }
3502
+ if (!principal) {
3503
+ return c.json({ error: "Unauthorized" }, 401);
3031
3504
  }
3032
- const key = c.req.query("key");
3033
- if (key && validateApiKey(key, deps.apiKey)) {
3505
+ const required = requiredCapability(path, method);
3506
+ if (required === null) {
3034
3507
  await next();
3035
3508
  return;
3036
3509
  }
3037
- return c.json({ error: "Unauthorized" }, 401);
3510
+ if (!hasCapability(principal, required)) {
3511
+ return c.json({ error: "Forbidden", code: "MISSING_CAPABILITY", required }, 403);
3512
+ }
3513
+ c.set("principal", principal);
3514
+ await next();
3038
3515
  };
3039
3516
 
3040
3517
  // src/api/middleware/cors.middleware.ts
@@ -3173,12 +3650,67 @@ var createCacheAlertRoutes = (deps) => {
3173
3650
  return app;
3174
3651
  };
3175
3652
 
3176
- // src/api/routes/conversations.routes.ts
3653
+ // src/api/routes/config.routes.ts
3177
3654
  var import_hono4 = require("hono");
3655
+
3656
+ // src/schemas/claudeFlags.schema.ts
3657
+ var import_zod2 = require("zod");
3658
+ var ClaudeFlagsBodySchema = import_zod2.z.object({
3659
+ values: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean(), import_zod2.z.array(import_zod2.z.string())])).default({}),
3660
+ // A newline would corrupt the flat one-line-per-key server.yaml, so reject
3661
+ // it here with a field error instead of silently stripping it.
3662
+ extraArgs: import_zod2.z.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
3663
+ }).strict();
3664
+
3665
+ // src/api/routes/config.routes.ts
3666
+ function readRawBody3(req) {
3667
+ return new Promise((resolve2, reject) => {
3668
+ const chunks = [];
3669
+ req.on("data", (chunk) => chunks.push(chunk));
3670
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
3671
+ req.on("error", reject);
3672
+ });
3673
+ }
3674
+ var createConfigRoutes = (deps) => {
3675
+ const app = new import_hono4.Hono();
3676
+ app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3677
+ app.put("/claude-flags", async (c) => {
3678
+ if (deps.localNoAuth) {
3679
+ return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
3680
+ }
3681
+ let body;
3682
+ try {
3683
+ const incoming = c.env?.incoming;
3684
+ const raw = incoming ? await readRawBody3(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
3685
+ body = raw ? JSON.parse(raw) : {};
3686
+ } catch {
3687
+ return c.json({ error: "invalid json" }, 400);
3688
+ }
3689
+ const parsed = ClaudeFlagsBodySchema.safeParse(body);
3690
+ if (!parsed.success) {
3691
+ return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
3692
+ }
3693
+ try {
3694
+ const result = deps.setClaudeFlagsConfig(parsed.data.values, parsed.data.extraArgs);
3695
+ return c.json({
3696
+ ...result,
3697
+ ...result.persisted ? {} : {
3698
+ warning: "Flags applied in memory only. The server was started with --claude-flag, so the CLI values will be restored on restart. Drop the flag and let the server manage them via ~/.threadbase/server.yaml for changes to survive restarts."
3699
+ }
3700
+ });
3701
+ } catch (err) {
3702
+ return c.json({ error: err instanceof Error ? err.message : "could not apply flags" }, 400);
3703
+ }
3704
+ });
3705
+ return app;
3706
+ };
3707
+
3708
+ // src/api/routes/conversations.routes.ts
3709
+ var import_hono5 = require("hono");
3178
3710
  var ALREADY_HANDLED2 = 597;
3179
3711
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
3180
3712
  var createConversationRoutes = (deps) => {
3181
- const app = new import_hono4.Hono();
3713
+ const app = new import_hono5.Hono();
3182
3714
  app.get("/count", async (c) => {
3183
3715
  const url = new URL(c.req.url);
3184
3716
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -3204,8 +3736,34 @@ var createConversationRoutes = (deps) => {
3204
3736
  return app;
3205
3737
  };
3206
3738
 
3739
+ // src/api/routes/devices.routes.ts
3740
+ var import_hono6 = require("hono");
3741
+ var createDeviceRoutes = (deps) => {
3742
+ const app = new import_hono6.Hono();
3743
+ app.get("/", (c) => {
3744
+ const repo = deps.devicesRepo();
3745
+ if (!repo) return c.json({ devices: [], available: false });
3746
+ return c.json({ devices: repo.list(), available: true });
3747
+ });
3748
+ app.post("/:id/revoke", (c) => {
3749
+ const repo = deps.devicesRepo();
3750
+ if (!repo) {
3751
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3752
+ }
3753
+ const id = c.req.param("id");
3754
+ const existing = repo.get(id);
3755
+ if (!existing) return c.json({ error: "Device not found" }, 404);
3756
+ if (existing.revoked_at != null) {
3757
+ return c.json({ ok: true, alreadyRevoked: true });
3758
+ }
3759
+ repo.revoke(id);
3760
+ return c.json({ ok: true, alreadyRevoked: false });
3761
+ });
3762
+ return app;
3763
+ };
3764
+
3207
3765
  // src/api/routes/health.routes.ts
3208
- var import_hono5 = require("hono");
3766
+ var import_hono7 = require("hono");
3209
3767
 
3210
3768
  // src/version.ts
3211
3769
  var import_node_fs2 = require("fs");
@@ -3242,7 +3800,7 @@ function resolveVersion() {
3242
3800
 
3243
3801
  // src/api/routes/health.routes.ts
3244
3802
  var createHealthRoutes = (deps) => {
3245
- const app = new import_hono5.Hono();
3803
+ const app = new import_hono7.Hono();
3246
3804
  app.get("/", (c) => {
3247
3805
  const cacheAlert = deps.cacheMonitor()?.healthzField();
3248
3806
  return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
@@ -3253,7 +3811,7 @@ var createHealthRoutes = (deps) => {
3253
3811
  // src/api/routes/logs.routes.ts
3254
3812
  var import_node_fs3 = require("fs");
3255
3813
  var import_node_path5 = require("path");
3256
- var import_hono6 = require("hono");
3814
+ var import_hono8 = require("hono");
3257
3815
 
3258
3816
  // src/lifecycle/constants.ts
3259
3817
  var import_node_os = require("os");
@@ -3311,7 +3869,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3311
3869
  }
3312
3870
  }
3313
3871
  function createLogsRoutes() {
3314
- const app = new import_hono6.Hono();
3872
+ const app = new import_hono8.Hono();
3315
3873
  app.get("/", (c) => {
3316
3874
  try {
3317
3875
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3382,7 +3940,7 @@ function createLogsRoutes() {
3382
3940
  // src/api/routes/misc.routes.ts
3383
3941
  var import_node_child_process = require("child_process");
3384
3942
  var import_node_crypto2 = require("crypto");
3385
- var import_hono7 = require("hono");
3943
+ var import_hono9 = require("hono");
3386
3944
  var import_os5 = require("os");
3387
3945
 
3388
3946
  // src/config/update-config.ts
@@ -3392,15 +3950,15 @@ var import_node_path6 = require("path");
3392
3950
  var import_yaml = require("yaml");
3393
3951
 
3394
3952
  // src/schemas/updateConfig.schema.ts
3395
- var import_zod2 = require("zod");
3396
- var UpdateConfigSchema = import_zod2.z.object({
3397
- auto_update: import_zod2.z.boolean().default(false),
3398
- channel: import_zod2.z.enum(["stable", "next"]).default("stable"),
3399
- allow: import_zod2.z.array(import_zod2.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3400
- poll_interval_minutes: import_zod2.z.number().int().min(0).default(1440),
3401
- defer_if_active_sessions: import_zod2.z.boolean().default(true),
3402
- github_repo: import_zod2.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3403
- webhook_secret: import_zod2.z.string().min(1).nullable().default(null)
3953
+ var import_zod3 = require("zod");
3954
+ var UpdateConfigSchema = import_zod3.z.object({
3955
+ auto_update: import_zod3.z.boolean().default(false),
3956
+ channel: import_zod3.z.enum(["stable", "next"]).default("stable"),
3957
+ allow: import_zod3.z.array(import_zod3.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3958
+ poll_interval_minutes: import_zod3.z.number().int().min(0).default(1440),
3959
+ defer_if_active_sessions: import_zod3.z.boolean().default(true),
3960
+ github_repo: import_zod3.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3961
+ webhook_secret: import_zod3.z.string().min(1).nullable().default(null)
3404
3962
  }).strict();
3405
3963
 
3406
3964
  // src/config/update-config.ts
@@ -3437,7 +3995,7 @@ function readJsonBody(req) {
3437
3995
  req.on("error", reject);
3438
3996
  });
3439
3997
  }
3440
- function readRawBody3(req) {
3998
+ function readRawBody4(req) {
3441
3999
  return new Promise((resolve2, reject) => {
3442
4000
  const chunks = [];
3443
4001
  req.on("data", (chunk) => chunks.push(chunk));
@@ -3456,7 +4014,7 @@ function verifyWebhookSignature(body, header, secret) {
3456
4014
  }
3457
4015
  var clientLog = getLogger("client");
3458
4016
  var createMiscRoutes = (deps) => {
3459
- const app = new import_hono7.Hono();
4017
+ const app = new import_hono9.Hono();
3460
4018
  app.get("/api/info", (c) => {
3461
4019
  const ptyIds = deps.ptyAttachedIds();
3462
4020
  return c.json({
@@ -3464,7 +4022,11 @@ var createMiscRoutes = (deps) => {
3464
4022
  machineName: (0, import_os5.hostname)(),
3465
4023
  platform: process.platform,
3466
4024
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
3467
- publicUrl: deps.publicUrl
4025
+ publicUrl: deps.publicUrl,
4026
+ // Capability flag: this server serves /api/config/claude-flags. Additive —
4027
+ // older clients ignore it, and clients talking to an older server see it
4028
+ // absent and hide the UI rather than 404ing.
4029
+ claudeFlags: true
3468
4030
  });
3469
4031
  });
3470
4032
  app.get("/api/profiles", (c) => c.json([]));
@@ -3481,7 +4043,32 @@ var createMiscRoutes = (deps) => {
3481
4043
  }
3482
4044
  });
3483
4045
  });
3484
- app.post("/api/push/register", (c) => c.json({ ok: true }));
4046
+ app.post("/api/push/register", async (c) => {
4047
+ const body = await readJsonBody(c.env.incoming).catch(() => null);
4048
+ const token = body?.token;
4049
+ const platform3 = body?.platform;
4050
+ if (typeof token !== "string" || token.length === 0) {
4051
+ return c.json({ error: "Missing token" }, 400);
4052
+ }
4053
+ if (platform3 !== "ios" && platform3 !== "android") {
4054
+ return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
4055
+ }
4056
+ const repo = deps.pushRepo();
4057
+ if (!repo) {
4058
+ return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
4059
+ }
4060
+ repo.register({
4061
+ token,
4062
+ platform: platform3,
4063
+ deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
4064
+ });
4065
+ return c.json({ ok: true });
4066
+ });
4067
+ app.get("/api/push/health", (c) => {
4068
+ const repo = deps.pushRepo();
4069
+ if (!repo) return c.json({ tokens: [], available: false });
4070
+ return c.json({ tokens: repo.listHealth(), available: true });
4071
+ });
3485
4072
  app.post("/api/__update", async (c) => {
3486
4073
  const cfg = loadUpdateConfig();
3487
4074
  if (!cfg?.webhook_secret) {
@@ -3489,7 +4076,7 @@ var createMiscRoutes = (deps) => {
3489
4076
  }
3490
4077
  let body;
3491
4078
  try {
3492
- body = await readRawBody3(c.env.incoming);
4079
+ body = await readRawBody4(c.env.incoming);
3493
4080
  } catch {
3494
4081
  return c.json({ error: "could not read body" }, 400);
3495
4082
  }
@@ -3532,11 +4119,11 @@ var createMiscRoutes = (deps) => {
3532
4119
  };
3533
4120
 
3534
4121
  // src/api/routes/pair.routes.ts
3535
- var import_hono8 = require("hono");
4122
+ var import_hono10 = require("hono");
3536
4123
  var ALREADY_HANDLED3 = 597;
3537
4124
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
3538
4125
  var createPairRoutes = (deps) => {
3539
- const app = new import_hono8.Hono();
4126
+ const app = new import_hono10.Hono();
3540
4127
  app.post("/start", (c) => {
3541
4128
  deps.handlePairStart(c.env.outgoing);
3542
4129
  return alreadyHandled3();
@@ -3549,11 +4136,11 @@ var createPairRoutes = (deps) => {
3549
4136
  };
3550
4137
 
3551
4138
  // src/api/routes/projects.routes.ts
3552
- var import_hono9 = require("hono");
4139
+ var import_hono11 = require("hono");
3553
4140
  var ALREADY_HANDLED4 = 597;
3554
4141
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
3555
4142
  var createProjectRoutes = (deps) => {
3556
- const app = new import_hono9.Hono();
4143
+ const app = new import_hono11.Hono();
3557
4144
  app.get("/", (c) => {
3558
4145
  const url = new URL(c.req.url);
3559
4146
  deps.handleListProjects(url, c.env.outgoing);
@@ -3567,12 +4154,147 @@ var createProjectRoutes = (deps) => {
3567
4154
  return app;
3568
4155
  };
3569
4156
 
4157
+ // src/api/routes/providers.routes.ts
4158
+ var import_hono12 = require("hono");
4159
+
4160
+ // src/services/providers/providerHealth.ts
4161
+ var import_child_process3 = require("child_process");
4162
+
4163
+ // src/services/providers/capabilities.ts
4164
+ var CLAUDE_CODE_CAPABILITIES = {
4165
+ freshSessionId: "explicit",
4166
+ resume: "native",
4167
+ systemPrompt: "flag",
4168
+ structuredQuestions: true,
4169
+ permissionGates: true,
4170
+ liveControl: true
4171
+ };
4172
+ var CODEX_CLI_CAPABILITIES = {
4173
+ freshSessionId: "late-bound",
4174
+ resume: "native",
4175
+ systemPrompt: "positional",
4176
+ structuredQuestions: false,
4177
+ permissionGates: true,
4178
+ liveControl: true
4179
+ };
4180
+ function capabilitiesFor(provider) {
4181
+ switch (provider) {
4182
+ case CLAUDE_CODE_PROVIDER:
4183
+ return CLAUDE_CODE_CAPABILITIES;
4184
+ case CODEX_CLI_PROVIDER:
4185
+ return CODEX_CLI_CAPABILITIES;
4186
+ }
4187
+ }
4188
+
4189
+ // src/services/providers/providerHealth.ts
4190
+ var VERIFIED_AGAINST = {
4191
+ [CLAUDE_CODE_PROVIDER]: { captured: ["2.1.214"], min: "2.1.0" },
4192
+ [CODEX_CLI_PROVIDER]: { captured: ["0.140.0-alpha.19"], min: "0.140.0" }
4193
+ };
4194
+ var VERSION_TIMEOUT_MS = 3e3;
4195
+ function parseVersionOutput(output) {
4196
+ const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
4197
+ return match ? match[0] : null;
4198
+ }
4199
+ function runVersion(exe) {
4200
+ return new Promise((resolve2) => {
4201
+ (0, import_child_process3.execFile)(exe, ["--version"], { timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => {
4202
+ if (err && !stdout && !stderr) return resolve2(null);
4203
+ resolve2(parseVersionOutput(`${stdout}${stderr}`));
4204
+ });
4205
+ });
4206
+ }
4207
+ function compareToVerified(version, verified) {
4208
+ if (version === null) {
4209
+ return {
4210
+ code: "version_undetectable",
4211
+ message: "Could not determine the installed version, so compatibility is unverified. Parsing and prompt detection may not match this build."
4212
+ };
4213
+ }
4214
+ if (verified.captured.includes(version)) return null;
4215
+ const below = verified.min != null && compareSemver(version, verified.min) < 0;
4216
+ const above = verified.max != null && compareSemver(version, verified.max) > 0;
4217
+ if (!below && !above && verified.max != null) return null;
4218
+ if (!below && verified.max == null && !isNewerThanAllCaptured(version, verified.captured)) {
4219
+ return null;
4220
+ }
4221
+ return {
4222
+ code: "version_unverified",
4223
+ message: `Installed version ${version} is outside the range these adapters were verified against (captured: ${verified.captured.join(", ")}). It will still run; parsing or prompt detection may differ.`
4224
+ };
4225
+ }
4226
+ function isNewerThanAllCaptured(version, captured) {
4227
+ return captured.every((c) => compareSemver(version, c) > 0);
4228
+ }
4229
+ function compareSemver(a, b) {
4230
+ const parse = (v) => {
4231
+ const [core, pre] = v.split("-", 2);
4232
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
4233
+ return { nums, pre: pre ?? null };
4234
+ };
4235
+ const pa = parse(a);
4236
+ const pb = parse(b);
4237
+ for (let i = 0; i < 3; i++) {
4238
+ const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
4239
+ if (d !== 0) return d < 0 ? -1 : 1;
4240
+ }
4241
+ if (pa.pre === pb.pre) return 0;
4242
+ if (pa.pre === null) return 1;
4243
+ if (pb.pre === null) return -1;
4244
+ return pa.pre < pb.pre ? -1 : 1;
4245
+ }
4246
+ async function providerHealth(name, resolveExe, detect = runVersion) {
4247
+ const verifiedAgainst = VERIFIED_AGAINST[name];
4248
+ const capabilities = capabilitiesFor(name);
4249
+ let exe;
4250
+ try {
4251
+ exe = resolveExe();
4252
+ } catch {
4253
+ return {
4254
+ name,
4255
+ available: false,
4256
+ version: null,
4257
+ verifiedAgainst,
4258
+ capabilities,
4259
+ warnings: [
4260
+ {
4261
+ code: "provider_not_found",
4262
+ message: `${name} could not be located. Sessions for this provider cannot start.`
4263
+ }
4264
+ ]
4265
+ };
4266
+ }
4267
+ const version = await detect(exe);
4268
+ const warning = compareToVerified(version, verifiedAgainst);
4269
+ return {
4270
+ name,
4271
+ available: true,
4272
+ version,
4273
+ verifiedAgainst,
4274
+ capabilities,
4275
+ warnings: warning ? [warning] : []
4276
+ };
4277
+ }
4278
+
4279
+ // src/api/routes/providers.routes.ts
4280
+ var createProviderRoutes = () => {
4281
+ const app = new import_hono12.Hono();
4282
+ app.get("/", async (c) => {
4283
+ const providers = await Promise.all([
4284
+ providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
4285
+ providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
4286
+ ]);
4287
+ return c.json({ providers });
4288
+ });
4289
+ return app;
4290
+ };
4291
+
3570
4292
  // src/api/routes/scanner.routes.ts
3571
- var import_hono10 = require("hono");
4293
+ var import_hono13 = require("hono");
3572
4294
  var ALREADY_HANDLED5 = 597;
3573
4295
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
3574
4296
  var createScannerRoutes = (deps) => {
3575
- const app = new import_hono10.Hono();
4297
+ const app = new import_hono13.Hono();
3576
4298
  app.get("/api/search", async (c) => {
3577
4299
  const url = new URL(c.req.url);
3578
4300
  await deps.handleSearch(url, c.env.outgoing);
@@ -3582,11 +4304,11 @@ var createScannerRoutes = (deps) => {
3582
4304
  };
3583
4305
 
3584
4306
  // src/api/routes/sessions.routes.ts
3585
- var import_hono11 = require("hono");
4307
+ var import_hono14 = require("hono");
3586
4308
  var ALREADY_HANDLED6 = 597;
3587
4309
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
3588
4310
  var createSessionRoutes = (deps) => {
3589
- const app = new import_hono11.Hono();
4311
+ const app = new import_hono14.Hono();
3590
4312
  app.get("/count", (c) => {
3591
4313
  deps.handleSessionsCount(c.env.outgoing);
3592
4314
  return alreadyHandled6();
@@ -3653,9 +4375,9 @@ var createSessionRoutes = (deps) => {
3653
4375
  };
3654
4376
 
3655
4377
  // src/api/routes/ws.routes.ts
3656
- var import_hono12 = require("hono");
4378
+ var import_hono15 = require("hono");
3657
4379
  var createWsRoutes = (deps, upgradeWebSocket) => {
3658
- const app = new import_hono12.Hono();
4380
+ const app = new import_hono15.Hono();
3659
4381
  app.get(
3660
4382
  "/ws",
3661
4383
  upgradeWebSocket(() => {
@@ -3681,7 +4403,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3681
4403
 
3682
4404
  // src/api/app.ts
3683
4405
  var createHonoApp = (deps, upgradeWebSocket) => {
3684
- const app = new import_hono13.Hono();
4406
+ const app = new import_hono16.Hono();
3685
4407
  const httpLog = getLogger("http");
3686
4408
  app.use("*", async (c, next) => {
3687
4409
  const start = Date.now();
@@ -3706,7 +4428,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3706
4428
  app.route("/api/sessions", createSessionRoutes(deps));
3707
4429
  app.route("/api/conversations", createConversationRoutes(deps));
3708
4430
  app.route("/api/cache/alert", createCacheAlertRoutes(deps));
4431
+ app.route("/api/config", createConfigRoutes(deps));
3709
4432
  app.route("/api/projects", createProjectRoutes(deps));
4433
+ app.route("/api/providers", createProviderRoutes());
4434
+ app.route("/api/devices", createDeviceRoutes(deps));
3710
4435
  app.route("/api/pair", createPairRoutes(deps));
3711
4436
  app.route("/api", createBrowseRoutes(deps));
3712
4437
  app.route("/", createScannerRoutes(deps));
@@ -3914,11 +4639,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
3914
4639
  }
3915
4640
 
3916
4641
  // src/utils/fileIdentity.ts
3917
- var import_crypto5 = require("crypto");
4642
+ var import_crypto6 = require("crypto");
3918
4643
  function fileIdentity(stat3, headBytes) {
3919
4644
  if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
3920
4645
  const head = headBytes ?? Buffer.alloc(0);
3921
- return `fp:${(0, import_crypto5.createHash)("sha1").update(head).digest("hex")}`;
4646
+ return `fp:${(0, import_crypto6.createHash)("sha1").update(head).digest("hex")}`;
3922
4647
  }
3923
4648
  function splitCompleteLines(buf, baseOffset) {
3924
4649
  const spans = [];
@@ -5200,8 +5925,122 @@ var ConversationsRepository = class {
5200
5925
  }
5201
5926
  };
5202
5927
 
5928
+ // src/db/repositories/managed-sessions.repository.ts
5929
+ var ManagedSessionsRepository = class {
5930
+ upsertStmt;
5931
+ updateStatusStmt;
5932
+ getStmt;
5933
+ listNonTerminalStmt;
5934
+ deleteStmt;
5935
+ constructor(db) {
5936
+ this.upsertStmt = db.prepare(`
5937
+ INSERT INTO managed_sessions (
5938
+ session_id, provider, pid, cmdline, project_path, project_name, branch,
5939
+ status, status_source, status_updated_at, started_at, completed_at,
5940
+ last_activity_at, prompt_count, session_name, project_id,
5941
+ bound_conversation_id, resumed_from_conversation_id, failure_reason,
5942
+ streamer_instance_id
5943
+ ) VALUES (
5944
+ @session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
5945
+ @status, @status_source, @status_updated_at, @started_at, @completed_at,
5946
+ @last_activity_at, @prompt_count, @session_name, @project_id,
5947
+ @bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
5948
+ @streamer_instance_id
5949
+ )
5950
+ ON CONFLICT(session_id) DO UPDATE SET
5951
+ pid = excluded.pid,
5952
+ cmdline = excluded.cmdline,
5953
+ project_path = excluded.project_path,
5954
+ project_name = excluded.project_name,
5955
+ branch = excluded.branch,
5956
+ status = excluded.status,
5957
+ status_source = excluded.status_source,
5958
+ status_updated_at = excluded.status_updated_at,
5959
+ completed_at = excluded.completed_at,
5960
+ last_activity_at = excluded.last_activity_at,
5961
+ prompt_count = excluded.prompt_count,
5962
+ session_name = excluded.session_name,
5963
+ project_id = excluded.project_id,
5964
+ bound_conversation_id = excluded.bound_conversation_id,
5965
+ resumed_from_conversation_id = excluded.resumed_from_conversation_id,
5966
+ failure_reason = excluded.failure_reason,
5967
+ streamer_instance_id = excluded.streamer_instance_id
5968
+ `);
5969
+ this.updateStatusStmt = db.prepare(`
5970
+ UPDATE managed_sessions
5971
+ SET status = @status,
5972
+ status_source = @status_source,
5973
+ status_updated_at = @status_updated_at,
5974
+ completed_at = @completed_at,
5975
+ last_activity_at = @last_activity_at,
5976
+ prompt_count = @prompt_count,
5977
+ failure_reason = COALESCE(@failure_reason, failure_reason)
5978
+ WHERE session_id = @session_id
5979
+ `);
5980
+ this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
5981
+ this.listNonTerminalStmt = db.prepare(`
5982
+ SELECT * FROM managed_sessions
5983
+ WHERE completed_at IS NULL
5984
+ ORDER BY started_at ASC
5985
+ `);
5986
+ this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
5987
+ }
5988
+ /** Record a session at spawn, or refresh every field of an existing row. */
5989
+ recordSpawn({ session, pid, cmdline, streamerInstanceId }) {
5990
+ this.upsertStmt.run({
5991
+ session_id: session.id,
5992
+ provider: session.provider ?? "claude-code",
5993
+ pid,
5994
+ cmdline,
5995
+ project_path: session.projectPath,
5996
+ project_name: session.projectName,
5997
+ branch: session.branch ?? "",
5998
+ status: session.status,
5999
+ status_source: "spawn",
6000
+ status_updated_at: Date.now(),
6001
+ started_at: session.startedAt.getTime(),
6002
+ completed_at: session.completedAt?.getTime() ?? null,
6003
+ last_activity_at: session.lastActivityAt?.getTime() ?? null,
6004
+ prompt_count: session.promptCount,
6005
+ session_name: session.sessionName ?? null,
6006
+ project_id: session.projectId ?? null,
6007
+ bound_conversation_id: session.boundConversationId ?? null,
6008
+ resumed_from_conversation_id: session.resumedFromConversationId ?? null,
6009
+ failure_reason: session.failureReason ?? null,
6010
+ streamer_instance_id: streamerInstanceId
6011
+ });
6012
+ }
6013
+ /**
6014
+ * Persist a status transition. `source` is required rather than defaulted:
6015
+ * a status whose provenance is unknown is the thing this table exists to
6016
+ * prevent, and the reconciler reads it to decide how much to trust the value.
6017
+ */
6018
+ recordStatus(sessionId, status, source, fields = {}) {
6019
+ this.updateStatusStmt.run({
6020
+ session_id: sessionId,
6021
+ status,
6022
+ status_source: source,
6023
+ status_updated_at: Date.now(),
6024
+ completed_at: fields.completedAt?.getTime() ?? null,
6025
+ last_activity_at: fields.lastActivityAt?.getTime() ?? null,
6026
+ prompt_count: fields.promptCount ?? 0,
6027
+ failure_reason: fields.failureReason ?? null
6028
+ });
6029
+ }
6030
+ get(sessionId) {
6031
+ return this.getStmt.get(sessionId) ?? null;
6032
+ }
6033
+ /** Rows with no recorded completion — the reconciler's probe set. */
6034
+ listNonTerminal() {
6035
+ return this.listNonTerminalStmt.all();
6036
+ }
6037
+ delete(sessionId) {
6038
+ this.deleteStmt.run(sessionId);
6039
+ }
6040
+ };
6041
+
5203
6042
  // src/db/repositories/projects.repository.ts
5204
- var import_crypto6 = require("crypto");
6043
+ var import_crypto7 = require("crypto");
5205
6044
 
5206
6045
  // src/utils/canonicalizeProjectPath.ts
5207
6046
  function canonicalizeProjectPath(projectPath) {
@@ -5294,7 +6133,7 @@ var ProjectsRepository = class {
5294
6133
  });
5295
6134
  return rowToProject(this.getById.get(existing.id));
5296
6135
  }
5297
- const id = (0, import_crypto6.randomUUID)();
6136
+ const id = (0, import_crypto7.randomUUID)();
5298
6137
  this.insert.run({
5299
6138
  id,
5300
6139
  path,
@@ -5316,6 +6155,125 @@ function deriveNameFromPath(path) {
5316
6155
  return parts.length > 0 ? parts[parts.length - 1] : null;
5317
6156
  }
5318
6157
 
6158
+ // src/db/repositories/push.repository.ts
6159
+ var FAILURE_STREAK_LIMIT = 5;
6160
+ function tokenState(row) {
6161
+ if (row.revoked_at != null) return "revoked";
6162
+ if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
6163
+ if (row.failure_streak > 0) return "failing";
6164
+ if (row.last_success_at == null) return "never-delivered";
6165
+ return "healthy";
6166
+ }
6167
+ function toHealth(row) {
6168
+ return {
6169
+ platform: row.platform,
6170
+ deviceId: row.device_id,
6171
+ registeredAt: row.registered_at,
6172
+ lastSuccessAt: row.last_success_at,
6173
+ lastFailureAt: row.last_failure_at,
6174
+ lastFailureCode: row.last_failure_code,
6175
+ failureStreak: row.failure_streak,
6176
+ revokedAt: row.revoked_at,
6177
+ state: tokenState(row)
6178
+ };
6179
+ }
6180
+ var PushRepository = class {
6181
+ upsertStmt;
6182
+ getStmt;
6183
+ listActiveStmt;
6184
+ listAllStmt;
6185
+ successStmt;
6186
+ failureStmt;
6187
+ revokeStmt;
6188
+ claimEventStmt;
6189
+ markDeliveredStmt;
6190
+ constructor(db) {
6191
+ this.upsertStmt = db.prepare(`
6192
+ INSERT INTO push_tokens (token, platform, device_id, registered_at)
6193
+ VALUES (@token, @platform, @device_id, @registered_at)
6194
+ ON CONFLICT(token) DO UPDATE SET
6195
+ platform = excluded.platform,
6196
+ device_id = COALESCE(excluded.device_id, push_tokens.device_id),
6197
+ registered_at = excluded.registered_at,
6198
+ -- A fresh registration clears prior failure state and any revocation:
6199
+ -- the client is telling us this token is live again.
6200
+ failure_streak = 0,
6201
+ last_failure_at = NULL,
6202
+ last_failure_code = NULL,
6203
+ revoked_at = NULL
6204
+ `);
6205
+ this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
6206
+ this.listActiveStmt = db.prepare(`
6207
+ SELECT * FROM push_tokens
6208
+ WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
6209
+ ORDER BY registered_at ASC
6210
+ `);
6211
+ this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
6212
+ this.successStmt = db.prepare(`
6213
+ UPDATE push_tokens
6214
+ SET last_success_at = @at, failure_streak = 0,
6215
+ last_failure_code = NULL
6216
+ WHERE token = @token
6217
+ `);
6218
+ this.failureStmt = db.prepare(`
6219
+ UPDATE push_tokens
6220
+ SET last_failure_at = @at, last_failure_code = @code,
6221
+ failure_streak = failure_streak + 1
6222
+ WHERE token = @token
6223
+ `);
6224
+ this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
6225
+ this.claimEventStmt = db.prepare(`
6226
+ INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
6227
+ VALUES (@event_id, @session_id, @created_at)
6228
+ `);
6229
+ this.markDeliveredStmt = db.prepare(
6230
+ "UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
6231
+ );
6232
+ }
6233
+ register(args) {
6234
+ this.upsertStmt.run({
6235
+ token: args.token,
6236
+ platform: args.platform,
6237
+ device_id: args.deviceId ?? null,
6238
+ registered_at: args.now ?? Date.now()
6239
+ });
6240
+ }
6241
+ get(token) {
6242
+ return this.getStmt.get(token) ?? null;
6243
+ }
6244
+ /** Tokens eligible for delivery — not revoked, not past the failure limit. */
6245
+ listDeliverable() {
6246
+ return this.listActiveStmt.all();
6247
+ }
6248
+ /** Every token, including dead and revoked ones, for the health report. */
6249
+ listHealth() {
6250
+ return this.listAllStmt.all().map(toHealth);
6251
+ }
6252
+ recordSuccess(token, now = Date.now()) {
6253
+ this.successStmt.run({ token, at: now });
6254
+ }
6255
+ recordFailure(token, code, now = Date.now()) {
6256
+ this.failureStmt.run({ token, at: now, code });
6257
+ }
6258
+ revoke(token, now = Date.now()) {
6259
+ return this.revokeStmt.run(now, token).changes > 0;
6260
+ }
6261
+ /**
6262
+ * Claim an event id for delivery.
6263
+ *
6264
+ * Returns true exactly once per event id. A retry, a reconnect
6265
+ * reconciliation, or two triggers firing for the same underlying event all
6266
+ * get false and must not notify — the user should never be told twice about
6267
+ * one thing.
6268
+ */
6269
+ claimEvent(eventId, sessionId, now = Date.now()) {
6270
+ return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
6271
+ }
6272
+ markDelivered(eventId, now = Date.now()) {
6273
+ this.markDeliveredStmt.run(now, eventId);
6274
+ }
6275
+ };
6276
+
5319
6277
  // src/db/repositories/sessions.repository.ts
5320
6278
  var SessionsRepository = class {
5321
6279
  constructor(store) {
@@ -5384,8 +6342,20 @@ function handleListProjects(url, res) {
5384
6342
  res.end(JSON.stringify({ projects: page, total }));
5385
6343
  }
5386
6344
 
6345
+ // src/lifecycle/process-liveness.ts
6346
+ function isPidAlive(pid) {
6347
+ if (!Number.isInteger(pid) || pid < 1) return false;
6348
+ try {
6349
+ process.kill(pid, 0);
6350
+ return true;
6351
+ } catch (err) {
6352
+ const code = err.code;
6353
+ return code === "EPERM";
6354
+ }
6355
+ }
6356
+
5387
6357
  // src/pair-store.ts
5388
- var import_crypto7 = require("crypto");
6358
+ var import_crypto8 = require("crypto");
5389
6359
  var DEFAULT_TTL_SECONDS = 180;
5390
6360
  var SWEEP_INTERVAL_MS = 6e4;
5391
6361
  var PairTokenStore = class {
@@ -5400,7 +6370,7 @@ var PairTokenStore = class {
5400
6370
  }
5401
6371
  }
5402
6372
  mint() {
5403
- const token = `pt_${(0, import_crypto7.randomBytes)(16).toString("hex")}`;
6373
+ const token = `pt_${(0, import_crypto8.randomBytes)(16).toString("hex")}`;
5404
6374
  const expiresAt = Date.now() + this.ttlMs;
5405
6375
  this.current = { token, expiresAt, used: false };
5406
6376
  return {
@@ -5468,7 +6438,7 @@ function setCacheMetadata(repo, key, value) {
5468
6438
  }
5469
6439
 
5470
6440
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
5471
- var import_crypto8 = require("crypto");
6441
+ var import_crypto9 = require("crypto");
5472
6442
  var import_fs13 = require("fs");
5473
6443
 
5474
6444
  // src/services/cache-integrity/alertStore.ts
@@ -5533,7 +6503,7 @@ function envInt(name, fallback) {
5533
6503
  }
5534
6504
  function fingerprintOf(ids) {
5535
6505
  const sorted = [...ids].sort();
5536
- return `sha256:${(0, import_crypto8.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
6506
+ return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
5537
6507
  }
5538
6508
  var CacheIntegrityMonitor = class {
5539
6509
  constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
@@ -6347,6 +7317,112 @@ function conversationBusy(input) {
6347
7317
  };
6348
7318
  }
6349
7319
 
7320
+ // src/services/sessions/idempotency.ts
7321
+ var IDEMPOTENCY_TTL_MS = 10 * 60 * 1e3;
7322
+ var IDEMPOTENCY_MAX_KEYS = 200;
7323
+ var IdempotencyStore = class {
7324
+ constructor(ttlMs = IDEMPOTENCY_TTL_MS, maxKeys = IDEMPOTENCY_MAX_KEYS) {
7325
+ this.ttlMs = ttlMs;
7326
+ this.maxKeys = maxKeys;
7327
+ }
7328
+ ttlMs;
7329
+ maxKeys;
7330
+ bySession = /* @__PURE__ */ new Map();
7331
+ /**
7332
+ * Previously recorded result for this key, or null if the key is new,
7333
+ * expired, or evicted. A miss always means "treat as a fresh request" —
7334
+ * failing open, because dropping a real prompt is far worse than allowing a
7335
+ * rare duplicate.
7336
+ */
7337
+ get(sessionId, key, now = Date.now()) {
7338
+ const entries = this.bySession.get(sessionId);
7339
+ if (!entries) return null;
7340
+ const hit = entries.find((e) => e.key === key);
7341
+ if (!hit) return null;
7342
+ if (now - hit.at > this.ttlMs) {
7343
+ this.bySession.set(
7344
+ sessionId,
7345
+ entries.filter((e) => e !== hit)
7346
+ );
7347
+ return null;
7348
+ }
7349
+ return hit.result;
7350
+ }
7351
+ /** Record the outcome of an accepted write so a retry can replay it. */
7352
+ set(sessionId, key, result, now = Date.now()) {
7353
+ const entries = this.bySession.get(sessionId) ?? [];
7354
+ const pruned = entries.filter((e) => e.key !== key && now - e.at <= this.ttlMs);
7355
+ pruned.push({ key, at: now, result });
7356
+ this.bySession.set(sessionId, pruned.slice(-this.maxKeys));
7357
+ }
7358
+ /** Drop everything for a session whose PTY is gone. */
7359
+ clear(sessionId) {
7360
+ this.bySession.delete(sessionId);
7361
+ }
7362
+ /** Test/diagnostic helper: how many keys are currently held for a session. */
7363
+ size(sessionId) {
7364
+ return this.bySession.get(sessionId)?.length ?? 0;
7365
+ }
7366
+ };
7367
+ function readIdempotencyKey(body) {
7368
+ const raw = body.idempotencyKey;
7369
+ if (raw === void 0 || raw === null) return void 0;
7370
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 200) {
7371
+ throw new Error("idempotencyKey must be a non-empty string of at most 200 characters");
7372
+ }
7373
+ return raw;
7374
+ }
7375
+
7376
+ // src/services/sessions/reconcileSessions.ts
7377
+ async function classifySession(row, probe, currentInstanceId) {
7378
+ const { session_id: sessionId } = row;
7379
+ if (row.completed_at != null) {
7380
+ const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
7381
+ return {
7382
+ sessionId,
7383
+ lifecycle: clean ? "completed" : "failed",
7384
+ reason: `terminal (${row.status_source})`
7385
+ };
7386
+ }
7387
+ if (row.pid == null) {
7388
+ return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
7389
+ }
7390
+ if (!probe.isPidAlive(row.pid)) {
7391
+ const clean = probe.endedCleanly?.(row) ?? false;
7392
+ if (clean) {
7393
+ return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
7394
+ }
7395
+ return {
7396
+ sessionId,
7397
+ lifecycle: "resumable",
7398
+ reason: "process gone, resumable from provider history"
7399
+ };
7400
+ }
7401
+ const args = await probe.getProcessArgs(row.pid);
7402
+ const token = row.cmdline;
7403
+ if (!token || !args?.includes(token)) {
7404
+ return {
7405
+ sessionId,
7406
+ lifecycle: "orphaned",
7407
+ reason: args ? "pid alive but command line does not match" : "pid alive but argv unreadable"
7408
+ };
7409
+ }
7410
+ const sameRun = row.streamer_instance_id === currentInstanceId;
7411
+ return {
7412
+ sessionId,
7413
+ lifecycle: sameRun ? "attached" : "detached",
7414
+ reason: sameRun ? "owned by this run" : "survived a previous streamer run"
7415
+ };
7416
+ }
7417
+ async function reconcileSessions(rows, probe, currentInstanceId) {
7418
+ return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
7419
+ }
7420
+
7421
+ // src/types.ts
7422
+ function confidenceForSource(source) {
7423
+ return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
7424
+ }
7425
+
6350
7426
  // src/session-store.ts
6351
7427
  var SessionStore = class {
6352
7428
  managed = /* @__PURE__ */ new Map();
@@ -6486,6 +7562,14 @@ function managedToResponse(s, ptyAttached) {
6486
7562
  conversationId: s.id,
6487
7563
  provider: s.provider ?? CLAUDE_CODE_PROVIDER,
6488
7564
  status: s.status,
7565
+ // Lifecycle for a session this run knows about. `attached` while we hold
7566
+ // its PTY; once the PTY is gone the session is terminal from this run's
7567
+ // perspective — `failed` when it recorded a reason, else `completed`.
7568
+ // Sessions left by *previous* runs never reach here: they aren't in the
7569
+ // in-memory store, and the boot reconciler classifies them instead
7570
+ // (docs/architecture/2026-07-24-durable-session-runtime.md).
7571
+ lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
7572
+ lifecycleSource: ptyAttached ? "spawn" : "exit",
6489
7573
  // We spawned it, so `status` is the authoritative signal — no inferred
6490
7574
  // `activity` is attached for managed sessions.
6491
7575
  ownership: "managed",
@@ -6509,6 +7593,13 @@ function managedToResponse(s, ptyAttached) {
6509
7593
  ...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
6510
7594
  ...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
6511
7595
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt.toISOString() },
7596
+ // C3: how the status was derived, and how far to trust it. Confidence is
7597
+ // derived from the source rather than stored, so the two cannot disagree.
7598
+ ...s.statusSource != null && {
7599
+ statusSource: s.statusSource,
7600
+ statusConfidence: confidenceForSource(s.statusSource)
7601
+ },
7602
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt.toISOString() },
6512
7603
  ...s.filePath != null && { filePath: s.filePath },
6513
7604
  ...s.failureReason != null && { failureReason: s.failureReason },
6514
7605
  ...s.resumedFromConversationId != null && {
@@ -6530,6 +7621,12 @@ function discoveredToResponse(d, conversationId) {
6530
7621
  // Discovery just enumerated this PID, so it was alive moments ago. We never
6531
7622
  // report "gone" here — a vanished process simply stops being listed.
6532
7623
  processLiveness: "alive",
7624
+ // Alive, but spawned outside this streamer, so we hold no PTY for it. That
7625
+ // is precisely `detached` — and it is strictly more informative than the
7626
+ // `status: "idle"` above, which discovery is forced to report because it
7627
+ // cannot see the process's prompt state.
7628
+ lifecycle: "detached",
7629
+ lifecycleSource: "probe",
6533
7630
  projectPath: d.projectPath,
6534
7631
  projectName: d.projectName,
6535
7632
  branch: d.branch,
@@ -6544,7 +7641,7 @@ function discoveredToResponse(d, conversationId) {
6544
7641
  }
6545
7642
 
6546
7643
  // src/uploads.ts
6547
- var import_crypto9 = require("crypto");
7644
+ var import_crypto10 = require("crypto");
6548
7645
  var import_promises6 = require("fs/promises");
6549
7646
  var import_heic_convert = __toESM(require("heic-convert"), 1);
6550
7647
  var import_path17 = require("path");
@@ -6578,7 +7675,7 @@ async function saveUploadFile(input) {
6578
7675
  mimeType = "image/jpeg";
6579
7676
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
6580
7677
  }
6581
- const id = `up_${(0, import_crypto9.randomBytes)(8).toString("hex")}`;
7678
+ const id = `up_${(0, import_crypto10.randomBytes)(8).toString("hex")}`;
6582
7679
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
6583
7680
  const dir = (0, import_path17.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
6584
7681
  await (0, import_promises6.mkdir)(dir, { recursive: true });
@@ -6612,21 +7709,46 @@ function extractCodexText(content) {
6612
7709
  return "";
6613
7710
  }).filter(Boolean).join("").trim();
6614
7711
  }
6615
- function normalizeCodexLineToClaudeShape(line) {
7712
+ var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
7713
+ function classifyCodexLine(line) {
6616
7714
  let entry;
6617
7715
  try {
6618
7716
  entry = JSON.parse(line);
6619
7717
  } catch {
6620
- return null;
7718
+ return { kind: "unknown", raw: line, reason: "line is not valid JSON" };
7719
+ }
7720
+ if (typeof entry.type !== "string" || !KNOWN_CODEX_TYPES.has(entry.type)) {
7721
+ return {
7722
+ kind: "unknown",
7723
+ raw: line,
7724
+ reason: `unrecognized rollout envelope type: ${String(entry.type)}`
7725
+ };
7726
+ }
7727
+ if (entry.type !== "response_item") {
7728
+ return { kind: "ignored", reason: `${entry.type} carries no chat content` };
6621
7729
  }
6622
- if (entry.type !== "response_item") return null;
6623
7730
  const payload = entry.payload;
6624
- if (payload?.type !== "message") return null;
7731
+ if (payload?.type !== "message") {
7732
+ return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
7733
+ }
6625
7734
  const role = payload.role;
6626
- if (role !== "user" && role !== "assistant") return null;
7735
+ if (role !== "user" && role !== "assistant") {
7736
+ return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
7737
+ }
6627
7738
  const text = extractCodexText(payload.content);
6628
- if (!text) return null;
6629
- if (role === "user" && isCodexInjectedContext(text)) return null;
7739
+ if (!text) {
7740
+ return { kind: "ignored", reason: "message has no extractable text" };
7741
+ }
7742
+ if (role === "user" && isCodexInjectedContext(text)) {
7743
+ return { kind: "ignored", reason: "synthetic injected context" };
7744
+ }
7745
+ return { kind: "message", line: buildClaudeShapedLine(entry, payload, role, text) };
7746
+ }
7747
+ function normalizeCodexLineToClaudeShape(line) {
7748
+ const result = classifyCodexLine(line);
7749
+ return result.kind === "message" ? result.line : null;
7750
+ }
7751
+ function buildClaudeShapedLine(entry, payload, role, text) {
6630
7752
  const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
6631
7753
  const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
6632
7754
  return JSON.stringify({
@@ -6831,6 +7953,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
6831
7953
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
6832
7954
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
6833
7955
  var GRACE_MAX_DEFERS = 4;
7956
+ var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
7957
+ var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
6834
7958
  var RESUME_DISCOVERY_TIMEOUT_MS = 750;
6835
7959
  var DISCOVERY_TTL_MS = 15e3;
6836
7960
  var ADOPT_KILL_TIMEOUT_MS = 5e3;
@@ -6952,6 +8076,16 @@ var StreamerServer = class {
6952
8076
  defaultPermissionMode;
6953
8077
  defaultModel;
6954
8078
  defaultEffort;
8079
+ // Allowlisted Claude CLI flags + free-text escape hatch, applied to every
8080
+ // spawn. Resolved once at startup (flag → server.yaml), then mutated in place
8081
+ // by PUT /api/config/claude-flags so a change applies to the next session
8082
+ // without a restart.
8083
+ claudeFlags;
8084
+ claudeExtraArgs;
8085
+ // True when the values came from server.yaml (and so a write persists).
8086
+ // False when they were pinned by a CLI flag, mirroring the api-key rotate
8087
+ // contract: the write still takes effect in memory but won't survive restart.
8088
+ claudeFlagsPersistable;
6955
8089
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
6956
8090
  ptyGraceTimers = /* @__PURE__ */ new Map();
6957
8091
  // Consecutive grace-timer defers for a still-`running` session (see
@@ -6959,6 +8093,18 @@ var StreamerServer = class {
6959
8093
  ptyGraceDeferCounts = /* @__PURE__ */ new Map();
6960
8094
  // Map of sessionId → set of subscribed WS clients
6961
8095
  sessionSubscribers = /* @__PURE__ */ new Map();
8096
+ // sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
8097
+ // every provider; read only by the idle reaper. Entries are dropped when the
8098
+ // session leaves the runner (reap/exit/hold).
8099
+ lastAgentChunkAt = /* @__PURE__ */ new Map();
8100
+ // Recently accepted input idempotency keys (C4). A retried POST replays its
8101
+ // original outcome instead of submitting the prompt to the agent twice.
8102
+ idempotency = new IdempotencyStore();
8103
+ // sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
8104
+ // this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
8105
+ sessionLifecycles = /* @__PURE__ */ new Map();
8106
+ // Periodic sweep that releases PTYs no agent is using. Null until listen().
8107
+ idleReaperTimer = null;
6962
8108
  // Map of clientId → WS socket (populated by the "register" WS handshake)
6963
8109
  clientIdToWs = /* @__PURE__ */ new Map();
6964
8110
  // Reverse map for cleanup on close
@@ -6968,7 +8114,20 @@ var StreamerServer = class {
6968
8114
  projectsRepo = null;
6969
8115
  conversationsRepo = null;
6970
8116
  sessionsRepo = null;
8117
+ // Durable session registry (C1 Phase 2). Null when the cache DB failed to
8118
+ // open — persistence degrades to today's in-memory-only behaviour rather than
8119
+ // taking the server down with it, so every write goes through `?.`.
8120
+ managedSessionsRepo = null;
8121
+ // Identifies this streamer run. A registry row carrying a different id is a
8122
+ // session that outlived the process that started it.
8123
+ streamerInstanceId = (0, import_crypto11.randomUUID)();
6971
8124
  cacheMetadataRepo = null;
8125
+ // Push registration + delivery state (C7). Null when the cache DB failed to
8126
+ // open — registration then degrades to a no-op rather than 500ing.
8127
+ pushRepo = null;
8128
+ // Paired-device registry (C5). Null when the cache DB failed to open — auth
8129
+ // then falls back to the shared API key alone, which is the pre-C5 behaviour.
8130
+ devicesRepo = null;
6972
8131
  discoveryCache = null;
6973
8132
  cacheDir;
6974
8133
  tailSize;
@@ -7007,6 +8166,9 @@ var StreamerServer = class {
7007
8166
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
7008
8167
  this.defaultModel = config.defaultModel ?? "sonnet";
7009
8168
  this.defaultEffort = config.defaultEffort ?? "low";
8169
+ this.claudeFlagsPersistable = config.claudeFlags === void 0;
8170
+ this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
8171
+ this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
7010
8172
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os9.homedir)(), ".threadbase", "cache");
7011
8173
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
7012
8174
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -7131,6 +8293,7 @@ var StreamerServer = class {
7131
8293
  this.ptyManager = new LiveSessionManager({
7132
8294
  logger: getLogger("pty"),
7133
8295
  onOutput: (sessionId, data) => {
8296
+ this.lastAgentChunkAt.set(sessionId, Date.now());
7134
8297
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
7135
8298
  },
7136
8299
  onUserMessage: (sessionId, text, ts) => {
@@ -7159,6 +8322,17 @@ var StreamerServer = class {
7159
8322
  completedAt: session.completedAt,
7160
8323
  ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
7161
8324
  });
8325
+ this.managedSessionsRepo?.recordStatus(
8326
+ session.id,
8327
+ session.status,
8328
+ session.completedAt != null ? "exit" : "transition",
8329
+ {
8330
+ completedAt: session.completedAt,
8331
+ lastActivityAt: session.lastActivityAt ?? null,
8332
+ promptCount: session.promptCount,
8333
+ failureReason: session.failureReason ?? null
8334
+ }
8335
+ );
7162
8336
  if (session.status === "waiting_input" || session.status === "idle") {
7163
8337
  const filePath = this.sessionFileMap.get(session.id);
7164
8338
  if (filePath) {
@@ -7230,6 +8404,8 @@ var StreamerServer = class {
7230
8404
  localNoAuth: this.localNoAuth,
7231
8405
  logMenubarRequests: this.logMenubarRequests,
7232
8406
  rotateApiKey: () => this.rotateApiKey(),
8407
+ claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
8408
+ setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
7233
8409
  publicUrl: this.publicUrl,
7234
8410
  browseRoot: this.browseRoot,
7235
8411
  browserCors: this.browserCors,
@@ -7238,6 +8414,8 @@ var StreamerServer = class {
7238
8414
  wsHub: this.wsHub,
7239
8415
  cache: () => this.cache,
7240
8416
  cacheMonitor: () => this.cacheMonitor,
8417
+ pushRepo: () => this.pushRepo,
8418
+ devicesRepo: () => this.devicesRepo,
7241
8419
  projectsRepo: () => this.projectsRepo,
7242
8420
  conversationsRepo: () => this.conversationsRepo,
7243
8421
  sessionsRepo: () => this.sessionsRepo,
@@ -7271,7 +8449,9 @@ var StreamerServer = class {
7271
8449
  handleMkdir: (req, res) => this.handleMkdir(req, res),
7272
8450
  handleWsOpen: (ws) => {
7273
8451
  this.wsHub.addClient(ws);
7274
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
8452
+ const sessions = this.withReconciledLifecycle(
8453
+ this.sessionStore.list(this.ptyAttachedIds())
8454
+ );
7275
8455
  ws.send(JSON.stringify({ type: "session_list", sessions }));
7276
8456
  if (!this.currentWarmupState()) {
7277
8457
  ws.send(JSON.stringify({ type: "cache_ready" }));
@@ -7347,11 +8527,8 @@ var StreamerServer = class {
7347
8527
  this.clientIdToWs.delete(clientId);
7348
8528
  this.wsToClientId.delete(ws);
7349
8529
  }
7350
- for (const [sessionId, subscribers] of this.sessionSubscribers) {
8530
+ for (const subscribers of this.sessionSubscribers.values()) {
7351
8531
  subscribers.delete(ws);
7352
- if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
7353
- this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
7354
- }
7355
8532
  }
7356
8533
  },
7357
8534
  agentClient,
@@ -7414,7 +8591,7 @@ var StreamerServer = class {
7414
8591
  const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
7415
8592
  const payload = {
7416
8593
  type: "session_list",
7417
- sessions: this.sessionStore.list(this.ptyAttachedIds())
8594
+ sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
7418
8595
  };
7419
8596
  if (ws) {
7420
8597
  this.wsHub.unicast(ws, payload);
@@ -7422,6 +8599,29 @@ var StreamerServer = class {
7422
8599
  this.wsHub.broadcast(payload);
7423
8600
  }
7424
8601
  }
8602
+ /**
8603
+ * Overlay boot-reconciliation verdicts onto session responses.
8604
+ *
8605
+ * A session left by a previous run is not in the in-memory store, so
8606
+ * SessionStore cannot classify it — it only ever sees what this run spawned.
8607
+ * Discovery may still surface the process, in which case the reconciler knows
8608
+ * strictly more about it than discovery does: it can tell `detached` (alive
8609
+ * and confirmed ours) from `orphaned` (alive but identity unconfirmed), which
8610
+ * a pid enumeration alone cannot.
8611
+ *
8612
+ * Only applied when the session is NOT live here: a session this run owns has
8613
+ * an authoritative lifecycle already, and a stale verdict must never override
8614
+ * it.
8615
+ */
8616
+ withReconciledLifecycle(sessions) {
8617
+ if (this.sessionLifecycles.size === 0) return sessions;
8618
+ return sessions.map((s) => {
8619
+ if (s.ptyAttached) return s;
8620
+ const verdict = this.sessionLifecycles.get(s.id);
8621
+ if (!verdict) return s;
8622
+ return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
8623
+ });
8624
+ }
7425
8625
  addSessionSubscriber(sessionId, ws) {
7426
8626
  let subs = this.sessionSubscribers.get(sessionId);
7427
8627
  if (!subs) {
@@ -7436,6 +8636,181 @@ var StreamerServer = class {
7436
8636
  }
7437
8637
  this.ptyGraceDeferCounts.delete(sessionId);
7438
8638
  }
8639
+ /**
8640
+ * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
8641
+ *
8642
+ * Agents already outlive the streamer today on the crash and dev-takeover
8643
+ * paths, which exit without reaching ptyManager.dispose() — they are just
8644
+ * invisible when they do, because nothing recorded that they existed. This
8645
+ * turns those rows into an explicit verdict per session.
8646
+ *
8647
+ * Read-only with respect to processes: it probes and classifies, and never
8648
+ * signals anything. `orphaned` is a report, not a cleanup trigger.
8649
+ */
8650
+ async reconcilePreviousSessions() {
8651
+ if (!this.managedSessionsRepo) return [];
8652
+ let verdicts = [];
8653
+ try {
8654
+ const rows = this.managedSessionsRepo.listNonTerminal();
8655
+ if (rows.length === 0) return [];
8656
+ verdicts = await reconcileSessions(
8657
+ rows,
8658
+ { isPidAlive, getProcessArgs },
8659
+ this.streamerInstanceId
8660
+ );
8661
+ for (const v of verdicts) {
8662
+ this.sessionLifecycles.set(v.sessionId, v.lifecycle);
8663
+ if (v.lifecycle === "completed" || v.lifecycle === "failed") {
8664
+ this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
8665
+ completedAt: /* @__PURE__ */ new Date()
8666
+ });
8667
+ }
8668
+ }
8669
+ this.log.info(`[reconcile] classified ${verdicts.length} session(s) from previous runs`, {
8670
+ event: "sessions.reconciled",
8671
+ counts: verdicts.reduce((acc, v) => {
8672
+ acc[v.lifecycle] = (acc[v.lifecycle] ?? 0) + 1;
8673
+ return acc;
8674
+ }, {})
8675
+ });
8676
+ } catch (err) {
8677
+ this.log.warn("[reconcile] failed to reconcile previous sessions", {
8678
+ event: "sessions.reconcile_failed",
8679
+ err
8680
+ });
8681
+ }
8682
+ return verdicts;
8683
+ }
8684
+ /**
8685
+ * Pick a token guaranteed to appear in the spawned process's argv, for the
8686
+ * reconciler's pid-reuse guard.
8687
+ *
8688
+ * Claude always passes the session id (`--resume <id>` or `--session-id
8689
+ * <id>`), so it is both present and unique. Codex only does on *resume*
8690
+ * (`codex resume <id>`); a fresh Codex spawn is `codex --cd <path>
8691
+ * --no-alt-screen` with no id at all, because the rollout id does not exist
8692
+ * until the CLI writes it. boundConversationId is what distinguishes the two:
8693
+ * it is set once that rollout has been discovered.
8694
+ */
8695
+ spawnArgvToken(session) {
8696
+ if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
8697
+ return session.boundConversationId ?? session.projectPath;
8698
+ }
8699
+ /**
8700
+ * Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
8701
+ *
8702
+ * Called at each addManaged() site rather than inside SessionStore, because
8703
+ * the store is a pure in-memory structure with no DB dependency and adding
8704
+ * one would drag persistence into every unit test that touches it.
8705
+ *
8706
+ * Best-effort by design: a failed registry write must never break session
8707
+ * start. Losing a row costs post-restart *visibility* for that session, which
8708
+ * is strictly better than refusing to run the agent at all.
8709
+ */
8710
+ recordSessionSpawn(session) {
8711
+ if (!this.managedSessionsRepo) return;
8712
+ try {
8713
+ const pid = this.ptyManager.getPid(session.id);
8714
+ this.managedSessionsRepo.recordSpawn({
8715
+ session,
8716
+ pid,
8717
+ // Identity guard against pid reuse: the reconciler requires this token
8718
+ // to appear in the live process's argv before it will claim the pid is
8719
+ // still ours (docs/architecture/2026-07-24-durable-session-runtime.md).
8720
+ //
8721
+ // Reading the real argv here would cost an async `ps` per session start
8722
+ // on a path the user is waiting on, so we record a token we already
8723
+ // know is in it. Claude always carries the session id (`--resume <id>`
8724
+ // on resume, `--session-id <id>` on fresh). A *fresh* Codex spawn does
8725
+ // not — its argv is only `--cd <path> --no-alt-screen`, because the
8726
+ // rollout id doesn't exist yet — so fall back to the project path,
8727
+ // which is present in every spawn path for both providers.
8728
+ //
8729
+ // The fallback is weaker: two sessions in one project share a token, so
8730
+ // it proves "a process of ours in this project" rather than "this exact
8731
+ // session". It still rejects an unrelated recycled pid, which is the
8732
+ // failure being guarded against.
8733
+ //
8734
+ // Note the Codex id is always *set* (a local placeholder) — it is just
8735
+ // not in the process's argv — so the choice keys off the provider, not
8736
+ // off the id being null.
8737
+ cmdline: pid != null ? this.spawnArgvToken(session) : null,
8738
+ streamerInstanceId: this.streamerInstanceId
8739
+ });
8740
+ } catch (err) {
8741
+ this.log.warn("[registry] failed to record session spawn", {
8742
+ event: "registry.spawn_write_failed",
8743
+ sessionId: session.id,
8744
+ err
8745
+ });
8746
+ }
8747
+ }
8748
+ /**
8749
+ * Stamp every live session as ended-by-shutdown before dispose() kills it.
8750
+ *
8751
+ * PTYManager.dispose() signals each child directly and fires no
8752
+ * onStatusChange, so the registry would otherwise keep rows sitting at
8753
+ * `running` forever and the next boot could not tell a deliberate restart
8754
+ * from a crash. Recording `shutdown` as the status source makes that
8755
+ * distinction explicit rather than inferred.
8756
+ *
8757
+ * Not a `completed_at` write for the agent's own work — the agent did not
8758
+ * finish, we stopped it — but the session is genuinely terminal, so it must
8759
+ * leave the reconciler's probe set.
8760
+ */
8761
+ recordShutdownState() {
8762
+ if (!this.managedSessionsRepo) return;
8763
+ const now = /* @__PURE__ */ new Date();
8764
+ for (const session of this.ptyManager.listSessions()) {
8765
+ try {
8766
+ this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
8767
+ completedAt: now,
8768
+ lastActivityAt: session.lastActivityAt ?? null,
8769
+ promptCount: session.promptCount
8770
+ });
8771
+ } catch (err) {
8772
+ this.log.warn("[registry] failed to record shutdown state", {
8773
+ event: "registry.shutdown_write_failed",
8774
+ sessionId: session.id,
8775
+ err
8776
+ });
8777
+ }
8778
+ }
8779
+ }
8780
+ /**
8781
+ * Release PTYs whose agent has been silent past IDLE_REAP_AFTER_MS.
8782
+ *
8783
+ * This is the bound that lets handleWsClose stop arming kill timers. The
8784
+ * distinction that matters: the old timer measured how long nobody was
8785
+ * *watching*, which is uncorrelated with whether work is in flight. This
8786
+ * measures how long the *agent* has produced nothing, and only ever considers
8787
+ * sessions that are already settled — a `running` PTY is skipped regardless of
8788
+ * age, so a long silent turn is never interrupted.
8789
+ *
8790
+ * Exposed (not private) so tests can drive one sweep deterministically instead
8791
+ * of waiting on the interval.
8792
+ */
8793
+ reapIdleSessions(now = Date.now()) {
8794
+ const reaped = [];
8795
+ for (const session of this.ptyManager.listSessions()) {
8796
+ if (session.status === "running") continue;
8797
+ const lastActive = this.lastAgentChunkAt.get(session.id) ?? session.lastActivityAt?.getTime() ?? session.startedAt.getTime();
8798
+ if (now - lastActive < IDLE_REAP_AFTER_MS) continue;
8799
+ this.log.info(
8800
+ `[reap] releasing idle PTY for ${session.id} (idle ${Math.round((now - lastActive) / 6e4)}m)`,
8801
+ { sessionId: session.id, event: "pty.idle_reap", idleMs: now - lastActive },
8802
+ "pino"
8803
+ );
8804
+ this.ptyManager.putOnHold(session.id);
8805
+ this.lastAgentChunkAt.delete(session.id);
8806
+ this.idempotency.clear(session.id);
8807
+ this.sessionSubscribers.delete(session.id);
8808
+ reaped.push(session.id);
8809
+ const held = this.sessionStore.get(session.id, this.ptyAttachedIds());
8810
+ if (held) this.wsHub.broadcast({ type: "session_update", session: held });
8811
+ }
8812
+ return reaped;
8813
+ }
7439
8814
  startGraceTimer(sessionId, delayMs) {
7440
8815
  const existing = this.ptyGraceTimers.get(sessionId);
7441
8816
  if (existing) clearTimeout(existing);
@@ -7530,6 +8905,8 @@ var StreamerServer = class {
7530
8905
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
7531
8906
  }
7532
8907
  await this.bindWithRetry(port);
8908
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
8909
+ this.idleReaperTimer.unref?.();
7533
8910
  const warmUp = new Promise((resolveWarm) => {
7534
8911
  {
7535
8912
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -7563,7 +8940,11 @@ var StreamerServer = class {
7563
8940
  this.projectsRepo = new ProjectsRepository(db);
7564
8941
  this.conversationsRepo = new ConversationsRepository(this.cache);
7565
8942
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
8943
+ this.managedSessionsRepo = new ManagedSessionsRepository(db);
8944
+ void this.reconcilePreviousSessions();
7566
8945
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
8946
+ this.pushRepo = new PushRepository(db);
8947
+ this.devicesRepo = new DevicesRepository(db);
7567
8948
  this.cacheMonitor = new CacheIntegrityMonitor(
7568
8949
  this.cache,
7569
8950
  this.wsHub,
@@ -7781,6 +9162,12 @@ var StreamerServer = class {
7781
9162
  async close() {
7782
9163
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
7783
9164
  this.ptyGraceTimers.clear();
9165
+ if (this.idleReaperTimer) {
9166
+ clearInterval(this.idleReaperTimer);
9167
+ this.idleReaperTimer = null;
9168
+ }
9169
+ this.lastAgentChunkAt.clear();
9170
+ this.recordShutdownState();
7784
9171
  this.markScannerStaleDebounced.cancel();
7785
9172
  await Promise.all([...this.inFlightCacheWrites]);
7786
9173
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -7870,12 +9257,28 @@ var StreamerServer = class {
7870
9257
  ip,
7871
9258
  ts
7872
9259
  });
9260
+ let device = null;
9261
+ try {
9262
+ const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
9263
+ const preset = body?.readOnly === true ? "read-only" : "full";
9264
+ device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
9265
+ } catch (err) {
9266
+ this.log.warn("[pair] device registration failed; pairing continues", {
9267
+ event: "pair.device_register_failed",
9268
+ err
9269
+ });
9270
+ }
7873
9271
  json(res, 200, {
7874
9272
  ciphertext: sealed.ciphertext,
7875
9273
  nonce: sealed.nonce,
7876
9274
  ephemeralPublicKey: sealed.ephemeralPublicKey,
7877
9275
  publicUrl: this.publicUrl,
7878
- machineName: hostname2()
9276
+ machineName: hostname2(),
9277
+ ...device && {
9278
+ deviceId: device.deviceId,
9279
+ deviceToken: device.deviceToken,
9280
+ capabilities: device.capabilities
9281
+ }
7879
9282
  });
7880
9283
  }
7881
9284
  rotateApiKey() {
@@ -7892,6 +9295,48 @@ var StreamerServer = class {
7892
9295
  });
7893
9296
  return { newKey, persisted };
7894
9297
  }
9298
+ getClaudeFlagsConfig() {
9299
+ return {
9300
+ registry: CLAUDE_FLAGS,
9301
+ values: this.claudeFlags,
9302
+ extraArgs: this.claudeExtraArgs ?? null,
9303
+ persisted: this.claudeFlagsPersistable
9304
+ };
9305
+ }
9306
+ /**
9307
+ * Replace the per-server flag set. Applies to the NEXT spawn — a live PTY
9308
+ * keeps the argv it was started with.
9309
+ *
9310
+ * Mirrors rotateApiKey(): when the values were pinned by a CLI flag we still
9311
+ * apply them in memory but skip the server.yaml write, because the flag would
9312
+ * win again on restart and silently revert them.
9313
+ *
9314
+ * Logged with old→new at info level on purpose: this can disable the
9315
+ * permission prompts entirely, so it needs a forensic trail.
9316
+ */
9317
+ setClaudeFlagsConfig(values, extraArgs) {
9318
+ const safe = validateFlagValues(values);
9319
+ const previous = { values: this.claudeFlags, extraArgs: this.claudeExtraArgs };
9320
+ if (this.claudeFlagsPersistable) {
9321
+ setClaudeExtraArgs(extraArgs);
9322
+ setClaudeFlags(safe);
9323
+ }
9324
+ this.claudeFlags = safe;
9325
+ this.claudeExtraArgs = extraArgs?.trim() ? extraArgs.trim() : void 0;
9326
+ this.log.info("Claude CLI flags updated", {
9327
+ event: "config.claude_flags_updated",
9328
+ persisted: this.claudeFlagsPersistable,
9329
+ previousValues: previous.values,
9330
+ previousExtraArgs: previous.extraArgs ?? null,
9331
+ values: this.claudeFlags,
9332
+ extraArgs: this.claudeExtraArgs ?? null
9333
+ });
9334
+ return {
9335
+ values: this.claudeFlags,
9336
+ extraArgs: this.claudeExtraArgs ?? null,
9337
+ persisted: this.claudeFlagsPersistable
9338
+ };
9339
+ }
7895
9340
  checkRateLimit(map, key, limit, windowMs) {
7896
9341
  const now = Date.now();
7897
9342
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -8909,7 +10354,13 @@ var StreamerServer = class {
8909
10354
  }
8910
10355
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
8911
10356
  if (!hasPaginationParams) {
8912
- json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
10357
+ json(
10358
+ res,
10359
+ 200,
10360
+ this.withExternalActivity(
10361
+ this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
10362
+ )
10363
+ );
8913
10364
  return;
8914
10365
  }
8915
10366
  const parsed = parseSessionListQuery(url);
@@ -8919,7 +10370,7 @@ var StreamerServer = class {
8919
10370
  }
8920
10371
  try {
8921
10372
  const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
8922
- page.sessions = this.withExternalActivity(page.sessions);
10373
+ page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
8923
10374
  json(res, 200, page);
8924
10375
  } catch (err) {
8925
10376
  if (err instanceof Error && err.message === "INVALID_CURSOR") {
@@ -8936,6 +10387,9 @@ var StreamerServer = class {
8936
10387
  if (!(0, import_fs18.existsSync)(session.projectPath)) {
8937
10388
  session.failureReason = `Project directory not found: ${session.projectPath}`;
8938
10389
  }
10390
+ const reconciled = this.withReconciledLifecycle([session])[0];
10391
+ session.lifecycle = reconciled.lifecycle;
10392
+ session.lifecycleSource = reconciled.lifecycleSource;
8939
10393
  if (this.ptyManager.hasSession(sessionId)) {
8940
10394
  try {
8941
10395
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -9033,10 +10487,13 @@ var StreamerServer = class {
9033
10487
  projectName: body.projectName,
9034
10488
  branch: body.branch,
9035
10489
  permissionMode: this.defaultPermissionMode,
10490
+ claudeFlags: this.claudeFlags,
10491
+ claudeExtraArgs: this.claudeExtraArgs,
9036
10492
  model: this.defaultModel,
9037
10493
  effort: this.defaultEffort
9038
10494
  });
9039
10495
  this.sessionStore.addManaged(session);
10496
+ this.recordSessionSpawn(session);
9040
10497
  void this.watchConversationFile(sessionId);
9041
10498
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
9042
10499
  this.broadcastOrUnicastSessionList(req);
@@ -9109,6 +10566,24 @@ var StreamerServer = class {
9109
10566
  }
9110
10567
  const body = await readBody(req);
9111
10568
  const { input, keys } = body;
10569
+ let idempotencyKey;
10570
+ try {
10571
+ idempotencyKey = readIdempotencyKey(body);
10572
+ } catch (err) {
10573
+ json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
10574
+ return;
10575
+ }
10576
+ if (idempotencyKey) {
10577
+ const replayed = this.idempotency.get(sessionId, idempotencyKey);
10578
+ if (replayed) {
10579
+ this.log.info(`[input.replay] ${sessionId.slice(0, 8)} duplicate idempotencyKey`, {
10580
+ event: "input.idempotent_replay",
10581
+ sessionId
10582
+ });
10583
+ json(res, replayed.status, replayed.body);
10584
+ return;
10585
+ }
10586
+ }
9112
10587
  if (typeof keys === "string") {
9113
10588
  try {
9114
10589
  this.ptyManager.sendKeys(sessionId, keys);
@@ -9116,7 +10591,9 @@ var StreamerServer = class {
9116
10591
  if (updated) {
9117
10592
  this.wsHub.broadcast({ type: "session_update", session: updated });
9118
10593
  }
9119
- json(res, 200, { ok: true });
10594
+ const result = { status: 200, body: { ok: true } };
10595
+ if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
10596
+ json(res, result.status, result.body);
9120
10597
  } catch (err) {
9121
10598
  const message = err instanceof Error ? err.message : "Failed to send keys";
9122
10599
  json(res, 400, { error: message });
@@ -9154,7 +10631,9 @@ var StreamerServer = class {
9154
10631
  });
9155
10632
  });
9156
10633
  }
9157
- json(res, 200, { ok: true });
10634
+ const result = { status: 200, body: { ok: true } };
10635
+ if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
10636
+ json(res, result.status, result.body);
9158
10637
  } catch (err) {
9159
10638
  const message = err instanceof Error ? err.message : "Failed to send input";
9160
10639
  json(res, 400, { error: message });
@@ -9459,10 +10938,13 @@ var StreamerServer = class {
9459
10938
  projectName,
9460
10939
  branch,
9461
10940
  permissionMode: this.defaultPermissionMode,
10941
+ claudeFlags: this.claudeFlags,
10942
+ claudeExtraArgs: this.claudeExtraArgs,
9462
10943
  model: this.defaultModel,
9463
10944
  effort: this.defaultEffort
9464
10945
  });
9465
10946
  this.sessionStore.addManaged(session);
10947
+ this.recordSessionSpawn(session);
9466
10948
  void this.watchConversationFile(session.id);
9467
10949
  this.wsHub.broadcast({
9468
10950
  type: "session_list",
@@ -9532,10 +11014,13 @@ var StreamerServer = class {
9532
11014
  projectName: body.projectName,
9533
11015
  systemPrompt: systemPromptParts.join("\n"),
9534
11016
  permissionMode: this.defaultPermissionMode,
11017
+ claudeFlags: this.claudeFlags,
11018
+ claudeExtraArgs: this.claudeExtraArgs,
9535
11019
  model: this.defaultModel,
9536
11020
  effort: this.defaultEffort
9537
11021
  });
9538
11022
  this.sessionStore.addManaged(session);
11023
+ this.recordSessionSpawn(session);
9539
11024
  const readyOrFailed = new Promise((resolve2) => {
9540
11025
  const handler = (status) => {
9541
11026
  if (status === "waiting_input" || status === "idle") {
@@ -10057,6 +11542,7 @@ function readBody(req) {
10057
11542
  SessionStore,
10058
11543
  StreamerServer,
10059
11544
  WSHub,
11545
+ confidenceForSource,
10060
11546
  createAgentClient,
10061
11547
  createConversationWriter,
10062
11548
  createPool,