@threadbase-sh/streamer 1.29.0 → 1.29.2

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.d.cts CHANGED
@@ -321,6 +321,7 @@ interface ServerConfig {
321
321
  codexRoots?: string[];
322
322
  scannerPersistent?: boolean;
323
323
  ptyGracePeriodMs?: number;
324
+ wsAuthTimeoutMs?: number;
324
325
  cacheDir?: string;
325
326
  tailSize?: number;
326
327
  directoryScanDebounceMs?: number;
@@ -859,7 +860,7 @@ type ApiDeps = {
859
860
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
860
861
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
861
862
  handleMkdir: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
862
- handleWsOpen: (ws: WebSocket) => void;
863
+ handleWsOpen: (ws: WebSocket, preAuthed: boolean) => void;
863
864
  handleWsMessage: (ws: WebSocket, raw: unknown) => void;
864
865
  handleWsClose: (ws: WebSocket) => void;
865
866
  agentClient: AgentClient | null;
@@ -1015,6 +1016,9 @@ declare class StreamerServer {
1015
1016
  private sessionSubscribers;
1016
1017
  private clientIdToWs;
1017
1018
  private wsToClientId;
1019
+ private wsAuthed;
1020
+ private wsAuthPending;
1021
+ private wsAuthTimeoutMs;
1018
1022
  private cache;
1019
1023
  private projectsRepo;
1020
1024
  private conversationsRepo;
@@ -1043,6 +1047,7 @@ declare class StreamerServer {
1043
1047
  * a full broadcast if no match exists (old clients, or no WS registered yet).
1044
1048
  */
1045
1049
  private broadcastOrUnicastSessionList;
1050
+ private completeWsAuth;
1046
1051
  private addSessionSubscriber;
1047
1052
  private startGraceTimer;
1048
1053
  get port(): number;
@@ -1076,6 +1081,23 @@ declare class StreamerServer {
1076
1081
  private rescanForRefresh;
1077
1082
  private findJsonlPath;
1078
1083
  private readCwdFromJsonl;
1084
+ /**
1085
+ * Live Codex sessions keep `SessionResponse.conversationId === managed.id`
1086
+ * (stable deep-link / PTY key) and store the rollout UUID separately as
1087
+ * `boundConversationId`. REST history is indexed under the rollout UUID, so
1088
+ * resolve the placeholder → bound id before looking up the scanner.
1089
+ */
1090
+ private resolveConversationLookupId;
1091
+ /** File path for a live managed session (placeholder id or bound Codex id). */
1092
+ private findLiveSessionFilePath;
1093
+ /** True when a conversation UUID is the bound rollout of a live PTY session. */
1094
+ private isBoundConversationLive;
1095
+ /**
1096
+ * Broadcast conversation JSONL lines to WS clients. Codex rollout lines are
1097
+ * normalized to the Claude `type:user|assistant` shape mobile understands;
1098
+ * Claude lines pass through unchanged so seq alignment stays intact.
1099
+ */
1100
+ private broadcastConversationLines;
1079
1101
  private findConversationByUuid;
1080
1102
  private isConversationSnapshotStale;
1081
1103
  private handleGetConversation;
package/dist/index.d.ts CHANGED
@@ -321,6 +321,7 @@ interface ServerConfig {
321
321
  codexRoots?: string[];
322
322
  scannerPersistent?: boolean;
323
323
  ptyGracePeriodMs?: number;
324
+ wsAuthTimeoutMs?: number;
324
325
  cacheDir?: string;
325
326
  tailSize?: number;
326
327
  directoryScanDebounceMs?: number;
@@ -859,7 +860,7 @@ type ApiDeps = {
859
860
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
860
861
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
861
862
  handleMkdir: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
862
- handleWsOpen: (ws: WebSocket) => void;
863
+ handleWsOpen: (ws: WebSocket, preAuthed: boolean) => void;
863
864
  handleWsMessage: (ws: WebSocket, raw: unknown) => void;
864
865
  handleWsClose: (ws: WebSocket) => void;
865
866
  agentClient: AgentClient | null;
@@ -1015,6 +1016,9 @@ declare class StreamerServer {
1015
1016
  private sessionSubscribers;
1016
1017
  private clientIdToWs;
1017
1018
  private wsToClientId;
1019
+ private wsAuthed;
1020
+ private wsAuthPending;
1021
+ private wsAuthTimeoutMs;
1018
1022
  private cache;
1019
1023
  private projectsRepo;
1020
1024
  private conversationsRepo;
@@ -1043,6 +1047,7 @@ declare class StreamerServer {
1043
1047
  * a full broadcast if no match exists (old clients, or no WS registered yet).
1044
1048
  */
1045
1049
  private broadcastOrUnicastSessionList;
1050
+ private completeWsAuth;
1046
1051
  private addSessionSubscriber;
1047
1052
  private startGraceTimer;
1048
1053
  get port(): number;
@@ -1076,6 +1081,23 @@ declare class StreamerServer {
1076
1081
  private rescanForRefresh;
1077
1082
  private findJsonlPath;
1078
1083
  private readCwdFromJsonl;
1084
+ /**
1085
+ * Live Codex sessions keep `SessionResponse.conversationId === managed.id`
1086
+ * (stable deep-link / PTY key) and store the rollout UUID separately as
1087
+ * `boundConversationId`. REST history is indexed under the rollout UUID, so
1088
+ * resolve the placeholder → bound id before looking up the scanner.
1089
+ */
1090
+ private resolveConversationLookupId;
1091
+ /** File path for a live managed session (placeholder id or bound Codex id). */
1092
+ private findLiveSessionFilePath;
1093
+ /** True when a conversation UUID is the bound rollout of a live PTY session. */
1094
+ private isBoundConversationLive;
1095
+ /**
1096
+ * Broadcast conversation JSONL lines to WS clients. Codex rollout lines are
1097
+ * normalized to the Claude `type:user|assistant` shape mobile understands;
1098
+ * Claude lines pass through unchanged so seq alignment stays intact.
1099
+ */
1100
+ private broadcastConversationLines;
1079
1101
  private findConversationByUuid;
1080
1102
  private isConversationSnapshotStale;
1081
1103
  private handleGetConversation;
package/dist/index.js CHANGED
@@ -164,7 +164,7 @@ function verifySignature(rawBody, signature, secret) {
164
164
  }
165
165
  }
166
166
  function isWithinSkew(timestampHeader, skewSeconds) {
167
- if (!timestampHeader) return true;
167
+ if (!timestampHeader) return false;
168
168
  const t = Number(timestampHeader);
169
169
  if (!Number.isFinite(t)) return false;
170
170
  const now = Math.floor(Date.now() / 1e3);
@@ -2551,7 +2551,7 @@ function isLocalRequest(remoteAddr) {
2551
2551
  const addr = remoteAddr ?? "";
2552
2552
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2553
2553
  }
2554
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2554
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2555
2555
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2556
2556
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
2557
2557
  var authMiddleware = (deps) => async (c, next) => {
@@ -3014,14 +3014,16 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3014
3014
  const app = new Hono10();
3015
3015
  app.get(
3016
3016
  "/ws",
3017
- upgradeWebSocket(() => {
3017
+ upgradeWebSocket((c) => {
3018
+ const key = c.req.query("key");
3019
+ const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3018
3020
  let openWs = null;
3019
3021
  return {
3020
3022
  onOpen(_evt, ws) {
3021
3023
  const raw = ws.raw;
3022
3024
  if (!raw) return;
3023
3025
  openWs = raw;
3024
- deps.handleWsOpen(raw);
3026
+ deps.handleWsOpen(raw, preAuthed);
3025
3027
  },
3026
3028
  onMessage(evt, _ws) {
3027
3029
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -4928,6 +4930,13 @@ function deriveProjectChatTitle(input) {
4928
4930
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
4929
4931
  }
4930
4932
 
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
+
4931
4940
  // src/services/questions/detectAskUserQuestion.ts
4932
4941
  function normalizeContent2(raw) {
4933
4942
  if (Array.isArray(raw)) return raw;
@@ -5292,6 +5301,89 @@ function sanitizeFilename(name) {
5292
5301
  return cleaned;
5293
5302
  }
5294
5303
 
5304
+ // src/utils/codexConversationLine.ts
5305
+ function extractCodexText(content) {
5306
+ if (typeof content === "string") return content.trim();
5307
+ if (!Array.isArray(content)) return "";
5308
+ return content.map((item) => {
5309
+ if (typeof item === "string") return item;
5310
+ const block = item;
5311
+ const t = block?.type;
5312
+ if ((t === "input_text" || t === "output_text" || t === "text") && typeof block.text === "string") {
5313
+ return block.text;
5314
+ }
5315
+ return "";
5316
+ }).filter(Boolean).join("").trim();
5317
+ }
5318
+ function normalizeCodexLineToClaudeShape(line) {
5319
+ let entry;
5320
+ try {
5321
+ entry = JSON.parse(line);
5322
+ } catch {
5323
+ return null;
5324
+ }
5325
+ if (entry.type !== "response_item") return null;
5326
+ const payload = entry.payload;
5327
+ if (payload?.type !== "message") return null;
5328
+ const role = payload.role;
5329
+ if (role !== "user" && role !== "assistant") return null;
5330
+ const text = extractCodexText(payload.content);
5331
+ if (!text) return null;
5332
+ if (role === "user" && isCodexInjectedContext(text)) return null;
5333
+ const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
5334
+ const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp}-${hashPrefix(text)}`;
5335
+ return JSON.stringify({
5336
+ type: role,
5337
+ uuid,
5338
+ timestamp,
5339
+ message: {
5340
+ role,
5341
+ content: [{ type: "text", text }]
5342
+ }
5343
+ });
5344
+ }
5345
+ function isCodexRolloutLine(line) {
5346
+ try {
5347
+ const entry = JSON.parse(line);
5348
+ return entry.type === "response_item" || entry.type === "event_msg" || entry.type === "session_meta" || entry.type === "turn_context";
5349
+ } catch {
5350
+ return false;
5351
+ }
5352
+ }
5353
+ function toClientConversationLines(lines) {
5354
+ if (lines.length === 0) return lines;
5355
+ const codex = lines.some(isCodexRolloutLine);
5356
+ if (!codex) return lines;
5357
+ const out = [];
5358
+ for (const line of lines) {
5359
+ const normalized = normalizeCodexLineToClaudeShape(line);
5360
+ if (normalized) out.push(normalized);
5361
+ }
5362
+ return out;
5363
+ }
5364
+ function isCodexInjectedContext(text) {
5365
+ if (text.startsWith("# AGENTS.md") || text.includes("<INSTRUCTIONS>")) return true;
5366
+ if (text.startsWith("<permissions instructions>") || text.includes("Filesystem sandboxing defines")) {
5367
+ return true;
5368
+ }
5369
+ if (text.includes("limit the options to at most 3")) return true;
5370
+ if (text.includes("You are working within the project boundary:")) return true;
5371
+ if (text.includes(
5372
+ "Do not read, write, or execute commands that access files or directories outside this boundary"
5373
+ )) {
5374
+ return true;
5375
+ }
5376
+ return false;
5377
+ }
5378
+ function hashPrefix(text) {
5379
+ let h = 0;
5380
+ const sample = text.slice(0, 64);
5381
+ for (let i = 0; i < sample.length; i++) {
5382
+ h = h * 31 + sample.charCodeAt(i) | 0;
5383
+ }
5384
+ return Math.abs(h).toString(36);
5385
+ }
5386
+
5295
5387
  // src/utils/conversationEtag.ts
5296
5388
  import { createHash as createHash2 } from "crypto";
5297
5389
  function computeConversationEtag({
@@ -5449,6 +5541,8 @@ var WSHub = class {
5449
5541
  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.`;
5450
5542
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5451
5543
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5544
+ var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5545
+ var WS_CLOSE_UNAUTHORIZED = 4401;
5452
5546
  var REFRESH_TTL_MS = 2e3;
5453
5547
  var START_READY_TIMEOUT_MS = 15e3;
5454
5548
  function parseIncludeAgentsEnv(raw) {
@@ -5539,6 +5633,14 @@ var StreamerServer = class {
5539
5633
  clientIdToWs = /* @__PURE__ */ new Map();
5540
5634
  // Reverse map for cleanup on close
5541
5635
  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;
5542
5644
  cache = null;
5543
5645
  projectsRepo = null;
5544
5646
  conversationsRepo = null;
@@ -5578,6 +5680,7 @@ var StreamerServer = class {
5578
5680
  this.scanProfiles = config.scanProfiles;
5579
5681
  this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
5580
5682
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
5683
+ this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
5581
5684
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5582
5685
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5583
5686
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
@@ -5676,15 +5779,7 @@ var StreamerServer = class {
5676
5779
  if (broadcast) this.wsHub.broadcast(m);
5677
5780
  }
5678
5781
  const seqs = this.pendingLineSeqs.get(filePath);
5679
- this.wsHub.broadcast({
5680
- type: "conversation_events",
5681
- sessionId,
5682
- lines,
5683
- ...seqs && seqs.length === lines.length ? { seqs } : {}
5684
- });
5685
- for (const line of lines) {
5686
- this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
5687
- }
5782
+ this.broadcastConversationLines(sessionId, lines, seqs);
5688
5783
  break;
5689
5784
  }
5690
5785
  }
@@ -5843,17 +5938,39 @@ var StreamerServer = class {
5843
5938
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
5844
5939
  handleBrowse: (url, res) => this.handleBrowse(url, res),
5845
5940
  handleMkdir: (req, res) => this.handleMkdir(req, res),
5846
- handleWsOpen: (ws) => {
5847
- this.wsHub.addClient(ws);
5848
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
5849
- ws.send(JSON.stringify({ type: "session_list", sessions }));
5850
- if (this.cacheReady) {
5851
- ws.send(JSON.stringify({ type: "cache_ready" }));
5941
+ handleWsOpen: (ws, preAuthed) => {
5942
+ if (preAuthed) {
5943
+ this.completeWsAuth(ws);
5944
+ return;
5852
5945
  }
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);
5853
5954
  },
5854
5955
  handleWsMessage: async (ws, raw) => {
5855
5956
  try {
5856
5957
  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
+ }
5857
5974
  if (msg.type === "register" && typeof msg.clientId === "string") {
5858
5975
  const oldClientId = this.wsToClientId.get(ws);
5859
5976
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -5868,12 +5985,20 @@ var StreamerServer = class {
5868
5985
  }
5869
5986
  }
5870
5987
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
5871
- this.startGraceTimer(msg.sessionId, 0);
5988
+ if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
5989
+ this.startGraceTimer(msg.sessionId, 0);
5990
+ }
5872
5991
  }
5873
5992
  } catch {
5874
5993
  }
5875
5994
  },
5876
5995
  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);
5877
6002
  const clientId = this.wsToClientId.get(ws);
5878
6003
  if (clientId) {
5879
6004
  this.clientIdToWs.delete(clientId);
@@ -5943,6 +6068,20 @@ var StreamerServer = class {
5943
6068
  this.wsHub.broadcast(payload);
5944
6069
  }
5945
6070
  }
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
+ }
5946
6085
  addSessionSubscriber(sessionId, ws) {
5947
6086
  let subs = this.sessionSubscribers.get(sessionId);
5948
6087
  if (!subs) {
@@ -6229,6 +6368,9 @@ var StreamerServer = class {
6229
6368
  this.ptyManager.dispose();
6230
6369
  this.fileWatcher.dispose();
6231
6370
  this.wsHub.dispose();
6371
+ for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6372
+ this.wsAuthPending.clear();
6373
+ this.wsAuthed.clear();
6232
6374
  this.pairTokens.dispose();
6233
6375
  if (this.dbPool) {
6234
6376
  await this.dbPool.end();
@@ -6665,11 +6807,62 @@ var StreamerServer = class {
6665
6807
  rl.on("error", () => resolve2(null));
6666
6808
  });
6667
6809
  }
6810
+ /**
6811
+ * Live Codex sessions keep `SessionResponse.conversationId === managed.id`
6812
+ * (stable deep-link / PTY key) and store the rollout UUID separately as
6813
+ * `boundConversationId`. REST history is indexed under the rollout UUID, so
6814
+ * resolve the placeholder → bound id before looking up the scanner.
6815
+ */
6816
+ resolveConversationLookupId(uuid) {
6817
+ const managed = this.sessionStore.getManaged(uuid);
6818
+ if (managed?.boundConversationId) return managed.boundConversationId;
6819
+ return uuid;
6820
+ }
6821
+ /** File path for a live managed session (placeholder id or bound Codex id). */
6822
+ findLiveSessionFilePath(uuid) {
6823
+ const direct = this.sessionFileMap.get(uuid);
6824
+ if (direct) return direct;
6825
+ for (const s of this.sessionStore.listManaged()) {
6826
+ if (s.boundConversationId === uuid) {
6827
+ return this.sessionFileMap.get(s.id) ?? null;
6828
+ }
6829
+ }
6830
+ return null;
6831
+ }
6832
+ /** True when a conversation UUID is the bound rollout of a live PTY session. */
6833
+ isBoundConversationLive(boundId) {
6834
+ for (const s of this.sessionStore.listManaged()) {
6835
+ if (s.boundConversationId === boundId && this.ptyManager.hasSession(s.id)) {
6836
+ return true;
6837
+ }
6838
+ }
6839
+ return false;
6840
+ }
6841
+ /**
6842
+ * Broadcast conversation JSONL lines to WS clients. Codex rollout lines are
6843
+ * normalized to the Claude `type:user|assistant` shape mobile understands;
6844
+ * Claude lines pass through unchanged so seq alignment stays intact.
6845
+ */
6846
+ broadcastConversationLines(sessionId, lines, seqs) {
6847
+ const clientLines = toClientConversationLines(lines);
6848
+ if (clientLines.length === 0) return;
6849
+ const seqsOk = !!seqs && seqs.length === lines.length && clientLines.length === lines.length;
6850
+ this.wsHub.broadcast({
6851
+ type: "conversation_events",
6852
+ sessionId,
6853
+ lines: clientLines,
6854
+ ...seqsOk ? { seqs } : {}
6855
+ });
6856
+ for (const line of clientLines) {
6857
+ this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
6858
+ }
6859
+ }
6668
6860
  async findConversationByUuid(uuid) {
6861
+ const lookupId = this.resolveConversationLookupId(uuid);
6669
6862
  if (!this.scannerReady && !this.scanProfiles) {
6670
- const filePath2 = this.findJsonlPath(uuid);
6863
+ const filePath2 = this.findJsonlPath(lookupId) ?? this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId);
6671
6864
  if (filePath2) {
6672
- const account = this.cache?.getMetaById(uuid)?.account ?? void 0;
6865
+ const account = this.cache?.getMetaById(lookupId)?.account ?? void 0;
6673
6866
  const coldScanner = this.scanner ?? this.newScanner();
6674
6867
  const page = await coldScanner.parseSingleFilePage(filePath2, account, {
6675
6868
  limit: Number.MAX_SAFE_INTEGER
@@ -6679,9 +6872,27 @@ var StreamerServer = class {
6679
6872
  return null;
6680
6873
  }
6681
6874
  const scanner = await this.getScanner(true);
6682
- const fromIndex = await scanner.getConversation(uuid);
6875
+ const fromIndex = await scanner.getConversation(lookupId);
6683
6876
  if (fromIndex) {
6684
- if (this.ptyManager.hasSession(uuid)) {
6877
+ if (this.ptyManager.hasSession(uuid) || this.ptyManager.hasSession(lookupId) || this.isBoundConversationLive(lookupId)) {
6878
+ const livePath = this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId) ?? fromIndex.filePath ?? null;
6879
+ const isCodexLive = this.isBoundConversationLive(lookupId) || this.sessionStore.getManaged(uuid)?.provider === CODEX_CLI_PROVIDER;
6880
+ if (isCodexLive && livePath) {
6881
+ try {
6882
+ const account = this.cache?.getMetaById(lookupId)?.account ?? fromIndex.account ?? void 0;
6883
+ const page = await scanner.parseSingleFilePage(livePath, account, {
6884
+ limit: Number.MAX_SAFE_INTEGER
6885
+ });
6886
+ if (page?.conversation) return page.conversation;
6887
+ } catch (err) {
6888
+ this.log.warn("codex.live_reparse_failed", {
6889
+ event: "codex.live_reparse_failed",
6890
+ conversationId: lookupId,
6891
+ filePath: livePath,
6892
+ err
6893
+ });
6894
+ }
6895
+ }
6685
6896
  return fromIndex;
6686
6897
  }
6687
6898
  if (fromIndex.filePath && this.isConversationSnapshotStale(fromIndex)) {
@@ -6701,12 +6912,12 @@ var StreamerServer = class {
6701
6912
  return fromIndex;
6702
6913
  }
6703
6914
  if (this.scanProfiles) return null;
6704
- const filePath = this.findJsonlPath(uuid);
6915
+ const filePath = this.findJsonlPath(lookupId) ?? this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId);
6705
6916
  if (!filePath) return null;
6706
6917
  this.scanner = null;
6707
6918
  this.scannerReady = null;
6708
6919
  const freshScanner = await this.getScanner();
6709
- return freshScanner.getConversation(uuid);
6920
+ return freshScanner.getConversation(lookupId);
6710
6921
  }
6711
6922
  // True when the JSONL on disk is meaningfully newer than the scanned
6712
6923
  // snapshot's last-activity timestamp — i.e. the file grew after the scan.
@@ -6787,7 +6998,9 @@ var StreamerServer = class {
6787
6998
  res.end();
6788
6999
  return;
6789
7000
  }
6790
- const filtered = conversation.messages;
7001
+ const filtered = conversation.messages.filter(
7002
+ (m) => !(m.role === "user" && typeof m.text === "string" && isCodexInjectedContext(m.text))
7003
+ );
6791
7004
  const total = filtered.length;
6792
7005
  const hasAnchor = url.searchParams.has("anchor_index");
6793
7006
  const hasAfter = url.searchParams.has("after_index");
@@ -6845,8 +7058,7 @@ var StreamerServer = class {
6845
7058
  })
6846
7059
  );
6847
7060
  }
6848
- const pagedScanner = !indexWindow && this.scannerReady ? await this.getScanner(true) : null;
6849
- const page = indexWindow ?? (scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null);
7061
+ const page = indexWindow;
6850
7062
  if (indexWindow) indexTotal = indexWindow.total;
6851
7063
  const start = page?.fromIndex ?? windowStart;
6852
7064
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
@@ -7285,12 +7497,16 @@ var StreamerServer = class {
7285
7497
  return;
7286
7498
  }
7287
7499
  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
+ });
7288
7504
  this.wsHub.broadcast({
7289
7505
  type: "permission",
7290
7506
  sessionId,
7291
7507
  ...gate.prompt ? { prompt: gate.prompt } : {},
7292
7508
  ...gate.detail ? { detail: gate.detail } : {},
7293
- options: gate.options,
7509
+ options: safeOptions,
7294
7510
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7295
7511
  });
7296
7512
  }
@@ -7654,10 +7870,7 @@ var StreamerServer = class {
7654
7870
  try {
7655
7871
  const existing = readFileSync6(resolvedFilePath, "utf8").split("\n").filter(Boolean);
7656
7872
  if (existing.length > 0) {
7657
- this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
7658
- for (const line of existing) {
7659
- this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
7660
- }
7873
+ this.broadcastConversationLines(sessionId, existing);
7661
7874
  }
7662
7875
  } catch {
7663
7876
  }
@@ -7758,10 +7971,7 @@ var StreamerServer = class {
7758
7971
  try {
7759
7972
  const existing = readFileSync6(candidatePath, "utf8").split("\n").filter(Boolean);
7760
7973
  if (existing.length > 0) {
7761
- this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
7762
- for (const line of existing) {
7763
- this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
7764
- }
7974
+ this.broadcastConversationLines(sessionId, existing);
7765
7975
  }
7766
7976
  } catch {
7767
7977
  }