@threadbase-sh/streamer 1.36.3 → 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
  }
@@ -1449,6 +1662,9 @@ var import_fs6 = require("fs");
1449
1662
  var import_path6 = require("path");
1450
1663
 
1451
1664
  // src/services/questions/detectPermissionGate.ts
1665
+ function permissionContentKey(gate) {
1666
+ return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
1667
+ }
1452
1668
  var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
1453
1669
  var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
1454
1670
  function hasPermissionOsc(rawData) {
@@ -1760,16 +1976,17 @@ var PTYManager = class {
1760
1976
  }
1761
1977
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1762
1978
  //
1763
- // options.permissionMode defaults to `acceptEdits` rather than
1764
- // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1765
- // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1766
- // flag renders a blocking "Bypass Permissions mode" warning menu on every
1767
- // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1768
- // v2.1.x) the session never reaches a usable prompt, so the mobile app
1769
- // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1770
- // without that warning gate, while still prompting for shell commands.
1771
- // `manual` (prompt for everything) is the only other mode callers may pass;
1772
- // `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.
1773
1990
  // (The other first-run gates — onboarding/theme, workspace trust,
1774
1991
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1775
1992
  // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
@@ -1787,28 +2004,27 @@ var PTYManager = class {
1787
2004
  async doStart(sessionId, options) {
1788
2005
  const nodePty = await loadPty2();
1789
2006
  const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
1790
- const proc = nodePty.spawn(
1791
- resolveClaudeExe(),
1792
- [
1793
- "--permission-mode",
1794
- options.permissionMode ?? "acceptEdits",
1795
- "--settings",
1796
- '{"spinnerTipsEnabled":false}',
1797
- "--model",
1798
- options.model ?? "sonnet",
1799
- "--effort",
1800
- options.effort ?? "low",
1801
- "--resume",
1802
- sessionId
1803
- ],
1804
- {
1805
- name: "xterm-256color",
1806
- cols: 120,
1807
- rows: 40,
1808
- cwd: options.projectPath,
1809
- env: buildSpawnEnv()
1810
- }
1811
- );
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
+ });
1812
2028
  const session = {
1813
2029
  id: sessionId,
1814
2030
  provider: CLAUDE_CODE_PROVIDER,
@@ -1816,6 +2032,8 @@ var PTYManager = class {
1816
2032
  projectName,
1817
2033
  branch: options.branch ?? "",
1818
2034
  status: "running",
2035
+ statusSource: "spawn",
2036
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
1819
2037
  startedAt: /* @__PURE__ */ new Date(),
1820
2038
  completedAt: null,
1821
2039
  promptCount: 0,
@@ -1843,11 +2061,12 @@ var PTYManager = class {
1843
2061
  const nodePty = await loadPty2();
1844
2062
  const sessionId = (0, import_crypto3.randomUUID)();
1845
2063
  const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
2064
+ const permissionMode = options.permissionMode ?? "acceptEdits";
1846
2065
  const args = [
1847
2066
  "--permission-mode",
1848
- options.permissionMode ?? "acceptEdits",
2067
+ permissionMode,
1849
2068
  "--settings",
1850
- '{"spinnerTipsEnabled":false}',
2069
+ buildSettingsJson(permissionMode),
1851
2070
  "--model",
1852
2071
  options.model ?? "sonnet",
1853
2072
  "--effort",
@@ -1858,6 +2077,7 @@ var PTYManager = class {
1858
2077
  if (options.systemPrompt) {
1859
2078
  args.push("--system-prompt", options.systemPrompt);
1860
2079
  }
2080
+ args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
1861
2081
  const proc = nodePty.spawn(resolveClaudeExe(), args, {
1862
2082
  name: "xterm-256color",
1863
2083
  cols: 120,
@@ -1872,6 +2092,8 @@ var PTYManager = class {
1872
2092
  projectName,
1873
2093
  branch: "",
1874
2094
  status: "running",
2095
+ statusSource: "spawn",
2096
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
1875
2097
  startedAt: /* @__PURE__ */ new Date(),
1876
2098
  completedAt: null,
1877
2099
  promptCount: 0,
@@ -1902,6 +2124,8 @@ var PTYManager = class {
1902
2124
  }
1903
2125
  if (session.status === "waiting_input") {
1904
2126
  session.status = "running";
2127
+ session.statusSource = "user-input";
2128
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1905
2129
  this.onStatusChange?.(toPublicSession2(session));
1906
2130
  }
1907
2131
  this.log.info(
@@ -1937,6 +2161,8 @@ var PTYManager = class {
1937
2161
  }
1938
2162
  if (session.status === "waiting_input") {
1939
2163
  session.status = "running";
2164
+ session.statusSource = "user-input";
2165
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1940
2166
  this.onStatusChange?.(toPublicSession2(session));
1941
2167
  }
1942
2168
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
@@ -2058,6 +2284,8 @@ var PTYManager = class {
2058
2284
  } catch {
2059
2285
  }
2060
2286
  session.status = "idle";
2287
+ session.statusSource = "shutdown";
2288
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2061
2289
  session.completedAt = /* @__PURE__ */ new Date();
2062
2290
  session.screen.dispose();
2063
2291
  this.sessions.delete(sessionId);
@@ -2093,6 +2321,13 @@ var PTYManager = class {
2093
2321
  getInputHistory(sessionId) {
2094
2322
  return this.sessions.get(sessionId)?.inputHistory ?? [];
2095
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
+ }
2096
2331
  // Record a submitted user message as ground truth and fire onUserMessage.
2097
2332
  // Called from writeSubmit (both direct and flush paths) — never from
2098
2333
  // sendKeys, so raw keystrokes aren't logged as messages.
@@ -2169,9 +2404,9 @@ var PTYManager = class {
2169
2404
  session.lastOutput = stripped;
2170
2405
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
2171
2406
  if (session.status === "running" && matchedMarker) {
2172
- this.markReady(sessionId, session, `marker:${matchedMarker}`);
2407
+ this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
2173
2408
  } else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
2174
- this.markReady(sessionId, session, "fallback:timeout");
2409
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2175
2410
  }
2176
2411
  this.onOutput?.(sessionId, data);
2177
2412
  this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
@@ -2271,7 +2506,7 @@ var PTYManager = class {
2271
2506
  const session = this.sessions.get(sessionId);
2272
2507
  if (session?.status !== "running") return;
2273
2508
  if (this.pendingReady.has(sessionId)) {
2274
- this.markReady(sessionId, session, "quiet:timeout");
2509
+ this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
2275
2510
  } else {
2276
2511
  this.recheckReadyFromScreen(sessionId).catch((err) => {
2277
2512
  this.log.warn("[pty.ready] screen recheck failed", {
@@ -2300,14 +2535,16 @@ var PTYManager = class {
2300
2535
  const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
2301
2536
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
2302
2537
  if (matchedMarker && session.status === "running") {
2303
- this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
2538
+ this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
2304
2539
  }
2305
2540
  }
2306
2541
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2307
2542
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2308
- markReady(sessionId, session, reason) {
2543
+ markReady(sessionId, session, source, reason) {
2309
2544
  session.lastActivityAt = /* @__PURE__ */ new Date();
2310
2545
  session.status = "waiting_input";
2546
+ session.statusSource = source;
2547
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2311
2548
  const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
2312
2549
  this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
2313
2550
  event: "pty.ready",
@@ -2327,6 +2564,8 @@ var PTYManager = class {
2327
2564
  if (!session) return;
2328
2565
  session.completedAt = /* @__PURE__ */ new Date();
2329
2566
  session.status = "idle";
2567
+ session.statusSource = "process-exit";
2568
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2330
2569
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
2331
2570
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
2332
2571
  if (!(0, import_fs6.existsSync)(session.projectPath)) {
@@ -2361,6 +2600,8 @@ function toPublicSession2(s) {
2361
2600
  lastOutput: s.lastOutput,
2362
2601
  ...s.failureReason != null && { failureReason: s.failureReason },
2363
2602
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2603
+ ...s.statusSource != null && { statusSource: s.statusSource },
2604
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2364
2605
  ...s.filePath != null && { filePath: s.filePath }
2365
2606
  };
2366
2607
  }
@@ -2433,6 +2674,16 @@ var LiveSessionManager = class {
2433
2674
  }
2434
2675
  return null;
2435
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
+ }
2436
2687
  hasSession(sessionId) {
2437
2688
  for (const runner of this.runners.values()) {
2438
2689
  if (runner.hasSession(sessionId)) return true;
@@ -2609,6 +2860,18 @@ async function getProcessCwdUnix(pid) {
2609
2860
  async function getProcessArgsUnix(pid) {
2610
2861
  return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2611
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
+ }
2612
2875
  async function getProcessStartTimeUnix(pid) {
2613
2876
  const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2614
2877
  const d = new Date(raw);
@@ -2724,6 +2987,7 @@ async function readGitBranch(dir) {
2724
2987
  var import_node_ws = require("@hono/node-ws");
2725
2988
  var import_client = require("@temporalio/client");
2726
2989
  var import_scanner3 = require("@threadbase-sh/scanner");
2990
+ var import_crypto11 = require("crypto");
2727
2991
  var import_events = require("events");
2728
2992
  var import_fs18 = require("fs");
2729
2993
  var import_promises7 = require("fs/promises");
@@ -2988,7 +3252,202 @@ async function handleStartAgentSession(body, deps) {
2988
3252
  }
2989
3253
 
2990
3254
  // src/api/app.ts
2991
- 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
+ };
2992
3451
 
2993
3452
  // src/api/middleware/auth.middleware.ts
2994
3453
  function isLocalRequest(remoteAddr) {
@@ -3019,19 +3478,40 @@ var authMiddleware = (deps) => async (c, next) => {
3019
3478
  }
3020
3479
  }
3021
3480
  const authorization = c.req.header("authorization");
3022
- if (authorization?.startsWith("Bearer ")) {
3023
- const token = authorization.slice(7);
3024
- if (validateApiKey(token, deps.apiKey)) {
3025
- await next();
3026
- 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 {
3027
3498
  }
3499
+ } else if (validateApiKey(presented, deps.apiKey)) {
3500
+ principal = legacyPrincipal();
3501
+ }
3502
+ if (!principal) {
3503
+ return c.json({ error: "Unauthorized" }, 401);
3028
3504
  }
3029
- const key = c.req.query("key");
3030
- if (key && validateApiKey(key, deps.apiKey)) {
3505
+ const required = requiredCapability(path, method);
3506
+ if (required === null) {
3031
3507
  await next();
3032
3508
  return;
3033
3509
  }
3034
- 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();
3035
3515
  };
3036
3516
 
3037
3517
  // src/api/middleware/cors.middleware.ts
@@ -3170,12 +3650,67 @@ var createCacheAlertRoutes = (deps) => {
3170
3650
  return app;
3171
3651
  };
3172
3652
 
3173
- // src/api/routes/conversations.routes.ts
3653
+ // src/api/routes/config.routes.ts
3174
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");
3175
3710
  var ALREADY_HANDLED2 = 597;
3176
3711
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
3177
3712
  var createConversationRoutes = (deps) => {
3178
- const app = new import_hono4.Hono();
3713
+ const app = new import_hono5.Hono();
3179
3714
  app.get("/count", async (c) => {
3180
3715
  const url = new URL(c.req.url);
3181
3716
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -3201,8 +3736,34 @@ var createConversationRoutes = (deps) => {
3201
3736
  return app;
3202
3737
  };
3203
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
+
3204
3765
  // src/api/routes/health.routes.ts
3205
- var import_hono5 = require("hono");
3766
+ var import_hono7 = require("hono");
3206
3767
 
3207
3768
  // src/version.ts
3208
3769
  var import_node_fs2 = require("fs");
@@ -3239,7 +3800,7 @@ function resolveVersion() {
3239
3800
 
3240
3801
  // src/api/routes/health.routes.ts
3241
3802
  var createHealthRoutes = (deps) => {
3242
- const app = new import_hono5.Hono();
3803
+ const app = new import_hono7.Hono();
3243
3804
  app.get("/", (c) => {
3244
3805
  const cacheAlert = deps.cacheMonitor()?.healthzField();
3245
3806
  return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
@@ -3250,7 +3811,7 @@ var createHealthRoutes = (deps) => {
3250
3811
  // src/api/routes/logs.routes.ts
3251
3812
  var import_node_fs3 = require("fs");
3252
3813
  var import_node_path5 = require("path");
3253
- var import_hono6 = require("hono");
3814
+ var import_hono8 = require("hono");
3254
3815
 
3255
3816
  // src/lifecycle/constants.ts
3256
3817
  var import_node_os = require("os");
@@ -3308,7 +3869,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3308
3869
  }
3309
3870
  }
3310
3871
  function createLogsRoutes() {
3311
- const app = new import_hono6.Hono();
3872
+ const app = new import_hono8.Hono();
3312
3873
  app.get("/", (c) => {
3313
3874
  try {
3314
3875
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3379,7 +3940,7 @@ function createLogsRoutes() {
3379
3940
  // src/api/routes/misc.routes.ts
3380
3941
  var import_node_child_process = require("child_process");
3381
3942
  var import_node_crypto2 = require("crypto");
3382
- var import_hono7 = require("hono");
3943
+ var import_hono9 = require("hono");
3383
3944
  var import_os5 = require("os");
3384
3945
 
3385
3946
  // src/config/update-config.ts
@@ -3389,15 +3950,15 @@ var import_node_path6 = require("path");
3389
3950
  var import_yaml = require("yaml");
3390
3951
 
3391
3952
  // src/schemas/updateConfig.schema.ts
3392
- var import_zod2 = require("zod");
3393
- var UpdateConfigSchema = import_zod2.z.object({
3394
- auto_update: import_zod2.z.boolean().default(false),
3395
- channel: import_zod2.z.enum(["stable", "next"]).default("stable"),
3396
- allow: import_zod2.z.array(import_zod2.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3397
- poll_interval_minutes: import_zod2.z.number().int().min(0).default(1440),
3398
- defer_if_active_sessions: import_zod2.z.boolean().default(true),
3399
- github_repo: import_zod2.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3400
- 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)
3401
3962
  }).strict();
3402
3963
 
3403
3964
  // src/config/update-config.ts
@@ -3434,7 +3995,7 @@ function readJsonBody(req) {
3434
3995
  req.on("error", reject);
3435
3996
  });
3436
3997
  }
3437
- function readRawBody3(req) {
3998
+ function readRawBody4(req) {
3438
3999
  return new Promise((resolve2, reject) => {
3439
4000
  const chunks = [];
3440
4001
  req.on("data", (chunk) => chunks.push(chunk));
@@ -3453,7 +4014,7 @@ function verifyWebhookSignature(body, header, secret) {
3453
4014
  }
3454
4015
  var clientLog = getLogger("client");
3455
4016
  var createMiscRoutes = (deps) => {
3456
- const app = new import_hono7.Hono();
4017
+ const app = new import_hono9.Hono();
3457
4018
  app.get("/api/info", (c) => {
3458
4019
  const ptyIds = deps.ptyAttachedIds();
3459
4020
  return c.json({
@@ -3461,7 +4022,11 @@ var createMiscRoutes = (deps) => {
3461
4022
  machineName: (0, import_os5.hostname)(),
3462
4023
  platform: process.platform,
3463
4024
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
3464
- 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
3465
4030
  });
3466
4031
  });
3467
4032
  app.get("/api/profiles", (c) => c.json([]));
@@ -3478,7 +4043,32 @@ var createMiscRoutes = (deps) => {
3478
4043
  }
3479
4044
  });
3480
4045
  });
3481
- 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
+ });
3482
4072
  app.post("/api/__update", async (c) => {
3483
4073
  const cfg = loadUpdateConfig();
3484
4074
  if (!cfg?.webhook_secret) {
@@ -3486,7 +4076,7 @@ var createMiscRoutes = (deps) => {
3486
4076
  }
3487
4077
  let body;
3488
4078
  try {
3489
- body = await readRawBody3(c.env.incoming);
4079
+ body = await readRawBody4(c.env.incoming);
3490
4080
  } catch {
3491
4081
  return c.json({ error: "could not read body" }, 400);
3492
4082
  }
@@ -3529,11 +4119,11 @@ var createMiscRoutes = (deps) => {
3529
4119
  };
3530
4120
 
3531
4121
  // src/api/routes/pair.routes.ts
3532
- var import_hono8 = require("hono");
4122
+ var import_hono10 = require("hono");
3533
4123
  var ALREADY_HANDLED3 = 597;
3534
4124
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
3535
4125
  var createPairRoutes = (deps) => {
3536
- const app = new import_hono8.Hono();
4126
+ const app = new import_hono10.Hono();
3537
4127
  app.post("/start", (c) => {
3538
4128
  deps.handlePairStart(c.env.outgoing);
3539
4129
  return alreadyHandled3();
@@ -3546,11 +4136,11 @@ var createPairRoutes = (deps) => {
3546
4136
  };
3547
4137
 
3548
4138
  // src/api/routes/projects.routes.ts
3549
- var import_hono9 = require("hono");
4139
+ var import_hono11 = require("hono");
3550
4140
  var ALREADY_HANDLED4 = 597;
3551
4141
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
3552
4142
  var createProjectRoutes = (deps) => {
3553
- const app = new import_hono9.Hono();
4143
+ const app = new import_hono11.Hono();
3554
4144
  app.get("/", (c) => {
3555
4145
  const url = new URL(c.req.url);
3556
4146
  deps.handleListProjects(url, c.env.outgoing);
@@ -3564,12 +4154,147 @@ var createProjectRoutes = (deps) => {
3564
4154
  return app;
3565
4155
  };
3566
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
+
3567
4292
  // src/api/routes/scanner.routes.ts
3568
- var import_hono10 = require("hono");
4293
+ var import_hono13 = require("hono");
3569
4294
  var ALREADY_HANDLED5 = 597;
3570
4295
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
3571
4296
  var createScannerRoutes = (deps) => {
3572
- const app = new import_hono10.Hono();
4297
+ const app = new import_hono13.Hono();
3573
4298
  app.get("/api/search", async (c) => {
3574
4299
  const url = new URL(c.req.url);
3575
4300
  await deps.handleSearch(url, c.env.outgoing);
@@ -3579,11 +4304,11 @@ var createScannerRoutes = (deps) => {
3579
4304
  };
3580
4305
 
3581
4306
  // src/api/routes/sessions.routes.ts
3582
- var import_hono11 = require("hono");
4307
+ var import_hono14 = require("hono");
3583
4308
  var ALREADY_HANDLED6 = 597;
3584
4309
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
3585
4310
  var createSessionRoutes = (deps) => {
3586
- const app = new import_hono11.Hono();
4311
+ const app = new import_hono14.Hono();
3587
4312
  app.get("/count", (c) => {
3588
4313
  deps.handleSessionsCount(c.env.outgoing);
3589
4314
  return alreadyHandled6();
@@ -3650,9 +4375,9 @@ var createSessionRoutes = (deps) => {
3650
4375
  };
3651
4376
 
3652
4377
  // src/api/routes/ws.routes.ts
3653
- var import_hono12 = require("hono");
4378
+ var import_hono15 = require("hono");
3654
4379
  var createWsRoutes = (deps, upgradeWebSocket) => {
3655
- const app = new import_hono12.Hono();
4380
+ const app = new import_hono15.Hono();
3656
4381
  app.get(
3657
4382
  "/ws",
3658
4383
  upgradeWebSocket(() => {
@@ -3678,7 +4403,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3678
4403
 
3679
4404
  // src/api/app.ts
3680
4405
  var createHonoApp = (deps, upgradeWebSocket) => {
3681
- const app = new import_hono13.Hono();
4406
+ const app = new import_hono16.Hono();
3682
4407
  const httpLog = getLogger("http");
3683
4408
  app.use("*", async (c, next) => {
3684
4409
  const start = Date.now();
@@ -3703,7 +4428,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3703
4428
  app.route("/api/sessions", createSessionRoutes(deps));
3704
4429
  app.route("/api/conversations", createConversationRoutes(deps));
3705
4430
  app.route("/api/cache/alert", createCacheAlertRoutes(deps));
4431
+ app.route("/api/config", createConfigRoutes(deps));
3706
4432
  app.route("/api/projects", createProjectRoutes(deps));
4433
+ app.route("/api/providers", createProviderRoutes());
4434
+ app.route("/api/devices", createDeviceRoutes(deps));
3707
4435
  app.route("/api/pair", createPairRoutes(deps));
3708
4436
  app.route("/api", createBrowseRoutes(deps));
3709
4437
  app.route("/", createScannerRoutes(deps));
@@ -3911,11 +4639,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
3911
4639
  }
3912
4640
 
3913
4641
  // src/utils/fileIdentity.ts
3914
- var import_crypto5 = require("crypto");
4642
+ var import_crypto6 = require("crypto");
3915
4643
  function fileIdentity(stat3, headBytes) {
3916
4644
  if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
3917
4645
  const head = headBytes ?? Buffer.alloc(0);
3918
- return `fp:${(0, import_crypto5.createHash)("sha1").update(head).digest("hex")}`;
4646
+ return `fp:${(0, import_crypto6.createHash)("sha1").update(head).digest("hex")}`;
3919
4647
  }
3920
4648
  function splitCompleteLines(buf, baseOffset) {
3921
4649
  const spans = [];
@@ -5197,8 +5925,122 @@ var ConversationsRepository = class {
5197
5925
  }
5198
5926
  };
5199
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
+
5200
6042
  // src/db/repositories/projects.repository.ts
5201
- var import_crypto6 = require("crypto");
6043
+ var import_crypto7 = require("crypto");
5202
6044
 
5203
6045
  // src/utils/canonicalizeProjectPath.ts
5204
6046
  function canonicalizeProjectPath(projectPath) {
@@ -5291,7 +6133,7 @@ var ProjectsRepository = class {
5291
6133
  });
5292
6134
  return rowToProject(this.getById.get(existing.id));
5293
6135
  }
5294
- const id = (0, import_crypto6.randomUUID)();
6136
+ const id = (0, import_crypto7.randomUUID)();
5295
6137
  this.insert.run({
5296
6138
  id,
5297
6139
  path,
@@ -5313,6 +6155,125 @@ function deriveNameFromPath(path) {
5313
6155
  return parts.length > 0 ? parts[parts.length - 1] : null;
5314
6156
  }
5315
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
+
5316
6277
  // src/db/repositories/sessions.repository.ts
5317
6278
  var SessionsRepository = class {
5318
6279
  constructor(store) {
@@ -5381,8 +6342,20 @@ function handleListProjects(url, res) {
5381
6342
  res.end(JSON.stringify({ projects: page, total }));
5382
6343
  }
5383
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
+
5384
6357
  // src/pair-store.ts
5385
- var import_crypto7 = require("crypto");
6358
+ var import_crypto8 = require("crypto");
5386
6359
  var DEFAULT_TTL_SECONDS = 180;
5387
6360
  var SWEEP_INTERVAL_MS = 6e4;
5388
6361
  var PairTokenStore = class {
@@ -5397,7 +6370,7 @@ var PairTokenStore = class {
5397
6370
  }
5398
6371
  }
5399
6372
  mint() {
5400
- const token = `pt_${(0, import_crypto7.randomBytes)(16).toString("hex")}`;
6373
+ const token = `pt_${(0, import_crypto8.randomBytes)(16).toString("hex")}`;
5401
6374
  const expiresAt = Date.now() + this.ttlMs;
5402
6375
  this.current = { token, expiresAt, used: false };
5403
6376
  return {
@@ -5465,7 +6438,7 @@ function setCacheMetadata(repo, key, value) {
5465
6438
  }
5466
6439
 
5467
6440
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
5468
- var import_crypto8 = require("crypto");
6441
+ var import_crypto9 = require("crypto");
5469
6442
  var import_fs13 = require("fs");
5470
6443
 
5471
6444
  // src/services/cache-integrity/alertStore.ts
@@ -5530,7 +6503,7 @@ function envInt(name, fallback) {
5530
6503
  }
5531
6504
  function fingerprintOf(ids) {
5532
6505
  const sorted = [...ids].sort();
5533
- 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")}`;
5534
6507
  }
5535
6508
  var CacheIntegrityMonitor = class {
5536
6509
  constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
@@ -6344,6 +7317,112 @@ function conversationBusy(input) {
6344
7317
  };
6345
7318
  }
6346
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
+
6347
7426
  // src/session-store.ts
6348
7427
  var SessionStore = class {
6349
7428
  managed = /* @__PURE__ */ new Map();
@@ -6483,6 +7562,14 @@ function managedToResponse(s, ptyAttached) {
6483
7562
  conversationId: s.id,
6484
7563
  provider: s.provider ?? CLAUDE_CODE_PROVIDER,
6485
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",
6486
7573
  // We spawned it, so `status` is the authoritative signal — no inferred
6487
7574
  // `activity` is attached for managed sessions.
6488
7575
  ownership: "managed",
@@ -6506,6 +7593,13 @@ function managedToResponse(s, ptyAttached) {
6506
7593
  ...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
6507
7594
  ...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
6508
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() },
6509
7603
  ...s.filePath != null && { filePath: s.filePath },
6510
7604
  ...s.failureReason != null && { failureReason: s.failureReason },
6511
7605
  ...s.resumedFromConversationId != null && {
@@ -6527,6 +7621,12 @@ function discoveredToResponse(d, conversationId) {
6527
7621
  // Discovery just enumerated this PID, so it was alive moments ago. We never
6528
7622
  // report "gone" here — a vanished process simply stops being listed.
6529
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",
6530
7630
  projectPath: d.projectPath,
6531
7631
  projectName: d.projectName,
6532
7632
  branch: d.branch,
@@ -6541,7 +7641,7 @@ function discoveredToResponse(d, conversationId) {
6541
7641
  }
6542
7642
 
6543
7643
  // src/uploads.ts
6544
- var import_crypto9 = require("crypto");
7644
+ var import_crypto10 = require("crypto");
6545
7645
  var import_promises6 = require("fs/promises");
6546
7646
  var import_heic_convert = __toESM(require("heic-convert"), 1);
6547
7647
  var import_path17 = require("path");
@@ -6575,7 +7675,7 @@ async function saveUploadFile(input) {
6575
7675
  mimeType = "image/jpeg";
6576
7676
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
6577
7677
  }
6578
- const id = `up_${(0, import_crypto9.randomBytes)(8).toString("hex")}`;
7678
+ const id = `up_${(0, import_crypto10.randomBytes)(8).toString("hex")}`;
6579
7679
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
6580
7680
  const dir = (0, import_path17.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
6581
7681
  await (0, import_promises6.mkdir)(dir, { recursive: true });
@@ -6609,21 +7709,46 @@ function extractCodexText(content) {
6609
7709
  return "";
6610
7710
  }).filter(Boolean).join("").trim();
6611
7711
  }
6612
- function normalizeCodexLineToClaudeShape(line) {
7712
+ var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
7713
+ function classifyCodexLine(line) {
6613
7714
  let entry;
6614
7715
  try {
6615
7716
  entry = JSON.parse(line);
6616
7717
  } catch {
6617
- 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` };
6618
7729
  }
6619
- if (entry.type !== "response_item") return null;
6620
7730
  const payload = entry.payload;
6621
- if (payload?.type !== "message") return null;
7731
+ if (payload?.type !== "message") {
7732
+ return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
7733
+ }
6622
7734
  const role = payload.role;
6623
- 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
+ }
6624
7738
  const text = extractCodexText(payload.content);
6625
- if (!text) return null;
6626
- 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) {
6627
7752
  const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
6628
7753
  const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
6629
7754
  return JSON.stringify({
@@ -6828,6 +7953,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
6828
7953
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
6829
7954
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
6830
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;
6831
7958
  var RESUME_DISCOVERY_TIMEOUT_MS = 750;
6832
7959
  var DISCOVERY_TTL_MS = 15e3;
6833
7960
  var ADOPT_KILL_TIMEOUT_MS = 5e3;
@@ -6884,6 +8011,11 @@ var StreamerServer = class {
6884
8011
  // to pendingQuestions; mobile answers it by sending the option index via
6885
8012
  // /input { keys }. Cleared when the gate closes.
6886
8013
  pendingPermission = /* @__PURE__ */ new Map();
8014
+ // Content key (prompt + detail + options + cursor) of the permission gate
8015
+ // currently broadcast for a session — mirrors pendingQuestionKey so a PTY
8016
+ // repaint of the same gate doesn't re-broadcast on every tick. Cleared
8017
+ // alongside pendingPermission.
8018
+ pendingPermissionKey = /* @__PURE__ */ new Map();
6887
8019
  scanner = null;
6888
8020
  // Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
6889
8021
  // ConversationCache.open throw), or when config.scannerPersistent is false
@@ -6944,6 +8076,16 @@ var StreamerServer = class {
6944
8076
  defaultPermissionMode;
6945
8077
  defaultModel;
6946
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;
6947
8089
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
6948
8090
  ptyGraceTimers = /* @__PURE__ */ new Map();
6949
8091
  // Consecutive grace-timer defers for a still-`running` session (see
@@ -6951,6 +8093,18 @@ var StreamerServer = class {
6951
8093
  ptyGraceDeferCounts = /* @__PURE__ */ new Map();
6952
8094
  // Map of sessionId → set of subscribed WS clients
6953
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;
6954
8108
  // Map of clientId → WS socket (populated by the "register" WS handshake)
6955
8109
  clientIdToWs = /* @__PURE__ */ new Map();
6956
8110
  // Reverse map for cleanup on close
@@ -6960,7 +8114,20 @@ var StreamerServer = class {
6960
8114
  projectsRepo = null;
6961
8115
  conversationsRepo = null;
6962
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)();
6963
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;
6964
8131
  discoveryCache = null;
6965
8132
  cacheDir;
6966
8133
  tailSize;
@@ -6999,6 +8166,9 @@ var StreamerServer = class {
6999
8166
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
7000
8167
  this.defaultModel = config.defaultModel ?? "sonnet";
7001
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();
7002
8172
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os9.homedir)(), ".threadbase", "cache");
7003
8173
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
7004
8174
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -7123,6 +8293,7 @@ var StreamerServer = class {
7123
8293
  this.ptyManager = new LiveSessionManager({
7124
8294
  logger: getLogger("pty"),
7125
8295
  onOutput: (sessionId, data) => {
8296
+ this.lastAgentChunkAt.set(sessionId, Date.now());
7126
8297
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
7127
8298
  },
7128
8299
  onUserMessage: (sessionId, text, ts) => {
@@ -7151,6 +8322,17 @@ var StreamerServer = class {
7151
8322
  completedAt: session.completedAt,
7152
8323
  ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
7153
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
+ );
7154
8336
  if (session.status === "waiting_input" || session.status === "idle") {
7155
8337
  const filePath = this.sessionFileMap.get(session.id);
7156
8338
  if (filePath) {
@@ -7181,6 +8363,7 @@ var StreamerServer = class {
7181
8363
  this.cancelPendingQuestion(session.id);
7182
8364
  }
7183
8365
  this.pendingPermission.delete(session.id);
8366
+ this.pendingPermissionKey.delete(session.id);
7184
8367
  this.contendedSessions.delete(session.id);
7185
8368
  this.rememberSelfPtyEnded(session.id);
7186
8369
  }
@@ -7221,6 +8404,8 @@ var StreamerServer = class {
7221
8404
  localNoAuth: this.localNoAuth,
7222
8405
  logMenubarRequests: this.logMenubarRequests,
7223
8406
  rotateApiKey: () => this.rotateApiKey(),
8407
+ claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
8408
+ setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
7224
8409
  publicUrl: this.publicUrl,
7225
8410
  browseRoot: this.browseRoot,
7226
8411
  browserCors: this.browserCors,
@@ -7229,6 +8414,8 @@ var StreamerServer = class {
7229
8414
  wsHub: this.wsHub,
7230
8415
  cache: () => this.cache,
7231
8416
  cacheMonitor: () => this.cacheMonitor,
8417
+ pushRepo: () => this.pushRepo,
8418
+ devicesRepo: () => this.devicesRepo,
7232
8419
  projectsRepo: () => this.projectsRepo,
7233
8420
  conversationsRepo: () => this.conversationsRepo,
7234
8421
  sessionsRepo: () => this.sessionsRepo,
@@ -7262,7 +8449,9 @@ var StreamerServer = class {
7262
8449
  handleMkdir: (req, res) => this.handleMkdir(req, res),
7263
8450
  handleWsOpen: (ws) => {
7264
8451
  this.wsHub.addClient(ws);
7265
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
8452
+ const sessions = this.withReconciledLifecycle(
8453
+ this.sessionStore.list(this.ptyAttachedIds())
8454
+ );
7266
8455
  ws.send(JSON.stringify({ type: "session_list", sessions }));
7267
8456
  if (!this.currentWarmupState()) {
7268
8457
  ws.send(JSON.stringify({ type: "cache_ready" }));
@@ -7338,11 +8527,8 @@ var StreamerServer = class {
7338
8527
  this.clientIdToWs.delete(clientId);
7339
8528
  this.wsToClientId.delete(ws);
7340
8529
  }
7341
- for (const [sessionId, subscribers] of this.sessionSubscribers) {
8530
+ for (const subscribers of this.sessionSubscribers.values()) {
7342
8531
  subscribers.delete(ws);
7343
- if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
7344
- this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
7345
- }
7346
8532
  }
7347
8533
  },
7348
8534
  agentClient,
@@ -7405,7 +8591,7 @@ var StreamerServer = class {
7405
8591
  const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
7406
8592
  const payload = {
7407
8593
  type: "session_list",
7408
- sessions: this.sessionStore.list(this.ptyAttachedIds())
8594
+ sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
7409
8595
  };
7410
8596
  if (ws) {
7411
8597
  this.wsHub.unicast(ws, payload);
@@ -7413,6 +8599,29 @@ var StreamerServer = class {
7413
8599
  this.wsHub.broadcast(payload);
7414
8600
  }
7415
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
+ }
7416
8625
  addSessionSubscriber(sessionId, ws) {
7417
8626
  let subs = this.sessionSubscribers.get(sessionId);
7418
8627
  if (!subs) {
@@ -7427,6 +8636,181 @@ var StreamerServer = class {
7427
8636
  }
7428
8637
  this.ptyGraceDeferCounts.delete(sessionId);
7429
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
+ }
7430
8814
  startGraceTimer(sessionId, delayMs) {
7431
8815
  const existing = this.ptyGraceTimers.get(sessionId);
7432
8816
  if (existing) clearTimeout(existing);
@@ -7521,6 +8905,8 @@ var StreamerServer = class {
7521
8905
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
7522
8906
  }
7523
8907
  await this.bindWithRetry(port);
8908
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
8909
+ this.idleReaperTimer.unref?.();
7524
8910
  const warmUp = new Promise((resolveWarm) => {
7525
8911
  {
7526
8912
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -7554,7 +8940,11 @@ var StreamerServer = class {
7554
8940
  this.projectsRepo = new ProjectsRepository(db);
7555
8941
  this.conversationsRepo = new ConversationsRepository(this.cache);
7556
8942
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
8943
+ this.managedSessionsRepo = new ManagedSessionsRepository(db);
8944
+ void this.reconcilePreviousSessions();
7557
8945
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
8946
+ this.pushRepo = new PushRepository(db);
8947
+ this.devicesRepo = new DevicesRepository(db);
7558
8948
  this.cacheMonitor = new CacheIntegrityMonitor(
7559
8949
  this.cache,
7560
8950
  this.wsHub,
@@ -7772,6 +9162,12 @@ var StreamerServer = class {
7772
9162
  async close() {
7773
9163
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
7774
9164
  this.ptyGraceTimers.clear();
9165
+ if (this.idleReaperTimer) {
9166
+ clearInterval(this.idleReaperTimer);
9167
+ this.idleReaperTimer = null;
9168
+ }
9169
+ this.lastAgentChunkAt.clear();
9170
+ this.recordShutdownState();
7775
9171
  this.markScannerStaleDebounced.cancel();
7776
9172
  await Promise.all([...this.inFlightCacheWrites]);
7777
9173
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -7861,12 +9257,28 @@ var StreamerServer = class {
7861
9257
  ip,
7862
9258
  ts
7863
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
+ }
7864
9271
  json(res, 200, {
7865
9272
  ciphertext: sealed.ciphertext,
7866
9273
  nonce: sealed.nonce,
7867
9274
  ephemeralPublicKey: sealed.ephemeralPublicKey,
7868
9275
  publicUrl: this.publicUrl,
7869
- machineName: hostname2()
9276
+ machineName: hostname2(),
9277
+ ...device && {
9278
+ deviceId: device.deviceId,
9279
+ deviceToken: device.deviceToken,
9280
+ capabilities: device.capabilities
9281
+ }
7870
9282
  });
7871
9283
  }
7872
9284
  rotateApiKey() {
@@ -7883,6 +9295,48 @@ var StreamerServer = class {
7883
9295
  });
7884
9296
  return { newKey, persisted };
7885
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
+ }
7886
9340
  checkRateLimit(map, key, limit, windowMs) {
7887
9341
  const now = Date.now();
7888
9342
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -8900,7 +10354,13 @@ var StreamerServer = class {
8900
10354
  }
8901
10355
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
8902
10356
  if (!hasPaginationParams) {
8903
- 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
+ );
8904
10364
  return;
8905
10365
  }
8906
10366
  const parsed = parseSessionListQuery(url);
@@ -8910,7 +10370,7 @@ var StreamerServer = class {
8910
10370
  }
8911
10371
  try {
8912
10372
  const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
8913
- page.sessions = this.withExternalActivity(page.sessions);
10373
+ page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
8914
10374
  json(res, 200, page);
8915
10375
  } catch (err) {
8916
10376
  if (err instanceof Error && err.message === "INVALID_CURSOR") {
@@ -8927,6 +10387,9 @@ var StreamerServer = class {
8927
10387
  if (!(0, import_fs18.existsSync)(session.projectPath)) {
8928
10388
  session.failureReason = `Project directory not found: ${session.projectPath}`;
8929
10389
  }
10390
+ const reconciled = this.withReconciledLifecycle([session])[0];
10391
+ session.lifecycle = reconciled.lifecycle;
10392
+ session.lifecycleSource = reconciled.lifecycleSource;
8930
10393
  if (this.ptyManager.hasSession(sessionId)) {
8931
10394
  try {
8932
10395
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -9024,10 +10487,13 @@ var StreamerServer = class {
9024
10487
  projectName: body.projectName,
9025
10488
  branch: body.branch,
9026
10489
  permissionMode: this.defaultPermissionMode,
10490
+ claudeFlags: this.claudeFlags,
10491
+ claudeExtraArgs: this.claudeExtraArgs,
9027
10492
  model: this.defaultModel,
9028
10493
  effort: this.defaultEffort
9029
10494
  });
9030
10495
  this.sessionStore.addManaged(session);
10496
+ this.recordSessionSpawn(session);
9031
10497
  void this.watchConversationFile(sessionId);
9032
10498
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
9033
10499
  this.broadcastOrUnicastSessionList(req);
@@ -9100,6 +10566,24 @@ var StreamerServer = class {
9100
10566
  }
9101
10567
  const body = await readBody(req);
9102
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
+ }
9103
10587
  if (typeof keys === "string") {
9104
10588
  try {
9105
10589
  this.ptyManager.sendKeys(sessionId, keys);
@@ -9107,7 +10591,9 @@ var StreamerServer = class {
9107
10591
  if (updated) {
9108
10592
  this.wsHub.broadcast({ type: "session_update", session: updated });
9109
10593
  }
9110
- 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);
9111
10597
  } catch (err) {
9112
10598
  const message = err instanceof Error ? err.message : "Failed to send keys";
9113
10599
  json(res, 400, { error: message });
@@ -9145,7 +10631,9 @@ var StreamerServer = class {
9145
10631
  });
9146
10632
  });
9147
10633
  }
9148
- 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);
9149
10637
  } catch (err) {
9150
10638
  const message = err instanceof Error ? err.message : "Failed to send input";
9151
10639
  json(res, 400, { error: message });
@@ -9217,10 +10705,14 @@ var StreamerServer = class {
9217
10705
  if (gate === null) {
9218
10706
  if (!this.pendingPermission.has(sessionId)) return;
9219
10707
  this.pendingPermission.delete(sessionId);
10708
+ this.pendingPermissionKey.delete(sessionId);
9220
10709
  this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
9221
10710
  return;
9222
10711
  }
10712
+ const key = permissionContentKey(gate);
10713
+ if (this.pendingPermissionKey.get(sessionId) === key) return;
9223
10714
  this.pendingPermission.set(sessionId, gate);
10715
+ this.pendingPermissionKey.set(sessionId, key);
9224
10716
  const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
9225
10717
  this.log.info(
9226
10718
  `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
@@ -9446,10 +10938,13 @@ var StreamerServer = class {
9446
10938
  projectName,
9447
10939
  branch,
9448
10940
  permissionMode: this.defaultPermissionMode,
10941
+ claudeFlags: this.claudeFlags,
10942
+ claudeExtraArgs: this.claudeExtraArgs,
9449
10943
  model: this.defaultModel,
9450
10944
  effort: this.defaultEffort
9451
10945
  });
9452
10946
  this.sessionStore.addManaged(session);
10947
+ this.recordSessionSpawn(session);
9453
10948
  void this.watchConversationFile(session.id);
9454
10949
  this.wsHub.broadcast({
9455
10950
  type: "session_list",
@@ -9519,10 +11014,13 @@ var StreamerServer = class {
9519
11014
  projectName: body.projectName,
9520
11015
  systemPrompt: systemPromptParts.join("\n"),
9521
11016
  permissionMode: this.defaultPermissionMode,
11017
+ claudeFlags: this.claudeFlags,
11018
+ claudeExtraArgs: this.claudeExtraArgs,
9522
11019
  model: this.defaultModel,
9523
11020
  effort: this.defaultEffort
9524
11021
  });
9525
11022
  this.sessionStore.addManaged(session);
11023
+ this.recordSessionSpawn(session);
9526
11024
  const readyOrFailed = new Promise((resolve2) => {
9527
11025
  const handler = (status) => {
9528
11026
  if (status === "waiting_input" || status === "idle") {
@@ -10044,6 +11542,7 @@ function readBody(req) {
10044
11542
  SessionStore,
10045
11543
  StreamerServer,
10046
11544
  WSHub,
11545
+ confidenceForSource,
10047
11546
  createAgentClient,
10048
11547
  createConversationWriter,
10049
11548
  createPool,