@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.js CHANGED
@@ -278,6 +278,165 @@ import { randomBytes, timingSafeEqual } from "crypto";
278
278
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
279
279
  import { homedir } from "os";
280
280
  import { join as join2 } from "path";
281
+
282
+ // src/claude-flags.ts
283
+ var PERMISSION_MODES = [
284
+ "acceptEdits",
285
+ "auto",
286
+ "bypassPermissions",
287
+ "manual",
288
+ "dontAsk",
289
+ "plan"
290
+ ];
291
+ function isPermissionMode(value) {
292
+ return typeof value === "string" && PERMISSION_MODES.includes(value);
293
+ }
294
+ var DANGEROUS_PERMISSION_MODES = [
295
+ "bypassPermissions",
296
+ "dontAsk"
297
+ ];
298
+ function isDangerousPermissionMode(mode) {
299
+ return DANGEROUS_PERMISSION_MODES.includes(mode);
300
+ }
301
+ var CLAUDE_FLAGS = [
302
+ {
303
+ id: "permissionMode",
304
+ flag: "--permission-mode",
305
+ valueType: "enum",
306
+ enumValues: PERMISSION_MODES,
307
+ risk: "low"
308
+ },
309
+ { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
310
+ { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
311
+ { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
312
+ { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
313
+ { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
314
+ ];
315
+ function findFlag(id) {
316
+ return CLAUDE_FLAGS.find((f) => f.id === id);
317
+ }
318
+ function validateFlagValues(raw) {
319
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
320
+ const out = {};
321
+ for (const [id, value] of Object.entries(raw)) {
322
+ const def = findFlag(id);
323
+ if (!def) continue;
324
+ switch (def.valueType) {
325
+ case "boolean":
326
+ if (typeof value === "boolean") out[id] = value;
327
+ break;
328
+ case "enum":
329
+ if (typeof value === "string" && def.enumValues?.includes(value)) out[id] = value;
330
+ break;
331
+ case "string":
332
+ if (typeof value === "string" && value.trim().length > 0) out[id] = value.trim();
333
+ break;
334
+ case "list": {
335
+ if (!Array.isArray(value)) break;
336
+ const items = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
337
+ if (items.length > 0) out[id] = items;
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+ function tokenizeExtraArgs(input) {
345
+ if (!input) return [];
346
+ const tokens = [];
347
+ let current = "";
348
+ let quote = null;
349
+ let started = false;
350
+ for (const ch of input) {
351
+ if (quote) {
352
+ if (ch === quote) quote = null;
353
+ else current += ch;
354
+ continue;
355
+ }
356
+ if (ch === '"' || ch === "'") {
357
+ quote = ch;
358
+ started = true;
359
+ continue;
360
+ }
361
+ if (/\s/.test(ch)) {
362
+ if (started) {
363
+ tokens.push(current);
364
+ current = "";
365
+ started = false;
366
+ }
367
+ continue;
368
+ }
369
+ current += ch;
370
+ started = true;
371
+ }
372
+ if (started) tokens.push(current);
373
+ return tokens;
374
+ }
375
+ function buildFlagArgs(values, extraArgs) {
376
+ const args = [];
377
+ const safe = validateFlagValues(values ?? {});
378
+ for (const def of CLAUDE_FLAGS) {
379
+ if (def.id === "permissionMode") continue;
380
+ const value = safe[def.id];
381
+ if (value === void 0) continue;
382
+ if (def.valueType === "boolean") {
383
+ if (value === true) args.push(def.flag);
384
+ continue;
385
+ }
386
+ if (Array.isArray(value)) {
387
+ args.push(def.flag, ...value);
388
+ continue;
389
+ }
390
+ args.push(def.flag, String(value));
391
+ }
392
+ args.push(...tokenizeExtraArgs(extraArgs));
393
+ return args;
394
+ }
395
+ function buildSettingsJson(permissionMode) {
396
+ const settings = { spinnerTipsEnabled: false };
397
+ if (isDangerousPermissionMode(permissionMode)) {
398
+ settings.skipDangerousModePermissionPrompt = true;
399
+ }
400
+ return JSON.stringify(settings);
401
+ }
402
+
403
+ // src/logger.ts
404
+ import pino from "pino";
405
+ var baseLogger = pino({
406
+ level: process.env.LOG_LEVEL ?? "info",
407
+ base: { service: "tb-streamer" },
408
+ timestamp: pino.stdTimeFunctions.isoTime,
409
+ redact: {
410
+ paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
411
+ censor: "[redacted]"
412
+ }
413
+ });
414
+ function emit(pinoChild, level, msg, fields, dest) {
415
+ if (dest === "pino" || dest === "both") {
416
+ if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
417
+ else pinoChild[level](msg);
418
+ }
419
+ if (dest === "console" || dest === "both") {
420
+ const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
421
+ console[consoleMethod](msg);
422
+ }
423
+ }
424
+ function build(pinoChild) {
425
+ return {
426
+ debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
427
+ info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
428
+ warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
429
+ error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
430
+ log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
431
+ pino: pinoChild
432
+ };
433
+ }
434
+ function getLogger(component) {
435
+ return build(component ? baseLogger.child({ component }) : baseLogger);
436
+ }
437
+ var logger = build(baseLogger);
438
+
439
+ // src/auth.ts
281
440
  function configDir() {
282
441
  return process.env.THREADBASE_CONFIG_DIR ?? join2(homedir(), ".threadbase");
283
442
  }
@@ -356,11 +515,79 @@ function loadDefaultPermissionMode() {
356
515
  const content = readFileSync(configFile(), "utf-8");
357
516
  const match = content.match(/default_permission_mode:\s*(\S+)/);
358
517
  const value = match?.[1]?.trim();
359
- if (value === "acceptEdits" || value === "manual") return value;
518
+ if (isPermissionMode(value)) return value;
360
519
  } catch {
361
520
  }
362
521
  return void 0;
363
522
  }
523
+ function setConfigValue(key, value) {
524
+ const file = configFile();
525
+ mkdirSync(configDir(), { recursive: true });
526
+ let content = "";
527
+ try {
528
+ content = readFileSync(file, "utf-8");
529
+ } catch (err) {
530
+ if (err.code !== "ENOENT") throw err;
531
+ }
532
+ const lineRe = new RegExp(`^${key}:\\s*.*$\\n?`, "m");
533
+ let updated;
534
+ if (value === void 0) {
535
+ updated = content.replace(lineRe, "");
536
+ } else {
537
+ const line = `${key}: ${value}`;
538
+ if (lineRe.test(content)) {
539
+ updated = content.replace(lineRe, `${line}
540
+ `);
541
+ } else if (content.length === 0 || content.endsWith("\n")) {
542
+ updated = `${content}${line}
543
+ `;
544
+ } else {
545
+ updated = `${content}
546
+ ${line}
547
+ `;
548
+ }
549
+ }
550
+ const tmpFile = `${file}.tmp`;
551
+ writeFileSync(tmpFile, updated, { encoding: "utf-8", mode: 384 });
552
+ chmodSync(tmpFile, 384);
553
+ renameSync(tmpFile, file);
554
+ }
555
+ function loadClaudeFlags() {
556
+ try {
557
+ const content = readFileSync(configFile(), "utf-8");
558
+ const match = content.match(/^claude_flags:\s*(.+)$/m);
559
+ if (!match?.[1]) return {};
560
+ return validateFlagValues(JSON.parse(match[1].trim()));
561
+ } catch (err) {
562
+ if (err.code !== "ENOENT") {
563
+ getLogger("auth").warn(`Ignoring unreadable claude_flags in server.yaml: ${String(err)}`, {
564
+ event: "config.claude_flags_parse_failed"
565
+ });
566
+ }
567
+ return {};
568
+ }
569
+ }
570
+ function setClaudeFlags(values) {
571
+ const safe = validateFlagValues(values);
572
+ setConfigValue("claude_flags", Object.keys(safe).length === 0 ? void 0 : JSON.stringify(safe));
573
+ }
574
+ function loadClaudeExtraArgs() {
575
+ try {
576
+ const content = readFileSync(configFile(), "utf-8");
577
+ const match = content.match(/^claude_extra_args:\s*(.+)$/m);
578
+ const value = match?.[1]?.trim();
579
+ return value && value.length > 0 ? value : void 0;
580
+ } catch {
581
+ }
582
+ return void 0;
583
+ }
584
+ function setClaudeExtraArgs(text) {
585
+ const trimmed = text?.trim();
586
+ if (trimmed && /[\r\n]/.test(trimmed)) {
587
+ throw new Error("claude_extra_args must not contain newlines");
588
+ }
589
+ setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
590
+ }
364
591
  function validatePublicUrl(raw) {
365
592
  let parsed;
366
593
  try {
@@ -508,42 +735,6 @@ import { randomUUID } from "crypto";
508
735
  import { existsSync as existsSync2 } from "fs";
509
736
  import { basename } from "path";
510
737
 
511
- // src/logger.ts
512
- import pino from "pino";
513
- var baseLogger = pino({
514
- level: process.env.LOG_LEVEL ?? "info",
515
- base: { service: "tb-streamer" },
516
- timestamp: pino.stdTimeFunctions.isoTime,
517
- redact: {
518
- paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
519
- censor: "[redacted]"
520
- }
521
- });
522
- function emit(pinoChild, level, msg, fields, dest) {
523
- if (dest === "pino" || dest === "both") {
524
- if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
525
- else pinoChild[level](msg);
526
- }
527
- if (dest === "console" || dest === "both") {
528
- const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
529
- console[consoleMethod](msg);
530
- }
531
- }
532
- function build(pinoChild) {
533
- return {
534
- debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
535
- info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
536
- warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
537
- error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
538
- log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
539
- pino: pinoChild
540
- };
541
- }
542
- function getLogger(component) {
543
- return build(component ? baseLogger.child({ component }) : baseLogger);
544
- }
545
- var logger = build(baseLogger);
546
-
547
738
  // src/platform.ts
548
739
  import { execFileSync } from "child_process";
549
740
  import { existsSync } from "fs";
@@ -895,6 +1086,8 @@ var CodexPtyRunner = class {
895
1086
  projectName,
896
1087
  branch: options.branch ?? "",
897
1088
  status: "running",
1089
+ statusSource: "spawn",
1090
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
898
1091
  startedAt: /* @__PURE__ */ new Date(),
899
1092
  completedAt: null,
900
1093
  promptCount: 0,
@@ -942,6 +1135,8 @@ var CodexPtyRunner = class {
942
1135
  projectName,
943
1136
  branch: "",
944
1137
  status: "running",
1138
+ statusSource: "spawn",
1139
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
945
1140
  startedAt: /* @__PURE__ */ new Date(),
946
1141
  completedAt: null,
947
1142
  promptCount: 0,
@@ -972,7 +1167,7 @@ var CodexPtyRunner = class {
972
1167
  this.readyFallbackTimers.delete(sessionId);
973
1168
  const session = this.sessions.get(sessionId);
974
1169
  if (session?.status === "running" && this.pendingReady.has(sessionId)) {
975
- this.markReady(sessionId, session, "fallback:timeout");
1170
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
976
1171
  }
977
1172
  }, CODEX_READY_FALLBACK_MS);
978
1173
  timer.unref?.();
@@ -987,6 +1182,8 @@ var CodexPtyRunner = class {
987
1182
  }
988
1183
  if (session.status === "waiting_input") {
989
1184
  session.status = "running";
1185
+ session.statusSource = "user-input";
1186
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
990
1187
  this.onStatusChange?.(toPublicSession(session));
991
1188
  }
992
1189
  const gate = this.openGate.get(sessionId);
@@ -1056,6 +1253,8 @@ var CodexPtyRunner = class {
1056
1253
  }
1057
1254
  if (session.status === "waiting_input") {
1058
1255
  session.status = "running";
1256
+ session.statusSource = "user-input";
1257
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1059
1258
  this.onStatusChange?.(toPublicSession(session));
1060
1259
  }
1061
1260
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
@@ -1156,6 +1355,8 @@ var CodexPtyRunner = class {
1156
1355
  } catch {
1157
1356
  }
1158
1357
  session.status = "idle";
1358
+ session.statusSource = "shutdown";
1359
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1159
1360
  session.completedAt = /* @__PURE__ */ new Date();
1160
1361
  session.screen.dispose();
1161
1362
  this.sessions.delete(sessionId);
@@ -1200,6 +1401,11 @@ var CodexPtyRunner = class {
1200
1401
  getInputHistory(sessionId) {
1201
1402
  return this.sessions.get(sessionId)?.inputHistory ?? [];
1202
1403
  }
1404
+ // OS pid of the spawned agent, or null if the session isn't live here.
1405
+ // Mirrors PTYManager.getPid — see there for why the registry records it.
1406
+ getPid(sessionId) {
1407
+ return this.sessions.get(sessionId)?.process?.pid ?? null;
1408
+ }
1203
1409
  // Record a submitted user message as ground truth and fire onUserMessage.
1204
1410
  // Called from writeSubmit (direct and flush paths) — never from sendKeys.
1205
1411
  recordUserMessage(session, text) {
@@ -1301,9 +1507,9 @@ var CodexPtyRunner = class {
1301
1507
  if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1302
1508
  const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1303
1509
  if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1304
- this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
1510
+ this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
1305
1511
  } else if (trigger === "quiet") {
1306
- this.markReady(sessionId, session, "quiet:timeout");
1512
+ this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
1307
1513
  }
1308
1514
  }
1309
1515
  // Answer a gate from the persisted remember-store, or surface it as a
@@ -1334,9 +1540,11 @@ var CodexPtyRunner = class {
1334
1540
  });
1335
1541
  this.onPermissionChange?.(sessionId, card);
1336
1542
  }
1337
- markReady(sessionId, session, reason) {
1543
+ markReady(sessionId, session, source, reason) {
1338
1544
  session.lastActivityAt = /* @__PURE__ */ new Date();
1339
1545
  session.status = "waiting_input";
1546
+ session.statusSource = source;
1547
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1340
1548
  this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1341
1549
  event: "codex.ready",
1342
1550
  sessionId,
@@ -1354,6 +1562,8 @@ var CodexPtyRunner = class {
1354
1562
  if (!session) return;
1355
1563
  session.completedAt = /* @__PURE__ */ new Date();
1356
1564
  session.status = "idle";
1565
+ session.statusSource = "process-exit";
1566
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1357
1567
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1358
1568
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1359
1569
  if (!existsSync2(session.projectPath)) {
@@ -1383,6 +1593,8 @@ function toPublicSession(s) {
1383
1593
  lastOutput: s.lastOutput,
1384
1594
  ...s.failureReason != null && { failureReason: s.failureReason },
1385
1595
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1596
+ ...s.statusSource != null && { statusSource: s.statusSource },
1597
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
1386
1598
  ...s.filePath != null && { filePath: s.filePath }
1387
1599
  };
1388
1600
  }
@@ -1397,6 +1609,9 @@ import { existsSync as existsSync3 } from "fs";
1397
1609
  import { basename as basename2 } from "path";
1398
1610
 
1399
1611
  // src/services/questions/detectPermissionGate.ts
1612
+ function permissionContentKey(gate) {
1613
+ return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
1614
+ }
1400
1615
  var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
1401
1616
  var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
1402
1617
  function hasPermissionOsc(rawData) {
@@ -1708,16 +1923,17 @@ var PTYManager = class {
1708
1923
  }
1709
1924
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1710
1925
  //
1711
- // options.permissionMode defaults to `acceptEdits` rather than
1712
- // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1713
- // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1714
- // flag renders a blocking "Bypass Permissions mode" warning menu on every
1715
- // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1716
- // v2.1.x) the session never reaches a usable prompt, so the mobile app
1717
- // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1718
- // without that warning gate, while still prompting for shell commands.
1719
- // `manual` (prompt for everything) is the only other mode callers may pass;
1720
- // `bypassPermissions`/`plan`/`dontAsk`/`auto` are not supported here.
1926
+ // options.permissionMode defaults to `acceptEdits` the safe default that
1927
+ // auto-approves file edits while still prompting for shell commands. All six
1928
+ // Claude CLI modes are accepted (see PERMISSION_MODES in claude-flags.ts).
1929
+ //
1930
+ // On the bypass modes: `bypassPermissions`/`dontAsk` DO trigger a blocking
1931
+ // "Bypass Permissions mode" warning menu at boot ("1. No, exit" /
1932
+ // "2. Yes, I accept") which would strand the PTY and leave mobile on an empty
1933
+ // screen. buildSettingsJson() suppresses it by adding
1934
+ // `skipDangerousModePermissionPrompt` to the `--settings` blob for exactly
1935
+ // those modes probe-verified on Claude Code v2.1.218. We never pass
1936
+ // `--dangerously-skip-permissions`; bypass is requested via --permission-mode.
1721
1937
  // (The other first-run gates — onboarding/theme, workspace trust,
1722
1938
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1723
1939
  // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
@@ -1735,28 +1951,27 @@ var PTYManager = class {
1735
1951
  async doStart(sessionId, options) {
1736
1952
  const nodePty = await loadPty2();
1737
1953
  const projectName = options.projectName ?? basename2(options.projectPath);
1738
- const proc = nodePty.spawn(
1739
- resolveClaudeExe(),
1740
- [
1741
- "--permission-mode",
1742
- options.permissionMode ?? "acceptEdits",
1743
- "--settings",
1744
- '{"spinnerTipsEnabled":false}',
1745
- "--model",
1746
- options.model ?? "sonnet",
1747
- "--effort",
1748
- options.effort ?? "low",
1749
- "--resume",
1750
- sessionId
1751
- ],
1752
- {
1753
- name: "xterm-256color",
1754
- cols: 120,
1755
- rows: 40,
1756
- cwd: options.projectPath,
1757
- env: buildSpawnEnv()
1758
- }
1759
- );
1954
+ const permissionMode = options.permissionMode ?? "acceptEdits";
1955
+ const args = [
1956
+ "--permission-mode",
1957
+ permissionMode,
1958
+ "--settings",
1959
+ buildSettingsJson(permissionMode),
1960
+ "--model",
1961
+ options.model ?? "sonnet",
1962
+ "--effort",
1963
+ options.effort ?? "low",
1964
+ "--resume",
1965
+ sessionId
1966
+ ];
1967
+ args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
1968
+ const proc = nodePty.spawn(resolveClaudeExe(), args, {
1969
+ name: "xterm-256color",
1970
+ cols: 120,
1971
+ rows: 40,
1972
+ cwd: options.projectPath,
1973
+ env: buildSpawnEnv()
1974
+ });
1760
1975
  const session = {
1761
1976
  id: sessionId,
1762
1977
  provider: CLAUDE_CODE_PROVIDER,
@@ -1764,6 +1979,8 @@ var PTYManager = class {
1764
1979
  projectName,
1765
1980
  branch: options.branch ?? "",
1766
1981
  status: "running",
1982
+ statusSource: "spawn",
1983
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
1767
1984
  startedAt: /* @__PURE__ */ new Date(),
1768
1985
  completedAt: null,
1769
1986
  promptCount: 0,
@@ -1791,11 +2008,12 @@ var PTYManager = class {
1791
2008
  const nodePty = await loadPty2();
1792
2009
  const sessionId = randomUUID2();
1793
2010
  const projectName = options.projectName ?? basename2(options.projectPath);
2011
+ const permissionMode = options.permissionMode ?? "acceptEdits";
1794
2012
  const args = [
1795
2013
  "--permission-mode",
1796
- options.permissionMode ?? "acceptEdits",
2014
+ permissionMode,
1797
2015
  "--settings",
1798
- '{"spinnerTipsEnabled":false}',
2016
+ buildSettingsJson(permissionMode),
1799
2017
  "--model",
1800
2018
  options.model ?? "sonnet",
1801
2019
  "--effort",
@@ -1806,6 +2024,7 @@ var PTYManager = class {
1806
2024
  if (options.systemPrompt) {
1807
2025
  args.push("--system-prompt", options.systemPrompt);
1808
2026
  }
2027
+ args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
1809
2028
  const proc = nodePty.spawn(resolveClaudeExe(), args, {
1810
2029
  name: "xterm-256color",
1811
2030
  cols: 120,
@@ -1820,6 +2039,8 @@ var PTYManager = class {
1820
2039
  projectName,
1821
2040
  branch: "",
1822
2041
  status: "running",
2042
+ statusSource: "spawn",
2043
+ statusUpdatedAt: /* @__PURE__ */ new Date(),
1823
2044
  startedAt: /* @__PURE__ */ new Date(),
1824
2045
  completedAt: null,
1825
2046
  promptCount: 0,
@@ -1850,6 +2071,8 @@ var PTYManager = class {
1850
2071
  }
1851
2072
  if (session.status === "waiting_input") {
1852
2073
  session.status = "running";
2074
+ session.statusSource = "user-input";
2075
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1853
2076
  this.onStatusChange?.(toPublicSession2(session));
1854
2077
  }
1855
2078
  this.log.info(
@@ -1885,6 +2108,8 @@ var PTYManager = class {
1885
2108
  }
1886
2109
  if (session.status === "waiting_input") {
1887
2110
  session.status = "running";
2111
+ session.statusSource = "user-input";
2112
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
1888
2113
  this.onStatusChange?.(toPublicSession2(session));
1889
2114
  }
1890
2115
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
@@ -2006,6 +2231,8 @@ var PTYManager = class {
2006
2231
  } catch {
2007
2232
  }
2008
2233
  session.status = "idle";
2234
+ session.statusSource = "shutdown";
2235
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2009
2236
  session.completedAt = /* @__PURE__ */ new Date();
2010
2237
  session.screen.dispose();
2011
2238
  this.sessions.delete(sessionId);
@@ -2041,6 +2268,13 @@ var PTYManager = class {
2041
2268
  getInputHistory(sessionId) {
2042
2269
  return this.sessions.get(sessionId)?.inputHistory ?? [];
2043
2270
  }
2271
+ // OS pid of the spawned agent, or null if the session isn't live here. The
2272
+ // durable registry records this so a later streamer run can probe whether the
2273
+ // process outlived it. Liveness alone is never identity — a recycled pid is
2274
+ // why the registry stores a cmdline alongside it.
2275
+ getPid(sessionId) {
2276
+ return this.sessions.get(sessionId)?.process?.pid ?? null;
2277
+ }
2044
2278
  // Record a submitted user message as ground truth and fire onUserMessage.
2045
2279
  // Called from writeSubmit (both direct and flush paths) — never from
2046
2280
  // sendKeys, so raw keystrokes aren't logged as messages.
@@ -2117,9 +2351,9 @@ var PTYManager = class {
2117
2351
  session.lastOutput = stripped;
2118
2352
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
2119
2353
  if (session.status === "running" && matchedMarker) {
2120
- this.markReady(sessionId, session, `marker:${matchedMarker}`);
2354
+ this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
2121
2355
  } else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
2122
- this.markReady(sessionId, session, "fallback:timeout");
2356
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2123
2357
  }
2124
2358
  this.onOutput?.(sessionId, data);
2125
2359
  this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
@@ -2219,7 +2453,7 @@ var PTYManager = class {
2219
2453
  const session = this.sessions.get(sessionId);
2220
2454
  if (session?.status !== "running") return;
2221
2455
  if (this.pendingReady.has(sessionId)) {
2222
- this.markReady(sessionId, session, "quiet:timeout");
2456
+ this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
2223
2457
  } else {
2224
2458
  this.recheckReadyFromScreen(sessionId).catch((err) => {
2225
2459
  this.log.warn("[pty.ready] screen recheck failed", {
@@ -2248,14 +2482,16 @@ var PTYManager = class {
2248
2482
  const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
2249
2483
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
2250
2484
  if (matchedMarker && session.status === "running") {
2251
- this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
2485
+ this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
2252
2486
  }
2253
2487
  }
2254
2488
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2255
2489
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2256
- markReady(sessionId, session, reason) {
2490
+ markReady(sessionId, session, source, reason) {
2257
2491
  session.lastActivityAt = /* @__PURE__ */ new Date();
2258
2492
  session.status = "waiting_input";
2493
+ session.statusSource = source;
2494
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2259
2495
  const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
2260
2496
  this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
2261
2497
  event: "pty.ready",
@@ -2275,6 +2511,8 @@ var PTYManager = class {
2275
2511
  if (!session) return;
2276
2512
  session.completedAt = /* @__PURE__ */ new Date();
2277
2513
  session.status = "idle";
2514
+ session.statusSource = "process-exit";
2515
+ session.statusUpdatedAt = /* @__PURE__ */ new Date();
2278
2516
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
2279
2517
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
2280
2518
  if (!existsSync3(session.projectPath)) {
@@ -2309,6 +2547,8 @@ function toPublicSession2(s) {
2309
2547
  lastOutput: s.lastOutput,
2310
2548
  ...s.failureReason != null && { failureReason: s.failureReason },
2311
2549
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2550
+ ...s.statusSource != null && { statusSource: s.statusSource },
2551
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2312
2552
  ...s.filePath != null && { filePath: s.filePath }
2313
2553
  };
2314
2554
  }
@@ -2381,6 +2621,16 @@ var LiveSessionManager = class {
2381
2621
  }
2382
2622
  return null;
2383
2623
  }
2624
+ // Scans rather than using runnerFor(): the registry records a pid on a
2625
+ // best-effort basis, so an unknown session must return null rather than
2626
+ // throw the way the input-routing methods do.
2627
+ getPid(sessionId) {
2628
+ for (const runner of this.runners.values()) {
2629
+ const pid = runner.getPid(sessionId);
2630
+ if (pid != null) return pid;
2631
+ }
2632
+ return null;
2633
+ }
2384
2634
  hasSession(sessionId) {
2385
2635
  for (const runner of this.runners.values()) {
2386
2636
  if (runner.hasSession(sessionId)) return true;
@@ -2557,6 +2807,18 @@ async function getProcessCwdUnix(pid) {
2557
2807
  async function getProcessArgsUnix(pid) {
2558
2808
  return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2559
2809
  }
2810
+ async function getProcessArgs(pid) {
2811
+ if (!Number.isInteger(pid) || pid < 1) return "";
2812
+ try {
2813
+ if (platform2() === "win32") {
2814
+ const info = await getProcessInfoWindows(pid);
2815
+ return info?.args ?? "";
2816
+ }
2817
+ return await getProcessArgsUnix(pid);
2818
+ } catch {
2819
+ return "";
2820
+ }
2821
+ }
2560
2822
  async function getProcessStartTimeUnix(pid) {
2561
2823
  const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2562
2824
  const d = new Date(raw);
@@ -2679,6 +2941,7 @@ import {
2679
2941
  ConversationScanner,
2680
2942
  search
2681
2943
  } from "@threadbase-sh/scanner";
2944
+ import { randomUUID as randomUUID5 } from "crypto";
2682
2945
  import { EventEmitter } from "events";
2683
2946
  import {
2684
2947
  createReadStream,
@@ -2950,7 +3213,202 @@ async function handleStartAgentSession(body, deps) {
2950
3213
  }
2951
3214
 
2952
3215
  // src/api/app.ts
2953
- import { Hono as Hono13 } from "hono";
3216
+ import { Hono as Hono16 } from "hono";
3217
+
3218
+ // src/db/repositories/devices.repository.ts
3219
+ import { createHash, randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual2 } from "crypto";
3220
+
3221
+ // src/services/security/capabilities.ts
3222
+ var CAPABILITIES = [
3223
+ "history:read",
3224
+ // read conversations, search
3225
+ "session:control",
3226
+ // start, resume, send input, interrupt
3227
+ "fs:browse",
3228
+ // browse the project tree
3229
+ "fs:upload",
3230
+ // upload files into a project
3231
+ "notifications",
3232
+ // register for push
3233
+ "admin"
3234
+ // rotate keys, manage devices
3235
+ ];
3236
+ function isCapability(value) {
3237
+ return typeof value === "string" && CAPABILITIES.includes(value);
3238
+ }
3239
+ var FULL_CAPABILITIES = [
3240
+ "history:read",
3241
+ "session:control",
3242
+ "fs:browse",
3243
+ "fs:upload",
3244
+ "notifications"
3245
+ ];
3246
+ var READ_ONLY_CAPABILITIES = ["history:read"];
3247
+ function capabilitiesForPreset(preset) {
3248
+ return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
3249
+ }
3250
+ function legacyPrincipal() {
3251
+ return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
3252
+ }
3253
+ function hasCapability(principal, required) {
3254
+ return principal.capabilities.includes(required);
3255
+ }
3256
+ var ROUTE_CAPABILITIES = [
3257
+ // Most specific first for readability; matching sorts by length anyway.
3258
+ ["/api/sessions/", "session:control"],
3259
+ ["/api/sessions", "history:read"],
3260
+ // listing sessions is a read
3261
+ ["/api/conversations", "history:read"],
3262
+ ["/api/projects", "history:read"],
3263
+ ["/api/search", "history:read"],
3264
+ ["/api/providers", "history:read"],
3265
+ ["/api/browse", "fs:browse"],
3266
+ ["/api/upload", "fs:upload"],
3267
+ ["/api/push", "notifications"],
3268
+ ["/api/devices", "admin"],
3269
+ ["/api/config", "admin"],
3270
+ ["/api/auth/rotate", "admin"],
3271
+ ["/api/backup", "admin"],
3272
+ // Server identity and capability discovery. A read-only device must be able
3273
+ // to see WHICH server it is talking to and what it supports, or it cannot
3274
+ // render anything at all.
3275
+ ["/api/info", "history:read"],
3276
+ ["/api/profiles", "history:read"],
3277
+ ["/api/diagnostics", "history:read"],
3278
+ ["/api/cache/alert", "history:read"],
3279
+ // Client log shipping: any authenticated client may report its own errors.
3280
+ // Gating this behind a capability would silence diagnostics from exactly the
3281
+ // devices most likely to be misbehaving.
3282
+ ["/api/__client-log", "history:read"],
3283
+ // Logs viewer is localhost-only and already bypasses this middleware; the
3284
+ // mapping exists so a remote request is classified rather than denied as
3285
+ // unclassified.
3286
+ ["/api/logs", "admin"],
3287
+ // Pairing routes other than the public exchange (e.g. minting a token).
3288
+ ["/api/pair", "admin"],
3289
+ // The live WebSocket. Subscribing is a read — terminal output, session
3290
+ // updates, conversation events. Control still flows through the HTTP input
3291
+ // routes, which carry their own capability check, so a read-only device can
3292
+ // watch a session stream without being able to drive it.
3293
+ ["/ws", "history:read"],
3294
+ // Progress webhook (multi-agent). Authenticated by HMAC in the handler and
3295
+ // already skipped by the middleware; classified so a stray request is denied
3296
+ // by rule rather than as "unclassified".
3297
+ ["/internal/sessions", "admin"]
3298
+ ];
3299
+ function requiredCapability(path, method) {
3300
+ if (path.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
3301
+ return "history:read";
3302
+ }
3303
+ let best = null;
3304
+ for (const [prefix, cap] of ROUTE_CAPABILITIES) {
3305
+ if (path.startsWith(prefix) && (best === null || prefix.length > best.len)) {
3306
+ best = { len: prefix.length, cap };
3307
+ }
3308
+ }
3309
+ return best?.cap ?? null;
3310
+ }
3311
+
3312
+ // src/db/repositories/devices.repository.ts
3313
+ function generateDeviceToken() {
3314
+ return randomBytes2(32).toString("base64url");
3315
+ }
3316
+ function hashDeviceToken(token) {
3317
+ return createHash("sha256").update(token).digest("hex");
3318
+ }
3319
+ function safeHashEquals(a, b) {
3320
+ if (a.length !== b.length) return false;
3321
+ return timingSafeEqual2(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
3322
+ }
3323
+ function parseCapabilities(raw) {
3324
+ try {
3325
+ const parsed = JSON.parse(raw);
3326
+ if (!Array.isArray(parsed)) return [];
3327
+ return parsed.filter(isCapability);
3328
+ } catch {
3329
+ return [];
3330
+ }
3331
+ }
3332
+ function toDeviceView(row) {
3333
+ return {
3334
+ deviceId: row.device_id,
3335
+ name: row.name,
3336
+ capabilities: parseCapabilities(row.capabilities),
3337
+ createdAt: row.created_at,
3338
+ lastSeenAt: row.last_seen_at,
3339
+ revokedAt: row.revoked_at
3340
+ };
3341
+ }
3342
+ var DevicesRepository = class {
3343
+ insertStmt;
3344
+ byTokenHashStmt;
3345
+ byIdStmt;
3346
+ listStmt;
3347
+ revokeStmt;
3348
+ touchStmt;
3349
+ constructor(db) {
3350
+ this.insertStmt = db.prepare(`
3351
+ INSERT INTO devices (
3352
+ device_id, public_key, token_hash, name, capabilities, created_at
3353
+ ) VALUES (
3354
+ @device_id, @public_key, @token_hash, @name, @capabilities, @created_at
3355
+ )
3356
+ `);
3357
+ this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
3358
+ this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
3359
+ this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
3360
+ this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
3361
+ this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
3362
+ }
3363
+ /**
3364
+ * Record a newly paired device and mint its token.
3365
+ *
3366
+ * The raw token is returned to the caller and never stored — this is the only
3367
+ * moment it exists outside the client.
3368
+ */
3369
+ register(args) {
3370
+ const deviceId = randomUUID3();
3371
+ const deviceToken = generateDeviceToken();
3372
+ const capabilities = capabilitiesForPreset(args.preset ?? "full");
3373
+ this.insertStmt.run({
3374
+ device_id: deviceId,
3375
+ public_key: args.publicKey,
3376
+ token_hash: hashDeviceToken(deviceToken),
3377
+ name: args.name ?? null,
3378
+ capabilities: JSON.stringify(capabilities),
3379
+ created_at: args.now ?? Date.now()
3380
+ });
3381
+ return { deviceId, deviceToken, capabilities };
3382
+ }
3383
+ /**
3384
+ * Resolve a presented token to a device, or null.
3385
+ *
3386
+ * Returns null for a revoked device, so revocation takes effect on the very
3387
+ * next request with no cache to go stale.
3388
+ */
3389
+ authenticate(token) {
3390
+ const hash = hashDeviceToken(token);
3391
+ const row = this.byTokenHashStmt.get(hash);
3392
+ if (!row) return null;
3393
+ if (!safeHashEquals(row.token_hash, hash)) return null;
3394
+ if (row.revoked_at != null) return null;
3395
+ return row;
3396
+ }
3397
+ get(deviceId) {
3398
+ return this.byIdStmt.get(deviceId) ?? null;
3399
+ }
3400
+ /** All devices, including revoked ones — an audit surface needs the history. */
3401
+ list() {
3402
+ return this.listStmt.all().map(toDeviceView);
3403
+ }
3404
+ /** Revoke one device. Others are untouched — no key rotation, no collateral. */
3405
+ revoke(deviceId, now = Date.now()) {
3406
+ return this.revokeStmt.run(now, deviceId).changes > 0;
3407
+ }
3408
+ touch(deviceId, now = Date.now()) {
3409
+ this.touchStmt.run(now, deviceId);
3410
+ }
3411
+ };
2954
3412
 
2955
3413
  // src/api/middleware/auth.middleware.ts
2956
3414
  function isLocalRequest(remoteAddr) {
@@ -2981,19 +3439,40 @@ var authMiddleware = (deps) => async (c, next) => {
2981
3439
  }
2982
3440
  }
2983
3441
  const authorization = c.req.header("authorization");
2984
- if (authorization?.startsWith("Bearer ")) {
2985
- const token = authorization.slice(7);
2986
- if (validateApiKey(token, deps.apiKey)) {
2987
- await next();
2988
- return;
3442
+ const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : void 0;
3443
+ const queryKey = c.req.query("key") ?? void 0;
3444
+ const presented = bearer ?? queryKey;
3445
+ if (!presented) {
3446
+ return c.json({ error: "Unauthorized" }, 401);
3447
+ }
3448
+ let principal = null;
3449
+ const device = deps.devicesRepo()?.authenticate(presented) ?? null;
3450
+ if (device) {
3451
+ principal = {
3452
+ kind: "device",
3453
+ deviceId: device.device_id,
3454
+ capabilities: parseCapabilities(device.capabilities)
3455
+ };
3456
+ try {
3457
+ deps.devicesRepo()?.touch(device.device_id);
3458
+ } catch {
2989
3459
  }
3460
+ } else if (validateApiKey(presented, deps.apiKey)) {
3461
+ principal = legacyPrincipal();
3462
+ }
3463
+ if (!principal) {
3464
+ return c.json({ error: "Unauthorized" }, 401);
2990
3465
  }
2991
- const key = c.req.query("key");
2992
- if (key && validateApiKey(key, deps.apiKey)) {
3466
+ const required = requiredCapability(path, method);
3467
+ if (required === null) {
2993
3468
  await next();
2994
3469
  return;
2995
3470
  }
2996
- return c.json({ error: "Unauthorized" }, 401);
3471
+ if (!hasCapability(principal, required)) {
3472
+ return c.json({ error: "Forbidden", code: "MISSING_CAPABILITY", required }, 403);
3473
+ }
3474
+ c.set("principal", principal);
3475
+ await next();
2997
3476
  };
2998
3477
 
2999
3478
  // src/api/middleware/cors.middleware.ts
@@ -3132,12 +3611,67 @@ var createCacheAlertRoutes = (deps) => {
3132
3611
  return app;
3133
3612
  };
3134
3613
 
3135
- // src/api/routes/conversations.routes.ts
3614
+ // src/api/routes/config.routes.ts
3136
3615
  import { Hono as Hono4 } from "hono";
3616
+
3617
+ // src/schemas/claudeFlags.schema.ts
3618
+ import { z as z2 } from "zod";
3619
+ var ClaudeFlagsBodySchema = z2.object({
3620
+ values: z2.record(z2.string(), z2.union([z2.string(), z2.boolean(), z2.array(z2.string())])).default({}),
3621
+ // A newline would corrupt the flat one-line-per-key server.yaml, so reject
3622
+ // it here with a field error instead of silently stripping it.
3623
+ extraArgs: z2.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
3624
+ }).strict();
3625
+
3626
+ // src/api/routes/config.routes.ts
3627
+ function readRawBody3(req) {
3628
+ return new Promise((resolve2, reject) => {
3629
+ const chunks = [];
3630
+ req.on("data", (chunk) => chunks.push(chunk));
3631
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
3632
+ req.on("error", reject);
3633
+ });
3634
+ }
3635
+ var createConfigRoutes = (deps) => {
3636
+ const app = new Hono4();
3637
+ app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3638
+ app.put("/claude-flags", async (c) => {
3639
+ if (deps.localNoAuth) {
3640
+ return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
3641
+ }
3642
+ let body;
3643
+ try {
3644
+ const incoming = c.env?.incoming;
3645
+ const raw = incoming ? await readRawBody3(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
3646
+ body = raw ? JSON.parse(raw) : {};
3647
+ } catch {
3648
+ return c.json({ error: "invalid json" }, 400);
3649
+ }
3650
+ const parsed = ClaudeFlagsBodySchema.safeParse(body);
3651
+ if (!parsed.success) {
3652
+ return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
3653
+ }
3654
+ try {
3655
+ const result = deps.setClaudeFlagsConfig(parsed.data.values, parsed.data.extraArgs);
3656
+ return c.json({
3657
+ ...result,
3658
+ ...result.persisted ? {} : {
3659
+ 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."
3660
+ }
3661
+ });
3662
+ } catch (err) {
3663
+ return c.json({ error: err instanceof Error ? err.message : "could not apply flags" }, 400);
3664
+ }
3665
+ });
3666
+ return app;
3667
+ };
3668
+
3669
+ // src/api/routes/conversations.routes.ts
3670
+ import { Hono as Hono5 } from "hono";
3137
3671
  var ALREADY_HANDLED2 = 597;
3138
3672
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
3139
3673
  var createConversationRoutes = (deps) => {
3140
- const app = new Hono4();
3674
+ const app = new Hono5();
3141
3675
  app.get("/count", async (c) => {
3142
3676
  const url = new URL(c.req.url);
3143
3677
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -3163,8 +3697,34 @@ var createConversationRoutes = (deps) => {
3163
3697
  return app;
3164
3698
  };
3165
3699
 
3700
+ // src/api/routes/devices.routes.ts
3701
+ import { Hono as Hono6 } from "hono";
3702
+ var createDeviceRoutes = (deps) => {
3703
+ const app = new Hono6();
3704
+ app.get("/", (c) => {
3705
+ const repo = deps.devicesRepo();
3706
+ if (!repo) return c.json({ devices: [], available: false });
3707
+ return c.json({ devices: repo.list(), available: true });
3708
+ });
3709
+ app.post("/:id/revoke", (c) => {
3710
+ const repo = deps.devicesRepo();
3711
+ if (!repo) {
3712
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3713
+ }
3714
+ const id = c.req.param("id");
3715
+ const existing = repo.get(id);
3716
+ if (!existing) return c.json({ error: "Device not found" }, 404);
3717
+ if (existing.revoked_at != null) {
3718
+ return c.json({ ok: true, alreadyRevoked: true });
3719
+ }
3720
+ repo.revoke(id);
3721
+ return c.json({ ok: true, alreadyRevoked: false });
3722
+ });
3723
+ return app;
3724
+ };
3725
+
3166
3726
  // src/api/routes/health.routes.ts
3167
- import { Hono as Hono5 } from "hono";
3727
+ import { Hono as Hono7 } from "hono";
3168
3728
 
3169
3729
  // src/version.ts
3170
3730
  import { readFileSync as readFileSync4, realpathSync } from "fs";
@@ -3201,7 +3761,7 @@ function resolveVersion() {
3201
3761
 
3202
3762
  // src/api/routes/health.routes.ts
3203
3763
  var createHealthRoutes = (deps) => {
3204
- const app = new Hono5();
3764
+ const app = new Hono7();
3205
3765
  app.get("/", (c) => {
3206
3766
  const cacheAlert = deps.cacheMonitor()?.healthzField();
3207
3767
  return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
@@ -3212,7 +3772,7 @@ var createHealthRoutes = (deps) => {
3212
3772
  // src/api/routes/logs.routes.ts
3213
3773
  import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
3214
3774
  import { join as join9 } from "path";
3215
- import { Hono as Hono6 } from "hono";
3775
+ import { Hono as Hono8 } from "hono";
3216
3776
 
3217
3777
  // src/lifecycle/constants.ts
3218
3778
  import { homedir as homedir4 } from "os";
@@ -3270,7 +3830,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3270
3830
  }
3271
3831
  }
3272
3832
  function createLogsRoutes() {
3273
- const app = new Hono6();
3833
+ const app = new Hono8();
3274
3834
  app.get("/", (c) => {
3275
3835
  try {
3276
3836
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3340,8 +3900,8 @@ function createLogsRoutes() {
3340
3900
 
3341
3901
  // src/api/routes/misc.routes.ts
3342
3902
  import { spawn } from "child_process";
3343
- import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
3344
- import { Hono as Hono7 } from "hono";
3903
+ import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
3904
+ import { Hono as Hono9 } from "hono";
3345
3905
  import { hostname } from "os";
3346
3906
 
3347
3907
  // src/config/update-config.ts
@@ -3351,15 +3911,15 @@ import { join as join10 } from "path";
3351
3911
  import { parse as parseYaml } from "yaml";
3352
3912
 
3353
3913
  // src/schemas/updateConfig.schema.ts
3354
- import { z as z2 } from "zod";
3355
- var UpdateConfigSchema = z2.object({
3356
- auto_update: z2.boolean().default(false),
3357
- channel: z2.enum(["stable", "next"]).default("stable"),
3358
- allow: z2.array(z2.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3359
- poll_interval_minutes: z2.number().int().min(0).default(1440),
3360
- defer_if_active_sessions: z2.boolean().default(true),
3361
- github_repo: z2.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3362
- webhook_secret: z2.string().min(1).nullable().default(null)
3914
+ import { z as z3 } from "zod";
3915
+ var UpdateConfigSchema = z3.object({
3916
+ auto_update: z3.boolean().default(false),
3917
+ channel: z3.enum(["stable", "next"]).default("stable"),
3918
+ allow: z3.array(z3.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3919
+ poll_interval_minutes: z3.number().int().min(0).default(1440),
3920
+ defer_if_active_sessions: z3.boolean().default(true),
3921
+ github_repo: z3.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3922
+ webhook_secret: z3.string().min(1).nullable().default(null)
3363
3923
  }).strict();
3364
3924
 
3365
3925
  // src/config/update-config.ts
@@ -3396,7 +3956,7 @@ function readJsonBody(req) {
3396
3956
  req.on("error", reject);
3397
3957
  });
3398
3958
  }
3399
- function readRawBody3(req) {
3959
+ function readRawBody4(req) {
3400
3960
  return new Promise((resolve2, reject) => {
3401
3961
  const chunks = [];
3402
3962
  req.on("data", (chunk) => chunks.push(chunk));
@@ -3411,11 +3971,11 @@ function verifyWebhookSignature(body, header, secret) {
3411
3971
  const a = Buffer.from(provided, "utf-8");
3412
3972
  const b = Buffer.from(expected, "utf-8");
3413
3973
  if (a.length !== b.length) return false;
3414
- return timingSafeEqual2(a, b);
3974
+ return timingSafeEqual3(a, b);
3415
3975
  }
3416
3976
  var clientLog = getLogger("client");
3417
3977
  var createMiscRoutes = (deps) => {
3418
- const app = new Hono7();
3978
+ const app = new Hono9();
3419
3979
  app.get("/api/info", (c) => {
3420
3980
  const ptyIds = deps.ptyAttachedIds();
3421
3981
  return c.json({
@@ -3423,7 +3983,11 @@ var createMiscRoutes = (deps) => {
3423
3983
  machineName: hostname(),
3424
3984
  platform: process.platform,
3425
3985
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
3426
- publicUrl: deps.publicUrl
3986
+ publicUrl: deps.publicUrl,
3987
+ // Capability flag: this server serves /api/config/claude-flags. Additive —
3988
+ // older clients ignore it, and clients talking to an older server see it
3989
+ // absent and hide the UI rather than 404ing.
3990
+ claudeFlags: true
3427
3991
  });
3428
3992
  });
3429
3993
  app.get("/api/profiles", (c) => c.json([]));
@@ -3440,7 +4004,32 @@ var createMiscRoutes = (deps) => {
3440
4004
  }
3441
4005
  });
3442
4006
  });
3443
- app.post("/api/push/register", (c) => c.json({ ok: true }));
4007
+ app.post("/api/push/register", async (c) => {
4008
+ const body = await readJsonBody(c.env.incoming).catch(() => null);
4009
+ const token = body?.token;
4010
+ const platform3 = body?.platform;
4011
+ if (typeof token !== "string" || token.length === 0) {
4012
+ return c.json({ error: "Missing token" }, 400);
4013
+ }
4014
+ if (platform3 !== "ios" && platform3 !== "android") {
4015
+ return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
4016
+ }
4017
+ const repo = deps.pushRepo();
4018
+ if (!repo) {
4019
+ return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
4020
+ }
4021
+ repo.register({
4022
+ token,
4023
+ platform: platform3,
4024
+ deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
4025
+ });
4026
+ return c.json({ ok: true });
4027
+ });
4028
+ app.get("/api/push/health", (c) => {
4029
+ const repo = deps.pushRepo();
4030
+ if (!repo) return c.json({ tokens: [], available: false });
4031
+ return c.json({ tokens: repo.listHealth(), available: true });
4032
+ });
3444
4033
  app.post("/api/__update", async (c) => {
3445
4034
  const cfg = loadUpdateConfig();
3446
4035
  if (!cfg?.webhook_secret) {
@@ -3448,7 +4037,7 @@ var createMiscRoutes = (deps) => {
3448
4037
  }
3449
4038
  let body;
3450
4039
  try {
3451
- body = await readRawBody3(c.env.incoming);
4040
+ body = await readRawBody4(c.env.incoming);
3452
4041
  } catch {
3453
4042
  return c.json({ error: "could not read body" }, 400);
3454
4043
  }
@@ -3491,11 +4080,11 @@ var createMiscRoutes = (deps) => {
3491
4080
  };
3492
4081
 
3493
4082
  // src/api/routes/pair.routes.ts
3494
- import { Hono as Hono8 } from "hono";
4083
+ import { Hono as Hono10 } from "hono";
3495
4084
  var ALREADY_HANDLED3 = 597;
3496
4085
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
3497
4086
  var createPairRoutes = (deps) => {
3498
- const app = new Hono8();
4087
+ const app = new Hono10();
3499
4088
  app.post("/start", (c) => {
3500
4089
  deps.handlePairStart(c.env.outgoing);
3501
4090
  return alreadyHandled3();
@@ -3508,11 +4097,11 @@ var createPairRoutes = (deps) => {
3508
4097
  };
3509
4098
 
3510
4099
  // src/api/routes/projects.routes.ts
3511
- import { Hono as Hono9 } from "hono";
4100
+ import { Hono as Hono11 } from "hono";
3512
4101
  var ALREADY_HANDLED4 = 597;
3513
4102
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
3514
4103
  var createProjectRoutes = (deps) => {
3515
- const app = new Hono9();
4104
+ const app = new Hono11();
3516
4105
  app.get("/", (c) => {
3517
4106
  const url = new URL(c.req.url);
3518
4107
  deps.handleListProjects(url, c.env.outgoing);
@@ -3526,12 +4115,147 @@ var createProjectRoutes = (deps) => {
3526
4115
  return app;
3527
4116
  };
3528
4117
 
4118
+ // src/api/routes/providers.routes.ts
4119
+ import { Hono as Hono12 } from "hono";
4120
+
4121
+ // src/services/providers/providerHealth.ts
4122
+ import { execFile as execFile2 } from "child_process";
4123
+
4124
+ // src/services/providers/capabilities.ts
4125
+ var CLAUDE_CODE_CAPABILITIES = {
4126
+ freshSessionId: "explicit",
4127
+ resume: "native",
4128
+ systemPrompt: "flag",
4129
+ structuredQuestions: true,
4130
+ permissionGates: true,
4131
+ liveControl: true
4132
+ };
4133
+ var CODEX_CLI_CAPABILITIES = {
4134
+ freshSessionId: "late-bound",
4135
+ resume: "native",
4136
+ systemPrompt: "positional",
4137
+ structuredQuestions: false,
4138
+ permissionGates: true,
4139
+ liveControl: true
4140
+ };
4141
+ function capabilitiesFor(provider) {
4142
+ switch (provider) {
4143
+ case CLAUDE_CODE_PROVIDER:
4144
+ return CLAUDE_CODE_CAPABILITIES;
4145
+ case CODEX_CLI_PROVIDER:
4146
+ return CODEX_CLI_CAPABILITIES;
4147
+ }
4148
+ }
4149
+
4150
+ // src/services/providers/providerHealth.ts
4151
+ var VERIFIED_AGAINST = {
4152
+ [CLAUDE_CODE_PROVIDER]: { captured: ["2.1.214"], min: "2.1.0" },
4153
+ [CODEX_CLI_PROVIDER]: { captured: ["0.140.0-alpha.19"], min: "0.140.0" }
4154
+ };
4155
+ var VERSION_TIMEOUT_MS = 3e3;
4156
+ function parseVersionOutput(output) {
4157
+ const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
4158
+ return match ? match[0] : null;
4159
+ }
4160
+ function runVersion(exe) {
4161
+ return new Promise((resolve2) => {
4162
+ execFile2(exe, ["--version"], { timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => {
4163
+ if (err && !stdout && !stderr) return resolve2(null);
4164
+ resolve2(parseVersionOutput(`${stdout}${stderr}`));
4165
+ });
4166
+ });
4167
+ }
4168
+ function compareToVerified(version, verified) {
4169
+ if (version === null) {
4170
+ return {
4171
+ code: "version_undetectable",
4172
+ message: "Could not determine the installed version, so compatibility is unverified. Parsing and prompt detection may not match this build."
4173
+ };
4174
+ }
4175
+ if (verified.captured.includes(version)) return null;
4176
+ const below = verified.min != null && compareSemver(version, verified.min) < 0;
4177
+ const above = verified.max != null && compareSemver(version, verified.max) > 0;
4178
+ if (!below && !above && verified.max != null) return null;
4179
+ if (!below && verified.max == null && !isNewerThanAllCaptured(version, verified.captured)) {
4180
+ return null;
4181
+ }
4182
+ return {
4183
+ code: "version_unverified",
4184
+ 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.`
4185
+ };
4186
+ }
4187
+ function isNewerThanAllCaptured(version, captured) {
4188
+ return captured.every((c) => compareSemver(version, c) > 0);
4189
+ }
4190
+ function compareSemver(a, b) {
4191
+ const parse = (v) => {
4192
+ const [core, pre] = v.split("-", 2);
4193
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
4194
+ return { nums, pre: pre ?? null };
4195
+ };
4196
+ const pa = parse(a);
4197
+ const pb = parse(b);
4198
+ for (let i = 0; i < 3; i++) {
4199
+ const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
4200
+ if (d !== 0) return d < 0 ? -1 : 1;
4201
+ }
4202
+ if (pa.pre === pb.pre) return 0;
4203
+ if (pa.pre === null) return 1;
4204
+ if (pb.pre === null) return -1;
4205
+ return pa.pre < pb.pre ? -1 : 1;
4206
+ }
4207
+ async function providerHealth(name, resolveExe, detect = runVersion) {
4208
+ const verifiedAgainst = VERIFIED_AGAINST[name];
4209
+ const capabilities = capabilitiesFor(name);
4210
+ let exe;
4211
+ try {
4212
+ exe = resolveExe();
4213
+ } catch {
4214
+ return {
4215
+ name,
4216
+ available: false,
4217
+ version: null,
4218
+ verifiedAgainst,
4219
+ capabilities,
4220
+ warnings: [
4221
+ {
4222
+ code: "provider_not_found",
4223
+ message: `${name} could not be located. Sessions for this provider cannot start.`
4224
+ }
4225
+ ]
4226
+ };
4227
+ }
4228
+ const version = await detect(exe);
4229
+ const warning = compareToVerified(version, verifiedAgainst);
4230
+ return {
4231
+ name,
4232
+ available: true,
4233
+ version,
4234
+ verifiedAgainst,
4235
+ capabilities,
4236
+ warnings: warning ? [warning] : []
4237
+ };
4238
+ }
4239
+
4240
+ // src/api/routes/providers.routes.ts
4241
+ var createProviderRoutes = () => {
4242
+ const app = new Hono12();
4243
+ app.get("/", async (c) => {
4244
+ const providers = await Promise.all([
4245
+ providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
4246
+ providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
4247
+ ]);
4248
+ return c.json({ providers });
4249
+ });
4250
+ return app;
4251
+ };
4252
+
3529
4253
  // src/api/routes/scanner.routes.ts
3530
- import { Hono as Hono10 } from "hono";
4254
+ import { Hono as Hono13 } from "hono";
3531
4255
  var ALREADY_HANDLED5 = 597;
3532
4256
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
3533
4257
  var createScannerRoutes = (deps) => {
3534
- const app = new Hono10();
4258
+ const app = new Hono13();
3535
4259
  app.get("/api/search", async (c) => {
3536
4260
  const url = new URL(c.req.url);
3537
4261
  await deps.handleSearch(url, c.env.outgoing);
@@ -3541,11 +4265,11 @@ var createScannerRoutes = (deps) => {
3541
4265
  };
3542
4266
 
3543
4267
  // src/api/routes/sessions.routes.ts
3544
- import { Hono as Hono11 } from "hono";
4268
+ import { Hono as Hono14 } from "hono";
3545
4269
  var ALREADY_HANDLED6 = 597;
3546
4270
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
3547
4271
  var createSessionRoutes = (deps) => {
3548
- const app = new Hono11();
4272
+ const app = new Hono14();
3549
4273
  app.get("/count", (c) => {
3550
4274
  deps.handleSessionsCount(c.env.outgoing);
3551
4275
  return alreadyHandled6();
@@ -3612,9 +4336,9 @@ var createSessionRoutes = (deps) => {
3612
4336
  };
3613
4337
 
3614
4338
  // src/api/routes/ws.routes.ts
3615
- import { Hono as Hono12 } from "hono";
4339
+ import { Hono as Hono15 } from "hono";
3616
4340
  var createWsRoutes = (deps, upgradeWebSocket) => {
3617
- const app = new Hono12();
4341
+ const app = new Hono15();
3618
4342
  app.get(
3619
4343
  "/ws",
3620
4344
  upgradeWebSocket(() => {
@@ -3640,7 +4364,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3640
4364
 
3641
4365
  // src/api/app.ts
3642
4366
  var createHonoApp = (deps, upgradeWebSocket) => {
3643
- const app = new Hono13();
4367
+ const app = new Hono16();
3644
4368
  const httpLog = getLogger("http");
3645
4369
  app.use("*", async (c, next) => {
3646
4370
  const start = Date.now();
@@ -3665,7 +4389,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3665
4389
  app.route("/api/sessions", createSessionRoutes(deps));
3666
4390
  app.route("/api/conversations", createConversationRoutes(deps));
3667
4391
  app.route("/api/cache/alert", createCacheAlertRoutes(deps));
4392
+ app.route("/api/config", createConfigRoutes(deps));
3668
4393
  app.route("/api/projects", createProjectRoutes(deps));
4394
+ app.route("/api/providers", createProviderRoutes());
4395
+ app.route("/api/devices", createDeviceRoutes(deps));
3669
4396
  app.route("/api/pair", createPairRoutes(deps));
3670
4397
  app.route("/api", createBrowseRoutes(deps));
3671
4398
  app.route("/", createScannerRoutes(deps));
@@ -3875,11 +4602,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
3875
4602
  }
3876
4603
 
3877
4604
  // src/utils/fileIdentity.ts
3878
- import { createHash } from "crypto";
4605
+ import { createHash as createHash2 } from "crypto";
3879
4606
  function fileIdentity(stat3, headBytes) {
3880
4607
  if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
3881
4608
  const head = headBytes ?? Buffer.alloc(0);
3882
- return `fp:${createHash("sha1").update(head).digest("hex")}`;
4609
+ return `fp:${createHash2("sha1").update(head).digest("hex")}`;
3883
4610
  }
3884
4611
  function splitCompleteLines(buf, baseOffset) {
3885
4612
  const spans = [];
@@ -5161,8 +5888,122 @@ var ConversationsRepository = class {
5161
5888
  }
5162
5889
  };
5163
5890
 
5891
+ // src/db/repositories/managed-sessions.repository.ts
5892
+ var ManagedSessionsRepository = class {
5893
+ upsertStmt;
5894
+ updateStatusStmt;
5895
+ getStmt;
5896
+ listNonTerminalStmt;
5897
+ deleteStmt;
5898
+ constructor(db) {
5899
+ this.upsertStmt = db.prepare(`
5900
+ INSERT INTO managed_sessions (
5901
+ session_id, provider, pid, cmdline, project_path, project_name, branch,
5902
+ status, status_source, status_updated_at, started_at, completed_at,
5903
+ last_activity_at, prompt_count, session_name, project_id,
5904
+ bound_conversation_id, resumed_from_conversation_id, failure_reason,
5905
+ streamer_instance_id
5906
+ ) VALUES (
5907
+ @session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
5908
+ @status, @status_source, @status_updated_at, @started_at, @completed_at,
5909
+ @last_activity_at, @prompt_count, @session_name, @project_id,
5910
+ @bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
5911
+ @streamer_instance_id
5912
+ )
5913
+ ON CONFLICT(session_id) DO UPDATE SET
5914
+ pid = excluded.pid,
5915
+ cmdline = excluded.cmdline,
5916
+ project_path = excluded.project_path,
5917
+ project_name = excluded.project_name,
5918
+ branch = excluded.branch,
5919
+ status = excluded.status,
5920
+ status_source = excluded.status_source,
5921
+ status_updated_at = excluded.status_updated_at,
5922
+ completed_at = excluded.completed_at,
5923
+ last_activity_at = excluded.last_activity_at,
5924
+ prompt_count = excluded.prompt_count,
5925
+ session_name = excluded.session_name,
5926
+ project_id = excluded.project_id,
5927
+ bound_conversation_id = excluded.bound_conversation_id,
5928
+ resumed_from_conversation_id = excluded.resumed_from_conversation_id,
5929
+ failure_reason = excluded.failure_reason,
5930
+ streamer_instance_id = excluded.streamer_instance_id
5931
+ `);
5932
+ this.updateStatusStmt = db.prepare(`
5933
+ UPDATE managed_sessions
5934
+ SET status = @status,
5935
+ status_source = @status_source,
5936
+ status_updated_at = @status_updated_at,
5937
+ completed_at = @completed_at,
5938
+ last_activity_at = @last_activity_at,
5939
+ prompt_count = @prompt_count,
5940
+ failure_reason = COALESCE(@failure_reason, failure_reason)
5941
+ WHERE session_id = @session_id
5942
+ `);
5943
+ this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
5944
+ this.listNonTerminalStmt = db.prepare(`
5945
+ SELECT * FROM managed_sessions
5946
+ WHERE completed_at IS NULL
5947
+ ORDER BY started_at ASC
5948
+ `);
5949
+ this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
5950
+ }
5951
+ /** Record a session at spawn, or refresh every field of an existing row. */
5952
+ recordSpawn({ session, pid, cmdline, streamerInstanceId }) {
5953
+ this.upsertStmt.run({
5954
+ session_id: session.id,
5955
+ provider: session.provider ?? "claude-code",
5956
+ pid,
5957
+ cmdline,
5958
+ project_path: session.projectPath,
5959
+ project_name: session.projectName,
5960
+ branch: session.branch ?? "",
5961
+ status: session.status,
5962
+ status_source: "spawn",
5963
+ status_updated_at: Date.now(),
5964
+ started_at: session.startedAt.getTime(),
5965
+ completed_at: session.completedAt?.getTime() ?? null,
5966
+ last_activity_at: session.lastActivityAt?.getTime() ?? null,
5967
+ prompt_count: session.promptCount,
5968
+ session_name: session.sessionName ?? null,
5969
+ project_id: session.projectId ?? null,
5970
+ bound_conversation_id: session.boundConversationId ?? null,
5971
+ resumed_from_conversation_id: session.resumedFromConversationId ?? null,
5972
+ failure_reason: session.failureReason ?? null,
5973
+ streamer_instance_id: streamerInstanceId
5974
+ });
5975
+ }
5976
+ /**
5977
+ * Persist a status transition. `source` is required rather than defaulted:
5978
+ * a status whose provenance is unknown is the thing this table exists to
5979
+ * prevent, and the reconciler reads it to decide how much to trust the value.
5980
+ */
5981
+ recordStatus(sessionId, status, source, fields = {}) {
5982
+ this.updateStatusStmt.run({
5983
+ session_id: sessionId,
5984
+ status,
5985
+ status_source: source,
5986
+ status_updated_at: Date.now(),
5987
+ completed_at: fields.completedAt?.getTime() ?? null,
5988
+ last_activity_at: fields.lastActivityAt?.getTime() ?? null,
5989
+ prompt_count: fields.promptCount ?? 0,
5990
+ failure_reason: fields.failureReason ?? null
5991
+ });
5992
+ }
5993
+ get(sessionId) {
5994
+ return this.getStmt.get(sessionId) ?? null;
5995
+ }
5996
+ /** Rows with no recorded completion — the reconciler's probe set. */
5997
+ listNonTerminal() {
5998
+ return this.listNonTerminalStmt.all();
5999
+ }
6000
+ delete(sessionId) {
6001
+ this.deleteStmt.run(sessionId);
6002
+ }
6003
+ };
6004
+
5164
6005
  // src/db/repositories/projects.repository.ts
5165
- import { randomUUID as randomUUID3 } from "crypto";
6006
+ import { randomUUID as randomUUID4 } from "crypto";
5166
6007
 
5167
6008
  // src/utils/canonicalizeProjectPath.ts
5168
6009
  function canonicalizeProjectPath(projectPath) {
@@ -5255,7 +6096,7 @@ var ProjectsRepository = class {
5255
6096
  });
5256
6097
  return rowToProject(this.getById.get(existing.id));
5257
6098
  }
5258
- const id = randomUUID3();
6099
+ const id = randomUUID4();
5259
6100
  this.insert.run({
5260
6101
  id,
5261
6102
  path,
@@ -5277,6 +6118,125 @@ function deriveNameFromPath(path) {
5277
6118
  return parts.length > 0 ? parts[parts.length - 1] : null;
5278
6119
  }
5279
6120
 
6121
+ // src/db/repositories/push.repository.ts
6122
+ var FAILURE_STREAK_LIMIT = 5;
6123
+ function tokenState(row) {
6124
+ if (row.revoked_at != null) return "revoked";
6125
+ if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
6126
+ if (row.failure_streak > 0) return "failing";
6127
+ if (row.last_success_at == null) return "never-delivered";
6128
+ return "healthy";
6129
+ }
6130
+ function toHealth(row) {
6131
+ return {
6132
+ platform: row.platform,
6133
+ deviceId: row.device_id,
6134
+ registeredAt: row.registered_at,
6135
+ lastSuccessAt: row.last_success_at,
6136
+ lastFailureAt: row.last_failure_at,
6137
+ lastFailureCode: row.last_failure_code,
6138
+ failureStreak: row.failure_streak,
6139
+ revokedAt: row.revoked_at,
6140
+ state: tokenState(row)
6141
+ };
6142
+ }
6143
+ var PushRepository = class {
6144
+ upsertStmt;
6145
+ getStmt;
6146
+ listActiveStmt;
6147
+ listAllStmt;
6148
+ successStmt;
6149
+ failureStmt;
6150
+ revokeStmt;
6151
+ claimEventStmt;
6152
+ markDeliveredStmt;
6153
+ constructor(db) {
6154
+ this.upsertStmt = db.prepare(`
6155
+ INSERT INTO push_tokens (token, platform, device_id, registered_at)
6156
+ VALUES (@token, @platform, @device_id, @registered_at)
6157
+ ON CONFLICT(token) DO UPDATE SET
6158
+ platform = excluded.platform,
6159
+ device_id = COALESCE(excluded.device_id, push_tokens.device_id),
6160
+ registered_at = excluded.registered_at,
6161
+ -- A fresh registration clears prior failure state and any revocation:
6162
+ -- the client is telling us this token is live again.
6163
+ failure_streak = 0,
6164
+ last_failure_at = NULL,
6165
+ last_failure_code = NULL,
6166
+ revoked_at = NULL
6167
+ `);
6168
+ this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
6169
+ this.listActiveStmt = db.prepare(`
6170
+ SELECT * FROM push_tokens
6171
+ WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
6172
+ ORDER BY registered_at ASC
6173
+ `);
6174
+ this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
6175
+ this.successStmt = db.prepare(`
6176
+ UPDATE push_tokens
6177
+ SET last_success_at = @at, failure_streak = 0,
6178
+ last_failure_code = NULL
6179
+ WHERE token = @token
6180
+ `);
6181
+ this.failureStmt = db.prepare(`
6182
+ UPDATE push_tokens
6183
+ SET last_failure_at = @at, last_failure_code = @code,
6184
+ failure_streak = failure_streak + 1
6185
+ WHERE token = @token
6186
+ `);
6187
+ this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
6188
+ this.claimEventStmt = db.prepare(`
6189
+ INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
6190
+ VALUES (@event_id, @session_id, @created_at)
6191
+ `);
6192
+ this.markDeliveredStmt = db.prepare(
6193
+ "UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
6194
+ );
6195
+ }
6196
+ register(args) {
6197
+ this.upsertStmt.run({
6198
+ token: args.token,
6199
+ platform: args.platform,
6200
+ device_id: args.deviceId ?? null,
6201
+ registered_at: args.now ?? Date.now()
6202
+ });
6203
+ }
6204
+ get(token) {
6205
+ return this.getStmt.get(token) ?? null;
6206
+ }
6207
+ /** Tokens eligible for delivery — not revoked, not past the failure limit. */
6208
+ listDeliverable() {
6209
+ return this.listActiveStmt.all();
6210
+ }
6211
+ /** Every token, including dead and revoked ones, for the health report. */
6212
+ listHealth() {
6213
+ return this.listAllStmt.all().map(toHealth);
6214
+ }
6215
+ recordSuccess(token, now = Date.now()) {
6216
+ this.successStmt.run({ token, at: now });
6217
+ }
6218
+ recordFailure(token, code, now = Date.now()) {
6219
+ this.failureStmt.run({ token, at: now, code });
6220
+ }
6221
+ revoke(token, now = Date.now()) {
6222
+ return this.revokeStmt.run(now, token).changes > 0;
6223
+ }
6224
+ /**
6225
+ * Claim an event id for delivery.
6226
+ *
6227
+ * Returns true exactly once per event id. A retry, a reconnect
6228
+ * reconciliation, or two triggers firing for the same underlying event all
6229
+ * get false and must not notify — the user should never be told twice about
6230
+ * one thing.
6231
+ */
6232
+ claimEvent(eventId, sessionId, now = Date.now()) {
6233
+ return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
6234
+ }
6235
+ markDelivered(eventId, now = Date.now()) {
6236
+ this.markDeliveredStmt.run(now, eventId);
6237
+ }
6238
+ };
6239
+
5280
6240
  // src/db/repositories/sessions.repository.ts
5281
6241
  var SessionsRepository = class {
5282
6242
  constructor(store) {
@@ -5345,8 +6305,20 @@ function handleListProjects(url, res) {
5345
6305
  res.end(JSON.stringify({ projects: page, total }));
5346
6306
  }
5347
6307
 
6308
+ // src/lifecycle/process-liveness.ts
6309
+ function isPidAlive(pid) {
6310
+ if (!Number.isInteger(pid) || pid < 1) return false;
6311
+ try {
6312
+ process.kill(pid, 0);
6313
+ return true;
6314
+ } catch (err) {
6315
+ const code = err.code;
6316
+ return code === "EPERM";
6317
+ }
6318
+ }
6319
+
5348
6320
  // src/pair-store.ts
5349
- import { randomBytes as randomBytes2 } from "crypto";
6321
+ import { randomBytes as randomBytes3 } from "crypto";
5350
6322
  var DEFAULT_TTL_SECONDS = 180;
5351
6323
  var SWEEP_INTERVAL_MS = 6e4;
5352
6324
  var PairTokenStore = class {
@@ -5361,7 +6333,7 @@ var PairTokenStore = class {
5361
6333
  }
5362
6334
  }
5363
6335
  mint() {
5364
- const token = `pt_${randomBytes2(16).toString("hex")}`;
6336
+ const token = `pt_${randomBytes3(16).toString("hex")}`;
5365
6337
  const expiresAt = Date.now() + this.ttlMs;
5366
6338
  this.current = { token, expiresAt, used: false };
5367
6339
  return {
@@ -5429,7 +6401,7 @@ function setCacheMetadata(repo, key, value) {
5429
6401
  }
5430
6402
 
5431
6403
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
5432
- import { createHash as createHash2 } from "crypto";
6404
+ import { createHash as createHash3 } from "crypto";
5433
6405
  import { existsSync as existsSync8 } from "fs";
5434
6406
 
5435
6407
  // src/services/cache-integrity/alertStore.ts
@@ -5494,7 +6466,7 @@ function envInt(name, fallback) {
5494
6466
  }
5495
6467
  function fingerprintOf(ids) {
5496
6468
  const sorted = [...ids].sort();
5497
- return `sha256:${createHash2("sha256").update(sorted.join("\n")).digest("hex")}`;
6469
+ return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
5498
6470
  }
5499
6471
  var CacheIntegrityMonitor = class {
5500
6472
  constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
@@ -6308,6 +7280,112 @@ function conversationBusy(input) {
6308
7280
  };
6309
7281
  }
6310
7282
 
7283
+ // src/services/sessions/idempotency.ts
7284
+ var IDEMPOTENCY_TTL_MS = 10 * 60 * 1e3;
7285
+ var IDEMPOTENCY_MAX_KEYS = 200;
7286
+ var IdempotencyStore = class {
7287
+ constructor(ttlMs = IDEMPOTENCY_TTL_MS, maxKeys = IDEMPOTENCY_MAX_KEYS) {
7288
+ this.ttlMs = ttlMs;
7289
+ this.maxKeys = maxKeys;
7290
+ }
7291
+ ttlMs;
7292
+ maxKeys;
7293
+ bySession = /* @__PURE__ */ new Map();
7294
+ /**
7295
+ * Previously recorded result for this key, or null if the key is new,
7296
+ * expired, or evicted. A miss always means "treat as a fresh request" —
7297
+ * failing open, because dropping a real prompt is far worse than allowing a
7298
+ * rare duplicate.
7299
+ */
7300
+ get(sessionId, key, now = Date.now()) {
7301
+ const entries = this.bySession.get(sessionId);
7302
+ if (!entries) return null;
7303
+ const hit = entries.find((e) => e.key === key);
7304
+ if (!hit) return null;
7305
+ if (now - hit.at > this.ttlMs) {
7306
+ this.bySession.set(
7307
+ sessionId,
7308
+ entries.filter((e) => e !== hit)
7309
+ );
7310
+ return null;
7311
+ }
7312
+ return hit.result;
7313
+ }
7314
+ /** Record the outcome of an accepted write so a retry can replay it. */
7315
+ set(sessionId, key, result, now = Date.now()) {
7316
+ const entries = this.bySession.get(sessionId) ?? [];
7317
+ const pruned = entries.filter((e) => e.key !== key && now - e.at <= this.ttlMs);
7318
+ pruned.push({ key, at: now, result });
7319
+ this.bySession.set(sessionId, pruned.slice(-this.maxKeys));
7320
+ }
7321
+ /** Drop everything for a session whose PTY is gone. */
7322
+ clear(sessionId) {
7323
+ this.bySession.delete(sessionId);
7324
+ }
7325
+ /** Test/diagnostic helper: how many keys are currently held for a session. */
7326
+ size(sessionId) {
7327
+ return this.bySession.get(sessionId)?.length ?? 0;
7328
+ }
7329
+ };
7330
+ function readIdempotencyKey(body) {
7331
+ const raw = body.idempotencyKey;
7332
+ if (raw === void 0 || raw === null) return void 0;
7333
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 200) {
7334
+ throw new Error("idempotencyKey must be a non-empty string of at most 200 characters");
7335
+ }
7336
+ return raw;
7337
+ }
7338
+
7339
+ // src/services/sessions/reconcileSessions.ts
7340
+ async function classifySession(row, probe, currentInstanceId) {
7341
+ const { session_id: sessionId } = row;
7342
+ if (row.completed_at != null) {
7343
+ const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
7344
+ return {
7345
+ sessionId,
7346
+ lifecycle: clean ? "completed" : "failed",
7347
+ reason: `terminal (${row.status_source})`
7348
+ };
7349
+ }
7350
+ if (row.pid == null) {
7351
+ return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
7352
+ }
7353
+ if (!probe.isPidAlive(row.pid)) {
7354
+ const clean = probe.endedCleanly?.(row) ?? false;
7355
+ if (clean) {
7356
+ return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
7357
+ }
7358
+ return {
7359
+ sessionId,
7360
+ lifecycle: "resumable",
7361
+ reason: "process gone, resumable from provider history"
7362
+ };
7363
+ }
7364
+ const args = await probe.getProcessArgs(row.pid);
7365
+ const token = row.cmdline;
7366
+ if (!token || !args?.includes(token)) {
7367
+ return {
7368
+ sessionId,
7369
+ lifecycle: "orphaned",
7370
+ reason: args ? "pid alive but command line does not match" : "pid alive but argv unreadable"
7371
+ };
7372
+ }
7373
+ const sameRun = row.streamer_instance_id === currentInstanceId;
7374
+ return {
7375
+ sessionId,
7376
+ lifecycle: sameRun ? "attached" : "detached",
7377
+ reason: sameRun ? "owned by this run" : "survived a previous streamer run"
7378
+ };
7379
+ }
7380
+ async function reconcileSessions(rows, probe, currentInstanceId) {
7381
+ return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
7382
+ }
7383
+
7384
+ // src/types.ts
7385
+ function confidenceForSource(source) {
7386
+ return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
7387
+ }
7388
+
6311
7389
  // src/session-store.ts
6312
7390
  var SessionStore = class {
6313
7391
  managed = /* @__PURE__ */ new Map();
@@ -6447,6 +7525,14 @@ function managedToResponse(s, ptyAttached) {
6447
7525
  conversationId: s.id,
6448
7526
  provider: s.provider ?? CLAUDE_CODE_PROVIDER,
6449
7527
  status: s.status,
7528
+ // Lifecycle for a session this run knows about. `attached` while we hold
7529
+ // its PTY; once the PTY is gone the session is terminal from this run's
7530
+ // perspective — `failed` when it recorded a reason, else `completed`.
7531
+ // Sessions left by *previous* runs never reach here: they aren't in the
7532
+ // in-memory store, and the boot reconciler classifies them instead
7533
+ // (docs/architecture/2026-07-24-durable-session-runtime.md).
7534
+ lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
7535
+ lifecycleSource: ptyAttached ? "spawn" : "exit",
6450
7536
  // We spawned it, so `status` is the authoritative signal — no inferred
6451
7537
  // `activity` is attached for managed sessions.
6452
7538
  ownership: "managed",
@@ -6470,6 +7556,13 @@ function managedToResponse(s, ptyAttached) {
6470
7556
  ...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
6471
7557
  ...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
6472
7558
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt.toISOString() },
7559
+ // C3: how the status was derived, and how far to trust it. Confidence is
7560
+ // derived from the source rather than stored, so the two cannot disagree.
7561
+ ...s.statusSource != null && {
7562
+ statusSource: s.statusSource,
7563
+ statusConfidence: confidenceForSource(s.statusSource)
7564
+ },
7565
+ ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt.toISOString() },
6473
7566
  ...s.filePath != null && { filePath: s.filePath },
6474
7567
  ...s.failureReason != null && { failureReason: s.failureReason },
6475
7568
  ...s.resumedFromConversationId != null && {
@@ -6491,6 +7584,12 @@ function discoveredToResponse(d, conversationId) {
6491
7584
  // Discovery just enumerated this PID, so it was alive moments ago. We never
6492
7585
  // report "gone" here — a vanished process simply stops being listed.
6493
7586
  processLiveness: "alive",
7587
+ // Alive, but spawned outside this streamer, so we hold no PTY for it. That
7588
+ // is precisely `detached` — and it is strictly more informative than the
7589
+ // `status: "idle"` above, which discovery is forced to report because it
7590
+ // cannot see the process's prompt state.
7591
+ lifecycle: "detached",
7592
+ lifecycleSource: "probe",
6494
7593
  projectPath: d.projectPath,
6495
7594
  projectName: d.projectName,
6496
7595
  branch: d.branch,
@@ -6505,7 +7604,7 @@ function discoveredToResponse(d, conversationId) {
6505
7604
  }
6506
7605
 
6507
7606
  // src/uploads.ts
6508
- import { randomBytes as randomBytes3 } from "crypto";
7607
+ import { randomBytes as randomBytes4 } from "crypto";
6509
7608
  import { mkdir as mkdir3, writeFile } from "fs/promises";
6510
7609
  import heicConvert from "heic-convert";
6511
7610
  import { join as join17 } from "path";
@@ -6539,7 +7638,7 @@ async function saveUploadFile(input) {
6539
7638
  mimeType = "image/jpeg";
6540
7639
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
6541
7640
  }
6542
- const id = `up_${randomBytes3(8).toString("hex")}`;
7641
+ const id = `up_${randomBytes4(8).toString("hex")}`;
6543
7642
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
6544
7643
  const dir = join17(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
6545
7644
  await mkdir3(dir, { recursive: true });
@@ -6573,21 +7672,46 @@ function extractCodexText(content) {
6573
7672
  return "";
6574
7673
  }).filter(Boolean).join("").trim();
6575
7674
  }
6576
- function normalizeCodexLineToClaudeShape(line) {
7675
+ var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
7676
+ function classifyCodexLine(line) {
6577
7677
  let entry;
6578
7678
  try {
6579
7679
  entry = JSON.parse(line);
6580
7680
  } catch {
6581
- return null;
7681
+ return { kind: "unknown", raw: line, reason: "line is not valid JSON" };
7682
+ }
7683
+ if (typeof entry.type !== "string" || !KNOWN_CODEX_TYPES.has(entry.type)) {
7684
+ return {
7685
+ kind: "unknown",
7686
+ raw: line,
7687
+ reason: `unrecognized rollout envelope type: ${String(entry.type)}`
7688
+ };
7689
+ }
7690
+ if (entry.type !== "response_item") {
7691
+ return { kind: "ignored", reason: `${entry.type} carries no chat content` };
6582
7692
  }
6583
- if (entry.type !== "response_item") return null;
6584
7693
  const payload = entry.payload;
6585
- if (payload?.type !== "message") return null;
7694
+ if (payload?.type !== "message") {
7695
+ return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
7696
+ }
6586
7697
  const role = payload.role;
6587
- if (role !== "user" && role !== "assistant") return null;
7698
+ if (role !== "user" && role !== "assistant") {
7699
+ return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
7700
+ }
6588
7701
  const text = extractCodexText(payload.content);
6589
- if (!text) return null;
6590
- if (role === "user" && isCodexInjectedContext(text)) return null;
7702
+ if (!text) {
7703
+ return { kind: "ignored", reason: "message has no extractable text" };
7704
+ }
7705
+ if (role === "user" && isCodexInjectedContext(text)) {
7706
+ return { kind: "ignored", reason: "synthetic injected context" };
7707
+ }
7708
+ return { kind: "message", line: buildClaudeShapedLine(entry, payload, role, text) };
7709
+ }
7710
+ function normalizeCodexLineToClaudeShape(line) {
7711
+ const result = classifyCodexLine(line);
7712
+ return result.kind === "message" ? result.line : null;
7713
+ }
7714
+ function buildClaudeShapedLine(entry, payload, role, text) {
6591
7715
  const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
6592
7716
  const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
6593
7717
  return JSON.stringify({
@@ -6643,13 +7767,13 @@ function hashPrefix(text) {
6643
7767
  }
6644
7768
 
6645
7769
  // src/utils/conversationEtag.ts
6646
- import { createHash as createHash3 } from "crypto";
7770
+ import { createHash as createHash4 } from "crypto";
6647
7771
  function computeConversationEtag({
6648
7772
  filePath,
6649
7773
  messageCount,
6650
7774
  timestamp: timestamp2
6651
7775
  }) {
6652
- const digest = createHash3("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
7776
+ const digest = createHash4("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
6653
7777
  return `"${digest}"`;
6654
7778
  }
6655
7779
 
@@ -6792,6 +7916,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
6792
7916
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
6793
7917
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
6794
7918
  var GRACE_MAX_DEFERS = 4;
7919
+ var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
7920
+ var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
6795
7921
  var RESUME_DISCOVERY_TIMEOUT_MS = 750;
6796
7922
  var DISCOVERY_TTL_MS = 15e3;
6797
7923
  var ADOPT_KILL_TIMEOUT_MS = 5e3;
@@ -6848,6 +7974,11 @@ var StreamerServer = class {
6848
7974
  // to pendingQuestions; mobile answers it by sending the option index via
6849
7975
  // /input { keys }. Cleared when the gate closes.
6850
7976
  pendingPermission = /* @__PURE__ */ new Map();
7977
+ // Content key (prompt + detail + options + cursor) of the permission gate
7978
+ // currently broadcast for a session — mirrors pendingQuestionKey so a PTY
7979
+ // repaint of the same gate doesn't re-broadcast on every tick. Cleared
7980
+ // alongside pendingPermission.
7981
+ pendingPermissionKey = /* @__PURE__ */ new Map();
6851
7982
  scanner = null;
6852
7983
  // Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
6853
7984
  // ConversationCache.open throw), or when config.scannerPersistent is false
@@ -6908,6 +8039,16 @@ var StreamerServer = class {
6908
8039
  defaultPermissionMode;
6909
8040
  defaultModel;
6910
8041
  defaultEffort;
8042
+ // Allowlisted Claude CLI flags + free-text escape hatch, applied to every
8043
+ // spawn. Resolved once at startup (flag → server.yaml), then mutated in place
8044
+ // by PUT /api/config/claude-flags so a change applies to the next session
8045
+ // without a restart.
8046
+ claudeFlags;
8047
+ claudeExtraArgs;
8048
+ // True when the values came from server.yaml (and so a write persists).
8049
+ // False when they were pinned by a CLI flag, mirroring the api-key rotate
8050
+ // contract: the write still takes effect in memory but won't survive restart.
8051
+ claudeFlagsPersistable;
6911
8052
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
6912
8053
  ptyGraceTimers = /* @__PURE__ */ new Map();
6913
8054
  // Consecutive grace-timer defers for a still-`running` session (see
@@ -6915,6 +8056,18 @@ var StreamerServer = class {
6915
8056
  ptyGraceDeferCounts = /* @__PURE__ */ new Map();
6916
8057
  // Map of sessionId → set of subscribed WS clients
6917
8058
  sessionSubscribers = /* @__PURE__ */ new Map();
8059
+ // sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
8060
+ // every provider; read only by the idle reaper. Entries are dropped when the
8061
+ // session leaves the runner (reap/exit/hold).
8062
+ lastAgentChunkAt = /* @__PURE__ */ new Map();
8063
+ // Recently accepted input idempotency keys (C4). A retried POST replays its
8064
+ // original outcome instead of submitting the prompt to the agent twice.
8065
+ idempotency = new IdempotencyStore();
8066
+ // sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
8067
+ // this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
8068
+ sessionLifecycles = /* @__PURE__ */ new Map();
8069
+ // Periodic sweep that releases PTYs no agent is using. Null until listen().
8070
+ idleReaperTimer = null;
6918
8071
  // Map of clientId → WS socket (populated by the "register" WS handshake)
6919
8072
  clientIdToWs = /* @__PURE__ */ new Map();
6920
8073
  // Reverse map for cleanup on close
@@ -6924,7 +8077,20 @@ var StreamerServer = class {
6924
8077
  projectsRepo = null;
6925
8078
  conversationsRepo = null;
6926
8079
  sessionsRepo = null;
8080
+ // Durable session registry (C1 Phase 2). Null when the cache DB failed to
8081
+ // open — persistence degrades to today's in-memory-only behaviour rather than
8082
+ // taking the server down with it, so every write goes through `?.`.
8083
+ managedSessionsRepo = null;
8084
+ // Identifies this streamer run. A registry row carrying a different id is a
8085
+ // session that outlived the process that started it.
8086
+ streamerInstanceId = randomUUID5();
6927
8087
  cacheMetadataRepo = null;
8088
+ // Push registration + delivery state (C7). Null when the cache DB failed to
8089
+ // open — registration then degrades to a no-op rather than 500ing.
8090
+ pushRepo = null;
8091
+ // Paired-device registry (C5). Null when the cache DB failed to open — auth
8092
+ // then falls back to the shared API key alone, which is the pre-C5 behaviour.
8093
+ devicesRepo = null;
6928
8094
  discoveryCache = null;
6929
8095
  cacheDir;
6930
8096
  tailSize;
@@ -6963,6 +8129,9 @@ var StreamerServer = class {
6963
8129
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
6964
8130
  this.defaultModel = config.defaultModel ?? "sonnet";
6965
8131
  this.defaultEffort = config.defaultEffort ?? "low";
8132
+ this.claudeFlagsPersistable = config.claudeFlags === void 0;
8133
+ this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
8134
+ this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
6966
8135
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
6967
8136
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
6968
8137
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -7087,6 +8256,7 @@ var StreamerServer = class {
7087
8256
  this.ptyManager = new LiveSessionManager({
7088
8257
  logger: getLogger("pty"),
7089
8258
  onOutput: (sessionId, data) => {
8259
+ this.lastAgentChunkAt.set(sessionId, Date.now());
7090
8260
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
7091
8261
  },
7092
8262
  onUserMessage: (sessionId, text, ts) => {
@@ -7115,6 +8285,17 @@ var StreamerServer = class {
7115
8285
  completedAt: session.completedAt,
7116
8286
  ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
7117
8287
  });
8288
+ this.managedSessionsRepo?.recordStatus(
8289
+ session.id,
8290
+ session.status,
8291
+ session.completedAt != null ? "exit" : "transition",
8292
+ {
8293
+ completedAt: session.completedAt,
8294
+ lastActivityAt: session.lastActivityAt ?? null,
8295
+ promptCount: session.promptCount,
8296
+ failureReason: session.failureReason ?? null
8297
+ }
8298
+ );
7118
8299
  if (session.status === "waiting_input" || session.status === "idle") {
7119
8300
  const filePath = this.sessionFileMap.get(session.id);
7120
8301
  if (filePath) {
@@ -7145,6 +8326,7 @@ var StreamerServer = class {
7145
8326
  this.cancelPendingQuestion(session.id);
7146
8327
  }
7147
8328
  this.pendingPermission.delete(session.id);
8329
+ this.pendingPermissionKey.delete(session.id);
7148
8330
  this.contendedSessions.delete(session.id);
7149
8331
  this.rememberSelfPtyEnded(session.id);
7150
8332
  }
@@ -7185,6 +8367,8 @@ var StreamerServer = class {
7185
8367
  localNoAuth: this.localNoAuth,
7186
8368
  logMenubarRequests: this.logMenubarRequests,
7187
8369
  rotateApiKey: () => this.rotateApiKey(),
8370
+ claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
8371
+ setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
7188
8372
  publicUrl: this.publicUrl,
7189
8373
  browseRoot: this.browseRoot,
7190
8374
  browserCors: this.browserCors,
@@ -7193,6 +8377,8 @@ var StreamerServer = class {
7193
8377
  wsHub: this.wsHub,
7194
8378
  cache: () => this.cache,
7195
8379
  cacheMonitor: () => this.cacheMonitor,
8380
+ pushRepo: () => this.pushRepo,
8381
+ devicesRepo: () => this.devicesRepo,
7196
8382
  projectsRepo: () => this.projectsRepo,
7197
8383
  conversationsRepo: () => this.conversationsRepo,
7198
8384
  sessionsRepo: () => this.sessionsRepo,
@@ -7226,7 +8412,9 @@ var StreamerServer = class {
7226
8412
  handleMkdir: (req, res) => this.handleMkdir(req, res),
7227
8413
  handleWsOpen: (ws) => {
7228
8414
  this.wsHub.addClient(ws);
7229
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
8415
+ const sessions = this.withReconciledLifecycle(
8416
+ this.sessionStore.list(this.ptyAttachedIds())
8417
+ );
7230
8418
  ws.send(JSON.stringify({ type: "session_list", sessions }));
7231
8419
  if (!this.currentWarmupState()) {
7232
8420
  ws.send(JSON.stringify({ type: "cache_ready" }));
@@ -7302,11 +8490,8 @@ var StreamerServer = class {
7302
8490
  this.clientIdToWs.delete(clientId);
7303
8491
  this.wsToClientId.delete(ws);
7304
8492
  }
7305
- for (const [sessionId, subscribers] of this.sessionSubscribers) {
8493
+ for (const subscribers of this.sessionSubscribers.values()) {
7306
8494
  subscribers.delete(ws);
7307
- if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
7308
- this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
7309
- }
7310
8495
  }
7311
8496
  },
7312
8497
  agentClient,
@@ -7369,7 +8554,7 @@ var StreamerServer = class {
7369
8554
  const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
7370
8555
  const payload = {
7371
8556
  type: "session_list",
7372
- sessions: this.sessionStore.list(this.ptyAttachedIds())
8557
+ sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
7373
8558
  };
7374
8559
  if (ws) {
7375
8560
  this.wsHub.unicast(ws, payload);
@@ -7377,6 +8562,29 @@ var StreamerServer = class {
7377
8562
  this.wsHub.broadcast(payload);
7378
8563
  }
7379
8564
  }
8565
+ /**
8566
+ * Overlay boot-reconciliation verdicts onto session responses.
8567
+ *
8568
+ * A session left by a previous run is not in the in-memory store, so
8569
+ * SessionStore cannot classify it — it only ever sees what this run spawned.
8570
+ * Discovery may still surface the process, in which case the reconciler knows
8571
+ * strictly more about it than discovery does: it can tell `detached` (alive
8572
+ * and confirmed ours) from `orphaned` (alive but identity unconfirmed), which
8573
+ * a pid enumeration alone cannot.
8574
+ *
8575
+ * Only applied when the session is NOT live here: a session this run owns has
8576
+ * an authoritative lifecycle already, and a stale verdict must never override
8577
+ * it.
8578
+ */
8579
+ withReconciledLifecycle(sessions) {
8580
+ if (this.sessionLifecycles.size === 0) return sessions;
8581
+ return sessions.map((s) => {
8582
+ if (s.ptyAttached) return s;
8583
+ const verdict = this.sessionLifecycles.get(s.id);
8584
+ if (!verdict) return s;
8585
+ return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
8586
+ });
8587
+ }
7380
8588
  addSessionSubscriber(sessionId, ws) {
7381
8589
  let subs = this.sessionSubscribers.get(sessionId);
7382
8590
  if (!subs) {
@@ -7391,6 +8599,181 @@ var StreamerServer = class {
7391
8599
  }
7392
8600
  this.ptyGraceDeferCounts.delete(sessionId);
7393
8601
  }
8602
+ /**
8603
+ * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
8604
+ *
8605
+ * Agents already outlive the streamer today on the crash and dev-takeover
8606
+ * paths, which exit without reaching ptyManager.dispose() — they are just
8607
+ * invisible when they do, because nothing recorded that they existed. This
8608
+ * turns those rows into an explicit verdict per session.
8609
+ *
8610
+ * Read-only with respect to processes: it probes and classifies, and never
8611
+ * signals anything. `orphaned` is a report, not a cleanup trigger.
8612
+ */
8613
+ async reconcilePreviousSessions() {
8614
+ if (!this.managedSessionsRepo) return [];
8615
+ let verdicts = [];
8616
+ try {
8617
+ const rows = this.managedSessionsRepo.listNonTerminal();
8618
+ if (rows.length === 0) return [];
8619
+ verdicts = await reconcileSessions(
8620
+ rows,
8621
+ { isPidAlive, getProcessArgs },
8622
+ this.streamerInstanceId
8623
+ );
8624
+ for (const v of verdicts) {
8625
+ this.sessionLifecycles.set(v.sessionId, v.lifecycle);
8626
+ if (v.lifecycle === "completed" || v.lifecycle === "failed") {
8627
+ this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
8628
+ completedAt: /* @__PURE__ */ new Date()
8629
+ });
8630
+ }
8631
+ }
8632
+ this.log.info(`[reconcile] classified ${verdicts.length} session(s) from previous runs`, {
8633
+ event: "sessions.reconciled",
8634
+ counts: verdicts.reduce((acc, v) => {
8635
+ acc[v.lifecycle] = (acc[v.lifecycle] ?? 0) + 1;
8636
+ return acc;
8637
+ }, {})
8638
+ });
8639
+ } catch (err) {
8640
+ this.log.warn("[reconcile] failed to reconcile previous sessions", {
8641
+ event: "sessions.reconcile_failed",
8642
+ err
8643
+ });
8644
+ }
8645
+ return verdicts;
8646
+ }
8647
+ /**
8648
+ * Pick a token guaranteed to appear in the spawned process's argv, for the
8649
+ * reconciler's pid-reuse guard.
8650
+ *
8651
+ * Claude always passes the session id (`--resume <id>` or `--session-id
8652
+ * <id>`), so it is both present and unique. Codex only does on *resume*
8653
+ * (`codex resume <id>`); a fresh Codex spawn is `codex --cd <path>
8654
+ * --no-alt-screen` with no id at all, because the rollout id does not exist
8655
+ * until the CLI writes it. boundConversationId is what distinguishes the two:
8656
+ * it is set once that rollout has been discovered.
8657
+ */
8658
+ spawnArgvToken(session) {
8659
+ if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
8660
+ return session.boundConversationId ?? session.projectPath;
8661
+ }
8662
+ /**
8663
+ * Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
8664
+ *
8665
+ * Called at each addManaged() site rather than inside SessionStore, because
8666
+ * the store is a pure in-memory structure with no DB dependency and adding
8667
+ * one would drag persistence into every unit test that touches it.
8668
+ *
8669
+ * Best-effort by design: a failed registry write must never break session
8670
+ * start. Losing a row costs post-restart *visibility* for that session, which
8671
+ * is strictly better than refusing to run the agent at all.
8672
+ */
8673
+ recordSessionSpawn(session) {
8674
+ if (!this.managedSessionsRepo) return;
8675
+ try {
8676
+ const pid = this.ptyManager.getPid(session.id);
8677
+ this.managedSessionsRepo.recordSpawn({
8678
+ session,
8679
+ pid,
8680
+ // Identity guard against pid reuse: the reconciler requires this token
8681
+ // to appear in the live process's argv before it will claim the pid is
8682
+ // still ours (docs/architecture/2026-07-24-durable-session-runtime.md).
8683
+ //
8684
+ // Reading the real argv here would cost an async `ps` per session start
8685
+ // on a path the user is waiting on, so we record a token we already
8686
+ // know is in it. Claude always carries the session id (`--resume <id>`
8687
+ // on resume, `--session-id <id>` on fresh). A *fresh* Codex spawn does
8688
+ // not — its argv is only `--cd <path> --no-alt-screen`, because the
8689
+ // rollout id doesn't exist yet — so fall back to the project path,
8690
+ // which is present in every spawn path for both providers.
8691
+ //
8692
+ // The fallback is weaker: two sessions in one project share a token, so
8693
+ // it proves "a process of ours in this project" rather than "this exact
8694
+ // session". It still rejects an unrelated recycled pid, which is the
8695
+ // failure being guarded against.
8696
+ //
8697
+ // Note the Codex id is always *set* (a local placeholder) — it is just
8698
+ // not in the process's argv — so the choice keys off the provider, not
8699
+ // off the id being null.
8700
+ cmdline: pid != null ? this.spawnArgvToken(session) : null,
8701
+ streamerInstanceId: this.streamerInstanceId
8702
+ });
8703
+ } catch (err) {
8704
+ this.log.warn("[registry] failed to record session spawn", {
8705
+ event: "registry.spawn_write_failed",
8706
+ sessionId: session.id,
8707
+ err
8708
+ });
8709
+ }
8710
+ }
8711
+ /**
8712
+ * Stamp every live session as ended-by-shutdown before dispose() kills it.
8713
+ *
8714
+ * PTYManager.dispose() signals each child directly and fires no
8715
+ * onStatusChange, so the registry would otherwise keep rows sitting at
8716
+ * `running` forever and the next boot could not tell a deliberate restart
8717
+ * from a crash. Recording `shutdown` as the status source makes that
8718
+ * distinction explicit rather than inferred.
8719
+ *
8720
+ * Not a `completed_at` write for the agent's own work — the agent did not
8721
+ * finish, we stopped it — but the session is genuinely terminal, so it must
8722
+ * leave the reconciler's probe set.
8723
+ */
8724
+ recordShutdownState() {
8725
+ if (!this.managedSessionsRepo) return;
8726
+ const now = /* @__PURE__ */ new Date();
8727
+ for (const session of this.ptyManager.listSessions()) {
8728
+ try {
8729
+ this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
8730
+ completedAt: now,
8731
+ lastActivityAt: session.lastActivityAt ?? null,
8732
+ promptCount: session.promptCount
8733
+ });
8734
+ } catch (err) {
8735
+ this.log.warn("[registry] failed to record shutdown state", {
8736
+ event: "registry.shutdown_write_failed",
8737
+ sessionId: session.id,
8738
+ err
8739
+ });
8740
+ }
8741
+ }
8742
+ }
8743
+ /**
8744
+ * Release PTYs whose agent has been silent past IDLE_REAP_AFTER_MS.
8745
+ *
8746
+ * This is the bound that lets handleWsClose stop arming kill timers. The
8747
+ * distinction that matters: the old timer measured how long nobody was
8748
+ * *watching*, which is uncorrelated with whether work is in flight. This
8749
+ * measures how long the *agent* has produced nothing, and only ever considers
8750
+ * sessions that are already settled — a `running` PTY is skipped regardless of
8751
+ * age, so a long silent turn is never interrupted.
8752
+ *
8753
+ * Exposed (not private) so tests can drive one sweep deterministically instead
8754
+ * of waiting on the interval.
8755
+ */
8756
+ reapIdleSessions(now = Date.now()) {
8757
+ const reaped = [];
8758
+ for (const session of this.ptyManager.listSessions()) {
8759
+ if (session.status === "running") continue;
8760
+ const lastActive = this.lastAgentChunkAt.get(session.id) ?? session.lastActivityAt?.getTime() ?? session.startedAt.getTime();
8761
+ if (now - lastActive < IDLE_REAP_AFTER_MS) continue;
8762
+ this.log.info(
8763
+ `[reap] releasing idle PTY for ${session.id} (idle ${Math.round((now - lastActive) / 6e4)}m)`,
8764
+ { sessionId: session.id, event: "pty.idle_reap", idleMs: now - lastActive },
8765
+ "pino"
8766
+ );
8767
+ this.ptyManager.putOnHold(session.id);
8768
+ this.lastAgentChunkAt.delete(session.id);
8769
+ this.idempotency.clear(session.id);
8770
+ this.sessionSubscribers.delete(session.id);
8771
+ reaped.push(session.id);
8772
+ const held = this.sessionStore.get(session.id, this.ptyAttachedIds());
8773
+ if (held) this.wsHub.broadcast({ type: "session_update", session: held });
8774
+ }
8775
+ return reaped;
8776
+ }
7394
8777
  startGraceTimer(sessionId, delayMs) {
7395
8778
  const existing = this.ptyGraceTimers.get(sessionId);
7396
8779
  if (existing) clearTimeout(existing);
@@ -7485,6 +8868,8 @@ var StreamerServer = class {
7485
8868
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
7486
8869
  }
7487
8870
  await this.bindWithRetry(port);
8871
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
8872
+ this.idleReaperTimer.unref?.();
7488
8873
  const warmUp = new Promise((resolveWarm) => {
7489
8874
  {
7490
8875
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -7518,7 +8903,11 @@ var StreamerServer = class {
7518
8903
  this.projectsRepo = new ProjectsRepository(db);
7519
8904
  this.conversationsRepo = new ConversationsRepository(this.cache);
7520
8905
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
8906
+ this.managedSessionsRepo = new ManagedSessionsRepository(db);
8907
+ void this.reconcilePreviousSessions();
7521
8908
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
8909
+ this.pushRepo = new PushRepository(db);
8910
+ this.devicesRepo = new DevicesRepository(db);
7522
8911
  this.cacheMonitor = new CacheIntegrityMonitor(
7523
8912
  this.cache,
7524
8913
  this.wsHub,
@@ -7736,6 +9125,12 @@ var StreamerServer = class {
7736
9125
  async close() {
7737
9126
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
7738
9127
  this.ptyGraceTimers.clear();
9128
+ if (this.idleReaperTimer) {
9129
+ clearInterval(this.idleReaperTimer);
9130
+ this.idleReaperTimer = null;
9131
+ }
9132
+ this.lastAgentChunkAt.clear();
9133
+ this.recordShutdownState();
7739
9134
  this.markScannerStaleDebounced.cancel();
7740
9135
  await Promise.all([...this.inFlightCacheWrites]);
7741
9136
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -7825,12 +9220,28 @@ var StreamerServer = class {
7825
9220
  ip,
7826
9221
  ts
7827
9222
  });
9223
+ let device = null;
9224
+ try {
9225
+ const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
9226
+ const preset = body?.readOnly === true ? "read-only" : "full";
9227
+ device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
9228
+ } catch (err) {
9229
+ this.log.warn("[pair] device registration failed; pairing continues", {
9230
+ event: "pair.device_register_failed",
9231
+ err
9232
+ });
9233
+ }
7828
9234
  json(res, 200, {
7829
9235
  ciphertext: sealed.ciphertext,
7830
9236
  nonce: sealed.nonce,
7831
9237
  ephemeralPublicKey: sealed.ephemeralPublicKey,
7832
9238
  publicUrl: this.publicUrl,
7833
- machineName: hostname2()
9239
+ machineName: hostname2(),
9240
+ ...device && {
9241
+ deviceId: device.deviceId,
9242
+ deviceToken: device.deviceToken,
9243
+ capabilities: device.capabilities
9244
+ }
7834
9245
  });
7835
9246
  }
7836
9247
  rotateApiKey() {
@@ -7847,6 +9258,48 @@ var StreamerServer = class {
7847
9258
  });
7848
9259
  return { newKey, persisted };
7849
9260
  }
9261
+ getClaudeFlagsConfig() {
9262
+ return {
9263
+ registry: CLAUDE_FLAGS,
9264
+ values: this.claudeFlags,
9265
+ extraArgs: this.claudeExtraArgs ?? null,
9266
+ persisted: this.claudeFlagsPersistable
9267
+ };
9268
+ }
9269
+ /**
9270
+ * Replace the per-server flag set. Applies to the NEXT spawn — a live PTY
9271
+ * keeps the argv it was started with.
9272
+ *
9273
+ * Mirrors rotateApiKey(): when the values were pinned by a CLI flag we still
9274
+ * apply them in memory but skip the server.yaml write, because the flag would
9275
+ * win again on restart and silently revert them.
9276
+ *
9277
+ * Logged with old→new at info level on purpose: this can disable the
9278
+ * permission prompts entirely, so it needs a forensic trail.
9279
+ */
9280
+ setClaudeFlagsConfig(values, extraArgs) {
9281
+ const safe = validateFlagValues(values);
9282
+ const previous = { values: this.claudeFlags, extraArgs: this.claudeExtraArgs };
9283
+ if (this.claudeFlagsPersistable) {
9284
+ setClaudeExtraArgs(extraArgs);
9285
+ setClaudeFlags(safe);
9286
+ }
9287
+ this.claudeFlags = safe;
9288
+ this.claudeExtraArgs = extraArgs?.trim() ? extraArgs.trim() : void 0;
9289
+ this.log.info("Claude CLI flags updated", {
9290
+ event: "config.claude_flags_updated",
9291
+ persisted: this.claudeFlagsPersistable,
9292
+ previousValues: previous.values,
9293
+ previousExtraArgs: previous.extraArgs ?? null,
9294
+ values: this.claudeFlags,
9295
+ extraArgs: this.claudeExtraArgs ?? null
9296
+ });
9297
+ return {
9298
+ values: this.claudeFlags,
9299
+ extraArgs: this.claudeExtraArgs ?? null,
9300
+ persisted: this.claudeFlagsPersistable
9301
+ };
9302
+ }
7850
9303
  checkRateLimit(map, key, limit, windowMs) {
7851
9304
  const now = Date.now();
7852
9305
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -8864,7 +10317,13 @@ var StreamerServer = class {
8864
10317
  }
8865
10318
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
8866
10319
  if (!hasPaginationParams) {
8867
- json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
10320
+ json(
10321
+ res,
10322
+ 200,
10323
+ this.withExternalActivity(
10324
+ this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
10325
+ )
10326
+ );
8868
10327
  return;
8869
10328
  }
8870
10329
  const parsed = parseSessionListQuery(url);
@@ -8874,7 +10333,7 @@ var StreamerServer = class {
8874
10333
  }
8875
10334
  try {
8876
10335
  const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
8877
- page.sessions = this.withExternalActivity(page.sessions);
10336
+ page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
8878
10337
  json(res, 200, page);
8879
10338
  } catch (err) {
8880
10339
  if (err instanceof Error && err.message === "INVALID_CURSOR") {
@@ -8891,6 +10350,9 @@ var StreamerServer = class {
8891
10350
  if (!existsSync10(session.projectPath)) {
8892
10351
  session.failureReason = `Project directory not found: ${session.projectPath}`;
8893
10352
  }
10353
+ const reconciled = this.withReconciledLifecycle([session])[0];
10354
+ session.lifecycle = reconciled.lifecycle;
10355
+ session.lifecycleSource = reconciled.lifecycleSource;
8894
10356
  if (this.ptyManager.hasSession(sessionId)) {
8895
10357
  try {
8896
10358
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -8988,10 +10450,13 @@ var StreamerServer = class {
8988
10450
  projectName: body.projectName,
8989
10451
  branch: body.branch,
8990
10452
  permissionMode: this.defaultPermissionMode,
10453
+ claudeFlags: this.claudeFlags,
10454
+ claudeExtraArgs: this.claudeExtraArgs,
8991
10455
  model: this.defaultModel,
8992
10456
  effort: this.defaultEffort
8993
10457
  });
8994
10458
  this.sessionStore.addManaged(session);
10459
+ this.recordSessionSpawn(session);
8995
10460
  void this.watchConversationFile(sessionId);
8996
10461
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
8997
10462
  this.broadcastOrUnicastSessionList(req);
@@ -9064,6 +10529,24 @@ var StreamerServer = class {
9064
10529
  }
9065
10530
  const body = await readBody(req);
9066
10531
  const { input, keys } = body;
10532
+ let idempotencyKey;
10533
+ try {
10534
+ idempotencyKey = readIdempotencyKey(body);
10535
+ } catch (err) {
10536
+ json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
10537
+ return;
10538
+ }
10539
+ if (idempotencyKey) {
10540
+ const replayed = this.idempotency.get(sessionId, idempotencyKey);
10541
+ if (replayed) {
10542
+ this.log.info(`[input.replay] ${sessionId.slice(0, 8)} duplicate idempotencyKey`, {
10543
+ event: "input.idempotent_replay",
10544
+ sessionId
10545
+ });
10546
+ json(res, replayed.status, replayed.body);
10547
+ return;
10548
+ }
10549
+ }
9067
10550
  if (typeof keys === "string") {
9068
10551
  try {
9069
10552
  this.ptyManager.sendKeys(sessionId, keys);
@@ -9071,7 +10554,9 @@ var StreamerServer = class {
9071
10554
  if (updated) {
9072
10555
  this.wsHub.broadcast({ type: "session_update", session: updated });
9073
10556
  }
9074
- json(res, 200, { ok: true });
10557
+ const result = { status: 200, body: { ok: true } };
10558
+ if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
10559
+ json(res, result.status, result.body);
9075
10560
  } catch (err) {
9076
10561
  const message = err instanceof Error ? err.message : "Failed to send keys";
9077
10562
  json(res, 400, { error: message });
@@ -9109,7 +10594,9 @@ var StreamerServer = class {
9109
10594
  });
9110
10595
  });
9111
10596
  }
9112
- json(res, 200, { ok: true });
10597
+ const result = { status: 200, body: { ok: true } };
10598
+ if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
10599
+ json(res, result.status, result.body);
9113
10600
  } catch (err) {
9114
10601
  const message = err instanceof Error ? err.message : "Failed to send input";
9115
10602
  json(res, 400, { error: message });
@@ -9181,10 +10668,14 @@ var StreamerServer = class {
9181
10668
  if (gate === null) {
9182
10669
  if (!this.pendingPermission.has(sessionId)) return;
9183
10670
  this.pendingPermission.delete(sessionId);
10671
+ this.pendingPermissionKey.delete(sessionId);
9184
10672
  this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
9185
10673
  return;
9186
10674
  }
10675
+ const key = permissionContentKey(gate);
10676
+ if (this.pendingPermissionKey.get(sessionId) === key) return;
9187
10677
  this.pendingPermission.set(sessionId, gate);
10678
+ this.pendingPermissionKey.set(sessionId, key);
9188
10679
  const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
9189
10680
  this.log.info(
9190
10681
  `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
@@ -9410,10 +10901,13 @@ var StreamerServer = class {
9410
10901
  projectName,
9411
10902
  branch,
9412
10903
  permissionMode: this.defaultPermissionMode,
10904
+ claudeFlags: this.claudeFlags,
10905
+ claudeExtraArgs: this.claudeExtraArgs,
9413
10906
  model: this.defaultModel,
9414
10907
  effort: this.defaultEffort
9415
10908
  });
9416
10909
  this.sessionStore.addManaged(session);
10910
+ this.recordSessionSpawn(session);
9417
10911
  void this.watchConversationFile(session.id);
9418
10912
  this.wsHub.broadcast({
9419
10913
  type: "session_list",
@@ -9483,10 +10977,13 @@ var StreamerServer = class {
9483
10977
  projectName: body.projectName,
9484
10978
  systemPrompt: systemPromptParts.join("\n"),
9485
10979
  permissionMode: this.defaultPermissionMode,
10980
+ claudeFlags: this.claudeFlags,
10981
+ claudeExtraArgs: this.claudeExtraArgs,
9486
10982
  model: this.defaultModel,
9487
10983
  effort: this.defaultEffort
9488
10984
  });
9489
10985
  this.sessionStore.addManaged(session);
10986
+ this.recordSessionSpawn(session);
9490
10987
  const readyOrFailed = new Promise((resolve2) => {
9491
10988
  const handler = (status) => {
9492
10989
  if (status === "waiting_input" || status === "idle") {
@@ -10007,6 +11504,7 @@ export {
10007
11504
  SessionStore,
10008
11505
  StreamerServer,
10009
11506
  WSHub,
11507
+ confidenceForSource,
10010
11508
  createAgentClient,
10011
11509
  createConversationWriter,
10012
11510
  createPool,