@threadbase-sh/streamer 1.30.0 → 1.31.1

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 false;
218
+ if (!timestampHeader) return true;
219
219
  const t = Number(timestampHeader);
220
220
  if (!Number.isFinite(t)) return false;
221
221
  const now = Math.floor(Date.now() / 1e3);
@@ -797,6 +797,7 @@ function debounce(fn, waitMs) {
797
797
 
798
798
  // src/codex-pty-runner.ts
799
799
  var OUTPUT_BUFFER_MAX = 65536;
800
+ var INPUT_HISTORY_MAX = 50;
800
801
  var PTY_COLS = 120;
801
802
  var PTY_ROWS = 40;
802
803
  var SCREEN_SCROLLBACK = 1e3;
@@ -877,6 +878,7 @@ var CodexPtyRunner = class {
877
878
  onPermissionChange;
878
879
  onLiveQuestion;
879
880
  onLiveQuestionGone;
881
+ onUserMessage;
880
882
  log;
881
883
  // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
882
884
  // "Ready" status bar — i.e. onReady hasn't fired.
@@ -907,6 +909,7 @@ var CodexPtyRunner = class {
907
909
  this.onPermissionChange = options.onPermissionChange;
908
910
  this.onLiveQuestion = options.onLiveQuestion;
909
911
  this.onLiveQuestionGone = options.onLiveQuestionGone;
912
+ this.onUserMessage = options.onUserMessage;
910
913
  this.log = options.logger ?? getLogger("codex-pty");
911
914
  }
912
915
  // Resume an existing Codex session. sessionId is the Codex-persisted
@@ -950,7 +953,8 @@ var CodexPtyRunner = class {
950
953
  lastOutput: "",
951
954
  process: proc,
952
955
  outputBuffer: Buffer.alloc(0),
953
- screen: createScreen()
956
+ screen: createScreen(),
957
+ inputHistory: []
954
958
  };
955
959
  this.sessions.set(sessionId, session);
956
960
  this.pendingReady.add(sessionId);
@@ -996,7 +1000,8 @@ var CodexPtyRunner = class {
996
1000
  lastOutput: "",
997
1001
  process: proc,
998
1002
  outputBuffer: Buffer.alloc(0),
999
- screen: createScreen()
1003
+ screen: createScreen(),
1004
+ inputHistory: []
1000
1005
  };
1001
1006
  this.sessions.set(sessionId, session);
1002
1007
  this.pendingReady.add(sessionId);
@@ -1114,6 +1119,7 @@ var CodexPtyRunner = class {
1114
1119
  // confirmed Codex accepts plain keystrokes), then submit \r after a short
1115
1120
  // delay so Codex's TUI gets an event-loop tick to process the input first.
1116
1121
  writeSubmit(sessionId, session, input, path, promptCount) {
1122
+ this.recordUserMessage(session, input);
1117
1123
  this.log.info(
1118
1124
  `[codex.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${input.length} digest=${digestBytes(input)}`,
1119
1125
  {
@@ -1243,6 +1249,19 @@ var CodexPtyRunner = class {
1243
1249
  }
1244
1250
  return lines.slice(-maxLines);
1245
1251
  }
1252
+ getInputHistory(sessionId) {
1253
+ return this.sessions.get(sessionId)?.inputHistory ?? [];
1254
+ }
1255
+ // Record a submitted user message as ground truth and fire onUserMessage.
1256
+ // Called from writeSubmit (direct and flush paths) — never from sendKeys.
1257
+ recordUserMessage(session, text) {
1258
+ const ts = Date.now();
1259
+ session.inputHistory.push({ text, ts });
1260
+ if (session.inputHistory.length > INPUT_HISTORY_MAX) {
1261
+ session.inputHistory.shift();
1262
+ }
1263
+ this.onUserMessage?.(session.id, text, ts);
1264
+ }
1246
1265
  getSession(sessionId) {
1247
1266
  const session = this.sessions.get(sessionId);
1248
1267
  return session ? toPublicSession(session) : null;
@@ -1620,6 +1639,7 @@ function detectShellPrompt(lines) {
1620
1639
 
1621
1640
  // src/pty-manager.ts
1622
1641
  var OUTPUT_BUFFER_MAX2 = 65536;
1642
+ var INPUT_HISTORY_MAX2 = 50;
1623
1643
  var PTY_COLS2 = 120;
1624
1644
  var PTY_ROWS2 = 40;
1625
1645
  var SCREEN_SCROLLBACK2 = 1e3;
@@ -1678,6 +1698,7 @@ var PTYManager = class {
1678
1698
  onPermissionChange;
1679
1699
  onLiveQuestion;
1680
1700
  onLiveQuestionGone;
1701
+ onUserMessage;
1681
1702
  // Per-session permission-gate state. True between an OSC 777 (gate open) and
1682
1703
  // the next prompt-ready without a fresh 777 (gate closed). Prevents
1683
1704
  // re-broadcasting open/close on every chunk.
@@ -1723,6 +1744,7 @@ var PTYManager = class {
1723
1744
  this.onPermissionChange = options.onPermissionChange;
1724
1745
  this.onLiveQuestion = options.onLiveQuestion;
1725
1746
  this.onLiveQuestionGone = options.onLiveQuestionGone;
1747
+ this.onUserMessage = options.onUserMessage;
1726
1748
  this.log = options.logger ?? getLogger("pty");
1727
1749
  }
1728
1750
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
@@ -1785,7 +1807,8 @@ var PTYManager = class {
1785
1807
  lastOutput: "",
1786
1808
  process: proc,
1787
1809
  outputBuffer: Buffer.alloc(0),
1788
- screen: createScreen2()
1810
+ screen: createScreen2(),
1811
+ inputHistory: []
1789
1812
  };
1790
1813
  this.sessions.set(sessionId, session);
1791
1814
  this.pendingReady.add(sessionId);
@@ -1836,7 +1859,8 @@ var PTYManager = class {
1836
1859
  lastOutput: "",
1837
1860
  process: proc,
1838
1861
  outputBuffer: Buffer.alloc(0),
1839
- screen: createScreen2()
1862
+ screen: createScreen2(),
1863
+ inputHistory: []
1840
1864
  };
1841
1865
  this.sessions.set(sessionId, session);
1842
1866
  this.pendingReady.add(sessionId);
@@ -1916,6 +1940,7 @@ var PTYManager = class {
1916
1940
  // step gives the TUI as many extra ticks as it needs, capped at
1917
1941
  // SUBMIT_MAX_WAIT_MS so a silent/wedged PTY still gets its \r eventually.
1918
1942
  writeSubmit(sessionId, session, input, path, promptCount) {
1943
+ this.recordUserMessage(session, input);
1919
1944
  const pasteBytes = buildPasteBytes(input);
1920
1945
  this.log.info(
1921
1946
  `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes2(pasteBytes)}`,
@@ -2046,6 +2071,20 @@ var PTYManager = class {
2046
2071
  }
2047
2072
  return lines.slice(-maxLines);
2048
2073
  }
2074
+ getInputHistory(sessionId) {
2075
+ return this.sessions.get(sessionId)?.inputHistory ?? [];
2076
+ }
2077
+ // Record a submitted user message as ground truth and fire onUserMessage.
2078
+ // Called from writeSubmit (both direct and flush paths) — never from
2079
+ // sendKeys, so raw keystrokes aren't logged as messages.
2080
+ recordUserMessage(session, text) {
2081
+ const ts = Date.now();
2082
+ session.inputHistory.push({ text, ts });
2083
+ if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2084
+ session.inputHistory.shift();
2085
+ }
2086
+ this.onUserMessage?.(session.id, text, ts);
2087
+ }
2049
2088
  getSession(sessionId) {
2050
2089
  const session = this.sessions.get(sessionId);
2051
2090
  return session ? toPublicSession2(session) : null;
@@ -2342,6 +2381,9 @@ var LiveSessionManager = class {
2342
2381
  getOutputLines(sessionId, maxLines) {
2343
2382
  return this.runnerFor(sessionId).getOutputLines(sessionId, maxLines);
2344
2383
  }
2384
+ getInputHistory(sessionId) {
2385
+ return this.runnerFor(sessionId).getInputHistory(sessionId);
2386
+ }
2345
2387
  getSession(sessionId) {
2346
2388
  for (const runner of this.runners.values()) {
2347
2389
  const session = runner.getSession(sessionId);
@@ -2801,7 +2843,7 @@ function isLocalRequest(remoteAddr) {
2801
2843
  const addr = remoteAddr ?? "";
2802
2844
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2803
2845
  }
2804
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2846
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2805
2847
  var LOCAL_ONLY_PATHS = /* @__PURE__ */ new Set(["/api/logs", "/api/logs/meta"]);
2806
2848
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2807
2849
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
@@ -3398,16 +3440,14 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3398
3440
  const app = new import_hono11.Hono();
3399
3441
  app.get(
3400
3442
  "/ws",
3401
- upgradeWebSocket((c) => {
3402
- const key = c.req.query("key");
3403
- const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3443
+ upgradeWebSocket(() => {
3404
3444
  let openWs = null;
3405
3445
  return {
3406
3446
  onOpen(_evt, ws) {
3407
3447
  const raw = ws.raw;
3408
3448
  if (!raw) return;
3409
3449
  openWs = raw;
3410
- deps.handleWsOpen(raw, preAuthed);
3450
+ deps.handleWsOpen(raw);
3411
3451
  },
3412
3452
  onMessage(evt, _ws) {
3413
3453
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -5313,13 +5353,6 @@ function deriveProjectChatTitle(input) {
5313
5353
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
5314
5354
  }
5315
5355
 
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
-
5323
5356
  // src/services/questions/detectAskUserQuestion.ts
5324
5357
  function normalizeContent2(raw) {
5325
5358
  if (Array.isArray(raw)) return raw;
@@ -5924,8 +5957,6 @@ var WSHub = class {
5924
5957
  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.`;
5925
5958
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5926
5959
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5927
- var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5928
- var WS_CLOSE_UNAUTHORIZED = 4401;
5929
5960
  var REFRESH_TTL_MS = 2e3;
5930
5961
  var START_READY_TIMEOUT_MS = 1e4;
5931
5962
  function parseIncludeAgentsEnv(raw) {
@@ -6016,14 +6047,6 @@ var StreamerServer = class {
6016
6047
  clientIdToWs = /* @__PURE__ */ new Map();
6017
6048
  // Reverse map for cleanup on close
6018
6049
  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;
6027
6050
  cache = null;
6028
6051
  projectsRepo = null;
6029
6052
  conversationsRepo = null;
@@ -6063,7 +6086,6 @@ var StreamerServer = class {
6063
6086
  this.scanProfiles = config.scanProfiles;
6064
6087
  this.codexRoots = config.codexRoots ?? [(0, import_path14.join)((0, import_os7.homedir)(), ".codex", "sessions")];
6065
6088
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
6066
- this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
6067
6089
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
6068
6090
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
6069
6091
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase", "cache");
@@ -6192,6 +6214,9 @@ var StreamerServer = class {
6192
6214
  onOutput: (sessionId, data) => {
6193
6215
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
6194
6216
  },
6217
+ onUserMessage: (sessionId, text, ts) => {
6218
+ this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
6219
+ },
6195
6220
  onPermissionChange: (sessionId, gate) => {
6196
6221
  this.handlePermissionChange(sessionId, gate);
6197
6222
  },
@@ -6321,39 +6346,17 @@ var StreamerServer = class {
6321
6346
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
6322
6347
  handleBrowse: (url, res) => this.handleBrowse(url, res),
6323
6348
  handleMkdir: (req, res) => this.handleMkdir(req, res),
6324
- handleWsOpen: (ws, preAuthed) => {
6325
- if (preAuthed) {
6326
- this.completeWsAuth(ws);
6327
- return;
6349
+ handleWsOpen: (ws) => {
6350
+ this.wsHub.addClient(ws);
6351
+ const sessions = this.sessionStore.list(this.ptyAttachedIds());
6352
+ ws.send(JSON.stringify({ type: "session_list", sessions }));
6353
+ if (this.cacheReady) {
6354
+ ws.send(JSON.stringify({ type: "cache_ready" }));
6328
6355
  }
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);
6337
6356
  },
6338
6357
  handleWsMessage: async (ws, raw) => {
6339
6358
  try {
6340
6359
  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
- }
6357
6360
  if (msg.type === "register" && typeof msg.clientId === "string") {
6358
6361
  const oldClientId = this.wsToClientId.get(ws);
6359
6362
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -6364,7 +6367,15 @@ var StreamerServer = class {
6364
6367
  this.addSessionSubscriber(msg.sessionId, ws);
6365
6368
  if (this.ptyManager.hasSession(msg.sessionId)) {
6366
6369
  const lines = await this.ptyManager.getOutputLines(msg.sessionId, 200);
6367
- ws.send(JSON.stringify({ type: "terminal_replay", sessionId: msg.sessionId, lines }));
6370
+ const userMessages = this.ptyManager.getInputHistory(msg.sessionId);
6371
+ ws.send(
6372
+ JSON.stringify({
6373
+ type: "terminal_replay",
6374
+ sessionId: msg.sessionId,
6375
+ lines,
6376
+ userMessages
6377
+ })
6378
+ );
6368
6379
  }
6369
6380
  const pendingGate = this.pendingPermission.get(msg.sessionId);
6370
6381
  if (pendingGate) {
@@ -6400,20 +6411,12 @@ var StreamerServer = class {
6400
6411
  }
6401
6412
  }
6402
6413
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
6403
- if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
6404
- this.startGraceTimer(msg.sessionId, 0);
6405
- }
6414
+ this.startGraceTimer(msg.sessionId, 0);
6406
6415
  }
6407
6416
  } catch {
6408
6417
  }
6409
6418
  },
6410
6419
  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);
6417
6420
  const clientId = this.wsToClientId.get(ws);
6418
6421
  if (clientId) {
6419
6422
  this.clientIdToWs.delete(clientId);
@@ -6483,20 +6486,6 @@ var StreamerServer = class {
6483
6486
  this.wsHub.broadcast(payload);
6484
6487
  }
6485
6488
  }
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
- }
6500
6489
  addSessionSubscriber(sessionId, ws) {
6501
6490
  let subs = this.sessionSubscribers.get(sessionId);
6502
6491
  if (!subs) {
@@ -6783,9 +6772,6 @@ var StreamerServer = class {
6783
6772
  this.ptyManager.dispose();
6784
6773
  this.fileWatcher.dispose();
6785
6774
  this.wsHub.dispose();
6786
- for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6787
- this.wsAuthPending.clear();
6788
- this.wsAuthed.clear();
6789
6775
  this.pairTokens.dispose();
6790
6776
  if (this.dbPool) {
6791
6777
  await this.dbPool.end();
@@ -7742,7 +7728,10 @@ var StreamerServer = class {
7742
7728
  const projectPath = jsonlCwd ?? conv?.projectPath;
7743
7729
  if (!projectPath) {
7744
7730
  if (!conv && !jsonlPath) {
7745
- json(res, 404, { error: "Conversation not found" });
7731
+ json(res, 404, {
7732
+ error: "Conversation history file is missing; it can no longer be resumed",
7733
+ code: "history_file_missing"
7734
+ });
7746
7735
  return;
7747
7736
  }
7748
7737
  json(res, 400, { error: "Could not determine project path" });
@@ -7912,10 +7901,6 @@ var StreamerServer = class {
7912
7901
  return;
7913
7902
  }
7914
7903
  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
7904
  const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
7920
7905
  this.log.info(
7921
7906
  `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
@@ -7926,7 +7911,7 @@ var StreamerServer = class {
7926
7911
  sessionId,
7927
7912
  ...gate.prompt ? { prompt: gate.prompt } : {},
7928
7913
  ...gate.detail ? { detail: gate.detail } : {},
7929
- options: safeOptions,
7914
+ options: gate.options,
7930
7915
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7931
7916
  });
7932
7917
  }