@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.cjs CHANGED
@@ -375,6 +375,15 @@ function loadPublicUrl() {
375
375
  }
376
376
  return void 0;
377
377
  }
378
+ function loadBrowserCors() {
379
+ try {
380
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
381
+ const match = content.match(/browser_cors:\s*(.+)/);
382
+ if (match?.[1]) return match[1].trim();
383
+ } catch {
384
+ }
385
+ return void 0;
386
+ }
378
387
  function loadCacheDir() {
379
388
  try {
380
389
  const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
@@ -393,6 +402,16 @@ function loadTailSize() {
393
402
  }
394
403
  return void 0;
395
404
  }
405
+ function loadDefaultPermissionMode() {
406
+ try {
407
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
408
+ const match = content.match(/default_permission_mode:\s*(\S+)/);
409
+ const value = match?.[1]?.trim();
410
+ if (value === "acceptEdits" || value === "manual") return value;
411
+ } catch {
412
+ }
413
+ return void 0;
414
+ }
396
415
  function validatePublicUrl(raw) {
397
416
  let parsed;
398
417
  try {
@@ -1353,6 +1372,37 @@ function detectShellPrompt(lines) {
1353
1372
  return null;
1354
1373
  }
1355
1374
 
1375
+ // src/utils/debounce.ts
1376
+ function debounce(fn, waitMs) {
1377
+ let timer = null;
1378
+ let lastArgs = null;
1379
+ const run2 = () => {
1380
+ timer = null;
1381
+ if (lastArgs) {
1382
+ const args = lastArgs;
1383
+ lastArgs = null;
1384
+ fn(...args);
1385
+ }
1386
+ };
1387
+ const debounced = (...args) => {
1388
+ lastArgs = args;
1389
+ if (timer) clearTimeout(timer);
1390
+ timer = setTimeout(run2, waitMs);
1391
+ };
1392
+ debounced.cancel = () => {
1393
+ if (timer) clearTimeout(timer);
1394
+ timer = null;
1395
+ lastArgs = null;
1396
+ };
1397
+ debounced.flush = () => {
1398
+ if (timer) {
1399
+ clearTimeout(timer);
1400
+ run2();
1401
+ }
1402
+ };
1403
+ return debounced;
1404
+ }
1405
+
1356
1406
  // src/pty-manager.ts
1357
1407
  var OUTPUT_BUFFER_MAX2 = 65536;
1358
1408
  var PTY_COLS2 = 120;
@@ -1360,11 +1410,13 @@ var PTY_ROWS2 = 40;
1360
1410
  var SCREEN_SCROLLBACK2 = 1e3;
1361
1411
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1362
1412
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1413
+ var QUIET_DETECT_MS = 500;
1363
1414
  function buildPasteBytes(input) {
1364
1415
  return `\x1B[200~${input}\x1B[201~`;
1365
1416
  }
1366
1417
  var SUBMIT_BYTES2 = "\r";
1367
1418
  var SUBMIT_DELAY_MS = 16;
1419
+ var SUBMIT_MAX_WAIT_MS = 500;
1368
1420
  function digestBytes2(s) {
1369
1421
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1370
1422
  if (escaped.length <= 200) return escaped;
@@ -1441,6 +1493,10 @@ var PTYManager = class {
1441
1493
  // to a given input or fell silent. Reset on dispose().
1442
1494
  chunkIndex = /* @__PURE__ */ new Map();
1443
1495
  lastChunkAt = /* @__PURE__ */ new Map();
1496
+ // Per-session debounced "went quiet" checker, re-armed on every chunk. Fires
1497
+ // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
1498
+ // wait for another chunk that may never arrive (Claude blocked on input).
1499
+ quietCheckers = /* @__PURE__ */ new Map();
1444
1500
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1445
1501
  // concurrent resume for the same session (double-tap, client retry) awaits
1446
1502
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -1456,16 +1512,19 @@ var PTYManager = class {
1456
1512
  }
1457
1513
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1458
1514
  //
1459
- // We use `--permission-mode acceptEdits` rather than `--dangerously-skip-permissions`.
1460
- // Both suppress file-edit prompts, but in an interactive (TUI) launch the
1461
- // skip-permissions flag renders a blocking "Bypass Permissions mode" warning
1462
- // menu on every boot that no known ~/.claude.json flag suppressed (as of
1463
- // Claude CLI v2.1.x) the session never reaches a usable prompt, so the
1464
- // mobile app shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1515
+ // options.permissionMode defaults to `acceptEdits` rather than
1516
+ // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1517
+ // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1518
+ // flag renders a blocking "Bypass Permissions mode" warning menu on every
1519
+ // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1520
+ // v2.1.x) the session never reaches a usable prompt, so the mobile app
1521
+ // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1465
1522
  // without that warning gate, while still prompting for shell commands.
1523
+ // `manual` (prompt for everything) is the only other mode callers may pass;
1524
+ // `bypassPermissions`/`plan`/`dontAsk`/`auto` are not supported here.
1466
1525
  // (The other first-run gates — onboarding/theme, workspace trust,
1467
1526
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1468
- // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
1527
+ // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
1469
1528
  async start(sessionId, options) {
1470
1529
  const existing = this.sessions.get(sessionId);
1471
1530
  if (existing) return toPublicSession2(existing);
@@ -1484,7 +1543,7 @@ var PTYManager = class {
1484
1543
  resolveClaudeExe(),
1485
1544
  [
1486
1545
  "--permission-mode",
1487
- "acceptEdits",
1546
+ options.permissionMode ?? "acceptEdits",
1488
1547
  "--settings",
1489
1548
  '{"spinnerTipsEnabled":false}',
1490
1549
  "--resume",
@@ -1533,7 +1592,7 @@ var PTYManager = class {
1533
1592
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1534
1593
  const args = [
1535
1594
  "--permission-mode",
1536
- "acceptEdits",
1595
+ options.permissionMode ?? "acceptEdits",
1537
1596
  "--settings",
1538
1597
  '{"spinnerTipsEnabled":false}',
1539
1598
  "--session-id",
@@ -1627,9 +1686,20 @@ var PTYManager = class {
1627
1686
  session.promptCount++;
1628
1687
  return session.promptCount;
1629
1688
  }
1630
- // Two-step paste-then-submit. Writes the bracketed-paste body, yields the
1631
- // event loop for SUBMIT_DELAY_MS, then writes \r. See buildPasteBytes() for
1632
- // why the split matters.
1689
+ // Two-step paste-then-submit. Writes the bracketed-paste body, then waits
1690
+ // for the PTY to go quiet before writing \r. See buildPasteBytes() for why
1691
+ // the split matters.
1692
+ //
1693
+ // The wait is quiescence-based, not a flat delay: a fixed SUBMIT_DELAY_MS
1694
+ // timer (the original fix) still races a TUI that's mid-redraw of its own
1695
+ // output (e.g. re-painting right after posting a question) when the paste
1696
+ // lands — the timer can elapse and fire \r while the TUI is still busy,
1697
+ // and that \r gets absorbed by the redraw instead of submitting (see
1698
+ // 2026-07 session 14dda340: a "Yes" reply was accepted into the input line
1699
+ // but never landed as a submitted JSONL turn). Polling in SUBMIT_DELAY_MS
1700
+ // steps and only submitting once lastChunkAt hasn't advanced for a full
1701
+ // step gives the TUI as many extra ticks as it needs, capped at
1702
+ // SUBMIT_MAX_WAIT_MS so a silent/wedged PTY still gets its \r eventually.
1633
1703
  writeSubmit(sessionId, session, input, path, promptCount) {
1634
1704
  const pasteBytes = buildPasteBytes(input);
1635
1705
  this.log.info(
@@ -1644,12 +1714,21 @@ var PTYManager = class {
1644
1714
  phase: "paste"
1645
1715
  }
1646
1716
  );
1717
+ const pasteAt = Date.now();
1647
1718
  session.process.write(pasteBytes);
1648
- setTimeout(() => {
1719
+ const trySubmit = () => {
1649
1720
  const current = this.sessions.get(sessionId);
1650
1721
  if (!current || current !== session) return;
1722
+ const now = Date.now();
1723
+ const lastChunk = this.lastChunkAt.get(sessionId) ?? pasteAt;
1724
+ const quiet = now - lastChunk >= SUBMIT_DELAY_MS;
1725
+ const timedOut = now - pasteAt >= SUBMIT_MAX_WAIT_MS;
1726
+ if (!quiet && !timedOut) {
1727
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1728
+ return;
1729
+ }
1651
1730
  this.log.info(
1652
- `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
1731
+ `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r waitedMs=${now - pasteAt} timedOut=${timedOut}`,
1653
1732
  {
1654
1733
  event: "pty.input_write",
1655
1734
  sessionId,
@@ -1657,11 +1736,14 @@ var PTYManager = class {
1657
1736
  byteLen: SUBMIT_BYTES2.length,
1658
1737
  digest: "\\r",
1659
1738
  path,
1660
- phase: "submit"
1739
+ phase: "submit",
1740
+ waitedMs: now - pasteAt,
1741
+ timedOut
1661
1742
  }
1662
1743
  );
1663
1744
  current.process.write(SUBMIT_BYTES2);
1664
- }, SUBMIT_DELAY_MS);
1745
+ };
1746
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1665
1747
  }
1666
1748
  // Drain any inputs that were sent while the session was still pendingReady,
1667
1749
  // writing them in arrival order now that Claude is at its prompt.
@@ -1710,6 +1792,8 @@ var PTYManager = class {
1710
1792
  this.permissionOpen.delete(sessionId);
1711
1793
  this.lastScreenQuestionKey.delete(sessionId);
1712
1794
  this.shellPromptOpen.delete(sessionId);
1795
+ this.quietCheckers.get(sessionId)?.cancel();
1796
+ this.quietCheckers.delete(sessionId);
1713
1797
  try {
1714
1798
  session.process.kill("SIGINT");
1715
1799
  } catch {
@@ -1769,6 +1853,8 @@ var PTYManager = class {
1769
1853
  this.firstChunkAt.clear();
1770
1854
  this.chunkIndex.clear();
1771
1855
  this.lastChunkAt.clear();
1856
+ for (const quiet of this.quietCheckers.values()) quiet.cancel();
1857
+ this.quietCheckers.clear();
1772
1858
  this.permissionOpen.clear();
1773
1859
  this.lastScreenQuestionKey.clear();
1774
1860
  this.shellPromptOpen.clear();
@@ -1822,6 +1908,12 @@ var PTYManager = class {
1822
1908
  err
1823
1909
  });
1824
1910
  });
1911
+ let quiet = this.quietCheckers.get(sessionId);
1912
+ if (!quiet) {
1913
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
1914
+ this.quietCheckers.set(sessionId, quiet);
1915
+ }
1916
+ quiet();
1825
1917
  }
1826
1918
  // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
1827
1919
  // menus from the rendered screen, firing the additive callbacks. Async because
@@ -1897,6 +1989,24 @@ var PTYManager = class {
1897
1989
  }
1898
1990
  }
1899
1991
  }
1992
+ // Fired QUIET_DETECT_MS after the last PTY chunk. Re-runs the same
1993
+ // ready/prompt detection handleOutput() runs per-chunk, using the last
1994
+ // rendered output — a session blocked on a prompt (or an unmarked boot
1995
+ // screen) may never produce another chunk to trigger detection otherwise.
1996
+ handleQuiet(sessionId) {
1997
+ const session = this.sessions.get(sessionId);
1998
+ if (session?.status !== "running") return;
1999
+ if (this.pendingReady.has(sessionId)) {
2000
+ this.markReady(sessionId, session, "quiet:timeout");
2001
+ }
2002
+ this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
2003
+ this.log.warn("[pty.prompt_detect] failed", {
2004
+ event: "pty.prompt_detect_failed",
2005
+ sessionId,
2006
+ err
2007
+ });
2008
+ });
2009
+ }
1900
2010
  // Transition a session from "running" to "waiting_input", clear pendingReady,
1901
2011
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
1902
2012
  markReady(sessionId, session, reason) {
@@ -1937,6 +2047,8 @@ var PTYManager = class {
1937
2047
  this.permissionOpen.delete(sessionId);
1938
2048
  this.lastScreenQuestionKey.delete(sessionId);
1939
2049
  this.shellPromptOpen.delete(sessionId);
2050
+ this.quietCheckers.get(sessionId)?.cancel();
2051
+ this.quietCheckers.delete(sessionId);
1940
2052
  }
1941
2053
  };
1942
2054
  function toPublicSession2(s) {
@@ -2509,25 +2621,55 @@ var authMiddleware = (deps) => async (c, next) => {
2509
2621
  };
2510
2622
 
2511
2623
  // src/api/middleware/cors.middleware.ts
2512
- var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
2624
+ var DEFAULT_DEV_ORIGINS = [
2513
2625
  "http://localhost:8081",
2514
2626
  "http://localhost:19006",
2515
2627
  "http://localhost:3000"
2516
- ]);
2517
- var corsMiddleware = () => async (c, next) => {
2518
- const origin = c.req.header("origin");
2519
- const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
2520
- if (allowedOrigin) {
2521
- c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2522
- c.res.headers.set("Vary", "Origin");
2523
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2524
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2525
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2526
- }
2527
- if (c.req.method === "OPTIONS") {
2528
- return c.newResponse(null, allowedOrigin ? 204 : 403);
2529
- }
2530
- await next();
2628
+ ];
2629
+ function resolveAllowedOrigins(raw) {
2630
+ if (!raw) return null;
2631
+ const trimmed = raw.trim();
2632
+ const lower = trimmed.toLowerCase();
2633
+ if (lower === "0" || lower === "false" || lower === "no" || lower === "off" || trimmed === "") {
2634
+ return null;
2635
+ }
2636
+ const origins = new Set(DEFAULT_DEV_ORIGINS);
2637
+ if (!["1", "true", "yes", "on"].includes(lower)) {
2638
+ for (const o of trimmed.split(",")) {
2639
+ const origin = o.trim();
2640
+ if (origin) origins.add(origin);
2641
+ }
2642
+ }
2643
+ return origins;
2644
+ }
2645
+ var corsMiddleware = (configValue) => {
2646
+ const allowedOrigins = resolveAllowedOrigins(
2647
+ process.env.THREADBASE_ALLOW_BROWSER_CORS ?? configValue
2648
+ );
2649
+ return async (c, next) => {
2650
+ const origin = c.req.header("origin");
2651
+ const allowedOrigin = allowedOrigins && origin && allowedOrigins.has(origin) ? origin : null;
2652
+ if (allowedOrigin) {
2653
+ const raw = c.env.outgoing;
2654
+ raw.setHeader("Access-Control-Allow-Origin", allowedOrigin);
2655
+ raw.setHeader("Vary", "Origin");
2656
+ raw.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2657
+ raw.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2658
+ raw.setHeader("Access-Control-Expose-Headers", "ETag");
2659
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2660
+ c.res.headers.set("Vary", "Origin");
2661
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2662
+ c.res.headers.set(
2663
+ "Access-Control-Allow-Headers",
2664
+ "Authorization, Content-Type, If-None-Match"
2665
+ );
2666
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2667
+ }
2668
+ if (c.req.method === "OPTIONS") {
2669
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
2670
+ }
2671
+ await next();
2672
+ };
2531
2673
  };
2532
2674
 
2533
2675
  // src/api/middleware/error.middleware.ts
@@ -2942,7 +3084,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
2942
3084
  event: "http.request"
2943
3085
  });
2944
3086
  });
2945
- app.use("*", corsMiddleware());
3087
+ app.use("*", corsMiddleware(deps.browserCors));
2946
3088
  app.use("*", authMiddleware(deps));
2947
3089
  app.onError(errorMiddleware);
2948
3090
  app.route("/healthz", createHealthRoutes());
@@ -3225,11 +3367,11 @@ var ConversationCache = class _ConversationCache {
3225
3367
  INSERT INTO conversation_meta
3226
3368
  (id, file_path, project_path, project_name, title, model, account, branch,
3227
3369
  message_count, last_activity, first_message, last_message, preview, updated_at,
3228
- mtime_ms, file_size, provider)
3370
+ mtime_ms, file_size, provider, scanner_meta_json)
3229
3371
  VALUES
3230
3372
  (@id, @file_path, @project_path, @project_name, @title, @model, @account, @branch,
3231
3373
  @message_count, @last_activity, @first_message, @last_message, @preview, @updated_at,
3232
- @mtime_ms, @file_size, @provider)
3374
+ @mtime_ms, @file_size, @provider, @scanner_meta_json)
3233
3375
  ON CONFLICT(id) DO UPDATE SET
3234
3376
  file_path = excluded.file_path,
3235
3377
  project_path = excluded.project_path,
@@ -3249,7 +3391,8 @@ var ConversationCache = class _ConversationCache {
3249
3391
  updated_at = excluded.updated_at,
3250
3392
  mtime_ms = excluded.mtime_ms,
3251
3393
  file_size = excluded.file_size,
3252
- provider = excluded.provider
3394
+ provider = excluded.provider,
3395
+ scanner_meta_json = excluded.scanner_meta_json
3253
3396
  WHERE conversation_meta.updated_at < excluded.updated_at
3254
3397
  `),
3255
3398
  getTail: db.prepare("SELECT * FROM conversation_tail WHERE conversation_id = ?"),
@@ -3286,6 +3429,27 @@ var ConversationCache = class _ConversationCache {
3286
3429
  allFileStats: db.prepare(
3287
3430
  "SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
3288
3431
  ),
3432
+ allScannerStatCacheRows: db.prepare(
3433
+ "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"
3434
+ ),
3435
+ updateScannerCache: db.prepare(
3436
+ "UPDATE conversation_meta SET mtime_ms = ?, file_size = ?, scanner_meta_json = ? WHERE id = ?"
3437
+ ),
3438
+ getFileMetadata: db.prepare(
3439
+ "SELECT mtime_ms, file_size, is_agent, agent_entrypoints_key FROM conversation_file_metadata WHERE file_path = ?"
3440
+ ),
3441
+ upsertFileMetadata: db.prepare(`
3442
+ INSERT INTO conversation_file_metadata
3443
+ (file_path, mtime_ms, file_size, is_agent, agent_entrypoints_key, updated_at)
3444
+ VALUES
3445
+ (@file_path, @mtime_ms, @file_size, @is_agent, @agent_entrypoints_key, @updated_at)
3446
+ ON CONFLICT(file_path) DO UPDATE SET
3447
+ mtime_ms = excluded.mtime_ms,
3448
+ file_size = excluded.file_size,
3449
+ is_agent = excluded.is_agent,
3450
+ agent_entrypoints_key = excluded.agent_entrypoints_key,
3451
+ updated_at = excluded.updated_at
3452
+ `),
3289
3453
  upsertSessionName: db.prepare(`
3290
3454
  INSERT INTO session_names (session_id, name, updated_at)
3291
3455
  VALUES (?, ?, ?)
@@ -3326,8 +3490,35 @@ var ConversationCache = class _ConversationCache {
3326
3490
  getDatabase() {
3327
3491
  return this.db;
3328
3492
  }
3329
- getAgentEntrypoints() {
3330
- return this.agentEntrypoints;
3493
+ agentEntrypointsKey() {
3494
+ return [...this.agentEntrypoints].sort().join(",");
3495
+ }
3496
+ classifyAgentFile(filePath, mtimeMs, fileSize) {
3497
+ if (this.agentEntrypoints.size === 0) return false;
3498
+ const entrypointsKey = this.agentEntrypointsKey();
3499
+ const cached2 = this.stmts.getFileMetadata.get(filePath);
3500
+ if (cached2 && cached2.mtime_ms === mtimeMs && cached2.file_size === fileSize && cached2.agent_entrypoints_key === entrypointsKey) {
3501
+ return cached2.is_agent === 1;
3502
+ }
3503
+ const isAgent = isAgentFile(filePath, this.agentEntrypoints);
3504
+ this.stmts.upsertFileMetadata.run({
3505
+ file_path: filePath,
3506
+ mtime_ms: mtimeMs,
3507
+ file_size: fileSize,
3508
+ is_agent: isAgent ? 1 : 0,
3509
+ agent_entrypoints_key: entrypointsKey,
3510
+ updated_at: Date.now()
3511
+ });
3512
+ return isAgent;
3513
+ }
3514
+ isAgentFileCached(filePath) {
3515
+ let s;
3516
+ try {
3517
+ s = (0, import_fs8.statSync)(filePath);
3518
+ } catch {
3519
+ return false;
3520
+ }
3521
+ return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3331
3522
  }
3332
3523
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3333
3524
  (0, import_fs8.mkdirSync)((0, import_path10.dirname)(dbPath), { recursive: true });
@@ -3503,11 +3694,9 @@ var ConversationCache = class _ConversationCache {
3503
3694
  // in conversation-cache.test.ts).
3504
3695
  upsertFromScannerMeta(metas) {
3505
3696
  const filter = this.filterAgentConversations;
3506
- const entrypoints = this.agentEntrypoints;
3507
3697
  const upsertedIds = [];
3508
3698
  const run2 = this.db.transaction((items) => {
3509
3699
  for (const m of items) {
3510
- if (filter && isAgentFile(m.filePath, entrypoints)) continue;
3511
3700
  const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
3512
3701
  const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
3513
3702
  let mtimeMs = null;
@@ -3519,6 +3708,10 @@ var ConversationCache = class _ConversationCache {
3519
3708
  } catch {
3520
3709
  }
3521
3710
  const seq = ++this.tailSeq;
3711
+ if (filter && mtimeMs !== null && fileSize !== null && this.classifyAgentFile(m.filePath, mtimeMs, fileSize)) {
3712
+ continue;
3713
+ }
3714
+ const scannerMetaJson = JSON.stringify(m);
3522
3715
  this.stmts.upsertFull.run({
3523
3716
  id,
3524
3717
  file_path: m.filePath,
@@ -3536,8 +3729,10 @@ var ConversationCache = class _ConversationCache {
3536
3729
  updated_at: seq,
3537
3730
  mtime_ms: mtimeMs,
3538
3731
  file_size: fileSize,
3539
- provider: m.provider ?? CLAUDE_CODE_PROVIDER
3732
+ provider: m.provider ?? CLAUDE_CODE_PROVIDER,
3733
+ scanner_meta_json: scannerMetaJson
3540
3734
  });
3735
+ this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
3541
3736
  if (this.fileIndexLoaded) this.fileIndex.set(m.filePath, id);
3542
3737
  upsertedIds.push(id);
3543
3738
  }
@@ -3661,6 +3856,21 @@ var ConversationCache = class _ConversationCache {
3661
3856
  }
3662
3857
  return map;
3663
3858
  }
3859
+ getScannerStatCache() {
3860
+ const rows = this.stmts.allScannerStatCacheRows.all();
3861
+ const map = /* @__PURE__ */ new Map();
3862
+ for (const r of rows) {
3863
+ try {
3864
+ const meta = JSON.parse(r.scanner_meta_json);
3865
+ map.set(r.file_path, {
3866
+ stat: { mtimeMs: r.mtime_ms, size: r.file_size },
3867
+ meta
3868
+ });
3869
+ } catch {
3870
+ }
3871
+ }
3872
+ return map;
3873
+ }
3664
3874
  getMetaById(id) {
3665
3875
  const row = this.stmts.getFullById.get(id);
3666
3876
  if (!row) return null;
@@ -4327,7 +4537,7 @@ function pruneAgentConversations(cache) {
4327
4537
  missing += 1;
4328
4538
  continue;
4329
4539
  }
4330
- if (isAgentFile(row.file_path, cache.getAgentEntrypoints())) {
4540
+ if (cache.isAgentFileCached(row.file_path)) {
4331
4541
  cache.deleteByFilePath(row.file_path);
4332
4542
  pruned += 1;
4333
4543
  }
@@ -4721,37 +4931,6 @@ function computeConversationEtag({
4721
4931
  return `"${digest}"`;
4722
4932
  }
4723
4933
 
4724
- // src/utils/debounce.ts
4725
- function debounce(fn, waitMs) {
4726
- let timer = null;
4727
- let lastArgs = null;
4728
- const run2 = () => {
4729
- timer = null;
4730
- if (lastArgs) {
4731
- const args = lastArgs;
4732
- lastArgs = null;
4733
- fn(...args);
4734
- }
4735
- };
4736
- const debounced = (...args) => {
4737
- lastArgs = args;
4738
- if (timer) clearTimeout(timer);
4739
- timer = setTimeout(run2, waitMs);
4740
- };
4741
- debounced.cancel = () => {
4742
- if (timer) clearTimeout(timer);
4743
- timer = null;
4744
- lastArgs = null;
4745
- };
4746
- debounced.flush = () => {
4747
- if (timer) {
4748
- clearTimeout(timer);
4749
- run2();
4750
- }
4751
- };
4752
- return debounced;
4753
- }
4754
-
4755
4934
  // src/utils/dates.ts
4756
4935
  var import_date_fns = require("date-fns");
4757
4936
  function parseIsoDateOrNull(value) {
@@ -4898,6 +5077,7 @@ var WSHub = class {
4898
5077
  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.`;
4899
5078
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
4900
5079
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5080
+ var START_READY_TIMEOUT_MS = 15e3;
4901
5081
  function parseIncludeAgentsEnv(raw) {
4902
5082
  if (raw === void 0) return false;
4903
5083
  const v = raw.trim().toLowerCase();
@@ -4960,12 +5140,14 @@ var StreamerServer = class {
4960
5140
  disableDb = false;
4961
5141
  browseRoot = null;
4962
5142
  publicUrl = null;
5143
+ browserCors;
4963
5144
  pairTokens = new PairTokenStore();
4964
5145
  exchangeAttempts = /* @__PURE__ */ new Map();
4965
5146
  sessionStartAttempts = /* @__PURE__ */ new Map();
4966
5147
  sessionInputAttempts = /* @__PURE__ */ new Map();
4967
5148
  ptyGracePeriodMs;
4968
5149
  defaultSystemPrompt;
5150
+ defaultPermissionMode;
4969
5151
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
4970
5152
  ptyGraceTimers = /* @__PURE__ */ new Map();
4971
5153
  // Map of sessionId → set of subscribed WS clients
@@ -5014,6 +5196,7 @@ var StreamerServer = class {
5014
5196
  this.codexRoots = config.codexRoots ?? [(0, import_path13.join)((0, import_os6.homedir)(), ".codex", "sessions")];
5015
5197
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
5016
5198
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5199
+ this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5017
5200
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path13.join)((0, import_os6.homedir)(), ".threadbase", "cache");
5018
5201
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
5019
5202
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -5043,6 +5226,7 @@ var StreamerServer = class {
5043
5226
  this.log.warn(`Warning: ${result.error}`, { error: result.error });
5044
5227
  }
5045
5228
  }
5229
+ this.browserCors = config.browserCors ?? loadBrowserCors();
5046
5230
  this.sessionStore = new SessionStore();
5047
5231
  this.wsHub = new WSHub();
5048
5232
  this.fileWatcher = new ConversationWatcher({
@@ -5197,6 +5381,7 @@ var StreamerServer = class {
5197
5381
  rotateApiKey: () => this.rotateApiKey(),
5198
5382
  publicUrl: this.publicUrl,
5199
5383
  browseRoot: this.browseRoot,
5384
+ browserCors: this.browserCors,
5200
5385
  ptyManager: this.ptyManager,
5201
5386
  sessionStore: this.sessionStore,
5202
5387
  wsHub: this.wsHub,
@@ -5443,9 +5628,9 @@ var StreamerServer = class {
5443
5628
  );
5444
5629
  this.scannerPersistenceDisabled = true;
5445
5630
  }
5446
- const warmupScanner = this.newScanner();
5447
- this.allScanners.add(warmupScanner);
5448
5631
  const warmupStatCache = this.buildStatCache(null);
5632
+ const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
5633
+ this.allScanners.add(warmupScanner);
5449
5634
  const shouldEmitProgress = createScanProgressThrottle();
5450
5635
  const scanOpts = {
5451
5636
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5885,6 +6070,10 @@ var StreamerServer = class {
5885
6070
  }
5886
6071
  buildStatCache(previousScanner) {
5887
6072
  if (!this.cache) return void 0;
6073
+ if (!previousScanner) {
6074
+ const persisted = this.cache.getScannerStatCache();
6075
+ return persisted.size > 0 ? persisted : void 0;
6076
+ }
5888
6077
  const dbStats = this.cache.getFileStats();
5889
6078
  if (dbStats.size === 0) return void 0;
5890
6079
  const metaByPath = /* @__PURE__ */ new Map();
@@ -5914,9 +6103,9 @@ var StreamerServer = class {
5914
6103
  // this — its per-file refreshFile (in findConversationByUuid) already
5915
6104
  // reconciles the one conversation being requested, so paying a full-tree
5916
6105
  // rescan just because some OTHER file changed is the stall this avoids.
5917
- newScanner() {
6106
+ newScanner(options) {
5918
6107
  return new import_scanner2.ConversationScanner(
5919
- this.scannerPersistenceDisabled ? { persistent: false } : void 0
6108
+ options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
5920
6109
  );
5921
6110
  }
5922
6111
  async getScanner(skipStaleRescan = false) {
@@ -5935,7 +6124,7 @@ var StreamerServer = class {
5935
6124
  }
5936
6125
  this.scannerStale = false;
5937
6126
  const statCache = this.buildStatCache(this.scanner);
5938
- this.scanner = this.newScanner();
6127
+ this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
5939
6128
  this.allScanners.add(this.scanner);
5940
6129
  this.scannerReady = this.scanner.scan({
5941
6130
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5958,9 +6147,8 @@ var StreamerServer = class {
5958
6147
  this.scannerReady = null;
5959
6148
  return this.getScanner();
5960
6149
  }
5961
- // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
5962
- // survive, so classify() still skips unchanged files) and re-run its scan
5963
- // with fullRescan:true — the escape hatch that bypasses the scanner's
6150
+ // refresh=1's scan: reuse the WARM scanner and re-run its scan with
6151
+ // fullRescan:true the escape hatch that bypasses the scanner's
5964
6152
  // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
5965
6153
  // the "don't trust the gate, check disk for real" signal. Unlike
5966
6154
  // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
@@ -6348,7 +6536,8 @@ var StreamerServer = class {
6348
6536
  provider,
6349
6537
  projectPath,
6350
6538
  projectName: body.projectName,
6351
- branch: body.branch
6539
+ branch: body.branch,
6540
+ permissionMode: this.defaultPermissionMode
6352
6541
  });
6353
6542
  this.sessionStore.addManaged(session);
6354
6543
  void this.watchConversationFile(sessionId);
@@ -6672,7 +6861,8 @@ var StreamerServer = class {
6672
6861
  const session = await this.ptyManager.start(convId, {
6673
6862
  projectPath,
6674
6863
  projectName,
6675
- branch
6864
+ branch,
6865
+ permissionMode: this.defaultPermissionMode
6676
6866
  });
6677
6867
  this.sessionStore.addManaged(session);
6678
6868
  void this.watchConversationFile(session.id);
@@ -6742,10 +6932,35 @@ var StreamerServer = class {
6742
6932
  provider,
6743
6933
  projectPath: resolvedPath,
6744
6934
  projectName: body.projectName,
6745
- systemPrompt: systemPromptParts.join("\n")
6935
+ systemPrompt: systemPromptParts.join("\n"),
6936
+ permissionMode: this.defaultPermissionMode
6746
6937
  });
6747
6938
  this.sessionStore.addManaged(session);
6748
- json(res, 202, { id: session.id, status: "pending" });
6939
+ const readyOrFailed = new Promise((resolve2) => {
6940
+ const handler = (status) => {
6941
+ if (status === "waiting_input" || status === "idle") {
6942
+ this.sessionStatusBus.off(`status:${session.id}`, handler);
6943
+ resolve2(status === "waiting_input" ? "ready" : "failed");
6944
+ }
6945
+ };
6946
+ this.sessionStatusBus.on(`status:${session.id}`, handler);
6947
+ });
6948
+ const timeoutPromise = new Promise(
6949
+ (resolve2) => setTimeout(() => resolve2("timeout"), START_READY_TIMEOUT_MS)
6950
+ );
6951
+ const outcome = await Promise.race([readyOrFailed, timeoutPromise]);
6952
+ const current = this.sessionStore.get(session.id, this.ptyAttachedIds());
6953
+ if (outcome === "ready" && current) {
6954
+ json(res, 200, { session: current });
6955
+ } else if (outcome === "failed" && current) {
6956
+ json(res, 502, {
6957
+ id: session.id,
6958
+ status: "idle",
6959
+ error: current.failureReason ?? "Session exited before becoming ready"
6960
+ });
6961
+ } else {
6962
+ json(res, 202, { id: session.id, status: "pending" });
6963
+ }
6749
6964
  if (provider === CODEX_CLI_PROVIDER) {
6750
6965
  this.watchForCodexRollout(session.id, resolvedPath);
6751
6966
  } else {