@threadbase-sh/streamer 1.29.2 → 1.31.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 false;
167
+ if (!timestampHeader) return true;
168
168
  const t = Number(timestampHeader);
169
169
  if (!Number.isFinite(t)) return false;
170
170
  const now = Math.floor(Date.now() / 1e3);
@@ -680,13 +680,80 @@ 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;
748
+ var INPUT_HISTORY_MAX = 50;
685
749
  var PTY_COLS = 120;
686
750
  var PTY_ROWS = 40;
687
751
  var SCREEN_SCROLLBACK = 1e3;
688
752
  var CODEX_PROMPT_READY_TEXT = "Ready";
689
753
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
754
+ var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
755
+ var QUIET_DETECT_MS = 500;
756
+ var CODEX_READY_FALLBACK_MS = 8e3;
690
757
  var SUBMIT_BYTES = "\r";
691
758
  var CODEX_SUBMIT_DELAY_MS = 16;
692
759
  function digestBytes(s) {
@@ -694,6 +761,40 @@ function digestBytes(s) {
694
761
  if (escaped.length <= 200) return escaped;
695
762
  return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
696
763
  }
764
+ function gateCard(gate, lines) {
765
+ if (gate === "hooks") {
766
+ const countLine = lines.find((l) => /new or changed/i.test(l))?.trim();
767
+ return {
768
+ prompt: [
769
+ "Hooks need review",
770
+ countLine,
771
+ "Hooks can run outside the sandbox after you trust them."
772
+ ].filter(Boolean).join(" \u2014 "),
773
+ options: [
774
+ { index: 2, label: "Trust all and continue", answerKeys: "2\r" },
775
+ { index: 3, label: "Continue without trusting (hooks won't run)", answerKeys: "3\r" },
776
+ {
777
+ index: 4,
778
+ label: "Trust all and continue (remember for all projects)",
779
+ answerKeys: "4\r"
780
+ },
781
+ {
782
+ index: 5,
783
+ label: "Continue without trusting (remember for all projects)",
784
+ answerKeys: "5\r"
785
+ }
786
+ ]
787
+ };
788
+ }
789
+ return {
790
+ prompt: lines.find((l) => CODEX_TRUST_GATE_REGEX.test(l))?.trim() ?? "Do you trust the contents of this directory?",
791
+ options: [
792
+ { index: 1, label: "Yes, continue", answerKeys: "1\r" },
793
+ { index: 2, label: "No, quit", answerKeys: "2\r" },
794
+ { index: 3, label: "Yes, continue (remember for all projects)", answerKeys: "3\r" }
795
+ ]
796
+ };
797
+ }
697
798
  var pty = null;
698
799
  async function loadPty() {
699
800
  if (pty) return pty;
@@ -720,11 +821,12 @@ var CodexPtyRunner = class {
720
821
  onOutput;
721
822
  onStatusChange;
722
823
  onReady;
723
- // Accepted for shape-compatibility with PTYManagerOptions; Codex has no
724
- // detected equivalent yet (Phase 0) never invoked.
824
+ // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
825
+ // as question cards; null dismisses the card once the gate leaves the screen.
725
826
  onPermissionChange;
726
827
  onLiveQuestion;
727
828
  onLiveQuestionGone;
829
+ onUserMessage;
728
830
  log;
729
831
  // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
730
832
  // "Ready" status bar — i.e. onReady hasn't fired.
@@ -732,8 +834,18 @@ var CodexPtyRunner = class {
732
834
  // Inputs received via sendInput() while the session was still pendingReady.
733
835
  // Flushed in arrival order once Codex reaches Ready.
734
836
  queuedInputs = /* @__PURE__ */ new Map();
735
- // Per-session debounce so the directory-trust gate's \r is only written once.
736
- trustGateAnswered = /* @__PURE__ */ new Set();
837
+ // Gate currently on a session's screen (card broadcast, unanswered). While
838
+ // set, queued-input flushes are held — a flushed digit would CONFIRM a
839
+ // dialog option — and sendKeys() intercepts remember-variant digits.
840
+ openGate = /* @__PURE__ */ new Map();
841
+ // `${sessionId}:${gate}` once a gate has been actioned (auto-answered or
842
+ // card broadcast) — dedupes repaints of the same dialog.
843
+ gateActioned = /* @__PURE__ */ new Set();
844
+ // Per-session trailing debounce re-armed on every chunk; on quiet, re-runs
845
+ // screen detection so a blocked/truncated boot still reaches ready.
846
+ quietCheckers = /* @__PURE__ */ new Map();
847
+ // Per-session flat backstop from spawn (CODEX_READY_FALLBACK_MS).
848
+ readyFallbackTimers = /* @__PURE__ */ new Map();
737
849
  // In-flight start()/startFresh() calls keyed by sessionId. A second
738
850
  // concurrent resume for the same session (double-tap, client retry) awaits
739
851
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -745,6 +857,7 @@ var CodexPtyRunner = class {
745
857
  this.onPermissionChange = options.onPermissionChange;
746
858
  this.onLiveQuestion = options.onLiveQuestion;
747
859
  this.onLiveQuestionGone = options.onLiveQuestionGone;
860
+ this.onUserMessage = options.onUserMessage;
748
861
  this.log = options.logger ?? getLogger("codex-pty");
749
862
  }
750
863
  // Resume an existing Codex session. sessionId is the Codex-persisted
@@ -788,10 +901,12 @@ var CodexPtyRunner = class {
788
901
  lastOutput: "",
789
902
  process: proc,
790
903
  outputBuffer: Buffer.alloc(0),
791
- screen: createScreen()
904
+ screen: createScreen(),
905
+ inputHistory: []
792
906
  };
793
907
  this.sessions.set(sessionId, session);
794
908
  this.pendingReady.add(sessionId);
909
+ this.armReadyFallback(sessionId);
795
910
  proc.onData((data) => {
796
911
  this.handleOutput(sessionId, data);
797
912
  });
@@ -833,10 +948,12 @@ var CodexPtyRunner = class {
833
948
  lastOutput: "",
834
949
  process: proc,
835
950
  outputBuffer: Buffer.alloc(0),
836
- screen: createScreen()
951
+ screen: createScreen(),
952
+ inputHistory: []
837
953
  };
838
954
  this.sessions.set(sessionId, session);
839
955
  this.pendingReady.add(sessionId);
956
+ this.armReadyFallback(sessionId);
840
957
  proc.onData((data) => {
841
958
  this.handleOutput(sessionId, data);
842
959
  });
@@ -846,6 +963,21 @@ var CodexPtyRunner = class {
846
963
  });
847
964
  return toPublicSession(session);
848
965
  }
966
+ // Flat backstop: if neither the "Ready" marker nor the quiet-checker settled
967
+ // the session within CODEX_READY_FALLBACK_MS of spawn, mark it ready anyway
968
+ // so start requests resolve and mobile can watch the boot live. unref() so a
969
+ // pending timer never holds the process open.
970
+ armReadyFallback(sessionId) {
971
+ const timer = setTimeout(() => {
972
+ this.readyFallbackTimers.delete(sessionId);
973
+ const session = this.sessions.get(sessionId);
974
+ if (session?.status === "running" && this.pendingReady.has(sessionId)) {
975
+ this.markReady(sessionId, session, "fallback:timeout");
976
+ }
977
+ }, CODEX_READY_FALLBACK_MS);
978
+ timer.unref?.();
979
+ this.readyFallbackTimers.set(sessionId, timer);
980
+ }
849
981
  // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
850
982
  sendKeys(sessionId, keys) {
851
983
  const session = this.sessions.get(sessionId);
@@ -857,13 +989,47 @@ var CodexPtyRunner = class {
857
989
  session.status = "running";
858
990
  this.onStatusChange?.(toPublicSession(session));
859
991
  }
992
+ const gate = this.openGate.get(sessionId);
993
+ const digit = gate ? /^([0-9])\r?$/.exec(keys)?.[1] : void 0;
994
+ const out = gate && digit ? this.resolveGateAnswer(sessionId, gate, digit) : keys;
860
995
  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 }
996
+ `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${out.length} digest=${digestBytes(out)}`,
997
+ { event: "codex.keys_write", sessionId, byteLen: out.length }
863
998
  );
864
- session.process.write(keys);
999
+ session.process.write(out);
865
1000
  session.lastActivityAt = /* @__PURE__ */ new Date();
866
1001
  }
