@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.cjs CHANGED
@@ -215,7 +215,7 @@ function verifySignature(rawBody, signature, secret) {
215
215
  }
216
216
  }
217
217
  function isWithinSkew(timestampHeader, skewSeconds) {
218
- if (!timestampHeader) return true;
218
+ if (!timestampHeader) return false;
219
219
  const t = Number(timestampHeader);
220
220
  if (!Number.isFinite(t)) return false;
221
221
  const now = Math.floor(Date.now() / 1e3);
@@ -552,13 +552,13 @@ async function createPool(config) {
552
552
  }
553
553
 
554
554
  // src/live-session-manager.ts
555
- var import_path6 = require("path");
555
+ var import_path7 = require("path");
556
556
 
557
557
  // src/codex-pty-runner.ts
558
558
  var import_headless = require("@xterm/headless");
559
559
  var import_crypto2 = require("crypto");
560
- var import_fs4 = require("fs");
561
- var import_path4 = require("path");
560
+ var import_fs5 = require("fs");
561
+ var import_path5 = require("path");
562
562
 
563
563
  // src/logger.ts
564
564
  var import_pino = __toESM(require("pino"), 1);
@@ -732,6 +732,69 @@ function isProviderResumable(_provider, availabilityResumable) {
732
732
  return availabilityResumable;
733
733
  }
734
734
 
735
+ // src/services/questions/codexGateAnswers.ts
736
+ var import_fs4 = require("fs");
737
+ var import_os3 = require("os");
738
+ var import_path4 = require("path");
739
+ function gateAnswersPath() {
740
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path4.join)((0, import_os3.homedir)(), ".threadbase");
741
+ return (0, import_path4.join)(dir, "gate-answers.json");
742
+ }
743
+ function loadGateAnswers() {
744
+ try {
745
+ const parsed = JSON.parse((0, import_fs4.readFileSync)(gateAnswersPath(), "utf-8"));
746
+ return parsed && typeof parsed === "object" ? parsed : {};
747
+ } catch {
748
+ return {};
749
+ }
750
+ }
751
+ function saveGateAnswer(key, value) {
752
+ const path = gateAnswersPath();
753
+ (0, import_fs4.mkdirSync)((0, import_path4.dirname)(path), { recursive: true });
754
+ (0, import_fs4.writeFileSync)(path, `${JSON.stringify({ ...loadGateAnswers(), [key]: value }, null, 2)}
755
+ `);
756
+ }
757
+ function rememberedGateDigit(gate) {
758
+ const answers = loadGateAnswers();
759
+ if (gate === "hooks") {
760
+ if (answers.codexHooksGate === "trust_all") return "2";
761
+ if (answers.codexHooksGate === "continue_untrusted") return "3";
762
+ return null;
763
+ }
764
+ return answers.codexTrustGate === "yes" ? "1" : null;
765
+ }
766
+
767
+ // src/utils/debounce.ts
768
+ function debounce(fn, waitMs) {
769
+ let timer = null;
770
+ let lastArgs = null;
771
+ const run2 = () => {
772
+ timer = null;
773
+ if (lastArgs) {
774
+ const args = lastArgs;
775
+ lastArgs = null;
776
+ fn(...args);
777
+ }
778
+ };
779
+ const debounced = (...args) => {
780
+ lastArgs = args;
781
+ if (timer) clearTimeout(timer);
782
+ timer = setTimeout(run2, waitMs);
783
+ };
784
+ debounced.cancel = () => {
785
+ if (timer) clearTimeout(timer);
786
+ timer = null;
787
+ lastArgs = null;
788
+ };
789
+ debounced.flush = () => {
790
+ if (timer) {
791
+ clearTimeout(timer);
792
+ run2();
793
+ }
794
+ };
795
+ return debounced;
796
+ }
797
+
735
798
  // src/codex-pty-runner.ts
736
799
  var OUTPUT_BUFFER_MAX = 65536;
737
800
  var PTY_COLS = 120;
@@ -739,6 +802,9 @@ var PTY_ROWS = 40;
739
802
  var SCREEN_SCROLLBACK = 1e3;
740
803
  var CODEX_PROMPT_READY_TEXT = "Ready";
741
804
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
805
+ var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
806
+ var QUIET_DETECT_MS = 500;
807
+ var CODEX_READY_FALLBACK_MS = 8e3;
742
808
  var SUBMIT_BYTES = "\r";
743
809
  var CODEX_SUBMIT_DELAY_MS = 16;
