@threadbase-sh/streamer 1.29.1 → 1.30.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
@@ -164,7 +164,7 @@ function verifySignature(rawBody, signature, secret) {
164
164
  }
165
165
  }
166
166
  function isWithinSkew(timestampHeader, skewSeconds) {
167
- if (!timestampHeader) return true;
167
+ if (!timestampHeader) return false;
168
168
  const t = Number(timestampHeader);
169
169
  if (!Number.isFinite(t)) return false;
170
170
  const now = Math.floor(Date.now() / 1e3);
@@ -680,6 +680,69 @@ function isProviderResumable(_provider, availabilityResumable) {
680
680
  return availabilityResumable;
681
681
  }
682
682
 
683
+ // src/services/questions/codexGateAnswers.ts
684
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
685
+ import { homedir as homedir3 } from "os";
686
+ import { dirname as dirname3, join as join5 } from "path";
687
+ function gateAnswersPath() {
688
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? join5(homedir3(), ".threadbase");
689
+ return join5(dir, "gate-answers.json");
690
+ }
691
+ function loadGateAnswers() {
692
+ try {
693
+ const parsed = JSON.parse(readFileSync3(gateAnswersPath(), "utf-8"));
694
+ return parsed && typeof parsed === "object" ? parsed : {};
695
+ } catch {
696
+ return {};
697
+ }
698
+ }
699
+ function saveGateAnswer(key, value) {
700
+ const path = gateAnswersPath();
701
+ mkdirSync2(dirname3(path), { recursive: true });
702
+ writeFileSync2(path, `${JSON.stringify({ ...loadGateAnswers(), [key]: value }, null, 2)}
703
+ `);
704
+ }
705
+ function rememberedGateDigit(gate) {
706
+ const answers = loadGateAnswers();
707
+ if (gate === "hooks") {
708
+ if (answers.codexHooksGate === "trust_all") return "2";
709
+ if (answers.codexHooksGate === "continue_untrusted") return "3";
710
+ return null;
711
+ }
712
+ return answers.codexTrustGate === "yes" ? "1" : null;
713
+ }
714
+
715
+ // src/utils/debounce.ts
716
+ function debounce(fn, waitMs) {
717
+ let timer = null;
718
+ let lastArgs = null;
719
+ const run2 = () => {
720
+ timer = null;
721
+ if (lastArgs) {
722
+ const args = lastArgs;
723
+ lastArgs = null;
724
+ fn(...args);
725
+ }
726
+ };
727
+ const debounced = (...args) => {
728
+ lastArgs = args;
729
+ if (timer) clearTimeout(timer);
730
+ timer = setTimeout(run2, waitMs);
731
+ };
732
+ debounced.cancel = () => {
733
+ if (timer) clearTimeout(timer);
734
+ timer = null;
735
+ lastArgs = null;
736
+ };
737
+ debounced.flush = () => {
738
+ if (timer) {
739
+ clearTimeout(timer);
740
+ run2();
741
+ }
742
+ };
743
+ return debounced;
744
+ }
745
+
683
746
  // src/codex-pty-runner.ts
684
747
  var OUTPUT_BUFFER_MAX = 65536;
685
748
  var PTY_COLS = 120;
@@ -687,6 +750,9 @@ var PTY_ROWS = 40;
687
750
  var SCREEN_SCROLLBACK = 1e3;
688
751
  var CODEX_PROMPT_READY_TEXT = "Ready";
689
752
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
753
+ var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
754
+ var QUIET_DETECT_MS = 500;
755
+ var CODEX_READY_FALLBACK_MS = 8e3;
690
756
  var SUBMIT_BYTES = "\r";
691
757
  var CODEX_SUBMIT_DELAY_MS = 16;
692
758
  function digestBytes(s) {
@@ -694,6 +760,40 @@ function digestBytes(s) {
694
760
  if (escaped.length <= 200) return escaped;
695
761
  return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
696
762
  }
763
+ function gateCard(gate, lines) {
764
+ if (gate === "hooks") {
765
+ const countLine = lines.find((l) => /new or changed/i.test(l))?.trim();
766
+ return {
767
+ prompt: [
768
+ "Hooks need review",
769
+ countLine,
770
+ "Hooks can run outside the sandbox after you trust them."
771
+ ].filter(Boolean).join(" \u2014 "),
772
+ options: [
773
+ { index: 2, label: "Trust all and continue", answerKeys: "2\r" },
774
+ { index: 3, label: "Continue without trusting (hooks won't run)", answerKeys: "3\r" },
775
+ {
776
+ index: 4,
777
+ label: "Trust all and continue (remember for all projects)",
778
+ answerKeys: "4\r"
779
+ },
780
+ {
781
+ index: 5,
782
+ label: "Continue without trusting (remember for all projects)",
783
+ answerKeys: "5\r"
784
+ }
785
+ ]
786
+ };
787
+ }
788
+ return {
789
+ prompt: lines.find((l) => CODEX_TRUST_GATE_REGEX.test(l))?.trim() ?? "Do you trust the contents of this directory?",
790
+ options: [
791
+ { index: 1, label: "Yes, continue", answerKeys: "1\r" },
792
+ { index: 2, label: "No, quit", answerKeys: "2\r" },
793
+ { index: 3, label: "Yes, continue (remember for all projects)", answerKeys: "3\r" }
794
+ ]
795
+ };
796
+ }
697
797
  var pty = null;
698
798
  async function loadPty() {
699
799
  if (pty) return pty;
@@ -720,8 +820,8 @@ var CodexPtyRunner = class {
720
820
  onOutput;
721
821
  onStatusChange;
722
822
  onReady;
723
- // Accepted for shape-compatibility with PTYManagerOptions; Codex has no
724
- // detected equivalent yet (Phase 0) never invoked.
823
+ // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
824
+ // as question cards; null dismisses the card once the gate leaves the screen.
725
825
  onPermissionChange;
726
826
  onLiveQuestion;
727
827
  onLiveQuestionGone;
@@ -732,8 +832,18 @@ var CodexPtyRunner = class {
732
832
  // Inputs received via sendInput() while the session was still pendingReady.
733
833
  // Flushed in arrival order once Codex reaches Ready.
734
834
  queuedInputs = /* @__PURE__ */ new Map();
735
- // Per-session debounce so the directory-trust gate's \r is only written once.
736
- trustGateAnswered = /* @__PURE__ */ new Set();
835
+ // Gate currently on a session's screen (card broadcast, unanswered). While
836
+ // set, queued-input flushes are held — a flushed digit would CONFIRM a
837
+ // dialog option — and sendKeys() intercepts remember-variant digits.
838
+ openGate = /* @__PURE__ */ new Map();
839
+ // `${sessionId}:${gate}` once a gate has been actioned (auto-answered or
840
+ // card broadcast) — dedupes repaints of the same dialog.
841
+ gateActioned = /* @__PURE__ */ new Set();
842
+ // Per-session trailing debounce re-armed on every chunk; on quiet, re-runs
843
+ // screen detection so a blocked/truncated boot still reaches ready.
844
+ quietCheckers = /* @__PURE__ */ new Map();
845
+ // Per-session flat backstop from spawn (CODEX_READY_FALLBACK_MS).
846
+ readyFallbackTimers = /* @__PURE__ */ new Map();
737
847
  // In-flight start()/startFresh() calls keyed by sessionId. A second
738
848
  // concurrent resume for the same session (double-tap, client retry) awaits
739
849
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -792,6 +902,7 @@ var CodexPtyRunner = class {
792
902
  };
793
903
  this.sessions.set(sessionId, session);
794
904
  this.pendingReady.add(sessionId);
905
+ this.armReadyFallback(sessionId);
795
906
  proc.onData((data) => {
796
907
  this.handleOutput(sessionId, data);
797
908
  });
@@ -837,6 +948,7 @@ var CodexPtyRunner = class {
837
948
  };
838
949
  this.sessions.set(sessionId, session);
839
950
  this.pendingReady.add(sessionId);
951
+ this.armReadyFallback(sessionId);
840
952
  proc.onData((data) => {
841
953
  this.handleOutput(sessionId, data);
842
954
  });
@@ -846,6 +958,21 @@ var CodexPtyRunner = class {
846
958
  });
847
959
  return toPublicSession(session);
848
960
  }
961
+ // Flat backstop: if neither the "Ready" marker nor the quiet-checker settled
962
+ // the session within CODEX_READY_FALLBACK_MS of spawn, mark it ready anyway
963
+ // so start requests resolve and mobile can watch the boot live. unref() so a
964
+ // pending timer never holds the process open.
965
+ armReadyFallback(sessionId) {
966
+ const timer = setTimeout(() => {
967
+ this.readyFallbackTimers.delete(sessionId);
968
+ const session = this.sessions.get(sessionId);
969
+ if (session?.status === "running" && this.pendingReady.has(sessionId)) {
970
+ this.markReady(sessionId, session, "fallback:timeout");
971
+ }
972
+ }, CODEX_READY_FALLBACK_MS);
973
+ timer.unref?.();
974
+ this.readyFallbackTimers.set(sessionId, timer);
975
+ }
849
976
  // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
850
977
  sendKeys(sessionId, keys) {
851
978
  const session = this.sessions.get(sessionId);
@@ -857,13 +984,47 @@ var CodexPtyRunner = class {
857
984
  session.status = "running";
858
985
  this.onStatusChange?.(toPublicSession(session));
859
986
  }
987
+ const gate = this.openGate.get(sessionId);
988
+ const digit = gate ? /^([0-9])\r?$/.exec(keys)?.[1] : void 0;
989
+ const out = gate && digit ? this.resolveGateAnswer(sessionId, gate, digit) : keys;
860
990
  this.log.info(
861
- `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
862
- { event: "codex.keys_write", sessionId, byteLen: keys.length }
991
+ `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${out.length} digest=${digestBytes(out)}`,
992
+ { event: "codex.keys_write", sessionId, byteLen: out.length }
863
993
  );
864
- session.process.write(keys);
994
+ session.process.write(out);
865
995
  session.lastActivityAt = /* @__PURE__ */ new Date();
866
996
  }
997
+ // Map a gate-card digit to the PTY bytes that answer the real dialog,
998
+ // persisting the choice when the digit was a synthetic "remember for all
999
+ // projects" option (those numbers don't exist on the actual dialog and must
1000
+ // never reach codex). The trailing \r mobile sends is dropped: a digit alone
1001
+ // selects AND confirms (live-probe verified), and a stray Enter would land
1002
+ // on whatever screen follows.
1003
+ resolveGateAnswer(sessionId, gate, digit) {
1004
+ let real = digit;
1005
+ let remembered = false;
1006
+ if (gate === "hooks" && digit === "4") {
1007
+ saveGateAnswer("codexHooksGate", "trust_all");
1008
+ real = "2";
1009
+ remembered = true;
1010
+ } else if (gate === "hooks" && digit === "5") {
1011
+ saveGateAnswer("codexHooksGate", "continue_untrusted");
1012
+ real = "3";
1013
+ remembered = true;
1014
+ } else if (gate === "trust" && digit === "3") {
1015
+ saveGateAnswer("codexTrustGate", "yes");
1016
+ real = "1";
1017
+ remembered = true;
1018
+ }
1019
+ this.log.info(`[codex.gate_answer] ${sessionId.slice(0, 8)} ${gate} digit=${real}`, {
1020
+ event: "codex.gate_answer",
1021
+ sessionId,
1022
+ gate,
1023
+ digit: real,
1024
+ remembered
1025
+ });
1026
+ return real;
1027
+ }
867
1028
  sendInput(sessionId, input) {
868
1029
  const session = this.sessions.get(sessionId);
869
1030
  if (!session) throw new Error(`Session not found: ${sessionId}`);
@@ -933,8 +1094,12 @@ var CodexPtyRunner = class {
933
1094
  }, CODEX_SUBMIT_DELAY_MS);
934
1095
  }
935
1096
  // Drain any inputs sent while the session was still pendingReady, writing
936
- // them in arrival order now that Codex is Ready.
1097
+ // them in arrival order now that Codex is Ready. No-op while a gate dialog
1098
+ // is open (a flushed digit would confirm a dialog option) or while still
1099
+ // pendingReady (markReady drains it) — the gate-close path re-drives it for
1100
+ // the ready-with-gate-open case.
937
1101
  flushQueuedInputs(sessionId) {
1102
+ if (this.openGate.has(sessionId) || this.pendingReady.has(sessionId)) return;
938
1103
  const queue = this.queuedInputs.get(sessionId);
939
1104
  if (!queue || queue.length === 0) return;
940
1105
  this.queuedInputs.delete(sessionId);
@@ -979,7 +1144,7 @@ var CodexPtyRunner = class {
979
1144
  if (!session) return;
980
1145
  this.pendingReady.delete(sessionId);
981
1146
  this.queuedInputs.delete(sessionId);
982
- this.trustGateAnswered.delete(sessionId);
1147
+ this.clearSessionDetectors(sessionId);
983
1148
  try {
984
1149
  session.process.kill("SIGINT");
985
1150
  } catch {
@@ -990,6 +1155,21 @@ var CodexPtyRunner = class {
990
1155
  this.sessions.delete(sessionId);
991
1156
  this.onStatusChange?.(toPublicSession(session));
992
1157
  }
1158
+ // Drop a session's detection state: quiet-checker, ready-fallback timer,
1159
+ // gate bookkeeping — and dismiss a still-open gate card so mobile doesn't
1160
+ // keep rendering a question for a dead PTY.
1161
+ clearSessionDetectors(sessionId) {
1162
+ this.quietCheckers.get(sessionId)?.cancel();
1163
+ this.quietCheckers.delete(sessionId);
1164
+ const timer = this.readyFallbackTimers.get(sessionId);
1165
+ if (timer) clearTimeout(timer);
1166
+ this.readyFallbackTimers.delete(sessionId);
1167
+ if (this.openGate.delete(sessionId)) {
1168
+ this.onPermissionChange?.(sessionId, null);
1169
+ }
1170
+ this.gateActioned.delete(`${sessionId}:hooks`);
1171
+ this.gateActioned.delete(`${sessionId}:trust`);
1172
+ }
993
1173
  getOutput(sessionId) {
994
1174
  const session = this.sessions.get(sessionId);
995
1175
  if (!session) throw new Error(`Session not found: ${sessionId}`);
@@ -1029,10 +1209,19 @@ var CodexPtyRunner = class {
1029
1209
  }
1030
1210
  session.screen.dispose();
1031
1211
  }
1212
+ for (const sessionId of Array.from(this.quietCheckers.keys())) {
1213
+ this.clearSessionDetectors(sessionId);
1214
+ }
1215
+ for (const timer of this.readyFallbackTimers.values()) {
1216
+ clearTimeout(timer);
1217
+ }
1032
1218
  this.sessions.clear();
1033
1219
  this.pendingReady.clear();
1034
1220
  this.queuedInputs.clear();
1035
- this.trustGateAnswered.clear();
1221
+ this.openGate.clear();
1222
+ this.gateActioned.clear();
1223
+ this.quietCheckers.clear();
1224
+ this.readyFallbackTimers.clear();
1036
1225
  }
1037
1226
  handleOutput(sessionId, data) {
1038
1227
  const session = this.sessions.get(sessionId);
@@ -1047,44 +1236,92 @@ var CodexPtyRunner = class {
1047
1236
  session.screen.write(data);
1048
1237
  session.lastOutput = stripAnsi(data);
1049
1238
  this.onOutput?.(sessionId, data);
1050
- this.detectReady(sessionId, session).catch((err) => {
1239
+ this.detectScreenState(sessionId, "chunk").catch((err) => {
1051
1240
  this.log.warn("[codex.ready_detect] failed", {
1052
1241
  event: "codex.ready_detect_failed",
1053
1242
  sessionId,
1054
1243
  err
1055
1244
  });
1056
1245
  });
1246
+ let quiet = this.quietCheckers.get(sessionId);
1247
+ if (!quiet) {
1248
+ quiet = debounce(() => {
1249
+ this.detectScreenState(sessionId, "quiet").catch((err) => {
1250
+ this.log.warn("[codex.ready_detect] failed", {
1251
+ event: "codex.ready_detect_failed",
1252
+ sessionId,
1253
+ err
1254
+ });
1255
+ });
1256
+ }, QUIET_DETECT_MS);
1257
+ this.quietCheckers.set(sessionId, quiet);
1258
+ }
1259
+ quiet();
1057
1260
  }
1058
- // Renders the session's headless screen and checks for the directory-trust
1059
- // gate (answered once, debounced) and the "Ready" status-bar text. Only
1060
- // transitions to waiting_input / fires onReady when the rendered status
1061
- // line literally contains "Ready" `›` alone (visible during "Starting")
1062
- // is NOT a valid readiness signal (Phase 0).
1063
- async detectReady(sessionId, session) {
1064
- if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1261
+ // Renders the session's headless screen and drives both detections:
1262
+ // - Gates (directory trust, hooks review) checked on EVERY pass,
1263
+ // independent of pendingReady, so a gate appearing after ready is still
1264
+ // surfaced and a gate leaving the screen closes its card.
1265
+ // - Readiness the "Ready" status-bar marker while pendingReady, plus the
1266
+ // quiet path: after QUIET_DETECT_MS of PTY silence a still-pending
1267
+ // session is marked ready anyway (`›` alone is NOT a marker — Phase 0 —
1268
+ // but a quiet boot screen is more useful to the user live than a
1269
+ // spinner, and "Ready" may be truncated off the 120-col status bar).
1270
+ async detectScreenState(sessionId, trigger) {
1271
+ const session = this.sessions.get(sessionId);
1272
+ if (!session || session.status === "idle") return;
1065
1273
  const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1066
1274
  const screenText = lines.join("\n");
1067
- if (CODEX_TRUST_GATE_REGEX.test(screenText)) {
1068
- if (!this.trustGateAnswered.has(sessionId)) {
1069
- this.trustGateAnswered.add(sessionId);
1070
- this.log.info(`[codex.trust_gate] ${sessionId.slice(0, 8)} auto-answering`, {
1071
- event: "codex.trust_gate",
1072
- sessionId
1073
- });
1074
- session.process.write("\r");
1075
- }
1076
- return;
1275
+ const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1276
+ if (gate) {
1277
+ this.handleGate(sessionId, session, gate, lines);
1278
+ } else if (this.openGate.delete(sessionId)) {
1279
+ this.onPermissionChange?.(sessionId, null);
1280
+ this.flushQueuedInputs(sessionId);
1077
1281
  }
1282
+ if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1078
1283
  const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1079
- if (!lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) return;
1080
- this.markReady(sessionId, session);
1284
+ if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1285
+ this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
1286
+ } else if (trigger === "quiet") {
1287
+ this.markReady(sessionId, session, "quiet:timeout");
1288
+ }
1081
1289
  }
1082
- markReady(sessionId, session) {
1290
+ // Answer a gate from the persisted remember-store, or surface it as a
1291
+ // question card over the permission transport. Actioned once per session and
1292
+ // gate type — repaints of the same dialog neither re-write nor re-broadcast.
1293
+ handleGate(sessionId, session, gate, lines) {
1294
+ const key = `${sessionId}:${gate}`;
1295
+ if (this.gateActioned.has(key)) return;
1296
+ this.gateActioned.add(key);
1297
+ const remembered = rememberedGateDigit(gate);
1298
+ if (remembered) {
1299
+ this.log.info(`[codex.gate_auto_answer] ${sessionId.slice(0, 8)} ${gate} \u2192 ${remembered}`, {
1300
+ event: "codex.gate_auto_answer",
1301
+ sessionId,
1302
+ gate,
1303
+ digit: remembered
1304
+ });
1305
+ session.process.write(remembered);
1306
+ return;
1307
+ }
1308
+ this.openGate.set(sessionId, gate);
1309
+ const card = gateCard(gate, lines);
1310
+ this.log.info(`[codex.gate_prompt] ${sessionId.slice(0, 8)} ${gate}`, {
1311
+ event: "codex.gate_prompt",
1312
+ sessionId,
1313
+ gate,
1314
+ prompt: card.prompt
1315
+ });
1316
+ this.onPermissionChange?.(sessionId, card);
1317
+ }
1318
+ markReady(sessionId, session, reason) {
1083
1319
  session.lastActivityAt = /* @__PURE__ */ new Date();
1084
1320
  session.status = "waiting_input";
1085
- this.log.info(`[codex.ready] ${sessionId.slice(0, 8)}`, {
1321
+ this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1086
1322
  event: "codex.ready",
1087
- sessionId
1323
+ sessionId,
1324
+ reason
1088
1325
  });
1089
1326
  this.onStatusChange?.(toPublicSession(session));
1090
1327
  if (this.pendingReady.has(sessionId)) {
@@ -1110,7 +1347,7 @@ var CodexPtyRunner = class {
1110
1347
  session.screen.dispose();
1111
1348
  this.sessions.delete(sessionId);
1112
1349
  this.queuedInputs.delete(sessionId);
1113
- this.trustGateAnswered.delete(sessionId);
1350
+ this.clearSessionDetectors(sessionId);
1114
1351
  }
1115
1352
  };
1116
1353
  function toPublicSession(s) {
@@ -1293,9 +1530,15 @@ function detectShellPrompt(lines) {
1293
1530
  ]
1294
1531
  };
1295
1532
  }
1296
- if (NUMBERED_RE.test(last.text)) {
1533
+ const lastNumberedIdx = (() => {
1534
+ for (let i = last.idx; i >= 0; i--) {
1535
+ if (NUMBERED_RE.test(lines[i])) return i;
1536
+ }
1537
+ return -1;
1538
+ })();
1539
+ if (lastNumberedIdx >= 0) {
1297
1540
  const options = [];
1298
- for (let i = 0; i <= last.idx; i++) {
1541
+ for (let i = 0; i <= lastNumberedIdx; i++) {
1299
1542
  const m = NUMBERED_RE.exec(lines[i]);
1300
1543
  if (!m) continue;
1301
1544
  const num = Number.parseInt(m[1], 10);
@@ -1323,37 +1566,6 @@ function detectShellPrompt(lines) {
1323
1566
  return null;
1324
1567
  }
1325
1568
 
1326
- // src/utils/debounce.ts
1327
- function debounce(fn, waitMs) {
1328
- let timer = null;
1329
- let lastArgs = null;
1330
- const run2 = () => {
1331
- timer = null;
1332
- if (lastArgs) {
1333
- const args = lastArgs;
1334
- lastArgs = null;
1335
- fn(...args);
1336
- }
1337
- };
1338
- const debounced = (...args) => {
1339
- lastArgs = args;
1340
- if (timer) clearTimeout(timer);
1341
- timer = setTimeout(run2, waitMs);
1342
- };
1343
- debounced.cancel = () => {
1344
- if (timer) clearTimeout(timer);
1345
- timer = null;
1346
- lastArgs = null;
1347
- };
1348
- debounced.flush = () => {
1349
- if (timer) {
1350
- clearTimeout(timer);
1351
- run2();
1352
- }
1353
- };
1354
- return debounced;
1355
- }
1356
-
1357
1569
  // src/pty-manager.ts
1358
1570
  var OUTPUT_BUFFER_MAX2 = 65536;
1359
1571
  var PTY_COLS2 = 120;
@@ -1361,7 +1573,7 @@ var PTY_ROWS2 = 40;
1361
1573
  var SCREEN_SCROLLBACK2 = 1e3;
1362
1574
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1363
1575
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1364
- var QUIET_DETECT_MS = 500;
1576
+ var QUIET_DETECT_MS2 = 500;
1365
1577
  function buildPasteBytes(input) {
1366
1578
  return `\x1B[200~${input}\x1B[201~`;
1367
1579
  }
@@ -1861,7 +2073,7 @@ var PTYManager = class {
1861
2073
  });
1862
2074
  let quiet = this.quietCheckers.get(sessionId);
1863
2075
  if (!quiet) {
1864
- quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
2076
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS2);
1865
2077
  this.quietCheckers.set(sessionId, quiet);
1866
2078
  }
1867
2079
  quiet();
@@ -2122,7 +2334,7 @@ var LiveSessionManager = class {
2122
2334
  // src/process-discovery.ts
2123
2335
  import { execFile } from "child_process";
2124
2336
  import { platform as platform2 } from "os";
2125
- import { basename as basename4, dirname as dirname3 } from "path";
2337
+ import { basename as basename4, dirname as dirname4 } from "path";
2126
2338
  async function discoverClaudeProcesses() {
2127
2339
  if (platform2() === "win32") return discoverWindows();
2128
2340
  return discoverUnix();
@@ -2244,7 +2456,7 @@ async function getProcessInfoWindows(pid) {
2244
2456
  const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2245
2457
  if (Number.isNaN(startedAt.getTime())) return null;
2246
2458
  const exePath = parts[3] ?? "";
2247
- const cwd = exePath ? dirname3(exePath) : "";
2459
+ const cwd = exePath ? dirname4(exePath) : "";
2248
2460
  return { cwd, args, startedAt };
2249
2461
  } catch {
2250
2462
  return null;
@@ -2276,16 +2488,16 @@ import {
2276
2488
  import { EventEmitter } from "events";
2277
2489
  import {
2278
2490
  createReadStream,
2279
- existsSync as existsSync7,
2491
+ existsSync as existsSync8,
2280
2492
  watch as fsWatch,
2281
2493
  readdirSync as readdirSync4,
2282
- readFileSync as readFileSync6,
2283
- statSync as statSync5
2494
+ readFileSync as readFileSync7,
2495
+ statSync as statSync6
2284
2496
  } from "fs";
2285
2497
  import { realpath as realpath2 } from "fs/promises";
2286
2498
  import { createServer } from "http";
2287
- import { homedir as homedir5 } from "os";
2288
- import { dirname as dirname7, join as join12 } from "path";
2499
+ import { homedir as homedir7 } from "os";
2500
+ import { dirname as dirname8, join as join15 } from "path";
2289
2501
  import { createInterface } from "readline";
2290
2502
 
2291
2503
  // node_modules/nanoid/index.js
@@ -2469,7 +2681,7 @@ async function handleSendAgentInput(sessionId, body, deps) {
2469
2681
 
2470
2682
  // src/agent/handle-start-agent-session.ts
2471
2683
  import { existsSync as existsSync4 } from "fs";
2472
- import { join as join5 } from "path";
2684
+ import { join as join6 } from "path";
2473
2685
  function validateBody(body) {
2474
2686
  if (body === null || body === void 0 || typeof body !== "object") {
2475
2687
  return { ok: false };
@@ -2499,7 +2711,7 @@ async function handleStartAgentSession(body, deps) {
2499
2711
  }
2500
2712
  let conversationId = parsed.conversationId;
2501
2713
  if (conversationId) {
2502
- const jsonlPath = join5(deps.conversationsDir, `${conversationId}.jsonl`);
2714
+ const jsonlPath = join6(deps.conversationsDir, `${conversationId}.jsonl`);
2503
2715
  if (!existsSync4(jsonlPath)) {
2504
2716
  return {
2505
2717
  status: 404,
@@ -2544,14 +2756,15 @@ async function handleStartAgentSession(body, deps) {
2544
2756
  }
2545
2757
 
2546
2758
  // src/api/app.ts
2547
- import { Hono as Hono11 } from "hono";
2759
+ import { Hono as Hono12 } from "hono";
2548
2760
 
2549
2761
  // src/api/middleware/auth.middleware.ts
2550
2762
  function isLocalRequest(remoteAddr) {
2551
2763
  const addr = remoteAddr ?? "";
2552
2764
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2553
2765
  }
2554
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2766
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2767
+ var LOCAL_ONLY_PATHS = /* @__PURE__ */ new Set(["/api/logs", "/api/logs/meta"]);
2555
2768
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2556
2769
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
2557
2770
  var authMiddleware = (deps) => async (c, next) => {
@@ -2562,8 +2775,12 @@ var authMiddleware = (deps) => async (c, next) => {
2562
2775
  await next();
2563
2776
  return;
2564
2777
  }
2778
+ const remoteAddr = c.env.incoming?.socket?.remoteAddress;
2779
+ if (LOCAL_ONLY_PATHS.has(path) && isLocalRequest(remoteAddr)) {
2780
+ await next();
2781
+ return;
2782
+ }
2565
2783
  if (deps.localNoAuth) {
2566
- const remoteAddr = c.env.incoming?.socket?.remoteAddress;
2567
2784
  if (isLocalRequest(remoteAddr)) {
2568
2785
  await next();
2569
2786
  return;
@@ -2696,8 +2913,8 @@ var createConversationRoutes = (deps) => {
2696
2913
  import { Hono as Hono4 } from "hono";
2697
2914
 
2698
2915
  // src/version.ts
2699
- import { readFileSync as readFileSync3, realpathSync } from "fs";
2700
- import { dirname as dirname4, join as join6 } from "path";
2916
+ import { readFileSync as readFileSync4, realpathSync } from "fs";
2917
+ import { dirname as dirname5, join as join7 } from "path";
2701
2918
  var cached;
2702
2919
  function getVersion() {
2703
2920
  if (cached !== void 0) return cached;
@@ -2706,22 +2923,22 @@ function getVersion() {
2706
2923
  }
2707
2924
  function resolveVersion() {
2708
2925
  const scriptPath = process.argv[1] ?? "";
2709
- const here = scriptPath ? dirname4(scriptPath) : process.cwd();
2926
+ const here = scriptPath ? dirname5(scriptPath) : process.cwd();
2710
2927
  let realHere = here;
2711
2928
  try {
2712
- realHere = dirname4(realpathSync(scriptPath));
2929
+ realHere = dirname5(realpathSync(scriptPath));
2713
2930
  } catch {
2714
2931
  }
2715
- const searchDirs = realHere === here ? [here, join6(here, "..")] : [here, join6(here, ".."), realHere, join6(realHere, "..")];
2932
+ const searchDirs = realHere === here ? [here, join7(here, "..")] : [here, join7(here, ".."), realHere, join7(realHere, "..")];
2716
2933
  for (const dir of searchDirs) {
2717
2934
  try {
2718
- const v = readFileSync3(join6(dir, "version.txt"), "utf8").trim();
2935
+ const v = readFileSync4(join7(dir, "version.txt"), "utf8").trim();
2719
2936
  if (v) return v;
2720
2937
  } catch {
2721
2938
  }
2722
2939
  }
2723
2940
  try {
2724
- const pkg = JSON.parse(readFileSync3(join6(here, "..", "package.json"), "utf8"));
2941
+ const pkg = JSON.parse(readFileSync4(join7(here, "..", "package.json"), "utf8"));
2725
2942
  if (pkg.version) return `${pkg.version}+source`;
2726
2943
  } catch {
2727
2944
  }
@@ -2735,16 +2952,145 @@ var createHealthRoutes = () => {
2735
2952
  return app;
2736
2953
  };
2737
2954
 
2955
+ // src/api/routes/logs.routes.ts
2956
+ import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
2957
+ import { join as join9 } from "path";
2958
+ import { Hono as Hono5 } from "hono";
2959
+
2960
+ // src/lifecycle/constants.ts
2961
+ import { homedir as homedir4 } from "os";
2962
+ import { join as join8 } from "path";
2963
+ var TASK_NAME = process.env.THREADBASE_TASK_NAME ?? "Threadbase";
2964
+ function installDir() {
2965
+ return process.env.THREADBASE_INSTALL_DIR ?? join8(homedir4(), ".threadbase");
2966
+ }
2967
+
2968
+ // src/api/routes/logs.routes.ts
2969
+ var logger2 = getLogger("logs-api");
2970
+ function resolveLogPath(source) {
2971
+ return join9(installDir(), "logs", `${source}.log`);
2972
+ }
2973
+ function pickDefaultSource() {
2974
+ for (const source of ["stdout", "stderr", "dev"]) {
2975
+ const p = resolveLogPath(source);
2976
+ if (existsSync5(p) && statSync(p).size > 0) return source;
2977
+ }
2978
+ return "stdout";
2979
+ }
2980
+ function readLogLines(filePath, sinceOffset, limit) {
2981
+ if (!existsSync5(filePath)) {
2982
+ return { lines: [], offset: 0, total: 0 };
2983
+ }
2984
+ const fd = openSync(filePath, "r");
2985
+ try {
2986
+ const { size } = fstatSync(fd);
2987
+ if (size === 0) return { lines: [], offset: 0, total: 0 };
2988
+ const maxBytes = Math.min(size, 2 * 1024 * 1024);
2989
+ const start = size - maxBytes;
2990
+ const buf = Buffer.alloc(maxBytes);
2991
+ readSync(fd, buf, 0, maxBytes, start);
2992
+ let text = buf.toString("utf8");
2993
+ if (start > 0) {
2994
+ const firstNl = text.indexOf("\n");
2995
+ if (firstNl >= 0) text = text.slice(firstNl + 1);
2996
+ }
2997
+ const allLines = text.split("\n").filter((line) => line.trim() && !line.startsWith("==="));
2998
+ let lines;
2999
+ let newOffset;
3000
+ if (sinceOffset > 0 && sinceOffset < allLines.length) {
3001
+ lines = allLines.slice(sinceOffset, sinceOffset + limit);
3002
+ newOffset = sinceOffset + lines.length;
3003
+ } else if (sinceOffset >= allLines.length && sinceOffset > 0) {
3004
+ lines = [];
3005
+ newOffset = allLines.length;
3006
+ } else {
3007
+ lines = allLines.slice(-limit);
3008
+ newOffset = allLines.length;
3009
+ }
3010
+ return { lines, offset: newOffset, total: allLines.length };
3011
+ } finally {
3012
+ closeSync(fd);
3013
+ }
3014
+ }
3015
+ function createLogsRoutes() {
3016
+ const app = new Hono5();
3017
+ app.get("/", (c) => {
3018
+ try {
3019
+ const sourceParam = (c.req.query("source") || "").toLowerCase();
3020
+ const source = sourceParam === "stdout" || sourceParam === "stderr" || sourceParam === "dev" ? sourceParam : pickDefaultSource();
3021
+ const logPath = resolveLogPath(source);
3022
+ const sinceOffset = parseInt(c.req.query("since") || "0", 10);
3023
+ const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
3024
+ if (!existsSync5(logPath)) {
3025
+ return c.json({
3026
+ logs: [],
3027
+ message: `No log file found for source=${source}`,
3028
+ offset: 0,
3029
+ total: 0,
3030
+ source
3031
+ });
3032
+ }
3033
+ const { lines, offset, total } = readLogLines(logPath, sinceOffset, limit);
3034
+ const stats = statSync(logPath);
3035
+ return c.json({
3036
+ logs: lines,
3037
+ offset,
3038
+ total,
3039
+ hasMore: offset < total,
3040
+ source,
3041
+ fileSize: stats.size,
3042
+ fileModified: stats.mtime.toISOString()
3043
+ });
3044
+ } catch (error) {
3045
+ logger2.error("Failed to read logs", { error: String(error) });
3046
+ return c.json(
3047
+ {
3048
+ error: "Failed to read logs",
3049
+ logs: [],
3050
+ offset: 0,
3051
+ total: 0
3052
+ },
3053
+ 500
3054
+ );
3055
+ }
3056
+ });
3057
+ app.get("/meta", (c) => {
3058
+ try {
3059
+ const sources = ["stdout", "stderr", "dev"].map((source) => {
3060
+ const logPath = resolveLogPath(source);
3061
+ if (!existsSync5(logPath)) {
3062
+ return { source, exists: false, total: 0, fileSize: 0 };
3063
+ }
3064
+ const stats = statSync(logPath);
3065
+ return {
3066
+ source,
3067
+ exists: true,
3068
+ fileSize: stats.size,
3069
+ fileModified: stats.mtime.toISOString()
3070
+ };
3071
+ });
3072
+ return c.json({
3073
+ defaultSource: pickDefaultSource(),
3074
+ sources
3075
+ });
3076
+ } catch (error) {
3077
+ logger2.error("Failed to read log metadata", { error: String(error) });
3078
+ return c.json({ error: "Failed to read log metadata", exists: false }, 500);
3079
+ }
3080
+ });
3081
+ return app;
3082
+ }
3083
+
2738
3084
  // src/api/routes/misc.routes.ts
2739
3085
  import { spawn } from "child_process";
2740
3086
  import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
2741
- import { Hono as Hono5 } from "hono";
3087
+ import { Hono as Hono6 } from "hono";
2742
3088
  import { hostname } from "os";
2743
3089
 
2744
3090
  // src/config/update-config.ts
2745
- import { readFileSync as readFileSync4 } from "fs";
2746
- import { homedir as homedir3 } from "os";
2747
- import { join as join7 } from "path";
3091
+ import { readFileSync as readFileSync5 } from "fs";
3092
+ import { homedir as homedir5 } from "os";
3093
+ import { join as join10 } from "path";
2748
3094
  import { parse as parseYaml } from "yaml";
2749
3095
 
2750
3096
  // src/schemas/updateConfig.schema.ts
@@ -2760,12 +3106,12 @@ var UpdateConfigSchema = z.object({
2760
3106
  }).strict();
2761
3107
 
2762
3108
  // src/config/update-config.ts
2763
- var DEFAULT_CONFIG_PATH = join7(homedir3(), ".threadbase", "update.yaml");
3109
+ var DEFAULT_CONFIG_PATH = join10(homedir5(), ".threadbase", "update.yaml");
2764
3110
  function loadUpdateConfig(opts = {}) {
2765
3111
  const path = opts.path ?? DEFAULT_CONFIG_PATH;
2766
3112
  let raw;
2767
3113
  try {
2768
- raw = readFileSync4(path, "utf-8");
3114
+ raw = readFileSync5(path, "utf-8");
2769
3115
  } catch (err) {
2770
3116
  if (err.code === "ENOENT") return null;
2771
3117
  throw err;
@@ -2812,7 +3158,7 @@ function verifyWebhookSignature(body, header, secret) {
2812
3158
  }
2813
3159
  var clientLog = getLogger("client");
2814
3160
  var createMiscRoutes = (deps) => {
2815
- const app = new Hono5();
3161
+ const app = new Hono6();
2816
3162
  app.get("/api/info", (c) => {
2817
3163
  const ptyIds = deps.ptyAttachedIds();
2818
3164
  return c.json({
@@ -2888,11 +3234,11 @@ var createMiscRoutes = (deps) => {
2888
3234
  };
2889
3235
 
2890
3236
  // src/api/routes/pair.routes.ts
2891
- import { Hono as Hono6 } from "hono";
3237
+ import { Hono as Hono7 } from "hono";
2892
3238
  var ALREADY_HANDLED3 = 597;
2893
3239
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
2894
3240
  var createPairRoutes = (deps) => {
2895
- const app = new Hono6();
3241
+ const app = new Hono7();
2896
3242
  app.post("/start", (c) => {
2897
3243
  deps.handlePairStart(c.env.outgoing);
2898
3244
  return alreadyHandled3();
@@ -2905,11 +3251,11 @@ var createPairRoutes = (deps) => {
2905
3251
  };
2906
3252
 
2907
3253
  // src/api/routes/projects.routes.ts
2908
- import { Hono as Hono7 } from "hono";
3254
+ import { Hono as Hono8 } from "hono";
2909
3255
  var ALREADY_HANDLED4 = 597;
2910
3256
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
2911
3257
  var createProjectRoutes = (deps) => {
2912
- const app = new Hono7();
3258
+ const app = new Hono8();
2913
3259
  app.get("/", (c) => {
2914
3260
  const url = new URL(c.req.url);
2915
3261
  deps.handleListProjects(url, c.env.outgoing);
@@ -2924,11 +3270,11 @@ var createProjectRoutes = (deps) => {
2924
3270
  };
2925
3271
 
2926
3272
  // src/api/routes/scanner.routes.ts
2927
- import { Hono as Hono8 } from "hono";
3273
+ import { Hono as Hono9 } from "hono";
2928
3274
  var ALREADY_HANDLED5 = 597;
2929
3275
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
2930
3276
  var createScannerRoutes = (deps) => {
2931
- const app = new Hono8();
3277
+ const app = new Hono9();
2932
3278
  app.get("/api/search", async (c) => {
2933
3279
  const url = new URL(c.req.url);
2934
3280
  await deps.handleSearch(url, c.env.outgoing);
@@ -2938,11 +3284,11 @@ var createScannerRoutes = (deps) => {
2938
3284
  };
2939
3285
 
2940
3286
  // src/api/routes/sessions.routes.ts
2941
- import { Hono as Hono9 } from "hono";
3287
+ import { Hono as Hono10 } from "hono";
2942
3288
  var ALREADY_HANDLED6 = 597;
2943
3289
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
2944
3290
  var createSessionRoutes = (deps) => {
2945
- const app = new Hono9();
3291
+ const app = new Hono10();
2946
3292
  app.get("/count", (c) => {
2947
3293
  deps.handleSessionsCount(c.env.outgoing);
2948
3294
  return alreadyHandled6();
@@ -3009,19 +3355,21 @@ var createSessionRoutes = (deps) => {
3009
3355
  };
3010
3356
 
3011
3357
  // src/api/routes/ws.routes.ts
3012
- import { Hono as Hono10 } from "hono";
3358
+ import { Hono as Hono11 } from "hono";
3013
3359
  var createWsRoutes = (deps, upgradeWebSocket) => {
3014
- const app = new Hono10();
3360
+ const app = new Hono11();
3015
3361
  app.get(
3016
3362
  "/ws",
3017
- upgradeWebSocket(() => {
3363
+ upgradeWebSocket((c) => {
3364
+ const key = c.req.query("key");
3365
+ const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3018
3366
  let openWs = null;
3019
3367
  return {
3020
3368
  onOpen(_evt, ws) {
3021
3369
  const raw = ws.raw;
3022
3370
  if (!raw) return;
3023
3371
  openWs = raw;
3024
- deps.handleWsOpen(raw);
3372
+ deps.handleWsOpen(raw, preAuthed);
3025
3373
  },
3026
3374
  onMessage(evt, _ws) {
3027
3375
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -3037,7 +3385,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3037
3385
 
3038
3386
  // src/api/app.ts
3039
3387
  var createHonoApp = (deps, upgradeWebSocket) => {
3040
- const app = new Hono11();
3388
+ const app = new Hono12();
3041
3389
  const httpLog = getLogger("http");
3042
3390
  app.use("*", async (c, next) => {
3043
3391
  const start = Date.now();
@@ -3066,6 +3414,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3066
3414
  app.route("/api", createBrowseRoutes(deps));
3067
3415
  app.route("/", createScannerRoutes(deps));
3068
3416
  app.route("/internal", createProgressRoutes(deps));
3417
+ app.route("/api/logs", createLogsRoutes());
3069
3418
  if (upgradeWebSocket) {
3070
3419
  app.route("/", createWsRoutes(deps, upgradeWebSocket));
3071
3420
  }
@@ -3074,7 +3423,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3074
3423
 
3075
3424
  // src/browse.ts
3076
3425
  import { mkdir as mkdir2, readdir, realpath, stat } from "fs/promises";
3077
- import { join as join8, resolve, sep } from "path";
3426
+ import { join as join11, resolve, sep } from "path";
3078
3427
  var BrowsePathNotFoundError = class extends Error {
3079
3428
  constructor(message) {
3080
3429
  super(message);
@@ -3112,7 +3461,7 @@ async function createDirectory(parentAbsolutePath, name) {
3112
3461
  if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
3113
3462
  throw new Error("Invalid directory name");
3114
3463
  }
3115
- const target = join8(parentAbsolutePath, name);
3464
+ const target = join11(parentAbsolutePath, name);
3116
3465
  try {
3117
3466
  const s = await stat(target);
3118
3467
  if (s.isDirectory()) throw new Error("Directory already exists");
@@ -3129,18 +3478,18 @@ import {
3129
3478
  parseJsonlLine
3130
3479
  } from "@threadbase-sh/scanner";
3131
3480
  import Database from "better-sqlite3";
3132
- import { closeSync as closeSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
3481
+ import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, openSync as openSync3, readSync as readSync3, statSync as statSync3 } from "fs";
3133
3482
  import { open as openAsync } from "fs/promises";
3134
- import { dirname as dirname6 } from "path";
3483
+ import { dirname as dirname7 } from "path";
3135
3484
  import { setImmediate as yieldToEventLoop } from "timers/promises";
3136
3485
 
3137
3486
  // src/db/sqlite-migrate.ts
3138
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
3139
- import { dirname as dirname5, join as join9 } from "path";
3487
+ import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
3488
+ import { dirname as dirname6, join as join12 } from "path";
3140
3489
  import { fileURLToPath as fileURLToPath2 } from "url";
3141
3490
  function getMigrationsDir2() {
3142
3491
  if (typeof import.meta !== "undefined" && import.meta.url) {
3143
- return dirname5(fileURLToPath2(import.meta.url));
3492
+ return dirname6(fileURLToPath2(import.meta.url));
3144
3493
  }
3145
3494
  return __dirname;
3146
3495
  }
@@ -3152,7 +3501,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
3152
3501
  `;
3153
3502
  function runSqliteMigrations(db, migrationsDir) {
3154
3503
  db.exec(SCHEMA_MIGRATIONS_SQL);
3155
- const dir = migrationsDir ?? join9(getMigrationsDir2(), "migrations");
3504
+ const dir = migrationsDir ?? join12(getMigrationsDir2(), "migrations");
3156
3505
  const files = readdirSync2(dir).filter((f) => f.endsWith(".sql")).sort();
3157
3506
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
3158
3507
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -3164,7 +3513,7 @@ function runSqliteMigrations(db, migrationsDir) {
3164
3513
  skipped.push(file);
3165
3514
  continue;
3166
3515
  }
3167
- const sql = readFileSync5(join9(dir, file), "utf-8");
3516
+ const sql = readFileSync6(join12(dir, file), "utf-8");
3168
3517
  const tx = db.transaction(() => {
3169
3518
  db.exec(sql);
3170
3519
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -3176,7 +3525,7 @@ function runSqliteMigrations(db, migrationsDir) {
3176
3525
  }
3177
3526
 
3178
3527
  // src/services/conversations/isAgentConversation.ts
3179
- import { closeSync, openSync, readSync, statSync } from "fs";
3528
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
3180
3529
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
3181
3530
  var CHUNK_BYTES = 64 * 1024;
3182
3531
  var ENTRYPOINT_PROBE = `"entrypoint":`;
@@ -3198,12 +3547,12 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3198
3547
  if (cached2 !== void 0) return cached2;
3199
3548
  let fd;
3200
3549
  try {
3201
- fd = openSync(filePath, "r");
3550
+ fd = openSync2(filePath, "r");
3202
3551
  } catch {
3203
3552
  return false;
3204
3553
  }
3205
3554
  try {
3206
- const fileSize = statSync(filePath).size;
3555
+ const fileSize = statSync2(filePath).size;
3207
3556
  if (fileSize === 0) {
3208
3557
  fileDecisionCache.set(key, false);
3209
3558
  return false;
@@ -3214,7 +3563,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3214
3563
  let carry = "";
3215
3564
  while (offset < fileSize) {
3216
3565
  const toRead = Math.min(CHUNK_BYTES, fileSize - offset);
3217
- const got = readSync(fd, buf, 0, toRead, offset);
3566
+ const got = readSync2(fd, buf, 0, toRead, offset);
3218
3567
  if (got <= 0) break;
3219
3568
  const chunk = carry + buf.toString("utf8", 0, got);
3220
3569
  for (const marker of markers) {
@@ -3235,7 +3584,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3235
3584
  } catch {
3236
3585
  return false;
3237
3586
  } finally {
3238
- closeSync(fd);
3587
+ closeSync2(fd);
3239
3588
  }
3240
3589
  }
3241
3590
  function parseAgentEntrypointsEnv(raw) {
@@ -3772,7 +4121,7 @@ var ConversationCache = class _ConversationCache {
3772
4121
  if (!fileState) return null;
3773
4122
  let stat3;
3774
4123
  try {
3775
- stat3 = statSync2(filePath);
4124
+ stat3 = statSync3(filePath);
3776
4125
  } catch {
3777
4126
  return null;
3778
4127
  }
@@ -3793,17 +4142,17 @@ var ConversationCache = class _ConversationCache {
3793
4142
  );
3794
4143
  if (rows.length === 0) return { messages: [], total, fromIndex: from };
3795
4144
  const messages = [];
3796
- const fd = openSync2(filePath, "r");
4145
+ const fd = openSync3(filePath, "r");
3797
4146
  try {
3798
4147
  const state = createJsonlParseState();
3799
4148
  for (const row of rows) {
3800
4149
  const buf = Buffer.alloc(row.byte_length);
3801
- readSync2(fd, buf, 0, row.byte_length, row.byte_offset);
4150
+ readSync3(fd, buf, 0, row.byte_length, row.byte_offset);
3802
4151
  const msg = parseJsonlLine(buf.toString("utf-8"), state);
3803
4152
  if (msg) messages.push(msg);
3804
4153
  }
3805
4154
  } finally {
3806
- closeSync2(fd);
4155
+ closeSync3(fd);
3807
4156
  }
3808
4157
  return { messages, total, fromIndex: from };
3809
4158
  }
@@ -3831,14 +4180,14 @@ var ConversationCache = class _ConversationCache {
3831
4180
  isAgentFileCached(filePath) {
3832
4181
  let s;
3833
4182
  try {
3834
- s = statSync2(filePath);
4183
+ s = statSync3(filePath);
3835
4184
  } catch {
3836
4185
  return false;
3837
4186
  }
3838
4187
  return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3839
4188
  }
3840
4189
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3841
- mkdirSync2(dirname6(dbPath), { recursive: true });
4190
+ mkdirSync3(dirname7(dbPath), { recursive: true });
3842
4191
  const db = new Database(dbPath);
3843
4192
  db.pragma("journal_mode = WAL");
3844
4193
  db.pragma("foreign_keys = ON");
@@ -4019,7 +4368,7 @@ var ConversationCache = class _ConversationCache {
4019
4368
  let mtimeMs = null;
4020
4369
  let fileSize = null;
4021
4370
  try {
4022
- const s = statSync2(m.filePath);
4371
+ const s = statSync3(m.filePath);
4023
4372
  mtimeMs = s.mtimeMs;
4024
4373
  fileSize = s.size;
4025
4374
  } catch {
@@ -4078,8 +4427,8 @@ var ConversationCache = class _ConversationCache {
4078
4427
  let fileSize;
4079
4428
  let fd;
4080
4429
  try {
4081
- fileSize = statSync2(filePath).size;
4082
- fd = openSync2(filePath, "r");
4430
+ fileSize = statSync3(filePath).size;
4431
+ fd = openSync3(filePath, "r");
4083
4432
  } catch {
4084
4433
  return false;
4085
4434
  }
@@ -4092,7 +4441,7 @@ var ConversationCache = class _ConversationCache {
4092
4441
  while (pos > 0 && lines.length < this.tailSize * 4) {
4093
4442
  const toRead = Math.min(CHUNK, pos);
4094
4443
  pos -= toRead;
4095
- readSync2(fd, buf, 0, toRead, pos);
4444
+ readSync3(fd, buf, 0, toRead, pos);
4096
4445
  const chunk = buf.subarray(0, toRead).toString("utf8");
4097
4446
  const combined = chunk + partial;
4098
4447
  const parts = combined.split("\n");
@@ -4103,7 +4452,7 @@ var ConversationCache = class _ConversationCache {
4103
4452
  }
4104
4453
  if (partial) lines.push(partial);
4105
4454
  } finally {
4106
- closeSync2(fd);
4455
+ closeSync3(fd);
4107
4456
  }
4108
4457
  const msgs = [];
4109
4458
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -4305,7 +4654,7 @@ var ConversationCache = class _ConversationCache {
4305
4654
  * `handleGetConversation` can still serve the cached tail even when the
4306
4655
  * JSONL has been deleted.
4307
4656
  */
4308
- pruneGhostFiles(exists = existsSync5) {
4657
+ pruneGhostFiles(exists = existsSync6) {
4309
4658
  const rows = this.stmts.allFilePaths.all();
4310
4659
  const ghosts = [];
4311
4660
  const prune = this.db.transaction((ids) => {
@@ -4360,7 +4709,7 @@ var ConversationCache = class _ConversationCache {
4360
4709
  * Returns the removed IDs.
4361
4710
  */
4362
4711
  reconcileDeletions(livePaths, opts) {
4363
- const exists = opts?.exists ?? existsSync5;
4712
+ const exists = opts?.exists ?? existsSync6;
4364
4713
  const rows = this.stmts.allFilePaths.all();
4365
4714
  const removed = [];
4366
4715
  const drop = this.db.transaction((ids) => {
@@ -4589,23 +4938,23 @@ async function recordUpload(pool2, instanceId, row) {
4589
4938
  }
4590
4939
 
4591
4940
  // src/handlers/handleListProjects.ts
4592
- import { readdirSync as readdirSync3, statSync as statSync3 } from "fs";
4593
- import { homedir as homedir4 } from "os";
4594
- import { join as join10 } from "path";
4941
+ import { readdirSync as readdirSync3, statSync as statSync4 } from "fs";
4942
+ import { homedir as homedir6 } from "os";
4943
+ import { join as join13 } from "path";
4595
4944
  function decodeProjectPath(dirName) {
4596
4945
  return dirName.replace(/-/g, "/");
4597
4946
  }
4598
4947
  function handleListProjects(url, res) {
4599
4948
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
4600
4949
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
4601
- const projectsDir = join10(homedir4(), ".claude", "projects");
4950
+ const projectsDir = join13(homedir6(), ".claude", "projects");
4602
4951
  let entries;
4603
4952
  try {
4604
4953
  entries = readdirSync3(projectsDir).map((dirName) => {
4605
- const fullPath = join10(projectsDir, dirName);
4954
+ const fullPath = join13(projectsDir, dirName);
4606
4955
  let mtime = 0;
4607
4956
  try {
4608
- mtime = statSync3(fullPath).mtimeMs;
4957
+ mtime = statSync4(fullPath).mtimeMs;
4609
4958
  } catch {
4610
4959
  }
4611
4960
  const path = decodeProjectPath(dirName);
@@ -4700,7 +5049,7 @@ function seal(plaintext, recipientPublicKeyBase64) {
4700
5049
 
4701
5050
  // src/services/conversations/conversationWatcher.ts
4702
5051
  import chokidar from "chokidar";
4703
- import { statSync as statSync4 } from "fs";
5052
+ import { statSync as statSync5 } from "fs";
4704
5053
  import { open, stat as stat2 } from "fs/promises";
4705
5054
  var ConversationWatcher = class {
4706
5055
  files = /* @__PURE__ */ new Map();
@@ -4723,7 +5072,7 @@ var ConversationWatcher = class {
4723
5072
  if (this.files.has(filePath)) return;
4724
5073
  let offset;
4725
5074
  try {
4726
- offset = statSync4(filePath).size;
5075
+ offset = statSync5(filePath).size;
4727
5076
  } catch {
4728
5077
  offset = 0;
4729
5078
  }
@@ -4898,14 +5247,14 @@ function findSearchTarget(messages, query) {
4898
5247
  }
4899
5248
 
4900
5249
  // src/services/conversations/pruneAgentConversations.ts
4901
- import { existsSync as existsSync6 } from "fs";
5250
+ import { existsSync as existsSync7 } from "fs";
4902
5251
  function pruneAgentConversations(cache) {
4903
5252
  const db = cache.getDatabase();
4904
5253
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
4905
5254
  let pruned = 0;
4906
5255
  let missing = 0;
4907
5256
  for (const row of rows) {
4908
- if (!existsSync6(row.file_path)) {
5257
+ if (!existsSync7(row.file_path)) {
4909
5258
  missing += 1;
4910
5259
  continue;
4911
5260
  }
@@ -4928,6 +5277,13 @@ function deriveProjectChatTitle(input) {
4928
5277
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
4929
5278
  }
4930
5279
 
5280
+ // src/services/questions/permissionAnswerKeys.ts
5281
+ var ANSWER_KEYS_ALLOWLIST = /^(?:\r|[yn]\r|\x03|\d+\r)$/;
5282
+ function sanitizeAnswerKeys(keys) {
5283
+ if (keys === void 0) return void 0;
5284
+ return ANSWER_KEYS_ALLOWLIST.test(keys) ? keys : void 0;
5285
+ }
5286
+
4931
5287
  // src/services/questions/detectAskUserQuestion.ts
4932
5288
  function normalizeContent2(raw) {
4933
5289
  if (Array.isArray(raw)) return raw;
@@ -5241,7 +5597,7 @@ function discoveredToResponse(d, conversationId) {
5241
5597
  import { randomBytes as randomBytes3 } from "crypto";
5242
5598
  import { mkdir as mkdir3, writeFile } from "fs/promises";
5243
5599
  import heicConvert from "heic-convert";
5244
- import { join as join11 } from "path";
5600
+ import { join as join14 } from "path";
5245
5601
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
5246
5602
  var MAX_BYTES = 25 * 1024 * 1024;
5247
5603
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -5274,9 +5630,9 @@ async function saveUploadFile(input) {
5274
5630
  }
5275
5631
  const id = `up_${randomBytes3(8).toString("hex")}`;
5276
5632
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
5277
- const dir = join11(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5633
+ const dir = join14(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5278
5634
  await mkdir3(dir, { recursive: true });
5279
- const filePath = join11(dir, `${Date.now()}-${id}-${safeName}`);
5635
+ const filePath = join14(dir, `${Date.now()}-${id}-${safeName}`);
5280
5636
  await writeFile(filePath, buffer);
5281
5637
  return {
5282
5638
  id,
@@ -5532,8 +5888,10 @@ var WSHub = class {
5532
5888
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
5533
5889
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5534
5890
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5891
+ var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5892
+ var WS_CLOSE_UNAUTHORIZED = 4401;
5535
5893
  var REFRESH_TTL_MS = 2e3;
5536
- var START_READY_TIMEOUT_MS = 15e3;
5894
+ var START_READY_TIMEOUT_MS = 1e4;
5537
5895
  function parseIncludeAgentsEnv(raw) {
5538
5896
  if (raw === void 0) return false;
5539
5897
  const v = raw.trim().toLowerCase();
@@ -5622,6 +5980,14 @@ var StreamerServer = class {
5622
5980
  clientIdToWs = /* @__PURE__ */ new Map();
5623
5981
  // Reverse map for cleanup on close
5624
5982
  wsToClientId = /* @__PURE__ */ new Map();
5983
+ // M1 — WS auth. Sockets that have authenticated (via ?key= at upgrade OR a
5984
+ // { type: "auth", token } first message). Only authed sockets are added to
5985
+ // the hub and receive broadcasts.
5986
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
5987
+ wsAuthed = /* @__PURE__ */ new Set();
5988
+ // Keyless sockets awaiting their first-message auth handshake → close timer.
5989
+ wsAuthPending = /* @__PURE__ */ new Map();
5990
+ wsAuthTimeoutMs;
5625
5991
  cache = null;
5626
5992
  projectsRepo = null;
5627
5993
  conversationsRepo = null;
@@ -5659,11 +6025,12 @@ var StreamerServer = class {
5659
6025
  this.disableDb = config.disableDb ?? false;
5660
6026
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
5661
6027
  this.scanProfiles = config.scanProfiles;
5662
- this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
6028
+ this.codexRoots = config.codexRoots ?? [join15(homedir7(), ".codex", "sessions")];
5663
6029
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
6030
+ this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
5664
6031
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5665
6032
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5666
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
6033
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join15(homedir7(), ".threadbase", "cache");
5667
6034
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
5668
6035
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
5669
6036
  this.markScannerStaleDebounced = debounce(() => {
@@ -5704,7 +6071,7 @@ var StreamerServer = class {
5704
6071
  const seqs = cache.extendMessageIndex(
5705
6072
  filePath,
5706
6073
  spans,
5707
- statSync5(filePath),
6074
+ statSync6(filePath),
5708
6075
  readFrom,
5709
6076
  endOffset
5710
6077
  );
@@ -5865,7 +6232,7 @@ var StreamerServer = class {
5865
6232
  temporalClient,
5866
6233
  taskQueue: agentConfig.temporal.taskQueue
5867
6234
  });
5868
- const conversationsBaseDir = agentConfig.conversationsDir || join12(dirname7(this.cacheDir), "conversations");
6235
+ const conversationsBaseDir = agentConfig.conversationsDir || join15(dirname8(this.cacheDir), "conversations");
5869
6236
  conversationWriter = createConversationWriter({
5870
6237
  baseDir: conversationsBaseDir
5871
6238
  });
@@ -5918,17 +6285,39 @@ var StreamerServer = class {
5918
6285
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
5919
6286
  handleBrowse: (url, res) => this.handleBrowse(url, res),
5920
6287
  handleMkdir: (req, res) => this.handleMkdir(req, res),
5921
- handleWsOpen: (ws) => {
5922
- this.wsHub.addClient(ws);
5923
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
5924
- ws.send(JSON.stringify({ type: "session_list", sessions }));
5925
- if (this.cacheReady) {
5926
- ws.send(JSON.stringify({ type: "cache_ready" }));
6288
+ handleWsOpen: (ws, preAuthed) => {
6289
+ if (preAuthed) {
6290
+ this.completeWsAuth(ws);
6291
+ return;
5927
6292
  }
6293
+ const timer = setTimeout(() => {
6294
+ this.wsAuthPending.delete(ws);
6295
+ try {
6296
+ ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
6297
+ } catch {
6298
+ }
6299
+ }, this.wsAuthTimeoutMs);
6300
+ this.wsAuthPending.set(ws, timer);
5928
6301
  },
5929
6302
  handleWsMessage: async (ws, raw) => {
5930
6303
  try {
5931
6304
  const msg = JSON.parse(String(raw));
6305
+ if (!this.wsAuthed.has(ws)) {
6306
+ if (msg.type === "auth" && typeof msg.token === "string") {
6307
+ const t = this.wsAuthPending.get(ws);
6308
+ if (t) clearTimeout(t);
6309
+ this.wsAuthPending.delete(ws);
6310
+ if (validateApiKey(msg.token, this.apiKey)) {
6311
+ this.completeWsAuth(ws);
6312
+ } else {
6313
+ try {
6314
+ ws.close(WS_CLOSE_UNAUTHORIZED, "unauthorized");
6315
+ } catch {
6316
+ }
6317
+ }
6318
+ }
6319
+ return;
6320
+ }
5932
6321
  if (msg.type === "register" && typeof msg.clientId === "string") {
5933
6322
  const oldClientId = this.wsToClientId.get(ws);
5934
6323
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -5941,14 +6330,54 @@ var StreamerServer = class {
5941
6330
  const lines = await this.ptyManager.getOutputLines(msg.sessionId, 200);
5942
6331
  ws.send(JSON.stringify({ type: "terminal_replay", sessionId: msg.sessionId, lines }));
5943
6332
  }
6333
+ const pendingGate = this.pendingPermission.get(msg.sessionId);
6334
+ if (pendingGate) {
6335
+ this.log.info(`[ws.replay_permission] ${msg.sessionId.slice(0, 8)}`, {
6336
+ event: "ws.replay_permission",
6337
+ sessionId: msg.sessionId
6338
+ });
6339
+ ws.send(
6340
+ JSON.stringify({
6341
+ type: "permission",
6342
+ sessionId: msg.sessionId,
6343
+ ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
6344
+ ...pendingGate.detail ? { detail: pendingGate.detail } : {},
6345
+ options: pendingGate.options,
6346
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
6347
+ })
6348
+ );
6349
+ }
6350
+ const pendingQuestion = this.pendingQuestions.get(msg.sessionId);
6351
+ if (pendingQuestion) {
6352
+ this.log.info(`[ws.replay_question] ${msg.sessionId.slice(0, 8)}`, {
6353
+ event: "ws.replay_question",
6354
+ sessionId: msg.sessionId
6355
+ });
6356
+ ws.send(
6357
+ JSON.stringify({
6358
+ type: "question",
6359
+ sessionId: msg.sessionId,
6360
+ toolUseId: pendingQuestion.toolUseId,
6361
+ questions: pendingQuestion.questions
6362
+ })
6363
+ );
6364
+ }
5944
6365
  }
5945
6366
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
5946
- this.startGraceTimer(msg.sessionId, 0);
6367
+ if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
6368
+ this.startGraceTimer(msg.sessionId, 0);
6369
+ }
5947
6370
  }
5948
6371
  } catch {
5949
6372
  }
5950
6373
  },
5951
6374
  handleWsClose: (ws) => {
6375
+ const pendingTimer = this.wsAuthPending.get(ws);
6376
+ if (pendingTimer) {
6377
+ clearTimeout(pendingTimer);
6378
+ this.wsAuthPending.delete(ws);
6379
+ }
6380
+ this.wsAuthed.delete(ws);
5952
6381
  const clientId = this.wsToClientId.get(ws);
5953
6382
  if (clientId) {
5954
6383
  this.clientIdToWs.delete(clientId);
@@ -6018,6 +6447,20 @@ var StreamerServer = class {
6018
6447
  this.wsHub.broadcast(payload);
6019
6448
  }
6020
6449
  }
6450
+ // M1: finalize a WebSocket auth (via ?key= at upgrade or a first-message
6451
+ // handshake) — register it with the hub and send the initial snapshot. Only
6452
+ // authed sockets reach this, so no unauthenticated client ever receives a
6453
+ // broadcast.
6454
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
6455
+ completeWsAuth(ws) {
6456
+ this.wsAuthed.add(ws);
6457
+ this.wsHub.addClient(ws);
6458
+ const sessions = this.sessionStore.list(this.ptyAttachedIds());
6459
+ ws.send(JSON.stringify({ type: "session_list", sessions }));
6460
+ if (this.cacheReady) {
6461
+ ws.send(JSON.stringify({ type: "cache_ready" }));
6462
+ }
6463
+ }
6021
6464
  addSessionSubscriber(sessionId, ws) {
6022
6465
  let subs = this.sessionSubscribers.get(sessionId);
6023
6466
  if (!subs) {
@@ -6089,7 +6532,7 @@ var StreamerServer = class {
6089
6532
  });
6090
6533
  try {
6091
6534
  this.cache = ConversationCache.open(
6092
- join12(this.cacheDir, "cache.db"),
6535
+ join15(this.cacheDir, "cache.db"),
6093
6536
  this.tailSize,
6094
6537
  void 0,
6095
6538
  {
@@ -6115,11 +6558,11 @@ var StreamerServer = class {
6115
6558
  if (this.scanProfiles && this.scanProfiles.length > 0) {
6116
6559
  for (const profile of this.scanProfiles) {
6117
6560
  if (profile.enabled) {
6118
- this.fileWatcher.watchDirectory(join12(profile.configDir, "projects"));
6561
+ this.fileWatcher.watchDirectory(join15(profile.configDir, "projects"));
6119
6562
  }
6120
6563
  }
6121
6564
  } else {
6122
- this.fileWatcher.watchDirectory(join12(homedir5(), ".claude", "projects"));
6565
+ this.fileWatcher.watchDirectory(join15(homedir7(), ".claude", "projects"));
6123
6566
  }
6124
6567
  } catch (err) {
6125
6568
  const message = err instanceof Error ? err.message : String(err);
@@ -6304,6 +6747,9 @@ var StreamerServer = class {
6304
6747
  this.ptyManager.dispose();
6305
6748
  this.fileWatcher.dispose();
6306
6749
  this.wsHub.dispose();
6750
+ for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6751
+ this.wsAuthPending.clear();
6752
+ this.wsAuthed.clear();
6307
6753
  this.pairTokens.dispose();
6308
6754
  if (this.dbPool) {
6309
6755
  await this.dbPool.end();
@@ -6701,17 +7147,17 @@ var StreamerServer = class {
6701
7147
  return scanner;
6702
7148
  }
6703
7149
  findJsonlPath(uuid) {
6704
- const projectsDir = join12(homedir5(), ".claude", "projects");
6705
- if (!existsSync7(projectsDir)) return null;
7150
+ const projectsDir = join15(homedir7(), ".claude", "projects");
7151
+ if (!existsSync8(projectsDir)) return null;
6706
7152
  const filename = `${uuid}.jsonl`;
6707
7153
  for (const dir of readdirSync4(projectsDir)) {
6708
- const fp = join12(projectsDir, dir, filename);
6709
- if (existsSync7(fp)) return fp;
6710
- const projectDir = join12(projectsDir, dir);
7154
+ const fp = join15(projectsDir, dir, filename);
7155
+ if (existsSync8(fp)) return fp;
7156
+ const projectDir = join15(projectsDir, dir);
6711
7157
  try {
6712
7158
  for (const sub of readdirSync4(projectDir)) {
6713
- const subagentPath = join12(projectDir, sub, "subagents", filename);
6714
- if (existsSync7(subagentPath)) return subagentPath;
7159
+ const subagentPath = join15(projectDir, sub, "subagents", filename);
7160
+ if (existsSync8(subagentPath)) return subagentPath;
6715
7161
  }
6716
7162
  } catch {
6717
7163
  }
@@ -6858,7 +7304,7 @@ var StreamerServer = class {
6858
7304
  if (!conv.filePath) return false;
6859
7305
  let mtimeMs = null;
6860
7306
  try {
6861
- mtimeMs = statSync5(conv.filePath).mtimeMs;
7307
+ mtimeMs = statSync6(conv.filePath).mtimeMs;
6862
7308
  } catch {
6863
7309
  return false;
6864
7310
  }
@@ -7226,7 +7672,7 @@ var StreamerServer = class {
7226
7672
  handleGetSession(sessionId, res) {
7227
7673
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
7228
7674
  if (session) {
7229
- if (!existsSync7(session.projectPath)) {
7675
+ if (!existsSync8(session.projectPath)) {
7230
7676
  session.failureReason = `Project directory not found: ${session.projectPath}`;
7231
7677
  }
7232
7678
  json(res, 200, session);
@@ -7430,12 +7876,21 @@ var StreamerServer = class {
7430
7876
  return;
7431
7877
  }
7432
7878
  this.pendingPermission.set(sessionId, gate);
7879
+ const safeOptions = gate.options.map((o) => {
7880
+ const answerKeys = sanitizeAnswerKeys(o.answerKeys);
7881
+ return answerKeys === void 0 ? { index: o.index, label: o.label } : { ...o, answerKeys };
7882
+ });
7883
+ const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
7884
+ this.log.info(
7885
+ `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
7886
+ { event: "ws.broadcast_permission", sessionId, subscriberCount }
7887
+ );
7433
7888
  this.wsHub.broadcast({
7434
7889
  type: "permission",
7435
7890
  sessionId,
7436
7891
  ...gate.prompt ? { prompt: gate.prompt } : {},
7437
7892
  ...gate.detail ? { detail: gate.detail } : {},
7438
- options: gate.options,
7893
+ options: safeOptions,
7439
7894
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7440
7895
  });
7441
7896
  }
@@ -7622,7 +8077,7 @@ var StreamerServer = class {
7622
8077
  sessionStore: this.sessionStore,
7623
8078
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
7624
8079
  agentClient: this.agentClient,
7625
- conversationsDir: this.cacheDir ? join12(dirname7(this.cacheDir), "conversations") : "",
8080
+ conversationsDir: this.cacheDir ? join15(dirname8(this.cacheDir), "conversations") : "",
7626
8081
  agentConfig: this.agentConfig
7627
8082
  });
7628
8083
  json(res, result.status, result.body);
@@ -7763,9 +8218,9 @@ var StreamerServer = class {
7763
8218
  // was passed to Claude via --session-id so the filename matches from the start.
7764
8219
  watchForJsonl(sessionId, projectPath) {
7765
8220
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
7766
- const projectsDir = join12(homedir5(), ".claude", "projects", encoded);
8221
+ const projectsDir = join15(homedir7(), ".claude", "projects", encoded);
7767
8222
  const expectedFile = `${sessionId}.jsonl`;
7768
- const filePath = join12(projectsDir, expectedFile);
8223
+ const filePath = join15(projectsDir, expectedFile);
7769
8224
  const deadline = Date.now() + 12e4;
7770
8225
  let watcher = null;
7771
8226
  const cleanup = () => {
@@ -7783,12 +8238,12 @@ var StreamerServer = class {
7783
8238
  cleanup();
7784
8239
  return;
7785
8240
  }
7786
- let resolvedFilePath = existsSync7(filePath) ? filePath : null;
7787
- if (!resolvedFilePath && existsSync7(projectsDir)) {
8241
+ let resolvedFilePath = existsSync8(filePath) ? filePath : null;
8242
+ if (!resolvedFilePath && existsSync8(projectsDir)) {
7788
8243
  try {
7789
8244
  const now = Date.now();
7790
- const recent = readdirSync4(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync5(join12(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
7791
- if (recent) resolvedFilePath = join12(projectsDir, recent.f);
8245
+ const recent = readdirSync4(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync6(join15(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
8246
+ if (recent) resolvedFilePath = join15(projectsDir, recent.f);
7792
8247
  } catch {
7793
8248
  }
7794
8249
  }
@@ -7797,7 +8252,7 @@ var StreamerServer = class {
7797
8252
  this.sessionFileMap.set(sessionId, resolvedFilePath);
7798
8253
  this.fileWatcher.watch(resolvedFilePath);
7799
8254
  try {
7800
- const existing = readFileSync6(resolvedFilePath, "utf8").split("\n").filter(Boolean);
8255
+ const existing = readFileSync7(resolvedFilePath, "utf8").split("\n").filter(Boolean);
7801
8256
  if (existing.length > 0) {
7802
8257
  this.broadcastConversationLines(sessionId, existing);
7803
8258
  }
@@ -7836,7 +8291,7 @@ var StreamerServer = class {
7836
8291
  watchForCodexRollout(sessionId, projectPath) {
7837
8292
  const deadline = Date.now() + 12e4;
7838
8293
  const now = /* @__PURE__ */ new Date();
7839
- const dateDir = join12(
8294
+ const dateDir = join15(
7840
8295
  String(now.getFullYear()),
7841
8296
  String(now.getMonth() + 1).padStart(2, "0"),
7842
8297
  String(now.getDate()).padStart(2, "0")
@@ -7849,7 +8304,7 @@ var StreamerServer = class {
7849
8304
  };
7850
8305
  const matchesProjectPath = (candidatePath) => {
7851
8306
  try {
7852
- const firstLine = readFileSync6(candidatePath, "utf8").split("\n", 1)[0];
8307
+ const firstLine = readFileSync7(candidatePath, "utf8").split("\n", 1)[0];
7853
8308
  if (!firstLine) return null;
7854
8309
  const parsed = JSON.parse(firstLine);
7855
8310
  if (parsed?.type !== "session_meta") return null;
@@ -7877,8 +8332,8 @@ var StreamerServer = class {
7877
8332
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
7878
8333
  );
7879
8334
  for (const root of this.codexRoots) {
7880
- const sessionsDir = join12(root, dateDir);
7881
- if (!existsSync7(sessionsDir)) continue;
8335
+ const sessionsDir = join15(root, dateDir);
8336
+ if (!existsSync8(sessionsDir)) continue;
7882
8337
  let candidateFiles;
7883
8338
  try {
7884
8339
  candidateFiles = readdirSync4(sessionsDir).filter((f) => f.endsWith(".jsonl"));
@@ -7886,9 +8341,9 @@ var StreamerServer = class {
7886
8341
  continue;
7887
8342
  }
7888
8343
  const nowMs = Date.now();
7889
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync5(join12(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
8344
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync6(join15(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
7890
8345
  for (const { f } of recentCandidates) {
7891
- const candidatePath = join12(sessionsDir, f);
8346
+ const candidatePath = join15(sessionsDir, f);
7892
8347
  const match = matchesProjectPath(candidatePath);
7893
8348
  if (!match) continue;
7894
8349
  if (boundElsewhere.has(match.id)) continue;
@@ -7898,7 +8353,7 @@ var StreamerServer = class {
7898
8353
  this.sessionFileMap.set(sessionId, candidatePath);
7899
8354
  this.fileWatcher.watch(candidatePath);
7900
8355
  try {
7901
- const existing = readFileSync6(candidatePath, "utf8").split("\n").filter(Boolean);
8356
+ const existing = readFileSync7(candidatePath, "utf8").split("\n").filter(Boolean);
7902
8357
  if (existing.length > 0) {
7903
8358
  this.broadcastConversationLines(sessionId, existing);
7904
8359
  }
@@ -8020,7 +8475,7 @@ var StreamerServer = class {
8020
8475
  };
8021
8476
  function classifyResumability(cwd) {
8022
8477
  if (!cwd) return { resumable: true };
8023
- if (existsSync7(cwd)) return { resumable: true };
8478
+ if (existsSync8(cwd)) return { resumable: true };
8024
8479
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
8025
8480
  return {
8026
8481
  resumable: false,