1002
+ // Map a gate-card digit to the PTY bytes that answer the real dialog,
1003
+ // persisting the choice when the digit was a synthetic "remember for all
1004
+ // projects" option (those numbers don't exist on the actual dialog and must
1005
+ // never reach codex). The trailing \r mobile sends is dropped: a digit alone
1006
+ // selects AND confirms (live-probe verified), and a stray Enter would land
1007
+ // on whatever screen follows.
1008
+ resolveGateAnswer(sessionId, gate, digit) {
1009
+ let real = digit;
1010
+ let remembered = false;
1011
+ if (gate === "hooks" && digit === "4") {
1012
+ saveGateAnswer("codexHooksGate", "trust_all");
1013
+ real = "2";
1014
+ remembered = true;
1015
+ } else if (gate === "hooks" && digit === "5") {
1016
+ saveGateAnswer("codexHooksGate", "continue_untrusted");
1017
+ real = "3";
1018
+ remembered = true;
1019
+ } else if (gate === "trust" && digit === "3") {
1020
+ saveGateAnswer("codexTrustGate", "yes");
1021
+ real = "1";
1022
+ remembered = true;
1023
+ }
1024
+ this.log.info(`[codex.gate_answer] ${sessionId.slice(0, 8)} ${gate} digit=${real}`, {
1025
+ event: "codex.gate_answer",
1026
+ sessionId,
1027
+ gate,
1028
+ digit: real,
1029
+ remembered
1030
+ });
1031
+ return real;
1032
+ }
867
1033
  sendInput(sessionId, input) {
868
1034
  const session = this.sessions.get(sessionId);
869
1035
  if (!session) throw new Error(`Session not found: ${sessionId}`);
@@ -901,6 +1067,7 @@ var CodexPtyRunner = class {
901
1067
  // confirmed Codex accepts plain keystrokes), then submit \r after a short
902
1068
  // delay so Codex's TUI gets an event-loop tick to process the input first.
903
1069
  writeSubmit(sessionId, session, input, path, promptCount) {
1070
+ this.recordUserMessage(session, input);
904
1071
  this.log.info(
905
1072
  `[codex.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${input.length} digest=${digestBytes(input)}`,
906
1073
  {
@@ -933,8 +1100,12 @@ var CodexPtyRunner = class {
933
1100
  }, CODEX_SUBMIT_DELAY_MS);
934
1101
  }
935
1102
  // Drain any inputs sent while the session was still pendingReady, writing
936
- // them in arrival order now that Codex is Ready.
1103
+ // them in arrival order now that Codex is Ready. No-op while a gate dialog
1104
+ // is open (a flushed digit would confirm a dialog option) or while still
1105
+ // pendingReady (markReady drains it) — the gate-close path re-drives it for
1106
+ // the ready-with-gate-open case.
937
1107
  flushQueuedInputs(sessionId) {
1108
+ if (this.openGate.has(sessionId) || this.pendingReady.has(sessionId)) return;
938
1109
  const queue = this.queuedInputs.get(sessionId);
939
1110
  if (!queue || queue.length === 0) return;
940
1111
  this.queuedInputs.delete(sessionId);
@@ -979,7 +1150,7 @@ var CodexPtyRunner = class {
979
1150
  if (!session) return;
980
1151
  this.pendingReady.delete(sessionId);
981
1152
  this.queuedInputs.delete(sessionId);
982
- this.trustGateAnswered.delete(sessionId);
1153
+ this.clearSessionDetectors(sessionId);
983
1154
  try {
984
1155
  session.process.kill("SIGINT");
985
1156
  } catch {
@@ -990,6 +1161,21 @@ var CodexPtyRunner = class {
990
1161
  this.sessions.delete(sessionId);
991
1162
  this.onStatusChange?.(toPublicSession(session));
992
1163
  }
1164
+ // Drop a session's detection state: quiet-checker, ready-fallback timer,
1165
+ // gate bookkeeping — and dismiss a still-open gate card so mobile doesn't
1166
+ // keep rendering a question for a dead PTY.
1167
+ clearSessionDetectors(sessionId) {
1168
+ this.quietCheckers.get(sessionId)?.cancel();
1169
+ this.quietCheckers.delete(sessionId);
1170
+ const timer = this.readyFallbackTimers.get(sessionId);
1171
+ if (timer) clearTimeout(timer);
1172
+ this.readyFallbackTimers.delete(sessionId);
1173
+ if (this.openGate.delete(sessionId)) {
1174
+ this.onPermissionChange?.(sessionId, null);
1175
+ }
1176
+ this.gateActioned.delete(`${sessionId}:hooks`);
1177
+ this.gateActioned.delete(`${sessionId}:trust`);
1178
+ }
993
1179
  getOutput(sessionId) {
994
1180
  const session = this.sessions.get(sessionId);
995
1181
  if (!session) throw new Error(`Session not found: ${sessionId}`);
@@ -1011,6 +1197,19 @@ var CodexPtyRunner = class {
1011
1197
  }
1012
1198
  return lines.slice(-maxLines);
1013
1199
  }
1200
+ getInputHistory(sessionId) {
1201
+ return this.sessions.get(sessionId)?.inputHistory ?? [];
1202
+ }
1203
+ // Record a submitted user message as ground truth and fire onUserMessage.
1204
+ // Called from writeSubmit (direct and flush paths) — never from sendKeys.
1205
+ recordUserMessage(session, text) {
1206
+ const ts = Date.now();
1207
+ session.inputHistory.push({ text, ts });
1208
+ if (session.inputHistory.length > INPUT_HISTORY_MAX) {
1209
+ session.inputHistory.shift();
1210
+ }
1211
+ this.onUserMessage?.(session.id, text, ts);
1212
+ }
1014
1213
  getSession(sessionId) {
1015
1214
  const session = this.sessions.get(sessionId);
1016
1215
  return session ? toPublicSession(session) : null;
@@ -1029,10 +1228,19 @@ var CodexPtyRunner = class {
1029
1228
  }
1030
1229
  session.screen.dispose();
1031
1230
  }
1231
+ for (const sessionId of Array.from(this.quietCheckers.keys())) {
1232
+ this.clearSessionDetectors(sessionId);
1233
+ }
1234
+ for (const timer of this.readyFallbackTimers.values()) {
1235
+ clearTimeout(timer);
1236
+ }
1032
1237
  this.sessions.clear();
1033
1238
  this.pendingReady.clear();
1034
1239
  this.queuedInputs.clear();
1035
- this.trustGateAnswered.clear();
1240
+ this.openGate.clear();
1241
+ this.gateActioned.clear();
1242
+ this.quietCheckers.clear();
1243
+ this.readyFallbackTimers.clear();
1036
1244
  }
1037
1245
  handleOutput(sessionId, data) {
1038
1246
  const session = this.sessions.get(sessionId);
@@ -1047,44 +1255,92 @@ var CodexPtyRunner = class {
1047
1255
  session.screen.write(data);
1048
1256
  session.lastOutput = stripAnsi(data);
1049
1257
  this.onOutput?.(sessionId, data);
1050
- this.detectReady(sessionId, session).catch((err) => {
1258
+ this.detectScreenState(sessionId, "chunk").catch((err) => {
1051
1259
  this.log.warn("[codex.ready_detect] failed", {
1052
1260
  event: "codex.ready_detect_failed",
1053
1261
  sessionId,
1054
1262
  err
1055
1263
  });
1056
1264
  });
1265
+ let quiet = this.quietCheckers.get(sessionId);
1266
+ if (!quiet) {
1267
+ quiet = debounce(() => {
1268
+ this.detectScreenState(sessionId, "quiet").catch((err) => {
1269
+ this.log.warn("[codex.ready_detect] failed", {
1270
+ event: "codex.ready_detect_failed",
1271
+ sessionId,
1272
+ err
1273
+ });
1274
+ });
1275
+ }, QUIET_DETECT_MS);
1276
+ this.quietCheckers.set(sessionId, quiet);
1277
+ }
1278
+ quiet();
1057
1279
  }
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;
1280
+ // Renders the session's headless screen and drives both detections:
1281
+ // - Gates (directory trust, hooks review) checked on EVERY pass,
1282
+ // independent of pendingReady, so a gate appearing after ready is still
1283
+ // surfaced and a gate leaving the screen closes its card.
1284
+ // - Readiness the "Ready" status-bar marker while pendingReady, plus the
1285
+ // quiet path: after QUIET_DETECT_MS of PTY silence a still-pending
1286
+ // session is marked ready anyway (`›` alone is NOT a marker — Phase 0 —
1287
+ // but a quiet boot screen is more useful to the user live than a
1288
+ // spinner, and "Ready" may be truncated off the 120-col status bar).
1289
+ async detectScreenState(sessionId, trigger) {
1290
+ const session = this.sessions.get(sessionId);
1291
+ if (!session || session.status === "idle") return;
1065
1292
  const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1066
1293
  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;
1294
+ const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1295
+ if (gate) {
1296
+ this.handleGate(sessionId, session, gate, lines);
1297
+ } else if (this.openGate.delete(sessionId)) {
1298
+ this.onPermissionChange?.(sessionId, null);
1299
+ this.flushQueuedInputs(sessionId);
1077
1300
  }
1301
+ if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1078
1302
  const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1079
- if (!lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) return;
1080
- this.markReady(sessionId, session);
1303
+ if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
1304
+ this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
1305
+ } else if (trigger === "quiet") {
1306
+ this.markReady(sessionId, session, "quiet:timeout");
1307
+ }
1081
1308
  }
1082
- markReady(sessionId, session) {
1309
+ // Answer a gate from the persisted remember-store, or surface it as a
1310
+ // question card over the permission transport. Actioned once per session and
1311
+ // gate type — repaints of the same dialog neither re-write nor re-broadcast.
1312
+ handleGate(sessionId, session, gate, lines) {
1313
+ const key = `${sessionId}:${gate}`;
1314
+ if (this.gateActioned.has(key)) return;
1315
+ this.gateActioned.add(key);
1316
+ const remembered = rememberedGateDigit(gate);
1317
+ if (remembered) {
1318
+ this.log.info(`[codex.gate_auto_answer] ${sessionId.slice(0, 8)} ${gate} \u2192 ${remembered}`, {
1319
+ event: "codex.gate_auto_answer",
1320
+ sessionId,
1321
+ gate,
1322
+ digit: remembered
1323
+ });
1324
+ session.process.write(remembered);
1325
+ return;
1326
+ }
1327
+ this.openGate.set(sessionId, gate);
1328
+ const card = gateCard(gate, lines);
1329
+ this.log.info(`[codex.gate_prompt] ${sessionId.slice(0, 8)} ${gate}`, {
1330
+ event: "codex.gate_prompt",
1331
+ sessionId,
1332
+ gate,
1333
+ prompt: card.prompt
1334
+ });
1335
+ this.onPermissionChange?.(sessionId, card);
1336
+ }
1337
+ markReady(sessionId, session, reason) {
1083
1338
  session.lastActivityAt = /* @__PURE__ */ new Date();
1084
1339
  session.status = "waiting_input";
1085
- this.log.info(`[codex.ready] ${sessionId.slice(0, 8)}`, {
1340
+ this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1086
1341
  event: "codex.ready",
1087
- sessionId
1342
+ sessionId,
1343
+ reason
1088
1344
  });
1089
1345
  this.onStatusChange?.(toPublicSession(session));
1090
1346
  if (this.pendingReady.has(sessionId)) {
@@ -1110,7 +1366,7 @@ var CodexPtyRunner = class {
1110
1366
  session.screen.dispose();
1111
1367
  this.sessions.delete(sessionId);
1112
1368
  this.queuedInputs.delete(sessionId);
1113
- this.trustGateAnswered.delete(sessionId);
1369
+ this.clearSessionDetectors(sessionId);
1114
1370
  }
1115
1371
  };
1116
1372
  function toPublicSession(s) {
@@ -1293,9 +1549,15 @@ function detectShellPrompt(lines) {
1293
1549
  ]
1294
1550
  };
1295
1551
  }
1296
- if (NUMBERED_RE.test(last.text)) {
1552
+ const lastNumberedIdx = (() => {
1553
+ for (let i = last.idx; i >= 0; i--) {
1554
+ if (NUMBERED_RE.test(lines[i])) return i;
1555
+ }
1556
+ return -1;
1557
+ })();
1558
+ if (lastNumberedIdx >= 0) {
1297
1559
  const options = [];
1298
- for (let i = 0; i <= last.idx; i++) {
1560
+ for (let i = 0; i <= lastNumberedIdx; i++) {
1299
1561
  const m = NUMBERED_RE.exec(lines[i]);
1300
1562
  if (!m) continue;
1301
1563
  const num = Number.parseInt(m[1], 10);
@@ -1323,45 +1585,15 @@ function detectShellPrompt(lines) {
1323
1585
  return null;
1324
1586
  }
1325
1587
 
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
1588
  // src/pty-manager.ts
1358
1589
  var OUTPUT_BUFFER_MAX2 = 65536;
1590
+ var INPUT_HISTORY_MAX2 = 50;
1359
1591
  var PTY_COLS2 = 120;
1360
1592
  var PTY_ROWS2 = 40;
1361
1593
  var SCREEN_SCROLLBACK2 = 1e3;
1362
1594
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1363
1595
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1364
- var QUIET_DETECT_MS = 500;
1596
+ var QUIET_DETECT_MS2 = 500;
1365
1597
  function buildPasteBytes(input) {
1366
1598
  return `\x1B[200~${input}\x1B[201~`;
1367
1599
  }
@@ -1414,6 +1646,7 @@ var PTYManager = class {
1414
1646
  onPermissionChange;
1415
1647
  onLiveQuestion;
1416
1648
  onLiveQuestionGone;
1649
+ onUserMessage;
1417
1650
  // Per-session permission-gate state. True between an OSC 777 (gate open) and
1418
1651
  // the next prompt-ready without a fresh 777 (gate closed). Prevents
1419
1652
  // re-broadcasting open/close on every chunk.
@@ -1459,6 +1692,7 @@ var PTYManager = class {
1459
1692
  this.onPermissionChange = options.onPermissionChange;
1460
1693
  this.onLiveQuestion = options.onLiveQuestion;
1461
1694
  this.onLiveQuestionGone = options.onLiveQuestionGone;
1695
+ this.onUserMessage = options.onUserMessage;
1462
1696
  this.log = options.logger ?? getLogger("pty");
1463
1697
  }
1464
1698
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
@@ -1521,7 +1755,8 @@ var PTYManager = class {
1521
1755
  lastOutput: "",
1522
1756
  process: proc,
1523
1757
  outputBuffer: Buffer.alloc(0),
1524
- screen: createScreen2()
1758
+ screen: createScreen2(),
1759
+ inputHistory: []
1525
1760
  };
1526
1761
  this.sessions.set(sessionId, session);
1527
1762
  this.pendingReady.add(sessionId);
@@ -1572,7 +1807,8 @@ var PTYManager = class {
1572
1807
  lastOutput: "",
1573
1808
  process: proc,
1574
1809
  outputBuffer: Buffer.alloc(0),
1575
- screen: createScreen2()
1810
+ screen: createScreen2(),
1811
+ inputHistory: []
1576
1812
  };
1577
1813
  this.sessions.set(sessionId, session);
1578
1814
  this.pendingReady.add(sessionId);
@@ -1652,6 +1888,7 @@ var PTYManager = class {
1652
1888
  // step gives the TUI as many extra ticks as it needs, capped at
1653
1889
  // SUBMIT_MAX_WAIT_MS so a silent/wedged PTY still gets its \r eventually.
1654
1890
  writeSubmit(sessionId, session, input, path, promptCount) {
1891
+ this.recordUserMessage(session, input);
1655
1892
  const pasteBytes = buildPasteBytes(input);
1656
1893
  this.log.info(
1657
1894
  `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes2(pasteBytes)}`,
@@ -1782,6 +2019,20 @@ var PTYManager = class {
1782
2019
  }
1783
2020
  return lines.slice(-maxLines);
1784
2021
  }
2022
+ getInputHistory(sessionId) {
2023
+ return this.sessions.get(sessionId)?.inputHistory ?? [];
2024
+ }
2025
+ // Record a submitted user message as ground truth and fire onUserMessage.
2026
+ // Called from writeSubmit (both direct and flush paths) — never from
2027
+ // sendKeys, so raw keystrokes aren't logged as messages.
2028
+ recordUserMessage(session, text) {
2029
+ const ts = Date.now();
2030
+ session.inputHistory.push({ text, ts });
2031
+ if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2032
+ session.inputHistory.shift();
2033
+ }
2034
+ this.onUserMessage?.(session.id, text, ts);
2035
+ }
1785
2036
  getSession(sessionId) {
1786
2037
  const session = this.sessions.get(sessionId);
1787
2038
  return session ? toPublicSession2(session) : null;
@@ -1861,7 +2112,7 @@ var PTYManager = class {
1861
2112
  });
1862
2113
  let quiet = this.quietCheckers.get(sessionId);
1863
2114
  if (!quiet) {
1864
- quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
2115
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS2);
1865
2116
  this.quietCheckers.set(sessionId, quiet);
1866
2117
  }
1867
2118
  quiet();
@@ -2078,6 +2329,9 @@ var LiveSessionManager = class {
2078
2329
  getOutputLines(sessionId, maxLines) {
2079
2330
  return this.runnerFor(sessionId).getOutputLines(sessionId, maxLines);
2080
2331
  }
2332
+ getInputHistory(sessionId) {
2333
+ return this.runnerFor(sessionId).getInputHistory(sessionId);
2334
+ }
2081
2335
  getSession(sessionId) {
2082
2336
  for (const runner of this.runners.values()) {
2083
2337
  const session = runner.getSession(sessionId);
@@ -2122,7 +2376,7 @@ var LiveSessionManager = class {
2122
2376
  // src/process-discovery.ts
2123
2377
  import { execFile } from "child_process";
2124
2378
  import { platform as platform2 } from "os";
2125
- import { basename as basename4, dirname as dirname3 } from "path";
2379
+ import { basename as basename4, dirname as dirname4 } from "path";
2126
2380
  async function discoverClaudeProcesses() {
2127
2381
  if (platform2() === "win32") return discoverWindows();
2128
2382
  return discoverUnix();
@@ -2244,7 +2498,7 @@ async function getProcessInfoWindows(pid) {
2244
2498
  const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2245
2499
  if (Number.isNaN(startedAt.getTime())) return null;
2246
2500
  const exePath = parts[3] ?? "";
2247
- const cwd = exePath ? dirname3(exePath) : "";
2501
+ const cwd = exePath ? dirname4(exePath) : "";
2248
2502
  return { cwd, args, startedAt };
2249
2503
  } catch {
2250
2504
  return null;
@@ -2276,16 +2530,16 @@ import {
2276
2530
  import { EventEmitter } from "events";
2277
2531
  import {
2278
2532
  createReadStream,
2279
- existsSync as existsSync7,
2533
+ existsSync as existsSync8,
2280
2534
  watch as fsWatch,
2281
2535
  readdirSync as readdirSync4,
2282
- readFileSync as readFileSync6,
2283
- statSync as statSync5
2536
+ readFileSync as readFileSync7,
2537
+ statSync as statSync6
2284
2538
  } from "fs";
2285
2539
  import { realpath as realpath2 } from "fs/promises";
2286
2540
  import { createServer } from "http";
2287
- import { homedir as homedir5 } from "os";
2288
- import { dirname as dirname7, join as join12 } from "path";
2541
+ import { homedir as homedir7 } from "os";
2542
+ import { dirname as dirname8, join as join15 } from "path";
2289
2543
  import { createInterface } from "readline";
2290
2544
 
2291
2545
  // node_modules/nanoid/index.js
@@ -2469,7 +2723,7 @@ async function handleSendAgentInput(sessionId, body, deps) {
2469
2723
 
2470
2724
  // src/agent/handle-start-agent-session.ts
2471
2725
  import { existsSync as existsSync4 } from "fs";
2472
- import { join as join5 } from "path";
2726
+ import { join as join6 } from "path";
2473
2727
  function validateBody(body) {
2474
2728
  if (body === null || body === void 0 || typeof body !== "object") {
2475
2729
  return { ok: false };
@@ -2499,7 +2753,7 @@ async function handleStartAgentSession(body, deps) {
2499
2753
  }
2500
2754
  let conversationId = parsed.conversationId;
2501
2755
  if (conversationId) {
2502
- const jsonlPath = join5(deps.conversationsDir, `${conversationId}.jsonl`);
2756
+ const jsonlPath = join6(deps.conversationsDir, `${conversationId}.jsonl`);
2503
2757
  if (!existsSync4(jsonlPath)) {
2504
2758
  return {
2505
2759
  status: 404,
@@ -2544,14 +2798,15 @@ async function handleStartAgentSession(body, deps) {
2544
2798
  }
2545
2799
 
2546
2800
  // src/api/app.ts
2547
- import { Hono as Hono11 } from "hono";
2801
+ import { Hono as Hono12 } from "hono";
2548
2802
 
2549
2803
  // src/api/middleware/auth.middleware.ts
2550
2804
  function isLocalRequest(remoteAddr) {
2551
2805
  const addr = remoteAddr ?? "";
2552
2806
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2553
2807
  }
2554
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2808
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2809
+ var LOCAL_ONLY_PATHS = /* @__PURE__ */ new Set(["/api/logs", "/api/logs/meta"]);
2555
2810
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2556
2811
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
2557
2812
  var authMiddleware = (deps) => async (c, next) => {
@@ -2562,8 +2817,12 @@ var authMiddleware = (deps) => async (c, next) => {
2562
2817
  await next();
2563
2818
  return;
2564
2819
  }
2820
+ const remoteAddr = c.env.incoming?.socket?.remoteAddress;
2821
+ if (LOCAL_ONLY_PATHS.has(path) && isLocalRequest(remoteAddr)) {
2822
+ await next();
2823
+ return;
2824
+ }
2565
2825
  if (deps.localNoAuth) {
2566
- const remoteAddr = c.env.incoming?.socket?.remoteAddress;
2567
2826
  if (isLocalRequest(remoteAddr)) {
2568
2827
  await next();
2569
2828
  return;
@@ -2696,8 +2955,8 @@ var createConversationRoutes = (deps) => {
2696
2955
  import { Hono as Hono4 } from "hono";
2697
2956
 
2698
2957
  // src/version.ts
2699
- import { readFileSync as readFileSync3, realpathSync } from "fs";
2700
- import { dirname as dirname4, join as join6 } from "path";
2958
+ import { readFileSync as readFileSync4, realpathSync } from "fs";
2959
+ import { dirname as dirname5, join as join7 } from "path";
2701
2960
  var cached;
2702
2961
  function getVersion() {
2703
2962
  if (cached !== void 0) return cached;
@@ -2706,22 +2965,22 @@ function getVersion() {
2706
2965
  }
2707
2966
  function resolveVersion() {
2708
2967
  const scriptPath = process.argv[1] ?? "";
2709
- const here = scriptPath ? dirname4(scriptPath) : process.cwd();
2968
+ const here = scriptPath ? dirname5(scriptPath) : process.cwd();
2710
2969
  let realHere = here;
2711
2970
  try {
2712
- realHere = dirname4(realpathSync(scriptPath));
2971
+ realHere = dirname5(realpathSync(scriptPath));
2713
2972
  } catch {
2714
2973
  }
2715
- const searchDirs = realHere === here ? [here, join6(here, "..")] : [here, join6(here, ".."), realHere, join6(realHere, "..")];
2974
+ const searchDirs = realHere === here ? [here, join7(here, "..")] : [here, join7(here, ".."), realHere, join7(realHere, "..")];
2716
2975
  for (const dir of searchDirs) {
2717
2976
  try {
2718
- const v = readFileSync3(join6(dir, "version.txt"), "utf8").trim();
2977
+ const v = readFileSync4(join7(dir, "version.txt"), "utf8").trim();
2719
2978
  if (v) return v;
2720
2979
  } catch {
2721
2980
  }
2722
2981
  }
2723
2982
  try {
2724
- const pkg = JSON.parse(readFileSync3(join6(here, "..", "package.json"), "utf8"));
2983
+ const pkg = JSON.parse(readFileSync4(join7(here, "..", "package.json"), "utf8"));
2725
2984
  if (pkg.version) return `${pkg.version}+source`;
2726
2985
  } catch {
2727
2986
  }
@@ -2735,16 +2994,145 @@ var createHealthRoutes = () => {
2735
2994
  return app;
2736
2995
  };
2737
2996
 
2997
+ // src/api/routes/logs.routes.ts
2998
+ import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
2999
+ import { join as join9 } from "path";
3000
+ import { Hono as Hono5 } from "hono";
3001
+
3002
+ // src/lifecycle/constants.ts
3003
+ import { homedir as homedir4 } from "os";
3004
+ import { join as join8 } from "path";
3005
+ var TASK_NAME = process.env.THREADBASE_TASK_NAME ?? "Threadbase";
3006
+ function installDir() {
3007
+ return process.env.THREADBASE_INSTALL_DIR ?? join8(homedir4(), ".threadbase");
3008
+ }
3009
+
3010
+ // src/api/routes/logs.routes.ts
3011
+ var logger2 = getLogger("logs-api");
3012
+ function resolveLogPath(source) {
3013
+ return join9(installDir(), "logs", `${source}.log`);
3014
+ }
3015
+ function pickDefaultSource() {
3016
+ for (const source of ["stdout", "stderr", "dev"]) {
3017
+ const p = resolveLogPath(source);
3018
+ if (existsSync5(p) && statSync(p).size > 0) return source;
3019
+ }
3020
+ return "stdout";
3021
+ }
3022
+ function readLogLines(filePath, sinceOffset, limit) {
3023
+ if (!existsSync5(filePath)) {
3024
+ return { lines: [], offset: 0, total: 0 };
3025
+ }
3026
+ const fd = openSync(filePath, "r");
3027
+ try {
3028
+ const { size } = fstatSync(fd);
3029
+ if (size === 0) return { lines: [], offset: 0, total: 0 };
3030
+ const maxBytes = Math.min(size, 2 * 1024 * 1024);
3031
+ const start = size - maxBytes;
3032
+ const buf = Buffer.alloc(maxBytes);
3033
+ readSync(fd, buf, 0, maxBytes, start);
3034
+ let text = buf.toString("utf8");
3035
+ if (start > 0) {
3036
+ const firstNl = text.indexOf("\n");
3037
+ if (firstNl >= 0) text = text.slice(firstNl + 1);
3038
+ }
3039
+ const allLines = text.split("\n").filter((line) => line.trim() && !line.startsWith("==="));
3040
+ let lines;
3041
+ let newOffset;
3042
+ if (sinceOffset > 0 && sinceOffset < allLines.length) {
3043
+ lines = allLines.slice(sinceOffset, sinceOffset + limit);
3044
+ newOffset = sinceOffset + lines.length;
3045
+ } else if (sinceOffset >= allLines.length && sinceOffset > 0) {
3046
+ lines = [];
3047
+ newOffset = allLines.length;
3048
+ } else {
3049
+ lines = allLines.slice(-limit);
3050
+ newOffset = allLines.length;
3051
+ }
3052
+ return { lines, offset: newOffset, total: allLines.length };
3053
+ } finally {
3054
+ closeSync(fd);
3055
+ }
3056
+ }
3057
+ function createLogsRoutes() {
3058
+ const app = new Hono5();
3059
+ app.get("/", (c) => {
3060
+ try {
3061
+ const sourceParam = (c.req.query("source") || "").toLowerCase();
3062
+ const source = sourceParam === "stdout" || sourceParam === "stderr" || sourceParam === "dev" ? sourceParam : pickDefaultSource();
3063
+ const logPath = resolveLogPath(source);
3064
+ const sinceOffset = parseInt(c.req.query("since") || "0", 10);
3065
+ const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
3066
+ if (!existsSync5(logPath)) {
3067
+ return c.json({
3068
+ logs: [],
3069
+ message: `No log file found for source=${source}`,
3070
+ offset: 0,
3071
+ total: 0,
3072
+ source
3073
+ });
3074
+ }
3075
+ const { lines, offset, total } = readLogLines(logPath, sinceOffset, limit);
3076
+ const stats = statSync(logPath);
3077
+ return c.json({
3078
+ logs: lines,
3079
+ offset,
3080
+ total,
3081
+ hasMore: offset < total,
3082
+ source,
3083
+ fileSize: stats.size,
3084
+ fileModified: stats.mtime.toISOString()
3085
+ });
3086
+ } catch (error) {
3087
+ logger2.error("Failed to read logs", { error: String(error) });
3088
+ return c.json(
3089
+ {
3090
+ error: "Failed to read logs",
3091
+ logs: [],
3092
+ offset: 0,
3093
+ total: 0
3094
+ },
3095
+ 500
3096
+ );
3097
+ }
3098
+ });
3099
+ app.get("/meta", (c) => {
3100
+ try {
3101
+ const sources = ["stdout", "stderr", "dev"].map((source) => {
3102
+ const logPath = resolveLogPath(source);
3103
+ if (!existsSync5(logPath)) {
3104
+ return { source, exists: false, total: 0, fileSize: 0 };
3105
+ }
3106
+ const stats = statSync(logPath);
3107
+ return {
3108
+ source,
3109
+ exists: true,
3110
+ fileSize: stats.size,
3111
+ fileModified: stats.mtime.toISOString()
3112
+ };
3113
+ });
3114
+ return c.json({
3115
+ defaultSource: pickDefaultSource(),
3116
+ sources
3117
+ });
3118
+ } catch (error) {
3119
+ logger2.error("Failed to read log metadata", { error: String(error) });
3120
+ return c.json({ error: "Failed to read log metadata", exists: false }, 500);
3121
+ }
3122
+ });
3123
+ return app;
3124
+ }
3125
+
2738
3126
  // src/api/routes/misc.routes.ts
2739
3127
  import { spawn } from "child_process";
2740
3128
  import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
2741
- import { Hono as Hono5 } from "hono";
3129
+ import { Hono as Hono6 } from "hono";
2742
3130
  import { hostname } from "os";
2743
3131
 
2744
3132
  // 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";
3133
+ import { readFileSync as readFileSync5 } from "fs";
3134
+ import { homedir as homedir5 } from "os";
3135
+ import { join as join10 } from "path";
2748
3136
  import { parse as parseYaml } from "yaml";
2749
3137
 
2750
3138
  // src/schemas/updateConfig.schema.ts
@@ -2760,12 +3148,12 @@ var UpdateConfigSchema = z.object({
2760
3148
  }).strict();
2761
3149
 
2762
3150
  // src/config/update-config.ts
2763
- var DEFAULT_CONFIG_PATH = join7(homedir3(), ".threadbase", "update.yaml");
3151
+ var DEFAULT_CONFIG_PATH = join10(homedir5(), ".threadbase", "update.yaml");
2764
3152
  function loadUpdateConfig(opts = {}) {
2765
3153
  const path = opts.path ?? DEFAULT_CONFIG_PATH;
2766
3154
  let raw;
2767
3155
  try {
2768
- raw = readFileSync4(path, "utf-8");
3156
+ raw = readFileSync5(path, "utf-8");
2769
3157
  } catch (err) {
2770
3158
  if (err.code === "ENOENT") return null;
2771
3159
  throw err;
@@ -2812,7 +3200,7 @@ function verifyWebhookSignature(body, header, secret) {
2812
3200
  }
2813
3201
  var clientLog = getLogger("client");
2814
3202
  var createMiscRoutes = (deps) => {
2815
- const app = new Hono5();
3203
+ const app = new Hono6();
2816
3204
  app.get("/api/info", (c) => {
2817
3205
  const ptyIds = deps.ptyAttachedIds();
2818
3206
  return c.json({
@@ -2888,11 +3276,11 @@ var createMiscRoutes = (deps) => {
2888
3276
  };
2889
3277
 
2890
3278
  // src/api/routes/pair.routes.ts
2891
- import { Hono as Hono6 } from "hono";
3279
+ import { Hono as Hono7 } from "hono";
2892
3280
  var ALREADY_HANDLED3 = 597;
2893
3281
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
2894
3282
  var createPairRoutes = (deps) => {
2895
- const app = new Hono6();
3283
+ const app = new Hono7();
2896
3284
  app.post("/start", (c) => {
2897
3285
  deps.handlePairStart(c.env.outgoing);
2898
3286
  return alreadyHandled3();
@@ -2905,11 +3293,11 @@ var createPairRoutes = (deps) => {
2905
3293
  };
2906
3294
 
2907
3295
  // src/api/routes/projects.routes.ts
2908
- import { Hono as Hono7 } from "hono";
3296
+ import { Hono as Hono8 } from "hono";
2909
3297
  var ALREADY_HANDLED4 = 597;
2910
3298
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
2911
3299
  var createProjectRoutes = (deps) => {
2912
- const app = new Hono7();
3300
+ const app = new Hono8();
2913
3301
  app.get("/", (c) => {
2914
3302
  const url = new URL(c.req.url);
2915
3303
  deps.handleListProjects(url, c.env.outgoing);
@@ -2924,11 +3312,11 @@ var createProjectRoutes = (deps) => {
2924
3312
  };
2925
3313
 
2926
3314
  // src/api/routes/scanner.routes.ts
2927
- import { Hono as Hono8 } from "hono";
3315
+ import { Hono as Hono9 } from "hono";
2928
3316
  var ALREADY_HANDLED5 = 597;
2929
3317
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
2930
3318
  var createScannerRoutes = (deps) => {
2931
- const app = new Hono8();
3319
+ const app = new Hono9();
2932
3320
  app.get("/api/search", async (c) => {
2933
3321
  const url = new URL(c.req.url);
2934
3322
  await deps.handleSearch(url, c.env.outgoing);
@@ -2938,11 +3326,11 @@ var createScannerRoutes = (deps) => {
2938
3326
  };
2939
3327
 
2940
3328
  // src/api/routes/sessions.routes.ts
2941
- import { Hono as Hono9 } from "hono";
3329
+ import { Hono as Hono10 } from "hono";
2942
3330
  var ALREADY_HANDLED6 = 597;
2943
3331
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
2944
3332
  var createSessionRoutes = (deps) => {
2945
- const app = new Hono9();
3333
+ const app = new Hono10();
2946
3334
  app.get("/count", (c) => {
2947
3335
  deps.handleSessionsCount(c.env.outgoing);
2948
3336
  return alreadyHandled6();
@@ -3009,21 +3397,19 @@ var createSessionRoutes = (deps) => {
3009
3397
  };
3010
3398
 
3011
3399
  // src/api/routes/ws.routes.ts
3012
- import { Hono as Hono10 } from "hono";
3400
+ import { Hono as Hono11 } from "hono";
3013
3401
  var createWsRoutes = (deps, upgradeWebSocket) => {
3014
- const app = new Hono10();
3402
+ const app = new Hono11();
3015
3403
  app.get(
3016
3404
  "/ws",
3017
- upgradeWebSocket((c) => {
3018
- const key = c.req.query("key");
3019
- const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3405
+ upgradeWebSocket(() => {
3020
3406
  let openWs = null;
3021
3407
  return {
3022
3408
  onOpen(_evt, ws) {
3023
3409
  const raw = ws.raw;
3024
3410
  if (!raw) return;
3025
3411
  openWs = raw;
3026
- deps.handleWsOpen(raw, preAuthed);
3412
+ deps.handleWsOpen(raw);
3027
3413
  },
3028
3414
  onMessage(evt, _ws) {
3029
3415
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -3039,7 +3425,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3039
3425
 
3040
3426
  // src/api/app.ts
3041
3427
  var createHonoApp = (deps, upgradeWebSocket) => {
3042
- const app = new Hono11();
3428
+ const app = new Hono12();
3043
3429
  const httpLog = getLogger("http");
3044
3430
  app.use("*", async (c, next) => {
3045
3431
  const start = Date.now();
@@ -3068,6 +3454,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3068
3454
  app.route("/api", createBrowseRoutes(deps));
3069
3455
  app.route("/", createScannerRoutes(deps));
3070
3456
  app.route("/internal", createProgressRoutes(deps));
3457
+ app.route("/api/logs", createLogsRoutes());
3071
3458
  if (upgradeWebSocket) {
3072
3459
  app.route("/", createWsRoutes(deps, upgradeWebSocket));
3073
3460
  }
@@ -3076,7 +3463,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3076
3463
 
3077
3464
  // src/browse.ts
3078
3465
  import { mkdir as mkdir2, readdir, realpath, stat } from "fs/promises";
3079
- import { join as join8, resolve, sep } from "path";
3466
+ import { join as join11, resolve, sep } from "path";
3080
3467
  var BrowsePathNotFoundError = class extends Error {
3081
3468
  constructor(message) {
3082
3469
  super(message);
@@ -3114,7 +3501,7 @@ async function createDirectory(parentAbsolutePath, name) {
3114
3501
  if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
3115
3502
  throw new Error("Invalid directory name");
3116
3503
  }
3117
- const target = join8(parentAbsolutePath, name);
3504
+ const target = join11(parentAbsolutePath, name);
3118
3505
  try {
3119
3506
  const s = await stat(target);
3120
3507
  if (s.isDirectory()) throw new Error("Directory already exists");
@@ -3131,18 +3518,18 @@ import {
3131
3518
  parseJsonlLine
3132
3519
  } from "@threadbase-sh/scanner";
3133
3520
  import Database from "better-sqlite3";
3134
- import { closeSync as closeSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
3521
+ import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, openSync as openSync3, readSync as readSync3, statSync as statSync3 } from "fs";
3135
3522
  import { open as openAsync } from "fs/promises";
3136
- import { dirname as dirname6 } from "path";
3523
+ import { dirname as dirname7 } from "path";
3137
3524
  import { setImmediate as yieldToEventLoop } from "timers/promises";
3138
3525
 
3139
3526
  // src/db/sqlite-migrate.ts
3140
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
3141
- import { dirname as dirname5, join as join9 } from "path";
3527
+ import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
3528
+ import { dirname as dirname6, join as join12 } from "path";
3142
3529
  import { fileURLToPath as fileURLToPath2 } from "url";
3143
3530
  function getMigrationsDir2() {
3144
3531
  if (typeof import.meta !== "undefined" && import.meta.url) {
3145
- return dirname5(fileURLToPath2(import.meta.url));
3532
+ return dirname6(fileURLToPath2(import.meta.url));
3146
3533
  }
3147
3534
  return __dirname;
3148
3535
  }
@@ -3154,7 +3541,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
3154
3541
  `;
3155
3542
  function runSqliteMigrations(db, migrationsDir) {
3156
3543
  db.exec(SCHEMA_MIGRATIONS_SQL);
3157
- const dir = migrationsDir ?? join9(getMigrationsDir2(), "migrations");
3544
+ const dir = migrationsDir ?? join12(getMigrationsDir2(), "migrations");
3158
3545
  const files = readdirSync2(dir).filter((f) => f.endsWith(".sql")).sort();
3159
3546
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
3160
3547
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -3166,7 +3553,7 @@ function runSqliteMigrations(db, migrationsDir) {
3166
3553
  skipped.push(file);
3167
3554
  continue;
3168
3555
  }
3169
- const sql = readFileSync5(join9(dir, file), "utf-8");
3556
+ const sql = readFileSync6(join12(dir, file), "utf-8");
3170
3557
  const tx = db.transaction(() => {
3171
3558
  db.exec(sql);
3172
3559
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -3178,7 +3565,7 @@ function runSqliteMigrations(db, migrationsDir) {
3178
3565
  }
3179
3566
 
3180
3567
  // src/services/conversations/isAgentConversation.ts
3181
- import { closeSync, openSync, readSync, statSync } from "fs";
3568
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
3182
3569
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
3183
3570
  var CHUNK_BYTES = 64 * 1024;
3184
3571
  var ENTRYPOINT_PROBE = `"entrypoint":`;
@@ -3200,12 +3587,12 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3200
3587
  if (cached2 !== void 0) return cached2;
3201
3588
  let fd;
3202
3589
  try {
3203
- fd = openSync(filePath, "r");
3590
+ fd = openSync2(filePath, "r");
3204
3591
  } catch {
3205
3592
  return false;
3206
3593
  }
3207
3594
  try {
3208
- const fileSize = statSync(filePath).size;
3595
+ const fileSize = statSync2(filePath).size;
3209
3596
  if (fileSize === 0) {
3210
3597
  fileDecisionCache.set(key, false);
3211
3598
  return false;
@@ -3216,7 +3603,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3216
3603
  let carry = "";
3217
3604
  while (offset < fileSize) {
3218
3605
  const toRead = Math.min(CHUNK_BYTES, fileSize - offset);
3219
- const got = readSync(fd, buf, 0, toRead, offset);
3606
+ const got = readSync2(fd, buf, 0, toRead, offset);
3220
3607
  if (got <= 0) break;
3221
3608
  const chunk = carry + buf.toString("utf8", 0, got);
3222
3609
  for (const marker of markers) {
@@ -3237,7 +3624,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
3237
3624
  } catch {
3238
3625
  return false;
3239
3626
  } finally {
3240
- closeSync(fd);
3627
+ closeSync2(fd);
3241
3628
  }
3242
3629
  }
3243
3630
  function parseAgentEntrypointsEnv(raw) {
@@ -3774,7 +4161,7 @@ var ConversationCache = class _ConversationCache {
3774
4161
  if (!fileState) return null;
3775
4162
  let stat3;
3776
4163
  try {
3777
- stat3 = statSync2(filePath);
4164
+ stat3 = statSync3(filePath);
3778
4165
  } catch {
3779
4166
  return null;
3780
4167
  }
@@ -3795,17 +4182,17 @@ var ConversationCache = class _ConversationCache {
3795
4182
  );
3796
4183
  if (rows.length === 0) return { messages: [], total, fromIndex: from };
3797
4184
  const messages = [];
3798
- const fd = openSync2(filePath, "r");
4185
+ const fd = openSync3(filePath, "r");
3799
4186
  try {
3800
4187
  const state = createJsonlParseState();
3801
4188
  for (const row of rows) {
3802
4189
  const buf = Buffer.alloc(row.byte_length);
3803
- readSync2(fd, buf, 0, row.byte_length, row.byte_offset);
4190
+ readSync3(fd, buf, 0, row.byte_length, row.byte_offset);
3804
4191
  const msg = parseJsonlLine(buf.toString("utf-8"), state);
3805
4192
  if (msg) messages.push(msg);
3806
4193
  }
3807
4194
  } finally {
3808
- closeSync2(fd);
4195
+ closeSync3(fd);
3809
4196
  }
3810
4197
  return { messages, total, fromIndex: from };
3811
4198
  }
@@ -3833,14 +4220,14 @@ var ConversationCache = class _ConversationCache {
3833
4220
  isAgentFileCached(filePath) {
3834
4221
  let s;
3835
4222
  try {
3836
- s = statSync2(filePath);
4223
+ s = statSync3(filePath);
3837
4224
  } catch {
3838
4225
  return false;
3839
4226
  }
3840
4227
  return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3841
4228
  }
3842
4229
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3843
- mkdirSync2(dirname6(dbPath), { recursive: true });
4230
+ mkdirSync3(dirname7(dbPath), { recursive: true });
3844
4231
  const db = new Database(dbPath);
3845
4232
  db.pragma("journal_mode = WAL");
3846
4233
  db.pragma("foreign_keys = ON");
@@ -4021,7 +4408,7 @@ var ConversationCache = class _ConversationCache {
4021
4408
  let mtimeMs = null;
4022
4409
  let fileSize = null;
4023
4410
  try {
4024
- const s = statSync2(m.filePath);
4411
+ const s = statSync3(m.filePath);
4025
4412
  mtimeMs = s.mtimeMs;
4026
4413
  fileSize = s.size;
4027
4414
  } catch {
@@ -4080,8 +4467,8 @@ var ConversationCache = class _ConversationCache {
4080
4467
  let fileSize;
4081
4468
  let fd;
4082
4469
  try {
4083
- fileSize = statSync2(filePath).size;
4084
- fd = openSync2(filePath, "r");
4470
+ fileSize = statSync3(filePath).size;
4471
+ fd = openSync3(filePath, "r");
4085
4472
  } catch {
4086
4473
  return false;
4087
4474
  }
@@ -4094,7 +4481,7 @@ var ConversationCache = class _ConversationCache {
4094
4481
  while (pos > 0 && lines.length < this.tailSize * 4) {
4095
4482
  const toRead = Math.min(CHUNK, pos);
4096
4483
  pos -= toRead;
4097
- readSync2(fd, buf, 0, toRead, pos);
4484
+ readSync3(fd, buf, 0, toRead, pos);
4098
4485
  const chunk = buf.subarray(0, toRead).toString("utf8");
4099
4486
  const combined = chunk + partial;
4100
4487
  const parts = combined.split("\n");
@@ -4105,7 +4492,7 @@ var ConversationCache = class _ConversationCache {
4105
4492
  }
4106
4493
  if (partial) lines.push(partial);
4107
4494
  } finally {
4108
- closeSync2(fd);
4495
+ closeSync3(fd);
4109
4496
  }
4110
4497
  const msgs = [];
4111
4498
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -4307,7 +4694,7 @@ var ConversationCache = class _ConversationCache {
4307
4694
  * `handleGetConversation` can still serve the cached tail even when the
4308
4695
  * JSONL has been deleted.
4309
4696
  */
4310
- pruneGhostFiles(exists = existsSync5) {
4697
+ pruneGhostFiles(exists = existsSync6) {
4311
4698
  const rows = this.stmts.allFilePaths.all();
4312
4699
  const ghosts = [];
4313
4700
  const prune = this.db.transaction((ids) => {
@@ -4362,7 +4749,7 @@ var ConversationCache = class _ConversationCache {
4362
4749
  * Returns the removed IDs.
4363
4750
  */
4364
4751
  reconcileDeletions(livePaths, opts) {
4365
- const exists = opts?.exists ?? existsSync5;
4752
+ const exists = opts?.exists ?? existsSync6;
4366
4753
  const rows = this.stmts.allFilePaths.all();
4367
4754
  const removed = [];
4368
4755
  const drop = this.db.transaction((ids) => {
@@ -4591,23 +4978,23 @@ async function recordUpload(pool2, instanceId, row) {
4591
4978
  }
4592
4979
 
4593
4980
  // src/handlers/handleListProjects.ts
4594
- import { readdirSync as readdirSync3, statSync as statSync3 } from "fs";
4595
- import { homedir as homedir4 } from "os";
4596
- import { join as join10 } from "path";
4981
+ import { readdirSync as readdirSync3, statSync as statSync4 } from "fs";
4982
+ import { homedir as homedir6 } from "os";
4983
+ import { join as join13 } from "path";
4597
4984
  function decodeProjectPath(dirName) {
4598
4985
  return dirName.replace(/-/g, "/");
4599
4986
  }
4600
4987
  function handleListProjects(url, res) {
4601
4988
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
4602
4989
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
4603
- const projectsDir = join10(homedir4(), ".claude", "projects");
4990
+ const projectsDir = join13(homedir6(), ".claude", "projects");
4604
4991
  let entries;
4605
4992
  try {
4606
4993
  entries = readdirSync3(projectsDir).map((dirName) => {
4607
- const fullPath = join10(projectsDir, dirName);
4994
+ const fullPath = join13(projectsDir, dirName);
4608
4995
  let mtime = 0;
4609
4996
  try {
4610
- mtime = statSync3(fullPath).mtimeMs;
4997
+ mtime = statSync4(fullPath).mtimeMs;
4611
4998
  } catch {
4612
4999
  }
4613
5000
  const path = decodeProjectPath(dirName);
@@ -4702,7 +5089,7 @@ function seal(plaintext, recipientPublicKeyBase64) {
4702
5089
 
4703
5090
  // src/services/conversations/conversationWatcher.ts
4704
5091
  import chokidar from "chokidar";
4705
- import { statSync as statSync4 } from "fs";
5092
+ import { statSync as statSync5 } from "fs";
4706
5093
  import { open, stat as stat2 } from "fs/promises";
4707
5094
  var ConversationWatcher = class {
4708
5095
  files = /* @__PURE__ */ new Map();
@@ -4725,7 +5112,7 @@ var ConversationWatcher = class {
4725
5112
  if (this.files.has(filePath)) return;
4726
5113
  let offset;
4727
5114
  try {
4728
- offset = statSync4(filePath).size;
5115
+ offset = statSync5(filePath).size;
4729
5116
  } catch {
4730
5117
  offset = 0;
4731
5118
  }
@@ -4900,14 +5287,14 @@ function findSearchTarget(messages, query) {
4900
5287
  }
4901
5288
 
4902
5289
  // src/services/conversations/pruneAgentConversations.ts
4903
- import { existsSync as existsSync6 } from "fs";
5290
+ import { existsSync as existsSync7 } from "fs";
4904
5291
  function pruneAgentConversations(cache) {
4905
5292
  const db = cache.getDatabase();
4906
5293
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
4907
5294
  let pruned = 0;
4908
5295
  let missing = 0;
4909
5296
  for (const row of rows) {
4910
- if (!existsSync6(row.file_path)) {
5297
+ if (!existsSync7(row.file_path)) {
4911
5298
  missing += 1;
4912
5299
  continue;
4913
5300
  }
@@ -4930,13 +5317,6 @@ function deriveProjectChatTitle(input) {
4930
5317
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
4931
5318
  }
4932
5319
 
4933
- // src/services/questions/permissionAnswerKeys.ts
4934
- var ANSWER_KEYS_ALLOWLIST = /^(?:\r|[yn]\r|\x03|\d+\r)$/;
4935
- function sanitizeAnswerKeys(keys) {
4936
- if (keys === void 0) return void 0;
4937
- return ANSWER_KEYS_ALLOWLIST.test(keys) ? keys : void 0;
4938
- }
4939
-
4940
5320
  // src/services/questions/detectAskUserQuestion.ts
4941
5321
  function normalizeContent2(raw) {
4942
5322
  if (Array.isArray(raw)) return raw;
@@ -5250,7 +5630,7 @@ function discoveredToResponse(d, conversationId) {
5250
5630
  import { randomBytes as randomBytes3 } from "crypto";
5251
5631
  import { mkdir as mkdir3, writeFile } from "fs/promises";
5252
5632
  import heicConvert from "heic-convert";
5253
- import { join as join11 } from "path";
5633
+ import { join as join14 } from "path";
5254
5634
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
5255
5635
  var MAX_BYTES = 25 * 1024 * 1024;
5256
5636
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -5283,9 +5663,9 @@ async function saveUploadFile(input) {
5283
5663
  }
5284
5664
  const id = `up_${randomBytes3(8).toString("hex")}`;
5285
5665
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
5286
- const dir = join11(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5666
+ const dir = join14(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5287
5667
  await mkdir3(dir, { recursive: true });
5288
- const filePath = join11(dir, `${Date.now()}-${id}-${safeName}`);
5668
+ const filePath = join14(dir, `${Date.now()}-${id}-${safeName}`);
5289
5669
  await writeFile(filePath, buffer);
5290
5670
  return {
5291
5671
  id,
@@ -5541,10 +5921,8 @@ var WSHub = class {
5541
5921
  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.`;
5542
5922
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5543
5923
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5544
- var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5545
- var WS_CLOSE_UNAUTHORIZED = 4401;
5546
5924
  var REFRESH_TTL_MS = 2e3;
5547
- var START_READY_TIMEOUT_MS = 15e3;
5925
+ var START_READY_TIMEOUT_MS = 1e4;
5548
5926
  function parseIncludeAgentsEnv(raw) {
5549
5927
  if (raw === void 0) return false;
5550
5928
  const v = raw.trim().toLowerCase();
@@ -5633,14 +6011,6 @@ var StreamerServer = class {
5633
6011
  clientIdToWs = /* @__PURE__ */ new Map();
5634
6012
  // Reverse map for cleanup on close
5635
6013
  wsToClientId = /* @__PURE__ */ new Map();
5636
- // M1 — WS auth. Sockets that have authenticated (via ?key= at upgrade OR a
5637
- // { type: "auth", token } first message). Only authed sockets are added to
5638
- // the hub and receive broadcasts.
5639
- // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
5640
- wsAuthed = /* @__PURE__ */ new Set();
5641
- // Keyless sockets awaiting their first-message auth handshake → close timer.
5642
- wsAuthPending = /* @__PURE__ */ new Map();
5643
- wsAuthTimeoutMs;
5644
6014
  cache = null;
5645
6015
  projectsRepo = null;
5646
6016
  conversationsRepo = null;
@@ -5678,12 +6048,11 @@ var StreamerServer = class {
5678
6048
  this.disableDb = config.disableDb ?? false;
5679
6049
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
5680
6050
  this.scanProfiles = config.scanProfiles;
5681
- this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
6051
+ this.codexRoots = config.codexRoots ?? [join15(homedir7(), ".codex", "sessions")];
5682
6052
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
5683
- this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
5684
6053
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5685
6054
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5686
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
6055
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join15(homedir7(), ".threadbase", "cache");
5687
6056
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
5688
6057
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
5689
6058
  this.markScannerStaleDebounced = debounce(() => {
@@ -5724,7 +6093,7 @@ var StreamerServer = class {
5724
6093
  const seqs = cache.extendMessageIndex(
5725
6094
  filePath,
5726
6095
  spans,
5727
- statSync5(filePath),
6096
+ statSync6(filePath),
5728
6097
  readFrom,
5729
6098
  endOffset
5730
6099
  );
@@ -5809,6 +6178,9 @@ var StreamerServer = class {
5809
6178
  onOutput: (sessionId, data) => {
5810
6179
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
5811
6180
  },
6181
+ onUserMessage: (sessionId, text, ts) => {
6182
+ this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
6183
+ },
5812
6184
  onPermissionChange: (sessionId, gate) => {
5813
6185
  this.handlePermissionChange(sessionId, gate);
5814
6186
  },
@@ -5885,7 +6257,7 @@ var StreamerServer = class {
5885
6257
  temporalClient,
5886
6258
  taskQueue: agentConfig.temporal.taskQueue
5887
6259
  });
5888
- const conversationsBaseDir = agentConfig.conversationsDir || join12(dirname7(this.cacheDir), "conversations");
6260
+ const conversationsBaseDir = agentConfig.conversationsDir || join15(dirname8(this.cacheDir), "conversations");
5889
6261
  conversationWriter = createConversationWriter({
5890
6262
  baseDir: conversationsBaseDir
5891
6263
  });
@@ -5938,39 +6310,17 @@ var StreamerServer = class {
5938
6310
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
5939
6311
  handleBrowse: (url, res) => this.handleBrowse(url, res),
5940
6312
  handleMkdir: (req, res) => this.handleMkdir(req, res),
5941
- handleWsOpen: (ws, preAuthed) => {
5942
- if (preAuthed) {
5943
- this.completeWsAuth(ws);
5944
- return;
6313
+ handleWsOpen: (ws) => {
6314
+ this.wsHub.addClient(ws);
6315
+ const sessions = this.sessionStore.list(this.ptyAttachedIds());
6316
+ ws.send(JSON.stringify({ type: "session_list", sessions }));
6317
+ if (this.cacheReady) {
6318
+ ws.send(JSON.stringify({ type: "cache_ready" }));
5945
6319
  }
5946
- const timer = setTimeout(() => {
5947
- this.wsAuthPending.delete(ws);
5948
- try {
5949
- ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
5950
- } catch {
5951
- }
5952
- }, this.wsAuthTimeoutMs);
5953
- this.wsAuthPending.set(ws, timer);
5954
6320
  },
5955
6321
  handleWsMessage: async (ws, raw) => {
5956
6322
  try {
5957
6323
  const msg = JSON.parse(String(raw));
5958
- if (!this.wsAuthed.has(ws)) {
5959
- if (msg.type === "auth" && typeof msg.token === "string") {
5960
- const t = this.wsAuthPending.get(ws);
5961
- if (t) clearTimeout(t);
5962
- this.wsAuthPending.delete(ws);
5963
- if (validateApiKey(msg.token, this.apiKey)) {
5964
- this.completeWsAuth(ws);
5965
- } else {
5966
- try {
5967
- ws.close(WS_CLOSE_UNAUTHORIZED, "unauthorized");
5968
- } catch {
5969
- }
5970
- }
5971
- }
5972
- return;
5973
- }
5974
6324
  if (msg.type === "register" && typeof msg.clientId === "string") {
5975
6325
  const oldClientId = this.wsToClientId.get(ws);
5976
6326
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -5981,24 +6331,56 @@ var StreamerServer = class {
5981
6331
  this.addSessionSubscriber(msg.sessionId, ws);
5982
6332
  if (this.ptyManager.hasSession(msg.sessionId)) {
5983
6333
  const lines = await this.ptyManager.getOutputLines(msg.sessionId, 200);
5984
- ws.send(JSON.stringify({ type: "terminal_replay", sessionId: msg.sessionId, lines }));
6334
+ const userMessages = this.ptyManager.getInputHistory(msg.sessionId);
6335
+ ws.send(
6336
+ JSON.stringify({
6337
+ type: "terminal_replay",
6338
+ sessionId: msg.sessionId,
6339
+ lines,
6340
+ userMessages
6341
+ })
6342
+ );
6343
+ }
6344
+ const pendingGate = this.pendingPermission.get(msg.sessionId);
6345
+ if (pendingGate) {
6346
+ this.log.info(`[ws.replay_permission] ${msg.sessionId.slice(0, 8)}`, {
6347
+ event: "ws.replay_permission",
6348
+ sessionId: msg.sessionId
6349
+ });
6350
+ ws.send(
6351
+ JSON.stringify({
6352
+ type: "permission",
6353
+ sessionId: msg.sessionId,
6354
+ ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
6355
+ ...pendingGate.detail ? { detail: pendingGate.detail } : {},
6356
+ options: pendingGate.options,
6357
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
6358
+ })
6359
+ );
6360
+ }
6361
+ const pendingQuestion = this.pendingQuestions.get(msg.sessionId);
6362
+ if (pendingQuestion) {
6363
+ this.log.info(`[ws.replay_question] ${msg.sessionId.slice(0, 8)}`, {
6364
+ event: "ws.replay_question",
6365
+ sessionId: msg.sessionId
6366
+ });
6367
+ ws.send(
6368
+ JSON.stringify({
6369
+ type: "question",
6370
+ sessionId: msg.sessionId,
6371
+ toolUseId: pendingQuestion.toolUseId,
6372
+ questions: pendingQuestion.questions
6373
+ })
6374
+ );
5985
6375
  }
5986
6376
  }
5987
6377
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
5988
- if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
5989
- this.startGraceTimer(msg.sessionId, 0);
5990
- }
6378
+ this.startGraceTimer(msg.sessionId, 0);
5991
6379
  }
5992
6380
  } catch {
5993
6381
  }
5994
6382
  },
5995
6383
  handleWsClose: (ws) => {
5996
- const pendingTimer = this.wsAuthPending.get(ws);
5997
- if (pendingTimer) {
5998
- clearTimeout(pendingTimer);
5999
- this.wsAuthPending.delete(ws);
6000
- }
6001
- this.wsAuthed.delete(ws);
6002
6384
  const clientId = this.wsToClientId.get(ws);
6003
6385
  if (clientId) {
6004
6386
  this.clientIdToWs.delete(clientId);
@@ -6068,20 +6450,6 @@ var StreamerServer = class {
6068
6450
  this.wsHub.broadcast(payload);
6069
6451
  }
6070
6452
  }
6071
- // M1: finalize a WebSocket auth (via ?key= at upgrade or a first-message
6072
- // handshake) — register it with the hub and send the initial snapshot. Only
6073
- // authed sockets reach this, so no unauthenticated client ever receives a
6074
- // broadcast.
6075
- // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
6076
- completeWsAuth(ws) {
6077
- this.wsAuthed.add(ws);
6078
- this.wsHub.addClient(ws);
6079
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
6080
- ws.send(JSON.stringify({ type: "session_list", sessions }));
6081
- if (this.cacheReady) {
6082
- ws.send(JSON.stringify({ type: "cache_ready" }));
6083
- }
6084
- }
6085
6453
  addSessionSubscriber(sessionId, ws) {
6086
6454
  let subs = this.sessionSubscribers.get(sessionId);
6087
6455
  if (!subs) {
@@ -6153,7 +6521,7 @@ var StreamerServer = class {
6153
6521
  });
6154
6522
  try {
6155
6523
  this.cache = ConversationCache.open(
6156
- join12(this.cacheDir, "cache.db"),
6524
+ join15(this.cacheDir, "cache.db"),
6157
6525
  this.tailSize,
6158
6526
  void 0,
6159
6527
  {
@@ -6179,11 +6547,11 @@ var StreamerServer = class {
6179
6547
  if (this.scanProfiles && this.scanProfiles.length > 0) {
6180
6548
  for (const profile of this.scanProfiles) {
6181
6549
  if (profile.enabled) {
6182
- this.fileWatcher.watchDirectory(join12(profile.configDir, "projects"));
6550
+ this.fileWatcher.watchDirectory(join15(profile.configDir, "projects"));
6183
6551
  }
6184
6552
  }
6185
6553
  } else {
6186
- this.fileWatcher.watchDirectory(join12(homedir5(), ".claude", "projects"));
6554
+ this.fileWatcher.watchDirectory(join15(homedir7(), ".claude", "projects"));
6187
6555
  }
6188
6556
  } catch (err) {
6189
6557
  const message = err instanceof Error ? err.message : String(err);
@@ -6368,9 +6736,6 @@ var StreamerServer = class {
6368
6736
  this.ptyManager.dispose();
6369
6737
  this.fileWatcher.dispose();
6370
6738
  this.wsHub.dispose();
6371
- for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6372
- this.wsAuthPending.clear();
6373
- this.wsAuthed.clear();
6374
6739
  this.pairTokens.dispose();
6375
6740
  if (this.dbPool) {
6376
6741
  await this.dbPool.end();
@@ -6768,17 +7133,17 @@ var StreamerServer = class {
6768
7133
  return scanner;
6769
7134
  }
6770
7135
  findJsonlPath(uuid) {
6771
- const projectsDir = join12(homedir5(), ".claude", "projects");
6772
- if (!existsSync7(projectsDir)) return null;
7136
+ const projectsDir = join15(homedir7(), ".claude", "projects");
7137
+ if (!existsSync8(projectsDir)) return null;
6773
7138
  const filename = `${uuid}.jsonl`;
6774
7139
  for (const dir of readdirSync4(projectsDir)) {
6775
- const fp = join12(projectsDir, dir, filename);
6776
- if (existsSync7(fp)) return fp;
6777
- const projectDir = join12(projectsDir, dir);
7140
+ const fp = join15(projectsDir, dir, filename);
7141
+ if (existsSync8(fp)) return fp;
7142
+ const projectDir = join15(projectsDir, dir);
6778
7143
  try {
6779
7144
  for (const sub of readdirSync4(projectDir)) {
6780
- const subagentPath = join12(projectDir, sub, "subagents", filename);
6781
- if (existsSync7(subagentPath)) return subagentPath;
7145
+ const subagentPath = join15(projectDir, sub, "subagents", filename);
7146
+ if (existsSync8(subagentPath)) return subagentPath;
6782
7147
  }
6783
7148
  } catch {
6784
7149
  }
@@ -6925,7 +7290,7 @@ var StreamerServer = class {
6925
7290
  if (!conv.filePath) return false;
6926
7291
  let mtimeMs = null;
6927
7292
  try {
6928
- mtimeMs = statSync5(conv.filePath).mtimeMs;
7293
+ mtimeMs = statSync6(conv.filePath).mtimeMs;
6929
7294
  } catch {
6930
7295
  return false;
6931
7296
  }
@@ -7293,7 +7658,7 @@ var StreamerServer = class {
7293
7658
  handleGetSession(sessionId, res) {
7294
7659
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
7295
7660
  if (session) {
7296
- if (!existsSync7(session.projectPath)) {
7661
+ if (!existsSync8(session.projectPath)) {
7297
7662
  session.failureReason = `Project directory not found: ${session.projectPath}`;
7298
7663
  }
7299
7664
  json(res, 200, session);
@@ -7497,16 +7862,17 @@ var StreamerServer = class {
7497
7862
  return;
7498
7863
  }
7499
7864
  this.pendingPermission.set(sessionId, gate);
7500
- const safeOptions = gate.options.map((o) => {
7501
- const answerKeys = sanitizeAnswerKeys(o.answerKeys);
7502
- return answerKeys === void 0 ? { index: o.index, label: o.label } : { ...o, answerKeys };
7503
- });
7865
+ const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
7866
+ this.log.info(
7867
+ `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
7868
+ { event: "ws.broadcast_permission", sessionId, subscriberCount }
7869
+ );
7504
7870
  this.wsHub.broadcast({
7505
7871
  type: "permission",
7506
7872
  sessionId,
7507
7873
  ...gate.prompt ? { prompt: gate.prompt } : {},
7508
7874
  ...gate.detail ? { detail: gate.detail } : {},
7509
- options: safeOptions,
7875
+ options: gate.options,
7510
7876
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7511
7877
  });
7512
7878
  }
@@ -7693,7 +8059,7 @@ var StreamerServer = class {
7693
8059
  sessionStore: this.sessionStore,
7694
8060
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
7695
8061
  agentClient: this.agentClient,
7696
- conversationsDir: this.cacheDir ? join12(dirname7(this.cacheDir), "conversations") : "",
8062
+ conversationsDir: this.cacheDir ? join15(dirname8(this.cacheDir), "conversations") : "",
7697
8063
  agentConfig: this.agentConfig
7698
8064
  });
7699
8065
  json(res, result.status, result.body);
@@ -7834,9 +8200,9 @@ var StreamerServer = class {
7834
8200
  // was passed to Claude via --session-id so the filename matches from the start.
7835
8201
  watchForJsonl(sessionId, projectPath) {
7836
8202
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
7837
- const projectsDir = join12(homedir5(), ".claude", "projects", encoded);
8203
+ const projectsDir = join15(homedir7(), ".claude", "projects", encoded);
7838
8204
  const expectedFile = `${sessionId}.jsonl`;
7839
- const filePath = join12(projectsDir, expectedFile);
8205
+ const filePath = join15(projectsDir, expectedFile);
7840
8206
  const deadline = Date.now() + 12e4;
7841
8207
  let watcher = null;
7842
8208
  const cleanup = () => {
@@ -7854,12 +8220,12 @@ var StreamerServer = class {
7854
8220
  cleanup();
7855
8221
  return;
7856
8222
  }
7857
- let resolvedFilePath = existsSync7(filePath) ? filePath : null;
7858
- if (!resolvedFilePath && existsSync7(projectsDir)) {
8223
+ let resolvedFilePath = existsSync8(filePath) ? filePath : null;
8224
+ if (!resolvedFilePath && existsSync8(projectsDir)) {
7859
8225
  try {
7860
8226
  const now = Date.now();
7861
- 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];
7862
- if (recent) resolvedFilePath = join12(projectsDir, recent.f);
8227
+ 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];
8228
+ if (recent) resolvedFilePath = join15(projectsDir, recent.f);
7863
8229
  } catch {
7864
8230
  }
7865
8231
  }
@@ -7868,7 +8234,7 @@ var StreamerServer = class {
7868
8234
  this.sessionFileMap.set(sessionId, resolvedFilePath);
7869
8235
  this.fileWatcher.watch(resolvedFilePath);
7870
8236
  try {
7871
- const existing = readFileSync6(resolvedFilePath, "utf8").split("\n").filter(Boolean);
8237
+ const existing = readFileSync7(resolvedFilePath, "utf8").split("\n").filter(Boolean);
7872
8238
  if (existing.length > 0) {
7873
8239
  this.broadcastConversationLines(sessionId, existing);
7874
8240
  }
@@ -7907,7 +8273,7 @@ var StreamerServer = class {
7907
8273
  watchForCodexRollout(sessionId, projectPath) {
7908
8274
  const deadline = Date.now() + 12e4;
7909
8275
  const now = /* @__PURE__ */ new Date();
7910
- const dateDir = join12(
8276
+ const dateDir = join15(
7911
8277
  String(now.getFullYear()),
7912
8278
  String(now.getMonth() + 1).padStart(2, "0"),
7913
8279
  String(now.getDate()).padStart(2, "0")
@@ -7920,7 +8286,7 @@ var StreamerServer = class {
7920
8286
  };
7921
8287
  const matchesProjectPath = (candidatePath) => {
7922
8288
  try {
7923
- const firstLine = readFileSync6(candidatePath, "utf8").split("\n", 1)[0];
8289
+ const firstLine = readFileSync7(candidatePath, "utf8").split("\n", 1)[0];
7924
8290
  if (!firstLine) return null;
7925
8291
  const parsed = JSON.parse(firstLine);
7926
8292
  if (parsed?.type !== "session_meta") return null;
@@ -7948,8 +8314,8 @@ var StreamerServer = class {
7948
8314
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
7949
8315
  );
7950
8316
  for (const root of this.codexRoots) {
7951
- const sessionsDir = join12(root, dateDir);
7952
- if (!existsSync7(sessionsDir)) continue;
8317
+ const sessionsDir = join15(root, dateDir);
8318
+ if (!existsSync8(sessionsDir)) continue;
7953
8319
  let candidateFiles;
7954
8320
  try {
7955
8321
  candidateFiles = readdirSync4(sessionsDir).filter((f) => f.endsWith(".jsonl"));
@@ -7957,9 +8323,9 @@ var StreamerServer = class {
7957
8323
  continue;
7958
8324
  }
7959
8325
  const nowMs = Date.now();
7960
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync5(join12(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
8326
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync6(join15(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
7961
8327
  for (const { f } of recentCandidates) {
7962
- const candidatePath = join12(sessionsDir, f);
8328
+ const candidatePath = join15(sessionsDir, f);
7963
8329
  const match = matchesProjectPath(candidatePath);
7964
8330
  if (!match) continue;
7965
8331
  if (boundElsewhere.has(match.id)) continue;
@@ -7969,7 +8335,7 @@ var StreamerServer = class {
7969
8335
  this.sessionFileMap.set(sessionId, candidatePath);
7970
8336
  this.fileWatcher.watch(candidatePath);
7971
8337
  try {
7972
- const existing = readFileSync6(candidatePath, "utf8").split("\n").filter(Boolean);
8338
+ const existing = readFileSync7(candidatePath, "utf8").split("\n").filter(Boolean);
7973
8339
  if (existing.length > 0) {
7974
8340
  this.broadcastConversationLines(sessionId, existing);
7975
8341
  }
@@ -8091,7 +8457,7 @@ var StreamerServer = class {
8091
8457
  };
8092
8458
  function classifyResumability(cwd) {
8093
8459
  if (!cwd) return { resumable: true };
8094
- if (existsSync7(cwd)) return { resumable: true };
8460
+ if (existsSync8(cwd)) return { resumable: true };
8095
8461
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
8096
8462
  return {
8097
8463
  resumable: false,