@threadbase-sh/streamer 1.24.7 → 1.26.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
@@ -324,6 +324,15 @@ function loadPublicUrl() {
324
324
  }
325
325
  return void 0;
326
326
  }
327
+ function loadBrowserCors() {
328
+ try {
329
+ const content = readFileSync(configFile(), "utf-8");
330
+ const match = content.match(/browser_cors:\s*(.+)/);
331
+ if (match?.[1]) return match[1].trim();
332
+ } catch {
333
+ }
334
+ return void 0;
335
+ }
327
336
  function loadCacheDir() {
328
337
  try {
329
338
  const content = readFileSync(configFile(), "utf-8");
@@ -342,6 +351,16 @@ function loadTailSize() {
342
351
  }
343
352
  return void 0;
344
353
  }
354
+ function loadDefaultPermissionMode() {
355
+ try {
356
+ const content = readFileSync(configFile(), "utf-8");
357
+ const match = content.match(/default_permission_mode:\s*(\S+)/);
358
+ const value = match?.[1]?.trim();
359
+ if (value === "acceptEdits" || value === "manual") return value;
360
+ } catch {
361
+ }
362
+ return void 0;
363
+ }
345
364
  function validatePublicUrl(raw) {
346
365
  let parsed;
347
366
  try {
@@ -1301,6 +1320,37 @@ function detectShellPrompt(lines) {
1301
1320
  return null;
1302
1321
  }
1303
1322
 
1323
+ // src/utils/debounce.ts
1324
+ function debounce(fn, waitMs) {
1325
+ let timer = null;
1326
+ let lastArgs = null;
1327
+ const run2 = () => {
1328
+ timer = null;
1329
+ if (lastArgs) {
1330
+ const args = lastArgs;
1331
+ lastArgs = null;
1332
+ fn(...args);
1333
+ }
1334
+ };
1335
+ const debounced = (...args) => {
1336
+ lastArgs = args;
1337
+ if (timer) clearTimeout(timer);
1338
+ timer = setTimeout(run2, waitMs);
1339
+ };
1340
+ debounced.cancel = () => {
1341
+ if (timer) clearTimeout(timer);
1342
+ timer = null;
1343
+ lastArgs = null;
1344
+ };
1345
+ debounced.flush = () => {
1346
+ if (timer) {
1347
+ clearTimeout(timer);
1348
+ run2();
1349
+ }
1350
+ };
1351
+ return debounced;
1352
+ }
1353
+
1304
1354
  // src/pty-manager.ts
1305
1355
  var OUTPUT_BUFFER_MAX2 = 65536;
1306
1356
  var PTY_COLS2 = 120;
@@ -1308,11 +1358,13 @@ var PTY_ROWS2 = 40;
1308
1358
  var SCREEN_SCROLLBACK2 = 1e3;
1309
1359
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1310
1360
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1361
+ var QUIET_DETECT_MS = 500;
1311
1362
  function buildPasteBytes(input) {
1312
1363
  return `\x1B[200~${input}\x1B[201~`;
1313
1364
  }
1314
1365
  var SUBMIT_BYTES2 = "\r";
1315
1366
  var SUBMIT_DELAY_MS = 16;
1367
+ var SUBMIT_MAX_WAIT_MS = 500;
1316
1368
  function digestBytes2(s) {
1317
1369
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1318
1370
  if (escaped.length <= 200) return escaped;
@@ -1389,6 +1441,10 @@ var PTYManager = class {
1389
1441
  // to a given input or fell silent. Reset on dispose().
1390
1442
  chunkIndex = /* @__PURE__ */ new Map();
1391
1443
  lastChunkAt = /* @__PURE__ */ new Map();
1444
+ // Per-session debounced "went quiet" checker, re-armed on every chunk. Fires
1445
+ // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
1446
+ // wait for another chunk that may never arrive (Claude blocked on input).
1447
+ quietCheckers = /* @__PURE__ */ new Map();
1392
1448
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1393
1449
  // concurrent resume for the same session (double-tap, client retry) awaits
1394
1450
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -1404,16 +1460,19 @@ var PTYManager = class {
1404
1460
  }
1405
1461
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1406
1462
  //
1407
- // We use `--permission-mode acceptEdits` rather than `--dangerously-skip-permissions`.
1408
- // Both suppress file-edit prompts, but in an interactive (TUI) launch the
1409
- // skip-permissions flag renders a blocking "Bypass Permissions mode" warning
1410
- // menu on every boot that no known ~/.claude.json flag suppressed (as of
1411
- // Claude CLI v2.1.x) the session never reaches a usable prompt, so the
1412
- // mobile app shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1463
+ // options.permissionMode defaults to `acceptEdits` rather than
1464
+ // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1465
+ // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1466
+ // flag renders a blocking "Bypass Permissions mode" warning menu on every
1467
+ // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1468
+ // v2.1.x) the session never reaches a usable prompt, so the mobile app
1469
+ // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1413
1470
  // without that warning gate, while still prompting for shell commands.
1471
+ // `manual` (prompt for everything) is the only other mode callers may pass;
1472
+ // `bypassPermissions`/`plan`/`dontAsk`/`auto` are not supported here.
1414
1473
  // (The other first-run gates — onboarding/theme, workspace trust,
1415
1474
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1416
- // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
1475
+ // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
1417
1476
  async start(sessionId, options) {
1418
1477
  const existing = this.sessions.get(sessionId);
1419
1478
  if (existing) return toPublicSession2(existing);
@@ -1432,7 +1491,7 @@ var PTYManager = class {
1432
1491
  resolveClaudeExe(),
1433
1492
  [
1434
1493
  "--permission-mode",
1435
- "acceptEdits",
1494
+ options.permissionMode ?? "acceptEdits",
1436
1495
  "--settings",
1437
1496
  '{"spinnerTipsEnabled":false}',
1438
1497
  "--resume",
@@ -1481,7 +1540,7 @@ var PTYManager = class {
1481
1540
  const projectName = options.projectName ?? basename2(options.projectPath);
1482
1541
  const args = [
1483
1542
  "--permission-mode",
1484
- "acceptEdits",
1543
+ options.permissionMode ?? "acceptEdits",
1485
1544
  "--settings",
1486
1545
  '{"spinnerTipsEnabled":false}',
1487
1546
  "--session-id",
@@ -1575,9 +1634,20 @@ var PTYManager = class {
1575
1634
  session.promptCount++;
1576
1635
  return session.promptCount;
1577
1636
  }
1578
- // Two-step paste-then-submit. Writes the bracketed-paste body, yields the
1579
- // event loop for SUBMIT_DELAY_MS, then writes \r. See buildPasteBytes() for
1580
- // why the split matters.
1637
+ // Two-step paste-then-submit. Writes the bracketed-paste body, then waits
1638
+ // for the PTY to go quiet before writing \r. See buildPasteBytes() for why
1639
+ // the split matters.
1640
+ //
1641
+ // The wait is quiescence-based, not a flat delay: a fixed SUBMIT_DELAY_MS
1642
+ // timer (the original fix) still races a TUI that's mid-redraw of its own
1643
+ // output (e.g. re-painting right after posting a question) when the paste
1644
+ // lands — the timer can elapse and fire \r while the TUI is still busy,
1645
+ // and that \r gets absorbed by the redraw instead of submitting (see
1646
+ // 2026-07 session 14dda340: a "Yes" reply was accepted into the input line
1647
+ // but never landed as a submitted JSONL turn). Polling in SUBMIT_DELAY_MS
1648
+ // steps and only submitting once lastChunkAt hasn't advanced for a full
1649
+ // step gives the TUI as many extra ticks as it needs, capped at
1650
+ // SUBMIT_MAX_WAIT_MS so a silent/wedged PTY still gets its \r eventually.
1581
1651
  writeSubmit(sessionId, session, input, path, promptCount) {
1582
1652
  const pasteBytes = buildPasteBytes(input);
1583
1653
  this.log.info(
@@ -1592,12 +1662,21 @@ var PTYManager = class {
1592
1662
  phase: "paste"
1593
1663
  }
1594
1664
  );
1665
+ const pasteAt = Date.now();
1595
1666
  session.process.write(pasteBytes);
1596
- setTimeout(() => {
1667
+ const trySubmit = () => {
1597
1668
  const current = this.sessions.get(sessionId);
1598
1669
  if (!current || current !== session) return;
1670
+ const now = Date.now();
1671
+ const lastChunk = this.lastChunkAt.get(sessionId) ?? pasteAt;
1672
+ const quiet = now - lastChunk >= SUBMIT_DELAY_MS;
1673
+ const timedOut = now - pasteAt >= SUBMIT_MAX_WAIT_MS;
1674
+ if (!quiet && !timedOut) {
1675
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1676
+ return;
1677
+ }
1599
1678
  this.log.info(
1600
- `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
1679
+ `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r waitedMs=${now - pasteAt} timedOut=${timedOut}`,
1601
1680
  {
1602
1681
  event: "pty.input_write",
1603
1682
  sessionId,
@@ -1605,11 +1684,14 @@ var PTYManager = class {
1605
1684
  byteLen: SUBMIT_BYTES2.length,
1606
1685
  digest: "\\r",
1607
1686
  path,
1608
- phase: "submit"
1687
+ phase: "submit",
1688
+ waitedMs: now - pasteAt,
1689
+ timedOut
1609
1690
  }
1610
1691
  );
1611
1692
  current.process.write(SUBMIT_BYTES2);
1612
- }, SUBMIT_DELAY_MS);
1693
+ };
1694
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1613
1695
  }
1614
1696
  // Drain any inputs that were sent while the session was still pendingReady,
1615
1697
  // writing them in arrival order now that Claude is at its prompt.
@@ -1658,6 +1740,8 @@ var PTYManager = class {
1658
1740
  this.permissionOpen.delete(sessionId);
1659
1741
  this.lastScreenQuestionKey.delete(sessionId);
1660
1742
  this.shellPromptOpen.delete(sessionId);
1743
+ this.quietCheckers.get(sessionId)?.cancel();
1744
+ this.quietCheckers.delete(sessionId);
1661
1745
  try {
1662
1746
  session.process.kill("SIGINT");
1663
1747
  } catch {
@@ -1717,6 +1801,8 @@ var PTYManager = class {
1717
1801
  this.firstChunkAt.clear();
1718
1802
  this.chunkIndex.clear();
1719
1803
  this.lastChunkAt.clear();
1804
+ for (const quiet of this.quietCheckers.values()) quiet.cancel();
1805
+ this.quietCheckers.clear();
1720
1806
  this.permissionOpen.clear();
1721
1807
  this.lastScreenQuestionKey.clear();
1722
1808
  this.shellPromptOpen.clear();
@@ -1770,6 +1856,12 @@ var PTYManager = class {
1770
1856
  err
1771
1857
  });
1772
1858
  });
1859
+ let quiet = this.quietCheckers.get(sessionId);
1860
+ if (!quiet) {
1861
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
1862
+ this.quietCheckers.set(sessionId, quiet);
1863
+ }
1864
+ quiet();
1773
1865
  }
1774
1866
  // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
1775
1867
  // menus from the rendered screen, firing the additive callbacks. Async because
@@ -1845,6 +1937,24 @@ var PTYManager = class {
1845
1937
  }
1846
1938
  }
1847
1939
  }
1940
+ // Fired QUIET_DETECT_MS after the last PTY chunk. Re-runs the same
1941
+ // ready/prompt detection handleOutput() runs per-chunk, using the last
1942
+ // rendered output — a session blocked on a prompt (or an unmarked boot
1943
+ // screen) may never produce another chunk to trigger detection otherwise.
1944
+ handleQuiet(sessionId) {
1945
+ const session = this.sessions.get(sessionId);
1946
+ if (session?.status !== "running") return;
1947
+ if (this.pendingReady.has(sessionId)) {
1948
+ this.markReady(sessionId, session, "quiet:timeout");
1949
+ }
1950
+ this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
1951
+ this.log.warn("[pty.prompt_detect] failed", {
1952
+ event: "pty.prompt_detect_failed",
1953
+ sessionId,
1954
+ err
1955
+ });
1956
+ });
1957
+ }
1848
1958
  // Transition a session from "running" to "waiting_input", clear pendingReady,
1849
1959
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
1850
1960
  markReady(sessionId, session, reason) {
@@ -1885,6 +1995,8 @@ var PTYManager = class {
1885
1995
  this.permissionOpen.delete(sessionId);
1886
1996
  this.lastScreenQuestionKey.delete(sessionId);
1887
1997
  this.shellPromptOpen.delete(sessionId);
1998
+ this.quietCheckers.get(sessionId)?.cancel();
1999
+ this.quietCheckers.delete(sessionId);
1888
2000
  }
1889
2001
  };
1890
2002
  function toPublicSession2(s) {
@@ -2471,25 +2583,55 @@ var authMiddleware = (deps) => async (c, next) => {
2471
2583
  };
2472
2584
 
2473
2585
  // src/api/middleware/cors.middleware.ts
2474
- var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
2586
+ var DEFAULT_DEV_ORIGINS = [
2475
2587
  "http://localhost:8081",
2476
2588
  "http://localhost:19006",
2477
2589
  "http://localhost:3000"
2478
- ]);
2479
- var corsMiddleware = () => async (c, next) => {
2480
- const origin = c.req.header("origin");
2481
- const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
2482
- if (allowedOrigin) {
2483
- c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2484
- c.res.headers.set("Vary", "Origin");
2485
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2486
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2487
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2488
- }
2489
- if (c.req.method === "OPTIONS") {
2490
- return c.newResponse(null, allowedOrigin ? 204 : 403);
2491
- }
2492
- await next();
2590
+ ];
2591
+ function resolveAllowedOrigins(raw) {
2592
+ if (!raw) return null;
2593
+ const trimmed = raw.trim();
2594
+ const lower = trimmed.toLowerCase();
2595
+ if (lower === "0" || lower === "false" || lower === "no" || lower === "off" || trimmed === "") {
2596
+ return null;
2597
+ }
2598
+ const origins = new Set(DEFAULT_DEV_ORIGINS);
2599
+ if (!["1", "true", "yes", "on"].includes(lower)) {
2600
+ for (const o of trimmed.split(",")) {
2601
+ const origin = o.trim();
2602
+ if (origin) origins.add(origin);
2603
+ }
2604
+ }
2605
+ return origins;
2606
+ }
2607
+ var corsMiddleware = (configValue) => {
2608
+ const allowedOrigins = resolveAllowedOrigins(
2609
+ process.env.THREADBASE_ALLOW_BROWSER_CORS ?? configValue
2610
+ );
2611
+ return async (c, next) => {
2612
+ const origin = c.req.header("origin");
2613
+ const allowedOrigin = allowedOrigins && origin && allowedOrigins.has(origin) ? origin : null;
2614
+ if (allowedOrigin) {
2615
+ const raw = c.env.outgoing;
2616
+ raw.setHeader("Access-Control-Allow-Origin", allowedOrigin);
2617
+ raw.setHeader("Vary", "Origin");
2618
+ raw.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2619
+ raw.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2620
+ raw.setHeader("Access-Control-Expose-Headers", "ETag");
2621
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2622
+ c.res.headers.set("Vary", "Origin");
2623
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2624
+ c.res.headers.set(
2625
+ "Access-Control-Allow-Headers",
2626
+ "Authorization, Content-Type, If-None-Match"
2627
+ );
2628
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2629
+ }
2630
+ if (c.req.method === "OPTIONS") {
2631
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
2632
+ }
2633
+ await next();
2634
+ };
2493
2635
  };
2494
2636
 
2495
2637
  // src/api/middleware/error.middleware.ts
@@ -2904,7 +3046,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
2904
3046
  event: "http.request"
2905
3047
  });
2906
3048
  });
2907
- app.use("*", corsMiddleware());
3049
+ app.use("*", corsMiddleware(deps.browserCors));
2908
3050
  app.use("*", authMiddleware(deps));
2909
3051
  app.onError(errorMiddleware);
2910
3052
  app.route("/healthz", createHealthRoutes());
@@ -3186,11 +3328,11 @@ var ConversationCache = class _ConversationCache {
3186
3328
  INSERT INTO conversation_meta
3187
3329
  (id, file_path, project_path, project_name, title, model, account, branch,
3188
3330
  message_count, last_activity, first_message, last_message, preview, updated_at,
3189
- mtime_ms, file_size, provider)
3331
+ mtime_ms, file_size, provider, scanner_meta_json)
3190
3332
  VALUES
3191
3333
  (@id, @file_path, @project_path, @project_name, @title, @model, @account, @branch,
3192
3334
  @message_count, @last_activity, @first_message, @last_message, @preview, @updated_at,
3193
- @mtime_ms, @file_size, @provider)
3335
+ @mtime_ms, @file_size, @provider, @scanner_meta_json)
3194
3336
  ON CONFLICT(id) DO UPDATE SET
3195
3337
  file_path = excluded.file_path,
3196
3338
  project_path = excluded.project_path,
@@ -3210,7 +3352,8 @@ var ConversationCache = class _ConversationCache {
3210
3352
  updated_at = excluded.updated_at,
3211
3353
  mtime_ms = excluded.mtime_ms,
3212
3354
  file_size = excluded.file_size,
3213
- provider = excluded.provider
3355
+ provider = excluded.provider,
3356
+ scanner_meta_json = excluded.scanner_meta_json
3214
3357
  WHERE conversation_meta.updated_at < excluded.updated_at
3215
3358
  `),
3216
3359
  getTail: db.prepare("SELECT * FROM conversation_tail WHERE conversation_id = ?"),
@@ -3247,6 +3390,27 @@ var ConversationCache = class _ConversationCache {
3247
3390
  allFileStats: db.prepare(
3248
3391
  "SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
3249
3392
  ),
3393
+ allScannerStatCacheRows: db.prepare(
3394
+ "SELECT file_path, mtime_ms, file_size, scanner_meta_json FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL AND scanner_meta_json IS NOT NULL"
3395
+ ),
3396
+ updateScannerCache: db.prepare(
3397
+ "UPDATE conversation_meta SET mtime_ms = ?, file_size = ?, scanner_meta_json = ? WHERE id = ?"
3398
+ ),
3399
+ getFileMetadata: db.prepare(
3400
+ "SELECT mtime_ms, file_size, is_agent, agent_entrypoints_key FROM conversation_file_metadata WHERE file_path = ?"
3401
+ ),
3402
+ upsertFileMetadata: db.prepare(`
3403
+ INSERT INTO conversation_file_metadata
3404
+ (file_path, mtime_ms, file_size, is_agent, agent_entrypoints_key, updated_at)
3405
+ VALUES
3406
+ (@file_path, @mtime_ms, @file_size, @is_agent, @agent_entrypoints_key, @updated_at)
3407
+ ON CONFLICT(file_path) DO UPDATE SET
3408
+ mtime_ms = excluded.mtime_ms,
3409
+ file_size = excluded.file_size,
3410
+ is_agent = excluded.is_agent,
3411
+ agent_entrypoints_key = excluded.agent_entrypoints_key,
3412
+ updated_at = excluded.updated_at
3413
+ `),
3250
3414
  upsertSessionName: db.prepare(`
3251
3415
  INSERT INTO session_names (session_id, name, updated_at)
3252
3416
  VALUES (?, ?, ?)
@@ -3287,8 +3451,35 @@ var ConversationCache = class _ConversationCache {
3287
3451
  getDatabase() {
3288
3452
  return this.db;
3289
3453
  }
3290
- getAgentEntrypoints() {
3291
- return this.agentEntrypoints;
3454
+ agentEntrypointsKey() {
3455
+ return [...this.agentEntrypoints].sort().join(",");
3456
+ }
3457
+ classifyAgentFile(filePath, mtimeMs, fileSize) {
3458
+ if (this.agentEntrypoints.size === 0) return false;
3459
+ const entrypointsKey = this.agentEntrypointsKey();
3460
+ const cached2 = this.stmts.getFileMetadata.get(filePath);
3461
+ if (cached2 && cached2.mtime_ms === mtimeMs && cached2.file_size === fileSize && cached2.agent_entrypoints_key === entrypointsKey) {
3462
+ return cached2.is_agent === 1;
3463
+ }
3464
+ const isAgent = isAgentFile(filePath, this.agentEntrypoints);
3465
+ this.stmts.upsertFileMetadata.run({
3466
+ file_path: filePath,
3467
+ mtime_ms: mtimeMs,
3468
+ file_size: fileSize,
3469
+ is_agent: isAgent ? 1 : 0,
3470
+ agent_entrypoints_key: entrypointsKey,
3471
+ updated_at: Date.now()
3472
+ });
3473
+ return isAgent;
3474
+ }
3475
+ isAgentFileCached(filePath) {
3476
+ let s;
3477
+ try {
3478
+ s = statSync2(filePath);
3479
+ } catch {
3480
+ return false;
3481
+ }
3482
+ return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3292
3483
  }
3293
3484
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3294
3485
  mkdirSync2(dirname6(dbPath), { recursive: true });
@@ -3464,11 +3655,9 @@ var ConversationCache = class _ConversationCache {
3464
3655
  // in conversation-cache.test.ts).
3465
3656
  upsertFromScannerMeta(metas) {
3466
3657
  const filter = this.filterAgentConversations;
3467
- const entrypoints = this.agentEntrypoints;
3468
3658
  const upsertedIds = [];
3469
3659
  const run2 = this.db.transaction((items) => {
3470
3660
  for (const m of items) {
3471
- if (filter && isAgentFile(m.filePath, entrypoints)) continue;
3472
3661
  const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
3473
3662
  const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
3474
3663
  let mtimeMs = null;
@@ -3480,6 +3669,10 @@ var ConversationCache = class _ConversationCache {
3480
3669
  } catch {
3481
3670
  }
3482
3671
  const seq = ++this.tailSeq;
3672
+ if (filter && mtimeMs !== null && fileSize !== null && this.classifyAgentFile(m.filePath, mtimeMs, fileSize)) {
3673
+ continue;
3674
+ }
3675
+ const scannerMetaJson = JSON.stringify(m);
3483
3676
  this.stmts.upsertFull.run({
3484
3677
  id,
3485
3678
  file_path: m.filePath,
@@ -3497,8 +3690,10 @@ var ConversationCache = class _ConversationCache {
3497
3690
  updated_at: seq,
3498
3691
  mtime_ms: mtimeMs,
3499
3692
  file_size: fileSize,
3500
- provider: m.provider ?? CLAUDE_CODE_PROVIDER
3693
+ provider: m.provider ?? CLAUDE_CODE_PROVIDER,
3694
+ scanner_meta_json: scannerMetaJson
3501
3695
  });
3696
+ this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
3502
3697
  if (this.fileIndexLoaded) this.fileIndex.set(m.filePath, id);
3503
3698
  upsertedIds.push(id);
3504
3699
  }
@@ -3622,6 +3817,21 @@ var ConversationCache = class _ConversationCache {
3622
3817
  }
3623
3818
  return map;
3624
3819
  }
3820
+ getScannerStatCache() {
3821
+ const rows = this.stmts.allScannerStatCacheRows.all();
3822
+ const map = /* @__PURE__ */ new Map();
3823
+ for (const r of rows) {
3824
+ try {
3825
+ const meta = JSON.parse(r.scanner_meta_json);
3826
+ map.set(r.file_path, {
3827
+ stat: { mtimeMs: r.mtime_ms, size: r.file_size },
3828
+ meta
3829
+ });
3830
+ } catch {
3831
+ }
3832
+ }
3833
+ return map;
3834
+ }
3625
3835
  getMetaById(id) {
3626
3836
  const row = this.stmts.getFullById.get(id);
3627
3837
  if (!row) return null;
@@ -4288,7 +4498,7 @@ function pruneAgentConversations(cache) {
4288
4498
  missing += 1;
4289
4499
  continue;
4290
4500
  }
4291
- if (isAgentFile(row.file_path, cache.getAgentEntrypoints())) {
4501
+ if (cache.isAgentFileCached(row.file_path)) {
4292
4502
  cache.deleteByFilePath(row.file_path);
4293
4503
  pruned += 1;
4294
4504
  }
@@ -4682,37 +4892,6 @@ function computeConversationEtag({
4682
4892
  return `"${digest}"`;
4683
4893
  }
4684
4894
 
4685
- // src/utils/debounce.ts
4686
- function debounce(fn, waitMs) {
4687
- let timer = null;
4688
- let lastArgs = null;
4689
- const run2 = () => {
4690
- timer = null;
4691
- if (lastArgs) {
4692
- const args = lastArgs;
4693
- lastArgs = null;
4694
- fn(...args);
4695
- }
4696
- };
4697
- const debounced = (...args) => {
4698
- lastArgs = args;
4699
- if (timer) clearTimeout(timer);
4700
- timer = setTimeout(run2, waitMs);
4701
- };
4702
- debounced.cancel = () => {
4703
- if (timer) clearTimeout(timer);
4704
- timer = null;
4705
- lastArgs = null;
4706
- };
4707
- debounced.flush = () => {
4708
- if (timer) {
4709
- clearTimeout(timer);
4710
- run2();
4711
- }
4712
- };
4713
- return debounced;
4714
- }
4715
-
4716
4895
  // src/utils/dates.ts
4717
4896
  import { compareDesc, isValid, parseISO } from "date-fns";
4718
4897
  function parseIsoDateOrNull(value) {
@@ -4859,6 +5038,7 @@ var WSHub = class {
4859
5038
  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.`;
4860
5039
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
4861
5040
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5041
+ var START_READY_TIMEOUT_MS = 15e3;
4862
5042
  function parseIncludeAgentsEnv(raw) {
4863
5043
  if (raw === void 0) return false;
4864
5044
  const v = raw.trim().toLowerCase();
@@ -4921,12 +5101,14 @@ var StreamerServer = class {
4921
5101
  disableDb = false;
4922
5102
  browseRoot = null;
4923
5103
  publicUrl = null;
5104
+ browserCors;
4924
5105
  pairTokens = new PairTokenStore();
4925
5106
  exchangeAttempts = /* @__PURE__ */ new Map();
4926
5107
  sessionStartAttempts = /* @__PURE__ */ new Map();
4927
5108
  sessionInputAttempts = /* @__PURE__ */ new Map();
4928
5109
  ptyGracePeriodMs;
4929
5110
  defaultSystemPrompt;
5111
+ defaultPermissionMode;
4930
5112
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
4931
5113
  ptyGraceTimers = /* @__PURE__ */ new Map();
4932
5114
  // Map of sessionId → set of subscribed WS clients
@@ -4975,6 +5157,7 @@ var StreamerServer = class {
4975
5157
  this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
4976
5158
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4977
5159
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5160
+ this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
4978
5161
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
4979
5162
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4980
5163
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -5004,6 +5187,7 @@ var StreamerServer = class {
5004
5187
  this.log.warn(`Warning: ${result.error}`, { error: result.error });
5005
5188
  }
5006
5189
  }
5190
+ this.browserCors = config.browserCors ?? loadBrowserCors();
5007
5191
  this.sessionStore = new SessionStore();
5008
5192
  this.wsHub = new WSHub();
5009
5193
  this.fileWatcher = new ConversationWatcher({
@@ -5158,6 +5342,7 @@ var StreamerServer = class {
5158
5342
  rotateApiKey: () => this.rotateApiKey(),
5159
5343
  publicUrl: this.publicUrl,
5160
5344
  browseRoot: this.browseRoot,
5345
+ browserCors: this.browserCors,
5161
5346
  ptyManager: this.ptyManager,
5162
5347
  sessionStore: this.sessionStore,
5163
5348
  wsHub: this.wsHub,
@@ -5404,9 +5589,9 @@ var StreamerServer = class {
5404
5589
  );
5405
5590
  this.scannerPersistenceDisabled = true;
5406
5591
  }
5407
- const warmupScanner = this.newScanner();
5408
- this.allScanners.add(warmupScanner);
5409
5592
  const warmupStatCache = this.buildStatCache(null);
5593
+ const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
5594
+ this.allScanners.add(warmupScanner);
5410
5595
  const shouldEmitProgress = createScanProgressThrottle();
5411
5596
  const scanOpts = {
5412
5597
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5846,6 +6031,10 @@ var StreamerServer = class {
5846
6031
  }
5847
6032
  buildStatCache(previousScanner) {
5848
6033
  if (!this.cache) return void 0;
6034
+ if (!previousScanner) {
6035
+ const persisted = this.cache.getScannerStatCache();
6036
+ return persisted.size > 0 ? persisted : void 0;
6037
+ }
5849
6038
  const dbStats = this.cache.getFileStats();
5850
6039
  if (dbStats.size === 0) return void 0;
5851
6040
  const metaByPath = /* @__PURE__ */ new Map();
@@ -5875,9 +6064,9 @@ var StreamerServer = class {
5875
6064
  // this — its per-file refreshFile (in findConversationByUuid) already
5876
6065
  // reconciles the one conversation being requested, so paying a full-tree
5877
6066
  // rescan just because some OTHER file changed is the stall this avoids.
5878
- newScanner() {
6067
+ newScanner(options) {
5879
6068
  return new ConversationScanner(
5880
- this.scannerPersistenceDisabled ? { persistent: false } : void 0
6069
+ options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
5881
6070
  );
5882
6071
  }
5883
6072
  async getScanner(skipStaleRescan = false) {
@@ -5896,7 +6085,7 @@ var StreamerServer = class {
5896
6085
  }
5897
6086
  this.scannerStale = false;
5898
6087
  const statCache = this.buildStatCache(this.scanner);
5899
- this.scanner = this.newScanner();
6088
+ this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
5900
6089
  this.allScanners.add(this.scanner);
5901
6090
  this.scannerReady = this.scanner.scan({
5902
6091
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5919,9 +6108,8 @@ var StreamerServer = class {
5919
6108
  this.scannerReady = null;
5920
6109
  return this.getScanner();
5921
6110
  }
5922
- // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
5923
- // survive, so classify() still skips unchanged files) and re-run its scan
5924
- // with fullRescan:true — the escape hatch that bypasses the scanner's
6111
+ // refresh=1's scan: reuse the WARM scanner and re-run its scan with
6112
+ // fullRescan:true the escape hatch that bypasses the scanner's
5925
6113
  // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
5926
6114
  // the "don't trust the gate, check disk for real" signal. Unlike
5927
6115
  // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
@@ -6309,7 +6497,8 @@ var StreamerServer = class {
6309
6497
  provider,
6310
6498
  projectPath,
6311
6499
  projectName: body.projectName,
6312
- branch: body.branch
6500
+ branch: body.branch,
6501
+ permissionMode: this.defaultPermissionMode
6313
6502
  });
6314
6503
  this.sessionStore.addManaged(session);
6315
6504
  void this.watchConversationFile(sessionId);
@@ -6633,7 +6822,8 @@ var StreamerServer = class {
6633
6822
  const session = await this.ptyManager.start(convId, {
6634
6823
  projectPath,
6635
6824
  projectName,
6636
- branch
6825
+ branch,
6826
+ permissionMode: this.defaultPermissionMode
6637
6827
  });
6638
6828
  this.sessionStore.addManaged(session);
6639
6829
  void this.watchConversationFile(session.id);
@@ -6703,10 +6893,35 @@ var StreamerServer = class {
6703
6893
  provider,
6704
6894
  projectPath: resolvedPath,
6705
6895
  projectName: body.projectName,
6706
- systemPrompt: systemPromptParts.join("\n")
6896
+ systemPrompt: systemPromptParts.join("\n"),
6897
+ permissionMode: this.defaultPermissionMode
6707
6898
  });
6708
6899
  this.sessionStore.addManaged(session);
6709
- json(res, 202, { id: session.id, status: "pending" });
6900
+ const readyOrFailed = new Promise((resolve2) => {
6901
+ const handler = (status) => {
6902
+ if (status === "waiting_input" || status === "idle") {
6903
+ this.sessionStatusBus.off(`status:${session.id}`, handler);
6904
+ resolve2(status === "waiting_input" ? "ready" : "failed");
6905
+ }
6906
+ };
6907
+ this.sessionStatusBus.on(`status:${session.id}`, handler);
6908
+ });
6909
+ const timeoutPromise = new Promise(
6910
+ (resolve2) => setTimeout(() => resolve2("timeout"), START_READY_TIMEOUT_MS)
6911
+ );
6912
+ const outcome = await Promise.race([readyOrFailed, timeoutPromise]);
6913
+ const current = this.sessionStore.get(session.id, this.ptyAttachedIds());
6914
+ if (outcome === "ready" && current) {
6915
+ json(res, 200, { session: current });
6916
+ } else if (outcome === "failed" && current) {
6917
+ json(res, 502, {
6918
+ id: session.id,
6919
+ status: "idle",
6920
+ error: current.failureReason ?? "Session exited before becoming ready"
6921
+ });
6922
+ } else {
6923
+ json(res, 202, { id: session.id, status: "pending" });
6924
+ }
6710
6925
  if (provider === CODEX_CLI_PROVIDER) {
6711
6926
  this.watchForCodexRollout(session.id, resolvedPath);
6712
6927
  } else {