744
810
  function digestBytes(s) {
@@ -746,6 +812,40 @@ function digestBytes(s) {
746
812
  if (escaped.length <= 200) return escaped;
747
813
  return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
748
814
  }
815
+ function gateCard(gate, lines) {
816
+ if (gate === "hooks") {
817
+ const countLine = lines.find((l) => /new or changed/i.test(l))?.trim();
818
+ return {
819
+ prompt: [
820
+ "Hooks need review",
821
+ countLine,
822
+ "Hooks can run outside the sandbox after you trust them."
823
+ ].filter(Boolean).join(" \u2014 "),
824
+ options: [
825
+ { index: 2, label: "Trust all and continue", answerKeys: "2\r" },
826
+ { index: 3, label: "Continue without trusting (hooks won't run)", answerKeys: "3\r" },
827
+ {
828
+ index: 4,
829
+ label: "Trust all and continue (remember for all projects)",
830
+ answerKeys: "4\r"
831
+ },
832
+ {
833
+ index: 5,
834
+ label: "Continue without trusting (remember for all projects)",
835
+ answerKeys: "5\r"
836
+ }
837
+ ]
838
+ };
839
+ }
840
+ return {
841
+ prompt: lines.find((l) => CODEX_TRUST_GATE_REGEX.test(l))?.trim() ?? "Do you trust the contents of this directory?",
842
+ options: [
843
+ { index: 1, label: "Yes, continue", answerKeys: "1\r" },
844
+ { index: 2, label: "No, quit", answerKeys: "2\r" },
845
+ { index: 3, label: "Yes, continue (remember for all projects)", answerKeys: "3\r" }
846
+ ]
847
+ };
848
+ }
749
849
  var pty = null;
750
850
  async function loadPty() {
751
851
  if (pty) return pty;
@@ -772,8 +872,8 @@ var CodexPtyRunner = class {
772
872
  onOutput;
773
873
  onStatusChange;
774
874
  onReady;
775
- // Accepted for shape-compatibility with PTYManagerOptions; Codex has no
776
- // detected equivalent yet (Phase 0) never invoked.
875
+ // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
876
+ // as question cards; null dismisses the card once the gate leaves the screen.
777
877
  onPermissionChange;
778
878
  onLiveQuestion;
779
879
  onLiveQuestionGone;
@@ -784,8 +884,18 @@ var CodexPtyRunner = class {
784
884
  // Inputs received via sendInput() while the session was still pendingReady.
785
885
  // Flushed in arrival order once Codex reaches Ready.
786
886
  queuedInputs = /* @__PURE__ */ new Map();
787
- // Per-session debounce so the directory-trust gate's \r is only written once.
788
- trustGateAnswered = /* @__PURE__ */ new Set();
887
+ // Gate currently on a session's screen (card broadcast, unanswered). While
888
+ // set, queued-input flushes are held — a flushed digit would CONFIRM a
889
+ // dialog option — and sendKeys() intercepts remember-variant digits.
890
+ openGate = /* @__PURE__ */ new Map();
891
+ // `${sessionId}:${gate}` once a gate has been actioned (auto-answered or
892
+ // card broadcast) — dedupes repaints of the same dialog.
893
+ gateActioned = /* @__PURE__ */ new Set();
894
+ // Per-session trailing debounce re-armed on every chunk; on quiet, re-runs
895
+ // screen detection so a blocked/truncated boot still reaches ready.
896
+ quietCheckers = /* @__PURE__ */ new Map();
897
+ // Per-session flat backstop from spawn (CODEX_READY_FALLBACK_MS).
898
+ readyFallbackTimers = /* @__PURE__ */ new Map();
789
899
  // In-flight start()/startFresh() calls keyed by sessionId. A second
790
900
  // concurrent resume for the same session (double-tap, client retry) awaits
791
901
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -815,7 +925,7 @@ var CodexPtyRunner = class {
815
925
  }
816
926
  async doStart(sessionId, options) {
817
927
  const nodePty = await loadPty();
818
- const projectName = options.projectName ?? (0, import_path4.basename)(options.projectPath);
928
+ const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
819
929
  const proc = nodePty.spawn(
820
930
  resolveCodexExe(),
821
931
  ["resume", sessionId, "--cd", options.projectPath, "--no-alt-screen"],
@@ -844,6 +954,7 @@ var CodexPtyRunner = class {
844
954
  };
845
955
  this.sessions.set(sessionId, session);
846
956
  this.pendingReady.add(sessionId);
957
+ this.armReadyFallback(sessionId);
847
958
  proc.onData((data) => {
848
959
  this.handleOutput(sessionId, data);
849
960
  });
@@ -860,7 +971,7 @@ var CodexPtyRunner = class {
860
971
  async startFresh(options) {
861
972
  const nodePty = await loadPty();
862
973
  const sessionId = (0, import_crypto2.randomUUID)();
863
- const projectName = options.projectName ?? (0, import_path4.basename)(options.projectPath);
974
+ const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
864
975
  const args = ["--cd", options.projectPath, "--no-alt-screen"];
865
976
  if (options.systemPrompt) {
866
977
  args.push(options.systemPrompt);
@@ -889,6 +1000,7 @@ var CodexPtyRunner = class {
889
1000
  };
890
1001
  this.sessions.set(sessionId, session);
891
1002
  this.pendingReady.add(sessionId);
1003
+ this.armReadyFallback(sessionId);
892
1004
  proc.onData((data) => {
893
1005
  this.handleOutput(sessionId, data);
894
1006
  });
@@ -898,6 +1010,21 @@ var CodexPtyRunner = class {
898
1010
  });
899
1011
  return toPublicSession(session);
900
1012
  }
1013
+ // Flat backstop: if neither the "Ready" marker nor the quiet-checker settled
1014
+ // the session within CODEX_READY_FALLBACK_MS of spawn, mark it ready anyway
1015
+ // so start requests resolve and mobile can watch the boot live. unref() so a
1016
+ // pending timer never holds the process open.
1017
+ armReadyFallback(sessionId) {
1018
+ const timer = setTimeout(() => {
1019
+ this.readyFallbackTimers.delete(sessionId);
1020
+ const session = this.sessions.get(sessionId);
1021
+ if (session?.status === "running" && this.pendingReady.has(sessionId)) {
1022
+ this.markReady(sessionId, session, "fallback:timeout");
1023
+ }
1024
+ }, CODEX_READY_FALLBACK_MS);
1025
+ timer.unref?.();
1026
+ this.readyFallbackTimers.set(sessionId, timer);
1027
+ }
901
1028
  // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
902
1029
  sendKeys(sessionId, keys) {
903
1030
  const session = this.sessions.get(sessionId);
@@ -909,13 +1036,47 @@ var CodexPtyRunner = class {
909
1036
  session.status = "running";
910
1037
  this.onStatusChange?.(toPublicSession(session));
911
1038
  }
1039
+ const gate = this.openGate.get(sessionId);
1040
+ const digit = gate ? /^([0-9])\r?$/.exec(keys)?.[1] : void 0;
1041
+ const out = gate && digit ? this.resolveGateAnswer(sessionId, gate, digit) : keys;
912
1042
  this.log.info(
913
- `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
914
- { event: "codex.keys_write", sessionId, byteLen: keys.length }
1043
+ `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${out.length} digest=${digestBytes(out)}`,
1044
+ { event: "codex.keys_write", sessionId, byteLen: out.length }
915
1045
  );
916
- session.process.write(keys);
1046
+ session.process.write(out);
917
1047
  session.lastActivityAt = /* @__PURE__ */ new Date();
918
1048
  }
1049
+ // Map a gate-card digit to the PTY bytes that answer the real dialog,
1050
+ // persisting the choice when the digit was a synthetic "remember for all
1051
+ // projects" option (those numbers don't exist on the actual dialog and must
1052
+ // never reach codex). The trailing \r mobile sends is dropped: a digit alone
1053
+ // selects AND confirms (live-probe verified), and a stray Enter would land
1054
+ // on whatever screen follows.
1055
+ resolveGateAnswer(sessionId, gate, digit) {
1056
+ let real = digit;
1057
+ let remembered = false;
1058
+ if (gate === "hooks" && digit === "4") {
1059
+ saveGateAnswer("codexHooksGate", "trust_all");
1060
+ real = "2";
1061
+ remembered = true;
1062
+ } else if (gate === "hooks" && digit === "5") {
1063
+ saveGateAnswer("codexHooksGate", "continue_untrusted");
1064
+ real = "3";
1065
+ remembered = true;
1066
+ } else if (gate === "trust" && digit === "3") {
1067
+ saveGateAnswer("codexTrustGate", "yes");
1068
+ real = "1";
1069
+ remembered = true;
1070
+ }
1071
+ this.log.info(`[codex.gate_answer] ${sessionId.slice(0, 8)} ${gate} digit=${real}`, {
1072
+ event: "codex.gate_answer",
1073
+ sessionId,
1074
+ gate,
1075
+ digit: real,
1076
+ remembered
1077
+ });
1078
+ return real;
1079
+ }
919
1080
  sendInput(sessionId, input) {
920
1081
  const session = this.sessions.get(sessionId);
921
1082
  if (!session) throw new Error(`Session not found: ${sessionId}`);
@@ -985,8 +1146,12 @@ var CodexPtyRunner = class {
985
1146
  }, CODEX_SUBMIT_DELAY_MS);
986
1147
  }
987
1148
  // Drain any inputs sent while the session was still pendingReady, writing
988
- // them in arrival order now that Codex is Ready.
1149
+ // them in arrival order now that Codex is Ready. No-op while a gate dialog
1150
+ // is open (a flushed digit would confirm a dialog option) or while still
1151
+ // pendingReady (markReady drains it) — the gate-close path re-drives it for
1152
+ // the ready-with-gate-open case.
989
1153
  flushQueuedInputs(sessionId) {
1154
+ if (this.openGate.has(sessionId) || this.pendingReady.has(sessionId)) return;
990
1155
  const queue = this.queuedInputs.get(sessionId);
991
1156
  if (!queue || queue.length === 0) return;
992
1157
  this.queuedInputs.delete(sessionId);
@@ -1031,7 +1196,7 @@ var CodexPtyRunner = class {
1031
1196
  if (!session) return;
1032
1197
  this.pendingReady.delete(sessionId);
1033
1198
  this.queuedInputs.delete(sessionId);
1034
- this.trustGateAnswered.delete(sessionId);
1199
+ this.clearSessionDetectors(sessionId);
1035
1200
  try {
1036
1201
  session.process.kill("SIGINT");
1037
1202
  } catch {
@@ -1042,6 +1207,21 @@ var CodexPtyRunner = class {
1042
1207
  this.sessions.delete(sessionId);
1043
1208
  this.onStatusChange?.(toPublicSession(session));
1044
1209
  }
1210
+ // Drop a session's detection state: quiet-checker, ready-fallback timer,
1211
+ // gate bookkeeping — and dismiss a still-open gate card so mobile doesn't
1212
+ // keep rendering a question for a dead PTY.
1213
+ clearSessionDetectors(sessionId) {
1214
+ this.quietCheckers.get(sessionId)?.cancel();
1215
+ this.quietCheckers.delete(sessionId);
1216
+ const timer = this.readyFallbackTimers.get(sessionId);
1217
+ if (timer) clearTimeout(timer);
1218
+ this.readyFallbackTimers.delete(sessionId);
1219
+ if (this.openGate.delete(sessionId)) {
1220
+ this.onPermissionChange?.(sessionId, null);
1221
+ }
1222
+ this.gateActioned.delete(`${sessionId}:hooks`);
1223
+ this.gateActioned.delete(`${sessionId}:trust`);
1224
+ }
1045
1225
  getOutput(sessionId) {
1046
1226
  const session = this.sessions.get(sessionId);
1047
1227
  if (!session) throw new Error(`Session not found: ${sessionId}`);
@@ -1081,10 +1261,19 @@ var CodexPtyRunner = class {
1081
1261
  }
1082
1262
  session.screen.dispose();
1083
1263
  }
1264
+ for (const sessionId of Array.from(this.quietCheckers.keys())) {
1265
+ this.clearSessionDetectors(sessionId);
1266
+ }
1267
+ for (const timer of this.readyFallbackTimers.values()) {
1268
+ clearTimeout(timer);
1269
+ }
1084
1270
  this.sessions.clear();
1085
1271
  this.pendingReady.clear();
1086
1272
  this.queuedInputs.clear();
1087
- this.trustGateAnswered.clear();
1273
+ this.openGate.clear();
1274
+ this.gateActioned.clear();
1275
+ this.quietCheckers.clear();
1276
+ this.readyFallbackTimers.clear();
1088
1277
  }
1089
1278
  handleOutput(sessionId, data) {
1090
1279
  const session = this.sessions.get(sessionId);
@@ -1099,44 +1288,92 @@ var CodexPtyRunner = class {
1099
1288
  session.screen.write(data);
1100
1289
  session.lastOutput = stripAnsi(data);
1101
1290
  this.onOutput?.(sessionId, data);
1102
- this.detectReady(sessionId, session).catch((err) => {
1291
+ this.detectScreenState(sessionId, "chunk").catch((err) => {
1103
1292
  this.log.warn("[codex.ready_detect] failed", {
1104
1293
  event: "codex.ready_detect_failed",
1105
1294
  sessionId,
1106
1295
  err
1107
1296
  });
1108
1297
  });
1298
+ let quiet = this.quietCheckers.get(sessionId);
1299
+ if (!quiet) {
1300
+ quiet = debounce(() => {
1301
+ this.detectScreenState(sessionId, "quiet").catch((err) => {
1302
+ this.log.warn("[codex.ready_detect] failed", {
1303
+ event: "codex.ready_detect_failed",
1304
+ sessionId,
1305
+ err
1306
+ });
1307
+ });
1308
+ }, QUIET_DETECT_MS);
1309
+ this.quietCheckers.set(sessionId, quiet);
1310
+ }
1311
+ quiet();
1109
1312
  }
1110
- // Renders the session's headless screen and checks for the directory-trust
1111
- // gate (answered once, debounced) and the "Ready" status-bar text. Only
1112
- // transitions to waiting_input / fires onReady when the rendered status
1113
- // line literally contains "Ready" `›` alone (visible during "Starting")
1114
- // is NOT a valid readiness signal (Phase 0).
1115
- async detectReady(sessionId, session) {
1116
- if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1313
+ // Renders the session's headless screen and drives both detections:
1314
+ // - Gates (directory trust, hooks review) checked on EVERY pass,
1315
+ // independent of pendingReady, so a gate appearing after ready is still
1316
+ // surfaced and a gate leaving the screen closes its card.
1317
+ // - Readiness the "Ready" status-bar marker while pendingReady, plus the
1318
+ // quiet path: after QUIET_DETECT_MS of PTY silence a still-pending
1319
+ // session is marked ready anyway (`›` alone is NOT a marker — Phase 0 —
1320
+ // but a quiet boot screen is more useful to the user live than a
1321
+ // spinner, and "Ready" may be truncated off the 120-col status bar).
1322
+ async detectScreenState(sessionId, trigger) {
1323
+ const session = this.sessions.get(sessionId);
1324
+ if (!session || session.status === "idle") return;
1117
1325
  const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1118
1326
  const screenText = lines.join("\n");
1119
- if (CODEX_TRUST_GATE_REGEX.test(screenText)) {
1120
- if (!this.trustGateAnswered.has(sessionId)) {
1121
- this.trustGateAnswered.add(sessionId);
1122
- this.log.info(`[codex.trust_gate] ${sessionId.slice(0, 8)} auto-answering`, {
1123
- event: "codex.trust_gate",
1124
- sessionId
1125
- });
1126
- session.process.write("\r");
1127
- }
1128
- return;
1327
+ const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1328
+ if (gate) {
1329
+ this.handleGate(sessionId, session, gate, lines);
1330
+ } else if (this.openGate.delete(sessionId)) {
1331
+ this.onPermissionChange?.(sessionId, null);
1332
+ this.flushQueuedInputs(sessionId);
1129
1333
  }
1334
+ if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1130
1335
  const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1131
- if (!lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) return;
1132
- this.markReady(sessionId, session);
1336
+ if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1337
+ this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
1338
+ } else if (trigger === "quiet") {
1339
+ this.markReady(sessionId, session, "quiet:timeout");
1340
+ }
1341
+ }
1342
+ // Answer a gate from the persisted remember-store, or surface it as a
1343
+ // question card over the permission transport. Actioned once per session and
1344
+ // gate type — repaints of the same dialog neither re-write nor re-broadcast.
1345
+ handleGate(sessionId, session, gate, lines) {
1346
+ const key = `${sessionId}:${gate}`;
1347
+ if (this.gateActioned.has(key)) return;
1348
+ this.gateActioned.add(key);
1349
+ const remembered = rememberedGateDigit(gate);
1350
+ if (remembered) {
1351
+ this.log.info(`[codex.gate_auto_answer] ${sessionId.slice(0, 8)} ${gate} \u2192 ${remembered}`, {
1352
+ event: "codex.gate_auto_answer",
1353
+ sessionId,
1354
+ gate,
1355
+ digit: remembered
1356
+ });
1357
+ session.process.write(remembered);
1358
+ return;
1359
+ }
1360
+ this.openGate.set(sessionId, gate);
1361
+ const card = gateCard(gate, lines);
1362
+ this.log.info(`[codex.gate_prompt] ${sessionId.slice(0, 8)} ${gate}`, {
1363
+ event: "codex.gate_prompt",
1364
+ sessionId,
1365
+ gate,
1366
+ prompt: card.prompt
1367
+ });
1368
+ this.onPermissionChange?.(sessionId, card);
1133
1369
  }
1134
- markReady(sessionId, session) {
1370
+ markReady(sessionId, session, reason) {
1135
1371
  session.lastActivityAt = /* @__PURE__ */ new Date();
1136
1372
  session.status = "waiting_input";
1137
- this.log.info(`[codex.ready] ${sessionId.slice(0, 8)}`, {
1373
+ this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1138
1374
  event: "codex.ready",
1139
- sessionId
1375
+ sessionId,
1376
+ reason
1140
1377
  });
1141
1378
  this.onStatusChange?.(toPublicSession(session));
1142
1379
  if (this.pendingReady.has(sessionId)) {
@@ -1152,7 +1389,7 @@ var CodexPtyRunner = class {
1152
1389
  session.status = "idle";
1153
1390
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1154
1391
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1155
- if (!(0, import_fs4.existsSync)(session.projectPath)) {
1392
+ if (!(0, import_fs5.existsSync)(session.projectPath)) {
1156
1393
  session.failureReason = `Project directory not found: ${session.projectPath}`;
1157
1394
  } else {
1158
1395
  session.failureReason = `Codex process exited immediately (code ${exitCode}).`;
@@ -1162,7 +1399,7 @@ var CodexPtyRunner = class {
1162
1399
  session.screen.dispose();
1163
1400
  this.sessions.delete(sessionId);
1164
1401
  this.queuedInputs.delete(sessionId);
1165
- this.trustGateAnswered.delete(sessionId);
1402
+ this.clearSessionDetectors(sessionId);
1166
1403
  }
1167
1404
  };
1168
1405
  function toPublicSession(s) {
@@ -1189,8 +1426,8 @@ function stripAnsi(str) {
1189
1426
  // src/pty-manager.ts
1190
1427
  var import_headless2 = require("@xterm/headless");
1191
1428
  var import_crypto3 = require("crypto");
1192
- var import_fs5 = require("fs");
1193
- var import_path5 = require("path");
1429
+ var import_fs6 = require("fs");
1430
+ var import_path6 = require("path");
1194
1431
 
1195
1432
  // src/services/questions/detectPermissionGate.ts
1196
1433
  var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
@@ -1345,9 +1582,15 @@ function detectShellPrompt(lines) {
1345
1582
  ]
1346
1583
  };
1347
1584
  }
1348
- if (NUMBERED_RE.test(last.text)) {
1585
+ const lastNumberedIdx = (() => {
1586
+ for (let i = last.idx; i >= 0; i--) {
1587
+ if (NUMBERED_RE.test(lines[i])) return i;
1588
+ }
1589
+ return -1;
1590
+ })();
1591
+ if (lastNumberedIdx >= 0) {
1349
1592
  const options = [];
1350
- for (let i = 0; i <= last.idx; i++) {
1593
+ for (let i = 0; i <= lastNumberedIdx; i++) {
1351
1594
  const m = NUMBERED_RE.exec(lines[i]);
1352
1595
  if (!m) continue;
1353
1596
  const num = Number.parseInt(m[1], 10);
@@ -1375,37 +1618,6 @@ function detectShellPrompt(lines) {
1375
1618
  return null;
1376
1619
  }
1377
1620
 
1378
- // src/utils/debounce.ts
1379
- function debounce(fn, waitMs) {
1380
- let timer = null;
1381
- let lastArgs = null;
1382
- const run2 = () => {
1383
- timer = null;
1384
- if (lastArgs) {
1385
- const args = lastArgs;
1386
- lastArgs = null;
1387
- fn(...args);
1388
- }
1389
- };
1390
- const debounced = (...args) => {
1391
- lastArgs = args;
1392
- if (timer) clearTimeout(timer);
1393
- timer = setTimeout(run2, waitMs);
1394
- };
1395
- debounced.cancel = () => {
1396
- if (timer) clearTimeout(timer);
1397
- timer = null;
1398
- lastArgs = null;
1399
- };
1400
- debounced.flush = () => {
1401
- if (timer) {
1402
- clearTimeout(timer);
1403
- run2();
1404
- }
1405
- };
1406
- return debounced;
1407
- }
1408
-
1409
1621
  // src/pty-manager.ts
1410
1622
  var OUTPUT_BUFFER_MAX2 = 65536;
1411
1623
  var PTY_COLS2 = 120;
@@ -1413,7 +1625,7 @@ var PTY_ROWS2 = 40;
1413
1625
  var SCREEN_SCROLLBACK2 = 1e3;
1414
1626
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1415
1627
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1416
- var QUIET_DETECT_MS = 500;
1628
+ var QUIET_DETECT_MS2 = 500;
1417
1629
  function buildPasteBytes(input) {
1418
1630
  return `\x1B[200~${input}\x1B[201~`;
1419
1631
  }
@@ -1541,7 +1753,7 @@ var PTYManager = class {
1541
1753
  }
1542
1754
  async doStart(sessionId, options) {
1543
1755
  const nodePty = await loadPty2();
1544
- const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1756
+ const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
1545
1757
  const proc = nodePty.spawn(
1546
1758
  resolveClaudeExe(),
1547
1759
  [
@@ -1592,7 +1804,7 @@ var PTYManager = class {
1592
1804
  async startFresh(options) {
1593
1805
  const nodePty = await loadPty2();
1594
1806
  const sessionId = (0, import_crypto3.randomUUID)();
1595
- const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1807
+ const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
1596
1808
  const args = [
1597
1809
  "--permission-mode",
1598
1810
  options.permissionMode ?? "acceptEdits",
@@ -1913,7 +2125,7 @@ var PTYManager = class {
1913
2125
  });
1914
2126
  let quiet = this.quietCheckers.get(sessionId);
1915
2127
  if (!quiet) {
1916
- quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
2128
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS2);
1917
2129
  this.quietCheckers.set(sessionId, quiet);
1918
2130
  }
1919
2131
  quiet();
@@ -2036,7 +2248,7 @@ var PTYManager = class {
2036
2248
  session.status = "idle";
2037
2249
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
2038
2250
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
2039
- if (!(0, import_fs5.existsSync)(session.projectPath)) {
2251
+ if (!(0, import_fs6.existsSync)(session.projectPath)) {
2040
2252
  session.failureReason = `Project directory not found: ${session.projectPath}`;
2041
2253
  } else {
2042
2254
  session.failureReason = `Process exited immediately (code ${exitCode}). Check that the Claude binary is installed and accessible.`;
@@ -2164,7 +2376,7 @@ var LiveSessionManager = class {
2164
2376
  const runner = this.runners.get(provider);
2165
2377
  if (runner) return runner;
2166
2378
  const err = new Error(
2167
- `Live ${provider} sessions are not implemented yet for ${(0, import_path6.basename)(projectPath)}`
2379
+ `Live ${provider} sessions are not implemented yet for ${(0, import_path7.basename)(projectPath)}`
2168
2380
  );
2169
2381
  err.statusCode = 501;
2170
2382
  throw err;
@@ -2173,10 +2385,10 @@ var LiveSessionManager = class {
2173
2385
 
2174
2386
  // src/process-discovery.ts
2175
2387
  var import_child_process2 = require("child_process");
2176
- var import_os3 = require("os");
2177
- var import_path7 = require("path");
2388
+ var import_os4 = require("os");
2389
+ var import_path8 = require("path");
2178
2390
  async function discoverClaudeProcesses() {
2179
- if ((0, import_os3.platform)() === "win32") return discoverWindows();
2391
+ if ((0, import_os4.platform)() === "win32") return discoverWindows();
2180
2392
  return discoverUnix();
2181
2393
  }
2182
2394
  async function discoverUnix() {
@@ -2193,7 +2405,7 @@ async function discoverUnix() {
2193
2405
  return {
2194
2406
  pid,
2195
2407
  projectPath: cwd,
2196
- projectName: (0, import_path7.basename)(cwd),
2408
+ projectName: (0, import_path8.basename)(cwd),
2197
2409
  branch: await readGitBranch(cwd),
2198
2410
  conversationId,
2199
2411
  startedAt
@@ -2215,7 +2427,7 @@ async function discoverWindows() {
2215
2427
  return {
2216
2428
  pid,
2217
2429
  projectPath: info.cwd,
2218
- projectName: (0, import_path7.basename)(info.cwd),
2430
+ projectName: (0, import_path8.basename)(info.cwd),
2219
2431
  branch: await readGitBranch(info.cwd),
2220
2432
  conversationId: extractResumeId(info.args),
2221
2433
  startedAt: info.startedAt
@@ -2296,7 +2508,7 @@ async function getProcessInfoWindows(pid) {
2296
2508
  const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2297
2509
  if (Number.isNaN(startedAt.getTime())) return null;
2298
2510
  const exePath = parts[3] ?? "";
2299
- const cwd = exePath ? (0, import_path7.dirname)(exePath) : "";
2511
+ const cwd = exePath ? (0, import_path8.dirname)(exePath) : "";
2300
2512
  return { cwd, args, startedAt };
2301
2513
  } catch {
2302
2514
  return null;
@@ -2319,11 +2531,11 @@ var import_node_ws = require("@hono/node-ws");
2319
2531
  var import_client = require("@temporalio/client");
2320
2532
  var import_scanner3 = require("@threadbase-sh/scanner");
2321
2533
  var import_events = require("events");
2322
- var import_fs12 = require("fs");
2534
+ var import_fs13 = require("fs");
2323
2535
  var import_promises7 = require("fs/promises");
2324
2536
  var import_http = require("http");
2325
- var import_os6 = require("os");
2326
- var import_path13 = require("path");
2537
+ var import_os7 = require("os");
2538
+ var import_path14 = require("path");
2327
2539
  var import_readline = require("readline");
2328
2540
 
2329
2541
  // node_modules/nanoid/index.js
@@ -2582,14 +2794,15 @@ async function handleStartAgentSession(body, deps) {
2582
2794
  }
2583
2795
 
2584
2796
  // src/api/app.ts
2585
- var import_hono11 = require("hono");
2797
+ var import_hono12 = require("hono");
2586
2798
 
2587
2799
  // src/api/middleware/auth.middleware.ts
2588
2800
  function isLocalRequest(remoteAddr) {
2589
2801
  const addr = remoteAddr ?? "";
2590
2802
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2591
2803
  }
2592
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2804
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2805
+ var LOCAL_ONLY_PATHS = /* @__PURE__ */ new Set(["/api/logs", "/api/logs/meta"]);
2593
2806
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2594
2807
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
2595
2808
  var authMiddleware = (deps) => async (c, next) => {
@@ -2600,8 +2813,12 @@ var authMiddleware = (deps) => async (c, next) => {
2600
2813
  await next();
2601
2814
  return;
2602
2815
  }
2816
+ const remoteAddr = c.env.incoming?.socket?.remoteAddress;
2817
+ if (LOCAL_ONLY_PATHS.has(path) && isLocalRequest(remoteAddr)) {
2818
+ await next();
2819
+ return;
2820
+ }
2603
2821
  if (deps.localNoAuth) {
2604
- const remoteAddr = c.env.incoming?.socket?.remoteAddress;
2605
2822
  if (isLocalRequest(remoteAddr)) {
2606
2823
  await next();
2607
2824
  return;
@@ -2773,16 +2990,145 @@ var createHealthRoutes = () => {
2773
2990
  return app;
2774
2991
  };
2775
2992
 
2993
+ // src/api/routes/logs.routes.ts
2994
+ var import_node_fs3 = require("fs");
2995
+ var import_node_path5 = require("path");
2996
+ var import_hono5 = require("hono");
2997
+
2998
+ // src/lifecycle/constants.ts
2999
+ var import_node_os = require("os");
3000
+ var import_node_path4 = require("path");
3001
+ var TASK_NAME = process.env.THREADBASE_TASK_NAME ?? "Threadbase";
3002
+ function installDir() {
3003
+ return process.env.THREADBASE_INSTALL_DIR ?? (0, import_node_path4.join)((0, import_node_os.homedir)(), ".threadbase");
3004
+ }
3005
+
3006
+ // src/api/routes/logs.routes.ts
3007
+ var logger2 = getLogger("logs-api");
3008
+ function resolveLogPath(source) {
3009
+ return (0, import_node_path5.join)(installDir(), "logs", `${source}.log`);
3010
+ }
3011
+ function pickDefaultSource() {
3012
+ for (const source of ["stdout", "stderr", "dev"]) {
3013
+ const p = resolveLogPath(source);
3014
+ if ((0, import_node_fs3.existsSync)(p) && (0, import_node_fs3.statSync)(p).size > 0) return source;
3015
+ }
3016
+ return "stdout";
3017
+ }
3018
+ function readLogLines(filePath, sinceOffset, limit) {
3019
+ if (!(0, import_node_fs3.existsSync)(filePath)) {
3020
+ return { lines: [], offset: 0, total: 0 };
3021
+ }
3022
+ const fd = (0, import_node_fs3.openSync)(filePath, "r");
3023
+ try {
3024
+ const { size } = (0, import_node_fs3.fstatSync)(fd);
3025
+ if (size === 0) return { lines: [], offset: 0, total: 0 };
3026
+ const maxBytes = Math.min(size, 2 * 1024 * 1024);
3027
+ const start = size - maxBytes;
3028
+ const buf = Buffer.alloc(maxBytes);
3029
+ (0, import_node_fs3.readSync)(fd, buf, 0, maxBytes, start);
3030
+ let text = buf.toString("utf8");
3031
+ if (start > 0) {
3032
+ const firstNl = text.indexOf("\n");
3033
+ if (firstNl >= 0) text = text.slice(firstNl + 1);
3034
+ }
3035
+ const allLines = text.split("\n").filter((line) => line.trim() && !line.startsWith("==="));
3036
+ let lines;
3037
+ let newOffset;
3038
+ if (sinceOffset > 0 && sinceOffset < allLines.length) {
3039
+ lines = allLines.slice(sinceOffset, sinceOffset + limit);
3040
+ newOffset = sinceOffset + lines.length;
3041
+ } else if (sinceOffset >= allLines.length && sinceOffset > 0) {
3042
+ lines = [];
3043
+ newOffset = allLines.length;
3044
+ } else {
3045
+ lines = allLines.slice(-limit);
3046
+ newOffset = allLines.length;
3047
+ }
3048
+ return { lines, offset: newOffset, total: allLines.length };
3049
+ } finally {
3050
+ (0, import_node_fs3.closeSync)(fd);
3051
+ }
3052
+ }
3053
+ function createLogsRoutes() {
3054
+ const app = new import_hono5.Hono();
3055
+ app.get("/", (c) => {
3056
+ try {
3057
+ const sourceParam = (c.req.query("source") || "").toLowerCase();
3058
+ const source = sourceParam === "stdout" || sourceParam === "stderr" || sourceParam === "dev" ? sourceParam : pickDefaultSource();
3059
+ const logPath = resolveLogPath(source);
3060
+ const sinceOffset = parseInt(c.req.query("since") || "0", 10);
3061
+ const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
3062
+ if (!(0, import_node_fs3.existsSync)(logPath)) {
3063
+ return c.json({
3064
+ logs: [],
3065
+ message: `No log file found for source=${source}`,
3066
+ offset: 0,
3067
+ total: 0,
3068
+ source
3069
+ });
3070
+ }
3071
+ const { lines, offset, total } = readLogLines(logPath, sinceOffset, limit);
3072
+ const stats = (0, import_node_fs3.statSync)(logPath);
3073
+ return c.json({
3074
+ logs: lines,
3075
+ offset,
3076
+ total,
3077
+ hasMore: offset < total,
3078
+ source,
3079
+ fileSize: stats.size,
3080
+ fileModified: stats.mtime.toISOString()
3081
+ });
3082
+ } catch (error) {
3083
+ logger2.error("Failed to read logs", { error: String(error) });
3084
+ return c.json(
3085
+ {
3086
+ error: "Failed to read logs",
3087
+ logs: [],
3088
+ offset: 0,
3089
+ total: 0
3090
+ },
3091
+ 500
3092
+ );
3093
+ }
3094
+ });
3095
+ app.get("/meta", (c) => {
3096
+ try {
3097
+ const sources = ["stdout", "stderr", "dev"].map((source) => {
3098
+ const logPath = resolveLogPath(source);
3099
+ if (!(0, import_node_fs3.existsSync)(logPath)) {
3100
+ return { source, exists: false, total: 0, fileSize: 0 };
3101
+ }
3102
+ const stats = (0, import_node_fs3.statSync)(logPath);
3103
+ return {
3104
+ source,
3105
+ exists: true,
3106
+ fileSize: stats.size,
3107
+ fileModified: stats.mtime.toISOString()
3108
+ };
3109
+ });
3110
+ return c.json({
3111
+ defaultSource: pickDefaultSource(),
3112
+ sources
3113
+ });
3114
+ } catch (error) {
3115
+ logger2.error("Failed to read log metadata", { error: String(error) });
3116
+ return c.json({ error: "Failed to read log metadata", exists: false }, 500);
3117
+ }
3118
+ });
3119
+ return app;
3120
+ }
3121
+
2776
3122
  // src/api/routes/misc.routes.ts
2777
3123
  var import_node_child_process = require("child_process");
2778
3124
  var import_node_crypto2 = require("crypto");
2779
- var import_hono5 = require("hono");
2780
- var import_os4 = require("os");
3125
+ var import_hono6 = require("hono");
3126
+ var import_os5 = require("os");
2781
3127
 
2782
3128
  // src/config/update-config.ts
2783
- var import_node_fs3 = require("fs");
2784
- var import_node_os = require("os");
2785
- var import_node_path4 = require("path");
3129
+ var import_node_fs4 = require("fs");
3130
+ var import_node_os2 = require("os");
3131
+ var import_node_path6 = require("path");
2786
3132
  var import_yaml = require("yaml");
2787
3133
 
2788
3134
  // src/schemas/updateConfig.schema.ts
@@ -2798,12 +3144,12 @@ var UpdateConfigSchema = import_zod.z.object({
2798
3144
  }).strict();
2799
3145
 
2800
3146
  // src/config/update-config.ts
2801
- var DEFAULT_CONFIG_PATH = (0, import_node_path4.join)((0, import_node_os.homedir)(), ".threadbase", "update.yaml");
3147
+ var DEFAULT_CONFIG_PATH = (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".threadbase", "update.yaml");
2802
3148
  function loadUpdateConfig(opts = {}) {
2803
3149
  const path = opts.path ?? DEFAULT_CONFIG_PATH;
2804
3150
  let raw;
2805
3151
  try {
2806
- raw = (0, import_node_fs3.readFileSync)(path, "utf-8");
3152
+ raw = (0, import_node_fs4.readFileSync)(path, "utf-8");
2807
3153
  } catch (err) {
2808
3154
  if (err.code === "ENOENT") return null;
2809
3155
  throw err;
@@ -2850,12 +3196,12 @@ function verifyWebhookSignature(body, header, secret) {
2850
3196
  }
2851
3197
  var clientLog = getLogger("client");
2852
3198
  var createMiscRoutes = (deps) => {
2853
- const app = new import_hono5.Hono();
3199
+ const app = new import_hono6.Hono();
2854
3200
  app.get("/api/info", (c) => {
2855
3201
  const ptyIds = deps.ptyAttachedIds();
2856
3202
  return c.json({
2857
3203
  version: getVersion(),
2858
- machineName: (0, import_os4.hostname)(),
3204
+ machineName: (0, import_os5.hostname)(),
2859
3205
  platform: process.platform,
2860
3206
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
2861
3207
  publicUrl: deps.publicUrl
@@ -2926,11 +3272,11 @@ var createMiscRoutes = (deps) => {
2926
3272
  };
2927
3273
 
2928
3274
  // src/api/routes/pair.routes.ts
2929
- var import_hono6 = require("hono");
3275
+ var import_hono7 = require("hono");
2930
3276
  var ALREADY_HANDLED3 = 597;
2931
3277
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
2932
3278
  var createPairRoutes = (deps) => {
2933
- const app = new import_hono6.Hono();
3279
+ const app = new import_hono7.Hono();
2934
3280
  app.post("/start", (c) => {
2935
3281
  deps.handlePairStart(c.env.outgoing);
2936
3282
  return alreadyHandled3();
@@ -2943,11 +3289,11 @@ var createPairRoutes = (deps) => {
2943
3289
  };
2944
3290
 
2945
3291
  // src/api/routes/projects.routes.ts
2946
- var import_hono7 = require("hono");
3292
+ var import_hono8 = require("hono");
2947
3293
  var ALREADY_HANDLED4 = 597;
2948
3294
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
2949
3295
  var createProjectRoutes = (deps) => {
2950
- const app = new import_hono7.Hono();
3296
+ const app = new import_hono8.Hono();
2951
3297
  app.get("/", (c) => {
2952
3298
  const url = new URL(c.req.url);
2953
3299
  deps.handleListProjects(url, c.env.outgoing);
@@ -2962,11 +3308,11 @@ var createProjectRoutes = (deps) => {
2962
3308
  };
2963
3309
 
2964
3310
  // src/api/routes/scanner.routes.ts
2965
- var import_hono8 = require("hono");
3311
+ var import_hono9 = require("hono");
2966
3312
  var ALREADY_HANDLED5 = 597;
2967
3313
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
2968
3314
  var createScannerRoutes = (deps) => {
2969
- const app = new import_hono8.Hono();
3315
+ const app = new import_hono9.Hono();
2970
3316
  app.get("/api/search", async (c) => {
2971
3317
  const url = new URL(c.req.url);
2972
3318
  await deps.handleSearch(url, c.env.outgoing);
@@ -2976,11 +3322,11 @@ var createScannerRoutes = (deps) => {
2976
3322
  };
2977
3323
 
2978
3324
  // src/api/routes/sessions.routes.ts
2979
- var import_hono9 = require("hono");
3325
+ var import_hono10 = require("hono");
2980
3326
  var ALREADY_HANDLED6 = 597;
2981
3327
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
2982
3328
  var createSessionRoutes = (deps) => {
2983
- const app = new import_hono9.Hono();
3329
+ const app = new import_hono10.Hono();
2984
3330
  app.get("/count", (c) => {
2985
3331
  deps.handleSessionsCount(c.env.outgoing);
2986
3332
  return alreadyHandled6();
@@ -3047,19 +3393,21 @@ var createSessionRoutes = (deps) => {
3047
3393
  };
3048
3394
 
3049
3395
  // src/api/routes/ws.routes.ts
3050
- var import_hono10 = require("hono");
3396
+ var import_hono11 = require("hono");
3051
3397
  var createWsRoutes = (deps, upgradeWebSocket) => {
3052
- const app = new import_hono10.Hono();
3398
+ const app = new import_hono11.Hono();
3053
3399
  app.get(
3054
3400
  "/ws",
3055
- upgradeWebSocket(() => {
3401
+ upgradeWebSocket((c) => {
3402
+ const key = c.req.query("key");
3403
+ const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3056
3404
  let openWs = null;
3057
3405
  return {
3058
3406
  onOpen(_evt, ws) {
3059
3407
  const raw = ws.raw;
3060
3408
  if (!raw) return;
3061
3409
  openWs = raw;
3062
- deps.handleWsOpen(raw);
3410
+ deps.handleWsOpen(raw, preAuthed);
3063
3411
  },
3064
3412
  onMessage(evt, _ws) {
3065
3413
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -3075,7 +3423,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3075
3423
 
3076
3424
  // src/api/app.ts
3077
3425
  var createHonoApp = (deps, upgradeWebSocket) => {
3078
- const app = new import_hono11.Hono();
3426
+ const app = new import_hono12.Hono();
3079
3427
  const httpLog = getLogger("http");
3080
3428
  app.use("*", async (c, next) => {
3081
3429
  const start = Date.now();
@@ -3104,6 +3452,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3104
3452
  app.route("/api", createBrowseRoutes(deps));
3105
3453
  app.route("/", createScannerRoutes(deps));
3106
3454
  app.route("/internal", createProgressRoutes(deps));
3455
+ app.route("/api/logs", createLogsRoutes());
3107
3456
  if (upgradeWebSocket) {
3108
3457
  app.route("/", createWsRoutes(deps, upgradeWebSocket));
3109
3458
  }
@@ -3112,7 +3461,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3112
3461
 
3113
3462
  // src/browse.ts
3114
3463
  var import_promises2 = require("fs/promises");
3115
- var import_path8 = require("path");
3464
+ var import_path9 = require("path");
3116
3465
  var BrowsePathNotFoundError = class extends Error {
3117
3466
  constructor(message) {
3118
3467
  super(message);
@@ -3120,15 +3469,15 @@ var BrowsePathNotFoundError = class extends Error {
3120
3469
  }
3121
3470
  };
3122
3471
  async function resolveBrowsePath(browseRoot, relativePath) {
3123
- const normalizedRoot = (0, import_path8.resolve)(browseRoot);
3472
+ const normalizedRoot = (0, import_path9.resolve)(browseRoot);
3124
3473
  let sanitized;
3125
3474
  if (process.platform !== "win32" && relativePath.startsWith("/") && relativePath.length > 1 && relativePath.includes("/", 1)) {
3126
3475
  sanitized = relativePath;
3127
3476
  } else {
3128
3477
  sanitized = relativePath.replace(/^[/\\]+/, "");
3129
3478
  }
3130
- const target = sanitized ? (0, import_path8.resolve)(normalizedRoot, sanitized) : normalizedRoot;
3131
- const rootPrefix = normalizedRoot.endsWith(import_path8.sep) ? normalizedRoot : `${normalizedRoot}${import_path8.sep}`;
3479
+ const target = sanitized ? (0, import_path9.resolve)(normalizedRoot, sanitized) : normalizedRoot;
3480
+ const rootPrefix = normalizedRoot.endsWith(import_path9.sep) ? normalizedRoot : `${normalizedRoot}${import_path9.sep}`;
3132
3481
  if (!target.startsWith(rootPrefix) && target !== normalizedRoot) {
3133
3482
  throw new Error("Path outside browse root");
3134
3483
  }
@@ -3150,7 +3499,7 @@ async function createDirectory(parentAbsolutePath, name) {
3150
3499
  if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
3151
3500
  throw new Error("Invalid directory name");
3152
3501
  }
3153
- const target = (0, import_path8.join)(parentAbsolutePath, name);
3502
+ const target = (0, import_path9.join)(parentAbsolutePath, name);
3154
3503
  try {
3155
3504
  const s = await (0, import_promises2.stat)(target);
3156
3505
  if (s.isDirectory()) throw new Error("Directory already exists");
@@ -3164,19 +3513,19 @@ async function createDirectory(parentAbsolutePath, name) {
3164
3513
  // src/conversation-cache.ts
3165
3514
  var import_scanner2 = require("@threadbase-sh/scanner");
3166
3515
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
3167
- var import_fs8 = require("fs");
3516
+ var import_fs9 = require("fs");
3168
3517
  var import_promises3 = require("fs/promises");
3169
- var import_path10 = require("path");
3518
+ var import_path11 = require("path");
3170
3519
  var import_promises4 = require("timers/promises");
3171
3520
 
3172
3521
  // src/db/sqlite-migrate.ts
3173
- var import_fs6 = require("fs");
3174
- var import_path9 = require("path");
3522
+ var import_fs7 = require("fs");
3523
+ var import_path10 = require("path");
3175
3524
  var import_url2 = require("url");
3176
3525
  var import_meta2 = {};
3177
3526
  function getMigrationsDir2() {
3178
3527
  if (typeof import_meta2 !== "undefined" && import_meta2.url) {
3179
- return (0, import_path9.dirname)((0, import_url2.fileURLToPath)(import_meta2.url));
3528
+ return (0, import_path10.dirname)((0, import_url2.fileURLToPath)(import_meta2.url));
3180
3529
  }
3181
3530
  return __dirname;
3182
3531
  }
@@ -3188,8 +3537,8 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
3188
3537
  `;
3189
3538
  function runSqliteMigrations(db, migrationsDir) {
3190
3539
  db.exec(SCHEMA_MIGRATIONS_SQL);
3191
- const dir = migrationsDir ?? (0, import_path9.join)(getMigrationsDir2(), "migrations");
3192
- const files = (0, import_fs6.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
3540
+ const dir = migrationsDir ?? (0, import_path10.join)(getMigrationsDir2(), "migrations");
3541
+ const files = (0, import_fs7.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
3193
3542
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
3194
3543
  const appliedSet = new Set(appliedRows.map((r) => r.id));
3195
3544
  const recordApplied = db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)");
@@ -3200,7 +3549,7 @@ function runSqliteMigrations(db, migrationsDir) {
3200
3549
  skipped.push(file);
3201
3550
  continue;
3202
3551
  }
3203
- const sql = (0, import_fs6.readFileSync)((0, import_path9.join)(dir, file), "utf-8");
3552
+ const sql = (0, import_fs7.readFileSync)((0, import_path10.join)(dir, file), "utf-8");
3204
3553
  const tx = db.transaction(() => {
3205
3554
  db.exec(sql);
3206
3555
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -3212,7 +3561,7 @@ function runSqliteMigrations(db, migrationsDir) {
3212
3561
  }
3213
3562
 
3214
3563
  // src/services/conversations/isAgentConversation.ts
3215
- var import_fs7 = require("fs");
3564
+ var import_fs8 = require("fs");
3216
3565
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
3217
3566
  var CHUNK_BYTES = 64 * 1024;
3218
3567
  var ENTRYPOINT_PROBE = `"entrypoint":`;
@@ -3234,12 +3583,12 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3234
3583
  if (cached2 !== void 0) return cached2;
3235
3584
  let fd;
3236
3585
  try {
3237
- fd = (0, import_fs7.openSync)(filePath, "r");
3586
+ fd = (0, import_fs8.openSync)(filePath, "r");
3238
3587
  } catch {
3239
3588
  return false;
3240
3589
  }
3241
3590
  try {
3242
- const fileSize = (0, import_fs7.statSync)(filePath).size;
3591
+ const fileSize = (0, import_fs8.statSync)(filePath).size;
3243
3592
  if (fileSize === 0) {
3244
3593
  fileDecisionCache.set(key, false);
3245
3594
  return false;
@@ -3250,7 +3599,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3250
3599
  let carry = "";
3251
3600
  while (offset < fileSize) {
3252
3601
  const toRead = Math.min(CHUNK_BYTES, fileSize - offset);
3253
- const got = (0, import_fs7.readSync)(fd, buf, 0, toRead, offset);
3602
+ const got = (0, import_fs8.readSync)(fd, buf, 0, toRead, offset);
3254
3603
  if (got <= 0) break;
3255
3604
  const chunk = carry + buf.toString("utf8", 0, got);
3256
3605
  for (const marker of markers) {
@@ -3271,7 +3620,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3271
3620
  } catch {
3272
3621
  return false;
3273
3622
  } finally {
3274
- (0, import_fs7.closeSync)(fd);
3623
+ (0, import_fs8.closeSync)(fd);
3275
3624
  }
3276
3625
  }
3277
3626
  function parseAgentEntrypointsEnv(raw) {
@@ -3808,7 +4157,7 @@ var ConversationCache = class _ConversationCache {
3808
4157
  if (!fileState) return null;
3809
4158
  let stat3;
3810
4159
  try {
3811
- stat3 = (0, import_fs8.statSync)(filePath);
4160
+ stat3 = (0, import_fs9.statSync)(filePath);
3812
4161
  } catch {
3813
4162
  return null;
3814
4163
  }
@@ -3829,17 +4178,17 @@ var ConversationCache = class _ConversationCache {
3829
4178
  );
3830
4179
  if (rows.length === 0) return { messages: [], total, fromIndex: from };
3831
4180
  const messages = [];
3832
- const fd = (0, import_fs8.openSync)(filePath, "r");
4181
+ const fd = (0, import_fs9.openSync)(filePath, "r");
3833
4182
  try {
3834
4183
  const state = (0, import_scanner2.createJsonlParseState)();
3835
4184
  for (const row of rows) {
3836
4185
  const buf = Buffer.alloc(row.byte_length);
3837
- (0, import_fs8.readSync)(fd, buf, 0, row.byte_length, row.byte_offset);
4186
+ (0, import_fs9.readSync)(fd, buf, 0, row.byte_length, row.byte_offset);
3838
4187
  const msg = (0, import_scanner2.parseJsonlLine)(buf.toString("utf-8"), state);
3839
4188
  if (msg) messages.push(msg);
3840
4189
  }
3841
4190
  } finally {
3842
- (0, import_fs8.closeSync)(fd);
4191
+ (0, import_fs9.closeSync)(fd);
3843
4192
  }
3844
4193
  return { messages, total, fromIndex: from };
3845
4194
  }
@@ -3867,14 +4216,14 @@ var ConversationCache = class _ConversationCache {
3867
4216
  isAgentFileCached(filePath) {
3868
4217
  let s;
3869
4218
  try {
3870
- s = (0, import_fs8.statSync)(filePath);
4219
+ s = (0, import_fs9.statSync)(filePath);
3871
4220
  } catch {
3872
4221
  return false;
3873
4222
  }
3874
4223
  return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3875
4224
  }
3876
4225
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3877
- (0, import_fs8.mkdirSync)((0, import_path10.dirname)(dbPath), { recursive: true });
4226
+ (0, import_fs9.mkdirSync)((0, import_path11.dirname)(dbPath), { recursive: true });
3878
4227
  const db = new import_better_sqlite3.default(dbPath);
3879
4228
  db.pragma("journal_mode = WAL");
3880
4229
  db.pragma("foreign_keys = ON");
@@ -4055,7 +4404,7 @@ var ConversationCache = class _ConversationCache {
4055
4404
  let mtimeMs = null;
4056
4405
  let fileSize = null;
4057
4406
  try {
4058
- const s = (0, import_fs8.statSync)(m.filePath);
4407
+ const s = (0, import_fs9.statSync)(m.filePath);
4059
4408
  mtimeMs = s.mtimeMs;
4060
4409
  fileSize = s.size;
4061
4410
  } catch {
@@ -4114,8 +4463,8 @@ var ConversationCache = class _ConversationCache {
4114
4463
  let fileSize;
4115
4464
  let fd;
4116
4465
  try {
4117
- fileSize = (0, import_fs8.statSync)(filePath).size;
4118
- fd = (0, import_fs8.openSync)(filePath, "r");
4466
+ fileSize = (0, import_fs9.statSync)(filePath).size;
4467
+ fd = (0, import_fs9.openSync)(filePath, "r");
4119
4468
  } catch {
4120
4469
  return false;
4121
4470
  }
@@ -4128,7 +4477,7 @@ var ConversationCache = class _ConversationCache {
4128
4477
  while (pos > 0 && lines.length < this.tailSize * 4) {
4129
4478
  const toRead = Math.min(CHUNK, pos);
4130
4479
  pos -= toRead;
4131
- (0, import_fs8.readSync)(fd, buf, 0, toRead, pos);
4480
+ (0, import_fs9.readSync)(fd, buf, 0, toRead, pos);
4132
4481
  const chunk = buf.subarray(0, toRead).toString("utf8");
4133
4482
  const combined = chunk + partial;
4134
4483
  const parts = combined.split("\n");
@@ -4139,7 +4488,7 @@ var ConversationCache = class _ConversationCache {
4139
4488
  }
4140
4489
  if (partial) lines.push(partial);
4141
4490
  } finally {
4142
- (0, import_fs8.closeSync)(fd);
4491
+ (0, import_fs9.closeSync)(fd);
4143
4492
  }
4144
4493
  const msgs = [];
4145
4494
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -4341,7 +4690,7 @@ var ConversationCache = class _ConversationCache {
4341
4690
  * `handleGetConversation` can still serve the cached tail even when the
4342
4691
  * JSONL has been deleted.
4343
4692
  */
4344
- pruneGhostFiles(exists = import_fs8.existsSync) {
4693
+ pruneGhostFiles(exists = import_fs9.existsSync) {
4345
4694
  const rows = this.stmts.allFilePaths.all();
4346
4695
  const ghosts = [];
4347
4696
  const prune = this.db.transaction((ids) => {
@@ -4396,7 +4745,7 @@ var ConversationCache = class _ConversationCache {
4396
4745
  * Returns the removed IDs.
4397
4746
  */
4398
4747
  reconcileDeletions(livePaths, opts) {
4399
- const exists = opts?.exists ?? import_fs8.existsSync;
4748
+ const exists = opts?.exists ?? import_fs9.existsSync;
4400
4749
  const rows = this.stmts.allFilePaths.all();
4401
4750
  const removed = [];
4402
4751
  const drop = this.db.transaction((ids) => {
@@ -4625,23 +4974,23 @@ async function recordUpload(pool2, instanceId, row) {
4625
4974
  }
4626
4975
 
4627
4976
  // src/handlers/handleListProjects.ts
4628
- var import_fs9 = require("fs");
4629
- var import_os5 = require("os");
4630
- var import_path11 = require("path");
4977
+ var import_fs10 = require("fs");
4978
+ var import_os6 = require("os");
4979
+ var import_path12 = require("path");
4631
4980
  function decodeProjectPath(dirName) {
4632
4981
  return dirName.replace(/-/g, "/");
4633
4982
  }
4634
4983
  function handleListProjects(url, res) {
4635
4984
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
4636
4985
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
4637
- const projectsDir = (0, import_path11.join)((0, import_os5.homedir)(), ".claude", "projects");
4986
+ const projectsDir = (0, import_path12.join)((0, import_os6.homedir)(), ".claude", "projects");
4638
4987
  let entries;
4639
4988
  try {
4640
- entries = (0, import_fs9.readdirSync)(projectsDir).map((dirName) => {
4641
- const fullPath = (0, import_path11.join)(projectsDir, dirName);
4989
+ entries = (0, import_fs10.readdirSync)(projectsDir).map((dirName) => {
4990
+ const fullPath = (0, import_path12.join)(projectsDir, dirName);
4642
4991
  let mtime = 0;
4643
4992
  try {
4644
- mtime = (0, import_fs9.statSync)(fullPath).mtimeMs;
4993
+ mtime = (0, import_fs10.statSync)(fullPath).mtimeMs;
4645
4994
  } catch {
4646
4995
  }
4647
4996
  const path = decodeProjectPath(dirName);
@@ -4736,7 +5085,7 @@ function seal(plaintext, recipientPublicKeyBase64) {
4736
5085
 
4737
5086
  // src/services/conversations/conversationWatcher.ts
4738
5087
  var import_chokidar = __toESM(require("chokidar"), 1);
4739
- var import_fs10 = require("fs");
5088
+ var import_fs11 = require("fs");
4740
5089
  var import_promises5 = require("fs/promises");
4741
5090
  var ConversationWatcher = class {
4742
5091
  files = /* @__PURE__ */ new Map();
@@ -4759,7 +5108,7 @@ var ConversationWatcher = class {
4759
5108
  if (this.files.has(filePath)) return;
4760
5109
  let offset;
4761
5110
  try {
4762
- offset = (0, import_fs10.statSync)(filePath).size;
5111
+ offset = (0, import_fs11.statSync)(filePath).size;
4763
5112
  } catch {
4764
5113
  offset = 0;
4765
5114
  }
@@ -4934,14 +5283,14 @@ function findSearchTarget(messages, query) {
4934
5283
  }
4935
5284
 
4936
5285
  // src/services/conversations/pruneAgentConversations.ts
4937
- var import_fs11 = require("fs");
5286
+ var import_fs12 = require("fs");
4938
5287
  function pruneAgentConversations(cache) {
4939
5288
  const db = cache.getDatabase();
4940
5289
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
4941
5290
  let pruned = 0;
4942
5291
  let missing = 0;
4943
5292
  for (const row of rows) {
4944
- if (!(0, import_fs11.existsSync)(row.file_path)) {
5293
+ if (!(0, import_fs12.existsSync)(row.file_path)) {
4945
5294
  missing += 1;
4946
5295
  continue;
4947
5296
  }
@@ -4964,6 +5313,13 @@ function deriveProjectChatTitle(input) {
4964
5313
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
4965
5314
  }
4966
5315
 
5316
+ // src/services/questions/permissionAnswerKeys.ts
5317
+ var ANSWER_KEYS_ALLOWLIST = /^(?:\r|[yn]\r|\x03|\d+\r)$/;
5318
+ function sanitizeAnswerKeys(keys) {
5319
+ if (keys === void 0) return void 0;
5320
+ return ANSWER_KEYS_ALLOWLIST.test(keys) ? keys : void 0;
5321
+ }
5322
+
4967
5323
  // src/services/questions/detectAskUserQuestion.ts
4968
5324
  function normalizeContent2(raw) {
4969
5325
  if (Array.isArray(raw)) return raw;
@@ -5277,7 +5633,7 @@ function discoveredToResponse(d, conversationId) {
5277
5633
  var import_crypto8 = require("crypto");
5278
5634
  var import_promises6 = require("fs/promises");
5279
5635
  var import_heic_convert = __toESM(require("heic-convert"), 1);
5280
- var import_path12 = require("path");
5636
+ var import_path13 = require("path");
5281
5637
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
5282
5638
  var MAX_BYTES = 25 * 1024 * 1024;
5283
5639
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -5310,9 +5666,9 @@ async function saveUploadFile(input) {
5310
5666
  }
5311
5667
  const id = `up_${(0, import_crypto8.randomBytes)(8).toString("hex")}`;
5312
5668
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
5313
- const dir = (0, import_path12.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5669
+ const dir = (0, import_path13.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5314
5670
  await (0, import_promises6.mkdir)(dir, { recursive: true });
5315
- const filePath = (0, import_path12.join)(dir, `${Date.now()}-${id}-${safeName}`);
5671
+ const filePath = (0, import_path13.join)(dir, `${Date.now()}-${id}-${safeName}`);
5316
5672
  await (0, import_promises6.writeFile)(filePath, buffer);
5317
5673
  return {
5318
5674
  id,
@@ -5568,8 +5924,10 @@ var WSHub = class {
5568
5924
  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.`;
5569
5925
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5570
5926
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5927
+ var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5928
+ var WS_CLOSE_UNAUTHORIZED = 4401;
5571
5929
  var REFRESH_TTL_MS = 2e3;
5572
- var START_READY_TIMEOUT_MS = 15e3;
5930
+ var START_READY_TIMEOUT_MS = 1e4;
5573
5931
  function parseIncludeAgentsEnv(raw) {
5574
5932
  if (raw === void 0) return false;
5575
5933
  const v = raw.trim().toLowerCase();
@@ -5658,6 +6016,14 @@ var StreamerServer = class {
5658
6016
  clientIdToWs = /* @__PURE__ */ new Map();
5659
6017
  // Reverse map for cleanup on close
5660
6018
  wsToClientId = /* @__PURE__ */ new Map();
6019
+ // M1 — WS auth. Sockets that have authenticated (via ?key= at upgrade OR a
6020
+ // { type: "auth", token } first message). Only authed sockets are added to
6021
+ // the hub and receive broadcasts.
6022
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
6023
+ wsAuthed = /* @__PURE__ */ new Set();
6024
+ // Keyless sockets awaiting their first-message auth handshake → close timer.
6025
+ wsAuthPending = /* @__PURE__ */ new Map();
6026
+ wsAuthTimeoutMs;
5661
6027
  cache = null;
5662
6028
  projectsRepo = null;
5663
6029
  conversationsRepo = null;
@@ -5695,11 +6061,12 @@ var StreamerServer = class {
5695
6061
  this.disableDb = config.disableDb ?? false;
5696
6062
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
5697
6063
  this.scanProfiles = config.scanProfiles;
5698
- this.codexRoots = config.codexRoots ?? [(0, import_path13.join)((0, import_os6.homedir)(), ".codex", "sessions")];
6064
+ this.codexRoots = config.codexRoots ?? [(0, import_path14.join)((0, import_os7.homedir)(), ".codex", "sessions")];
5699
6065
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
6066
+ this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
5700
6067
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5701
6068
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5702
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path13.join)((0, import_os6.homedir)(), ".threadbase", "cache");
6069
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase", "cache");
5703
6070
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
5704
6071
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
5705
6072
  this.markScannerStaleDebounced = debounce(() => {
@@ -5740,7 +6107,7 @@ var StreamerServer = class {
5740
6107
  const seqs = cache.extendMessageIndex(
5741
6108
  filePath,
5742
6109
  spans,
5743
- (0, import_fs12.statSync)(filePath),
6110
+ (0, import_fs13.statSync)(filePath),
5744
6111
  readFrom,
5745
6112
  endOffset
5746
6113
  );
@@ -5901,7 +6268,7 @@ var StreamerServer = class {
5901
6268
  temporalClient,
5902
6269
  taskQueue: agentConfig.temporal.taskQueue
5903
6270
  });
5904
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path13.join)((0, import_path13.dirname)(this.cacheDir), "conversations");
6271
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path14.join)((0, import_path14.dirname)(this.cacheDir), "conversations");
5905
6272
  conversationWriter = createConversationWriter({
5906
6273
  baseDir: conversationsBaseDir
5907
6274
  });
@@ -5954,17 +6321,39 @@ var StreamerServer = class {
5954
6321
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
5955
6322
  handleBrowse: (url, res) => this.handleBrowse(url, res),
5956
6323
  handleMkdir: (req, res) => this.handleMkdir(req, res),
5957
- handleWsOpen: (ws) => {
5958
- this.wsHub.addClient(ws);
5959
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
5960
- ws.send(JSON.stringify({ type: "session_list", sessions }));
5961
- if (this.cacheReady) {
5962
- ws.send(JSON.stringify({ type: "cache_ready" }));
6324
+ handleWsOpen: (ws, preAuthed) => {
6325
+ if (preAuthed) {
6326
+ this.completeWsAuth(ws);
6327
+ return;
5963
6328
  }
6329
+ const timer = setTimeout(() => {
6330
+ this.wsAuthPending.delete(ws);
6331
+ try {
6332
+ ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
6333
+ } catch {
6334
+ }
6335
+ }, this.wsAuthTimeoutMs);
6336
+ this.wsAuthPending.set(ws, timer);
5964
6337
  },
5965
6338
  handleWsMessage: async (ws, raw) => {
5966
6339
  try {
5967
6340
  const msg = JSON.parse(String(raw));
6341
+ if (!this.wsAuthed.has(ws)) {
6342
+ if (msg.type === "auth" && typeof msg.token === "string") {
6343
+ const t = this.wsAuthPending.get(ws);
6344
+ if (t) clearTimeout(t);
6345
+ this.wsAuthPending.delete(ws);
6346
+ if (validateApiKey(msg.token, this.apiKey)) {
6347
+ this.completeWsAuth(ws);
6348
+ } else {
6349
+ try {
6350
+ ws.close(WS_CLOSE_UNAUTHORIZED, "unauthorized");
6351
+ } catch {
6352
+ }
6353
+ }
6354
+ }
6355
+ return;
6356
+ }
5968
6357
  if (msg.type === "register" && typeof msg.clientId === "string") {
5969
6358
  const oldClientId = this.wsToClientId.get(ws);
5970
6359
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -5977,14 +6366,54 @@ var StreamerServer = class {
5977
6366
  const lines = await this.ptyManager.getOutputLines(msg.sessionId, 200);
5978
6367
  ws.send(JSON.stringify({ type: "terminal_replay", sessionId: msg.sessionId, lines }));
5979
6368
  }
6369
+ const pendingGate = this.pendingPermission.get(msg.sessionId);
6370
+ if (pendingGate) {
6371
+ this.log.info(`[ws.replay_permission] ${msg.sessionId.slice(0, 8)}`, {
6372
+ event: "ws.replay_permission",
6373
+ sessionId: msg.sessionId
6374
+ });
6375
+ ws.send(
6376
+ JSON.stringify({
6377
+ type: "permission",
6378
+ sessionId: msg.sessionId,
6379
+ ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
6380
+ ...pendingGate.detail ? { detail: pendingGate.detail } : {},
6381
+ options: pendingGate.options,
6382
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
6383
+ })
6384
+ );
6385
+ }
6386
+ const pendingQuestion = this.pendingQuestions.get(msg.sessionId);
6387
+ if (pendingQuestion) {
6388
+ this.log.info(`[ws.replay_question] ${msg.sessionId.slice(0, 8)}`, {
6389
+ event: "ws.replay_question",
6390
+ sessionId: msg.sessionId
6391
+ });
6392
+ ws.send(
6393
+ JSON.stringify({
6394
+ type: "question",
6395
+ sessionId: msg.sessionId,
6396
+ toolUseId: pendingQuestion.toolUseId,
6397
+ questions: pendingQuestion.questions
6398
+ })
6399
+ );
6400
+ }
5980
6401
  }
5981
6402
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
5982
- this.startGraceTimer(msg.sessionId, 0);
6403
+ if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
6404
+ this.startGraceTimer(msg.sessionId, 0);
6405
+ }
5983
6406
  }
5984
6407
  } catch {
5985
6408
  }
5986
6409
  },
5987
6410
  handleWsClose: (ws) => {
6411
+ const pendingTimer = this.wsAuthPending.get(ws);
6412
+ if (pendingTimer) {
6413
+ clearTimeout(pendingTimer);
6414
+ this.wsAuthPending.delete(ws);
6415
+ }
6416
+ this.wsAuthed.delete(ws);
5988
6417
  const clientId = this.wsToClientId.get(ws);
5989
6418
  if (clientId) {
5990
6419
  this.clientIdToWs.delete(clientId);
@@ -6054,6 +6483,20 @@ var StreamerServer = class {
6054
6483
  this.wsHub.broadcast(payload);
6055
6484
  }
6056
6485
  }
6486
+ // M1: finalize a WebSocket auth (via ?key= at upgrade or a first-message
6487
+ // handshake) — register it with the hub and send the initial snapshot. Only
6488
+ // authed sockets reach this, so no unauthenticated client ever receives a
6489
+ // broadcast.
6490
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
6491
+ completeWsAuth(ws) {
6492
+ this.wsAuthed.add(ws);
6493
+ this.wsHub.addClient(ws);
6494
+ const sessions = this.sessionStore.list(this.ptyAttachedIds());
6495
+ ws.send(JSON.stringify({ type: "session_list", sessions }));
6496
+ if (this.cacheReady) {
6497
+ ws.send(JSON.stringify({ type: "cache_ready" }));
6498
+ }
6499
+ }
6057
6500
  addSessionSubscriber(sessionId, ws) {
6058
6501
  let subs = this.sessionSubscribers.get(sessionId);
6059
6502
  if (!subs) {
@@ -6125,7 +6568,7 @@ var StreamerServer = class {
6125
6568
  });
6126
6569
  try {
6127
6570
  this.cache = ConversationCache.open(
6128
- (0, import_path13.join)(this.cacheDir, "cache.db"),
6571
+ (0, import_path14.join)(this.cacheDir, "cache.db"),
6129
6572
  this.tailSize,
6130
6573
  void 0,
6131
6574
  {
@@ -6151,11 +6594,11 @@ var StreamerServer = class {
6151
6594
  if (this.scanProfiles && this.scanProfiles.length > 0) {
6152
6595
  for (const profile of this.scanProfiles) {
6153
6596
  if (profile.enabled) {
6154
- this.fileWatcher.watchDirectory((0, import_path13.join)(profile.configDir, "projects"));
6597
+ this.fileWatcher.watchDirectory((0, import_path14.join)(profile.configDir, "projects"));
6155
6598
  }
6156
6599
  }
6157
6600
  } else {
6158
- this.fileWatcher.watchDirectory((0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects"));
6601
+ this.fileWatcher.watchDirectory((0, import_path14.join)((0, import_os7.homedir)(), ".claude", "projects"));
6159
6602
  }
6160
6603
  } catch (err) {
6161
6604
  const message = err instanceof Error ? err.message : String(err);
@@ -6340,6 +6783,9 @@ var StreamerServer = class {
6340
6783
  this.ptyManager.dispose();
6341
6784
  this.fileWatcher.dispose();
6342
6785
  this.wsHub.dispose();
6786
+ for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6787
+ this.wsAuthPending.clear();
6788
+ this.wsAuthed.clear();
6343
6789
  this.pairTokens.dispose();
6344
6790
  if (this.dbPool) {
6345
6791
  await this.dbPool.end();
@@ -6737,17 +7183,17 @@ var StreamerServer = class {
6737
7183
  return scanner;
6738
7184
  }
6739
7185
  findJsonlPath(uuid) {
6740
- const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
6741
- if (!(0, import_fs12.existsSync)(projectsDir)) return null;
7186
+ const projectsDir = (0, import_path14.join)((0, import_os7.homedir)(), ".claude", "projects");
7187
+ if (!(0, import_fs13.existsSync)(projectsDir)) return null;
6742
7188
  const filename = `${uuid}.jsonl`;
6743
- for (const dir of (0, import_fs12.readdirSync)(projectsDir)) {
6744
- const fp = (0, import_path13.join)(projectsDir, dir, filename);
6745
- if ((0, import_fs12.existsSync)(fp)) return fp;
6746
- const projectDir = (0, import_path13.join)(projectsDir, dir);
7189
+ for (const dir of (0, import_fs13.readdirSync)(projectsDir)) {
7190
+ const fp = (0, import_path14.join)(projectsDir, dir, filename);
7191
+ if ((0, import_fs13.existsSync)(fp)) return fp;
7192
+ const projectDir = (0, import_path14.join)(projectsDir, dir);
6747
7193
  try {
6748
- for (const sub of (0, import_fs12.readdirSync)(projectDir)) {
6749
- const subagentPath = (0, import_path13.join)(projectDir, sub, "subagents", filename);
6750
- if ((0, import_fs12.existsSync)(subagentPath)) return subagentPath;
7194
+ for (const sub of (0, import_fs13.readdirSync)(projectDir)) {
7195
+ const subagentPath = (0, import_path14.join)(projectDir, sub, "subagents", filename);
7196
+ if ((0, import_fs13.existsSync)(subagentPath)) return subagentPath;
6751
7197
  }
6752
7198
  } catch {
6753
7199
  }
@@ -6756,7 +7202,7 @@ var StreamerServer = class {
6756
7202
  }
6757
7203
  async readCwdFromJsonl(filePath) {
6758
7204
  return new Promise((resolve2) => {
6759
- const rl = (0, import_readline.createInterface)({ input: (0, import_fs12.createReadStream)(filePath), crlfDelay: Infinity });
7205
+ const rl = (0, import_readline.createInterface)({ input: (0, import_fs13.createReadStream)(filePath), crlfDelay: Infinity });
6760
7206
  let found = false;
6761
7207
  rl.on("line", (line) => {
6762
7208
  if (found) return;
@@ -6894,7 +7340,7 @@ var StreamerServer = class {
6894
7340
  if (!conv.filePath) return false;
6895
7341
  let mtimeMs = null;
6896
7342
  try {
6897
- mtimeMs = (0, import_fs12.statSync)(conv.filePath).mtimeMs;
7343
+ mtimeMs = (0, import_fs13.statSync)(conv.filePath).mtimeMs;
6898
7344
  } catch {
6899
7345
  return false;
6900
7346
  }
@@ -7262,7 +7708,7 @@ var StreamerServer = class {
7262
7708
  handleGetSession(sessionId, res) {
7263
7709
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
7264
7710
  if (session) {
7265
- if (!(0, import_fs12.existsSync)(session.projectPath)) {
7711
+ if (!(0, import_fs13.existsSync)(session.projectPath)) {
7266
7712
  session.failureReason = `Project directory not found: ${session.projectPath}`;
7267
7713
  }
7268
7714
  json(res, 200, session);
@@ -7466,12 +7912,21 @@ var StreamerServer = class {
7466
7912
  return;
7467
7913
  }
7468
7914
  this.pendingPermission.set(sessionId, gate);
7915
+ const safeOptions = gate.options.map((o) => {
7916
+ const answerKeys = sanitizeAnswerKeys(o.answerKeys);
7917
+ return answerKeys === void 0 ? { index: o.index, label: o.label } : { ...o, answerKeys };
7918
+ });
7919
+ const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
7920
+ this.log.info(
7921
+ `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
7922
+ { event: "ws.broadcast_permission", sessionId, subscriberCount }
7923
+ );
7469
7924
  this.wsHub.broadcast({
7470
7925
  type: "permission",
7471
7926
  sessionId,
7472
7927
  ...gate.prompt ? { prompt: gate.prompt } : {},
7473
7928
  ...gate.detail ? { detail: gate.detail } : {},
7474
- options: gate.options,
7929
+ options: safeOptions,
7475
7930
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7476
7931
  });
7477
7932
  }
@@ -7658,7 +8113,7 @@ var StreamerServer = class {
7658
8113
  sessionStore: this.sessionStore,
7659
8114
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
7660
8115
  agentClient: this.agentClient,
7661
- conversationsDir: this.cacheDir ? (0, import_path13.join)((0, import_path13.dirname)(this.cacheDir), "conversations") : "",
8116
+ conversationsDir: this.cacheDir ? (0, import_path14.join)((0, import_path14.dirname)(this.cacheDir), "conversations") : "",
7662
8117
  agentConfig: this.agentConfig
7663
8118
  });
7664
8119
  json(res, result.status, result.body);
@@ -7799,9 +8254,9 @@ var StreamerServer = class {
7799
8254
  // was passed to Claude via --session-id so the filename matches from the start.
7800
8255
  watchForJsonl(sessionId, projectPath) {
7801
8256
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
7802
- const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects", encoded);
8257
+ const projectsDir = (0, import_path14.join)((0, import_os7.homedir)(), ".claude", "projects", encoded);
7803
8258
  const expectedFile = `${sessionId}.jsonl`;
7804
- const filePath = (0, import_path13.join)(projectsDir, expectedFile);
8259
+ const filePath = (0, import_path14.join)(projectsDir, expectedFile);
7805
8260
  const deadline = Date.now() + 12e4;
7806
8261
  let watcher = null;
7807
8262
  const cleanup = () => {
@@ -7819,12 +8274,12 @@ var StreamerServer = class {
7819
8274
  cleanup();
7820
8275
  return;
7821
8276
  }
7822
- let resolvedFilePath = (0, import_fs12.existsSync)(filePath) ? filePath : null;
7823
- if (!resolvedFilePath && (0, import_fs12.existsSync)(projectsDir)) {
8277
+ let resolvedFilePath = (0, import_fs13.existsSync)(filePath) ? filePath : null;
8278
+ if (!resolvedFilePath && (0, import_fs13.existsSync)(projectsDir)) {
7824
8279
  try {
7825
8280
  const now = Date.now();
7826
- const recent = (0, import_fs12.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs12.statSync)((0, import_path13.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
7827
- if (recent) resolvedFilePath = (0, import_path13.join)(projectsDir, recent.f);
8281
+ const recent = (0, import_fs13.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs13.statSync)((0, import_path14.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
8282
+ if (recent) resolvedFilePath = (0, import_path14.join)(projectsDir, recent.f);
7828
8283
  } catch {
7829
8284
  }
7830
8285
  }
@@ -7833,7 +8288,7 @@ var StreamerServer = class {
7833
8288
  this.sessionFileMap.set(sessionId, resolvedFilePath);
7834
8289
  this.fileWatcher.watch(resolvedFilePath);
7835
8290
  try {
7836
- const existing = (0, import_fs12.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
8291
+ const existing = (0, import_fs13.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
7837
8292
  if (existing.length > 0) {
7838
8293
  this.broadcastConversationLines(sessionId, existing);
7839
8294
  }
@@ -7856,7 +8311,7 @@ var StreamerServer = class {
7856
8311
  if (this.sessionFileMap.has(sessionId)) return;
7857
8312
  try {
7858
8313
  require("fs").mkdirSync(projectsDir, { recursive: true });
7859
- watcher = (0, import_fs12.watch)(projectsDir, tryWire);
8314
+ watcher = (0, import_fs13.watch)(projectsDir, tryWire);
7860
8315
  watcher.on("error", cleanup);
7861
8316
  } catch {
7862
8317
  }
@@ -7872,7 +8327,7 @@ var StreamerServer = class {
7872
8327
  watchForCodexRollout(sessionId, projectPath) {
7873
8328
  const deadline = Date.now() + 12e4;
7874
8329
  const now = /* @__PURE__ */ new Date();
7875
- const dateDir = (0, import_path13.join)(
8330
+ const dateDir = (0, import_path14.join)(
7876
8331
  String(now.getFullYear()),
7877
8332
  String(now.getMonth() + 1).padStart(2, "0"),
7878
8333
  String(now.getDate()).padStart(2, "0")
@@ -7885,7 +8340,7 @@ var StreamerServer = class {
7885
8340
  };
7886
8341
  const matchesProjectPath = (candidatePath) => {
7887
8342
  try {
7888
- const firstLine = (0, import_fs12.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
8343
+ const firstLine = (0, import_fs13.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
7889
8344
  if (!firstLine) return null;
7890
8345
  const parsed = JSON.parse(firstLine);
7891
8346
  if (parsed?.type !== "session_meta") return null;
@@ -7913,18 +8368,18 @@ var StreamerServer = class {
7913
8368
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
7914
8369
  );
7915
8370
  for (const root of this.codexRoots) {
7916
- const sessionsDir = (0, import_path13.join)(root, dateDir);
7917
- if (!(0, import_fs12.existsSync)(sessionsDir)) continue;
8371
+ const sessionsDir = (0, import_path14.join)(root, dateDir);
8372
+ if (!(0, import_fs13.existsSync)(sessionsDir)) continue;
7918
8373
  let candidateFiles;
7919
8374
  try {
7920
- candidateFiles = (0, import_fs12.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
8375
+ candidateFiles = (0, import_fs13.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
7921
8376
  } catch {
7922
8377
  continue;
7923
8378
  }
7924
8379
  const nowMs = Date.now();
7925
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs12.statSync)((0, import_path13.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
8380
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs13.statSync)((0, import_path14.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
7926
8381
  for (const { f } of recentCandidates) {
7927
- const candidatePath = (0, import_path13.join)(sessionsDir, f);
8382
+ const candidatePath = (0, import_path14.join)(sessionsDir, f);
7928
8383
  const match = matchesProjectPath(candidatePath);
7929
8384
  if (!match) continue;
7930
8385
  if (boundElsewhere.has(match.id)) continue;
@@ -7934,7 +8389,7 @@ var StreamerServer = class {
7934
8389
  this.sessionFileMap.set(sessionId, candidatePath);
7935
8390
  this.fileWatcher.watch(candidatePath);
7936
8391
  try {
7937
- const existing = (0, import_fs12.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
8392
+ const existing = (0, import_fs13.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
7938
8393
  if (existing.length > 0) {
7939
8394
  this.broadcastConversationLines(sessionId, existing);
7940
8395
  }
@@ -8056,7 +8511,7 @@ var StreamerServer = class {
8056
8511
  };
8057
8512
  function classifyResumability(cwd) {
8058
8513
  if (!cwd) return { resumable: true };
8059
- if ((0, import_fs12.existsSync)(cwd)) return { resumable: true };
8514
+ if ((0, import_fs13.existsSync)(cwd)) return { resumable: true };
8060
8515
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
8061
8516
  return {
8062
8517
  resumable: false,