@threadbase-sh/streamer 1.24.6 → 1.25.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
@@ -393,6 +393,16 @@ function loadTailSize() {
393
393
  }
394
394
  return void 0;
395
395
  }
396
+ function loadDefaultPermissionMode() {
397
+ try {
398
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
399
+ const match = content.match(/default_permission_mode:\s*(\S+)/);
400
+ const value = match?.[1]?.trim();
401
+ if (value === "acceptEdits" || value === "manual") return value;
402
+ } catch {
403
+ }
404
+ return void 0;
405
+ }
396
406
  function validatePublicUrl(raw) {
397
407
  let parsed;
398
408
  try {
@@ -1353,6 +1363,37 @@ function detectShellPrompt(lines) {
1353
1363
  return null;
1354
1364
  }
1355
1365
 
1366
+ // src/utils/debounce.ts
1367
+ function debounce(fn, waitMs) {
1368
+ let timer = null;
1369
+ let lastArgs = null;
1370
+ const run2 = () => {
1371
+ timer = null;
1372
+ if (lastArgs) {
1373
+ const args = lastArgs;
1374
+ lastArgs = null;
1375
+ fn(...args);
1376
+ }
1377
+ };
1378
+ const debounced = (...args) => {
1379
+ lastArgs = args;
1380
+ if (timer) clearTimeout(timer);
1381
+ timer = setTimeout(run2, waitMs);
1382
+ };
1383
+ debounced.cancel = () => {
1384
+ if (timer) clearTimeout(timer);
1385
+ timer = null;
1386
+ lastArgs = null;
1387
+ };
1388
+ debounced.flush = () => {
1389
+ if (timer) {
1390
+ clearTimeout(timer);
1391
+ run2();
1392
+ }
1393
+ };
1394
+ return debounced;
1395
+ }
1396
+
1356
1397
  // src/pty-manager.ts
1357
1398
  var OUTPUT_BUFFER_MAX2 = 65536;
1358
1399
  var PTY_COLS2 = 120;
@@ -1360,11 +1401,13 @@ var PTY_ROWS2 = 40;
1360
1401
  var SCREEN_SCROLLBACK2 = 1e3;
1361
1402
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1362
1403
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1404
+ var QUIET_DETECT_MS = 500;
1363
1405
  function buildPasteBytes(input) {
1364
1406
  return `\x1B[200~${input}\x1B[201~`;
1365
1407
  }
1366
1408
  var SUBMIT_BYTES2 = "\r";
1367
1409
  var SUBMIT_DELAY_MS = 16;
1410
+ var SUBMIT_MAX_WAIT_MS = 500;
1368
1411
  function digestBytes2(s) {
1369
1412
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1370
1413
  if (escaped.length <= 200) return escaped;
@@ -1441,6 +1484,10 @@ var PTYManager = class {
1441
1484
  // to a given input or fell silent. Reset on dispose().
1442
1485
  chunkIndex = /* @__PURE__ */ new Map();
1443
1486
  lastChunkAt = /* @__PURE__ */ new Map();
1487
+ // Per-session debounced "went quiet" checker, re-armed on every chunk. Fires
1488
+ // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
1489
+ // wait for another chunk that may never arrive (Claude blocked on input).
1490
+ quietCheckers = /* @__PURE__ */ new Map();
1444
1491
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1445
1492
  // concurrent resume for the same session (double-tap, client retry) awaits
1446
1493
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -1456,16 +1503,19 @@ var PTYManager = class {
1456
1503
  }
1457
1504
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1458
1505
  //
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
1506
+ // options.permissionMode defaults to `acceptEdits` rather than
1507
+ // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1508
+ // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1509
+ // flag renders a blocking "Bypass Permissions mode" warning menu on every
1510
+ // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1511
+ // v2.1.x) the session never reaches a usable prompt, so the mobile app
1512
+ // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1465
1513
  // without that warning gate, while still prompting for shell commands.
1514
+ // `manual` (prompt for everything) is the only other mode callers may pass;
1515
+ // `bypassPermissions`/`plan`/`dontAsk`/`auto` are not supported here.
1466
1516
  // (The other first-run gates — onboarding/theme, workspace trust,
1467
1517
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1468
- // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
1518
+ // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
1469
1519
  async start(sessionId, options) {
1470
1520
  const existing = this.sessions.get(sessionId);
1471
1521
  if (existing) return toPublicSession2(existing);
@@ -1484,7 +1534,7 @@ var PTYManager = class {
1484
1534
  resolveClaudeExe(),
1485
1535
  [
1486
1536
  "--permission-mode",
1487
- "acceptEdits",
1537
+ options.permissionMode ?? "acceptEdits",
1488
1538
  "--settings",
1489
1539
  '{"spinnerTipsEnabled":false}',
1490
1540
  "--resume",
@@ -1533,7 +1583,7 @@ var PTYManager = class {
1533
1583
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1534
1584
  const args = [
1535
1585
  "--permission-mode",
1536
- "acceptEdits",
1586
+ options.permissionMode ?? "acceptEdits",
1537
1587
  "--settings",
1538
1588
  '{"spinnerTipsEnabled":false}',
1539
1589
  "--session-id",
@@ -1627,9 +1677,20 @@ var PTYManager = class {
1627
1677
  session.promptCount++;
1628
1678
  return session.promptCount;
1629
1679
  }
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.
1680
+ // Two-step paste-then-submit. Writes the bracketed-paste body, then waits
1681
+ // for the PTY to go quiet before writing \r. See buildPasteBytes() for why
1682
+ // the split matters.
1683
+ //
1684
+ // The wait is quiescence-based, not a flat delay: a fixed SUBMIT_DELAY_MS
1685
+ // timer (the original fix) still races a TUI that's mid-redraw of its own
1686
+ // output (e.g. re-painting right after posting a question) when the paste
1687
+ // lands — the timer can elapse and fire \r while the TUI is still busy,
1688
+ // and that \r gets absorbed by the redraw instead of submitting (see
1689
+ // 2026-07 session 14dda340: a "Yes" reply was accepted into the input line
1690
+ // but never landed as a submitted JSONL turn). Polling in SUBMIT_DELAY_MS
1691
+ // steps and only submitting once lastChunkAt hasn't advanced for a full
1692
+ // step gives the TUI as many extra ticks as it needs, capped at
1693
+ // SUBMIT_MAX_WAIT_MS so a silent/wedged PTY still gets its \r eventually.
1633
1694
  writeSubmit(sessionId, session, input, path, promptCount) {
1634
1695
  const pasteBytes = buildPasteBytes(input);
1635
1696
  this.log.info(
@@ -1644,12 +1705,21 @@ var PTYManager = class {
1644
1705
  phase: "paste"
1645
1706
  }
1646
1707
  );
1708
+ const pasteAt = Date.now();
1647
1709
  session.process.write(pasteBytes);
1648
- setTimeout(() => {
1710
+ const trySubmit = () => {
1649
1711
  const current = this.sessions.get(sessionId);
1650
1712
  if (!current || current !== session) return;
1713
+ const now = Date.now();
1714
+ const lastChunk = this.lastChunkAt.get(sessionId) ?? pasteAt;
1715
+ const quiet = now - lastChunk >= SUBMIT_DELAY_MS;
1716
+ const timedOut = now - pasteAt >= SUBMIT_MAX_WAIT_MS;
1717
+ if (!quiet && !timedOut) {
1718
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1719
+ return;
1720
+ }
1651
1721
  this.log.info(
1652
- `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
1722
+ `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r waitedMs=${now - pasteAt} timedOut=${timedOut}`,
1653
1723
  {
1654
1724
  event: "pty.input_write",
1655
1725
  sessionId,
@@ -1657,11 +1727,14 @@ var PTYManager = class {
1657
1727
  byteLen: SUBMIT_BYTES2.length,
1658
1728
  digest: "\\r",
1659
1729
  path,
1660
- phase: "submit"
1730
+ phase: "submit",
1731
+ waitedMs: now - pasteAt,
1732
+ timedOut
1661
1733
  }
1662
1734
  );
1663
1735
  current.process.write(SUBMIT_BYTES2);
1664
- }, SUBMIT_DELAY_MS);
1736
+ };
1737
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1665
1738
  }
1666
1739
  // Drain any inputs that were sent while the session was still pendingReady,
1667
1740
  // writing them in arrival order now that Claude is at its prompt.
@@ -1710,6 +1783,8 @@ var PTYManager = class {
1710
1783
  this.permissionOpen.delete(sessionId);
1711
1784
  this.lastScreenQuestionKey.delete(sessionId);
1712
1785
  this.shellPromptOpen.delete(sessionId);
1786
+ this.quietCheckers.get(sessionId)?.cancel();
1787
+ this.quietCheckers.delete(sessionId);
1713
1788
  try {
1714
1789
  session.process.kill("SIGINT");
1715
1790
  } catch {
@@ -1769,6 +1844,8 @@ var PTYManager = class {
1769
1844
  this.firstChunkAt.clear();
1770
1845
  this.chunkIndex.clear();
1771
1846
  this.lastChunkAt.clear();
1847
+ for (const quiet of this.quietCheckers.values()) quiet.cancel();
1848
+ this.quietCheckers.clear();
1772
1849
  this.permissionOpen.clear();
1773
1850
  this.lastScreenQuestionKey.clear();
1774
1851
  this.shellPromptOpen.clear();
@@ -1822,6 +1899,12 @@ var PTYManager = class {
1822
1899
  err
1823
1900
  });
1824
1901
  });
1902
+ let quiet = this.quietCheckers.get(sessionId);
1903
+ if (!quiet) {
1904
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
1905
+ this.quietCheckers.set(sessionId, quiet);
1906
+ }
1907
+ quiet();
1825
1908
  }
1826
1909
  // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
1827
1910
  // menus from the rendered screen, firing the additive callbacks. Async because
@@ -1897,6 +1980,24 @@ var PTYManager = class {
1897
1980
  }
1898
1981
  }
1899
1982
  }
1983
+ // Fired QUIET_DETECT_MS after the last PTY chunk. Re-runs the same
1984
+ // ready/prompt detection handleOutput() runs per-chunk, using the last
1985
+ // rendered output — a session blocked on a prompt (or an unmarked boot
1986
+ // screen) may never produce another chunk to trigger detection otherwise.
1987
+ handleQuiet(sessionId) {
1988
+ const session = this.sessions.get(sessionId);
1989
+ if (session?.status !== "running") return;
1990
+ if (this.pendingReady.has(sessionId)) {
1991
+ this.markReady(sessionId, session, "quiet:timeout");
1992
+ }
1993
+ this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
1994
+ this.log.warn("[pty.prompt_detect] failed", {
1995
+ event: "pty.prompt_detect_failed",
1996
+ sessionId,
1997
+ err
1998
+ });
1999
+ });
2000
+ }
1900
2001
  // Transition a session from "running" to "waiting_input", clear pendingReady,
1901
2002
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
1902
2003
  markReady(sessionId, session, reason) {
@@ -1937,6 +2038,8 @@ var PTYManager = class {
1937
2038
  this.permissionOpen.delete(sessionId);
1938
2039
  this.lastScreenQuestionKey.delete(sessionId);
1939
2040
  this.shellPromptOpen.delete(sessionId);
2041
+ this.quietCheckers.get(sessionId)?.cancel();
2042
+ this.quietCheckers.delete(sessionId);
1940
2043
  }
1941
2044
  };
1942
2045
  function toPublicSession2(s) {
@@ -3225,11 +3328,11 @@ var ConversationCache = class _ConversationCache {
3225
3328
  INSERT INTO conversation_meta
3226
3329
  (id, file_path, project_path, project_name, title, model, account, branch,
3227
3330
  message_count, last_activity, first_message, last_message, preview, updated_at,
3228
- mtime_ms, file_size, provider)
3331
+ mtime_ms, file_size, provider, scanner_meta_json)
3229
3332
  VALUES
3230
3333
  (@id, @file_path, @project_path, @project_name, @title, @model, @account, @branch,
3231
3334
  @message_count, @last_activity, @first_message, @last_message, @preview, @updated_at,
3232
- @mtime_ms, @file_size, @provider)
3335
+ @mtime_ms, @file_size, @provider, @scanner_meta_json)
3233
3336
  ON CONFLICT(id) DO UPDATE SET
3234
3337
  file_path = excluded.file_path,
3235
3338
  project_path = excluded.project_path,
@@ -3249,7 +3352,8 @@ var ConversationCache = class _ConversationCache {
3249
3352
  updated_at = excluded.updated_at,
3250
3353
  mtime_ms = excluded.mtime_ms,
3251
3354
  file_size = excluded.file_size,
3252
- provider = excluded.provider
3355
+ provider = excluded.provider,
3356
+ scanner_meta_json = excluded.scanner_meta_json
3253
3357
  WHERE conversation_meta.updated_at < excluded.updated_at
3254
3358
  `),
3255
3359
  getTail: db.prepare("SELECT * FROM conversation_tail WHERE conversation_id = ?"),
@@ -3286,6 +3390,27 @@ var ConversationCache = class _ConversationCache {
3286
3390
  allFileStats: db.prepare(
3287
3391
  "SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
3288
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
+ `),
3289
3414
  upsertSessionName: db.prepare(`
3290
3415
  INSERT INTO session_names (session_id, name, updated_at)
3291
3416
  VALUES (?, ?, ?)
@@ -3326,8 +3451,35 @@ var ConversationCache = class _ConversationCache {
3326
3451
  getDatabase() {
3327
3452
  return this.db;
3328
3453
  }
3329
- getAgentEntrypoints() {
3330
- 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 = (0, import_fs8.statSync)(filePath);
3479
+ } catch {
3480
+ return false;
3481
+ }
3482
+ return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3331
3483
  }
3332
3484
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3333
3485
  (0, import_fs8.mkdirSync)((0, import_path10.dirname)(dbPath), { recursive: true });
@@ -3503,11 +3655,9 @@ var ConversationCache = class _ConversationCache {
3503
3655
  // in conversation-cache.test.ts).
3504
3656
  upsertFromScannerMeta(metas) {
3505
3657
  const filter = this.filterAgentConversations;
3506
- const entrypoints = this.agentEntrypoints;
3507
3658
  const upsertedIds = [];
3508
3659
  const run2 = this.db.transaction((items) => {
3509
3660
  for (const m of items) {
3510
- if (filter && isAgentFile(m.filePath, entrypoints)) continue;
3511
3661
  const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
3512
3662
  const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
3513
3663
  let mtimeMs = null;
@@ -3519,6 +3669,10 @@ var ConversationCache = class _ConversationCache {
3519
3669
  } catch {
3520
3670
  }
3521
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);
3522
3676
  this.stmts.upsertFull.run({
3523
3677
  id,
3524
3678
  file_path: m.filePath,
@@ -3536,8 +3690,10 @@ var ConversationCache = class _ConversationCache {
3536
3690
  updated_at: seq,
3537
3691
  mtime_ms: mtimeMs,
3538
3692
  file_size: fileSize,
3539
- provider: m.provider ?? CLAUDE_CODE_PROVIDER
3693
+ provider: m.provider ?? CLAUDE_CODE_PROVIDER,
3694
+ scanner_meta_json: scannerMetaJson
3540
3695
  });
3696
+ this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
3541
3697
  if (this.fileIndexLoaded) this.fileIndex.set(m.filePath, id);
3542
3698
  upsertedIds.push(id);
3543
3699
  }
@@ -3661,6 +3817,21 @@ var ConversationCache = class _ConversationCache {
3661
3817
  }
3662
3818
  return map;
3663
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
+ }
3664
3835
  getMetaById(id) {
3665
3836
  const row = this.stmts.getFullById.get(id);
3666
3837
  if (!row) return null;
@@ -4327,7 +4498,7 @@ function pruneAgentConversations(cache) {
4327
4498
  missing += 1;
4328
4499
  continue;
4329
4500
  }
4330
- if (isAgentFile(row.file_path, cache.getAgentEntrypoints())) {
4501
+ if (cache.isAgentFileCached(row.file_path)) {
4331
4502
  cache.deleteByFilePath(row.file_path);
4332
4503
  pruned += 1;
4333
4504
  }
@@ -4721,37 +4892,6 @@ function computeConversationEtag({
4721
4892
  return `"${digest}"`;
4722
4893
  }
4723
4894
 
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
4895
  // src/utils/dates.ts
4756
4896
  var import_date_fns = require("date-fns");
4757
4897
  function parseIsoDateOrNull(value) {
@@ -4898,6 +5038,7 @@ var WSHub = class {
4898
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.`;
4899
5039
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
4900
5040
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5041
+ var START_READY_TIMEOUT_MS = 15e3;
4901
5042
  function parseIncludeAgentsEnv(raw) {
4902
5043
  if (raw === void 0) return false;
4903
5044
  const v = raw.trim().toLowerCase();
@@ -4923,7 +5064,9 @@ var StreamerServer = class {
4923
5064
  pendingPermission = /* @__PURE__ */ new Map();
4924
5065
  scanner = null;
4925
5066
  // Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
4926
- // ConversationCache.open throw). All scanners are then built with
5067
+ // ConversationCache.open throw), or when config.scannerPersistent is false
5068
+ // (test isolation — the scanner's default SQLite index is a single shared
5069
+ // file unscoped by scanProfiles). All scanners are then built with
4927
5070
  // persistent: false so requests serve from disk instead of 500ing on
4928
5071
  // every touch of the scanner's own SQLite index.
4929
5072
  scannerPersistenceDisabled = false;
@@ -4964,6 +5107,7 @@ var StreamerServer = class {
4964
5107
  sessionInputAttempts = /* @__PURE__ */ new Map();
4965
5108
  ptyGracePeriodMs;
4966
5109
  defaultSystemPrompt;
5110
+ defaultPermissionMode;
4967
5111
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
4968
5112
  ptyGraceTimers = /* @__PURE__ */ new Map();
4969
5113
  // Map of sessionId → set of subscribed WS clients
@@ -5007,10 +5151,12 @@ var StreamerServer = class {
5007
5151
  }
5008
5152
  this.verbose = config.verbose ?? false;
5009
5153
  this.disableDb = config.disableDb ?? false;
5154
+ this.scannerPersistenceDisabled = config.scannerPersistent === false;
5010
5155
  this.scanProfiles = config.scanProfiles;
5011
5156
  this.codexRoots = config.codexRoots ?? [(0, import_path13.join)((0, import_os6.homedir)(), ".codex", "sessions")];
5012
5157
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
5013
5158
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5159
+ this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5014
5160
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path13.join)((0, import_os6.homedir)(), ".threadbase", "cache");
5015
5161
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
5016
5162
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -5440,9 +5586,9 @@ var StreamerServer = class {
5440
5586
  );
5441
5587
  this.scannerPersistenceDisabled = true;
5442
5588
  }
5443
- const warmupScanner = this.newScanner();
5444
- this.allScanners.add(warmupScanner);
5445
5589
  const warmupStatCache = this.buildStatCache(null);
5590
+ const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
5591
+ this.allScanners.add(warmupScanner);
5446
5592
  const shouldEmitProgress = createScanProgressThrottle();
5447
5593
  const scanOpts = {
5448
5594
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5882,6 +6028,10 @@ var StreamerServer = class {
5882
6028
  }
5883
6029
  buildStatCache(previousScanner) {
5884
6030
  if (!this.cache) return void 0;
6031
+ if (!previousScanner) {
6032
+ const persisted = this.cache.getScannerStatCache();
6033
+ return persisted.size > 0 ? persisted : void 0;
6034
+ }
5885
6035
  const dbStats = this.cache.getFileStats();
5886
6036
  if (dbStats.size === 0) return void 0;
5887
6037
  const metaByPath = /* @__PURE__ */ new Map();
@@ -5911,9 +6061,9 @@ var StreamerServer = class {
5911
6061
  // this — its per-file refreshFile (in findConversationByUuid) already
5912
6062
  // reconciles the one conversation being requested, so paying a full-tree
5913
6063
  // rescan just because some OTHER file changed is the stall this avoids.
5914
- newScanner() {
6064
+ newScanner(options) {
5915
6065
  return new import_scanner2.ConversationScanner(
5916
- this.scannerPersistenceDisabled ? { persistent: false } : void 0
6066
+ options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
5917
6067
  );
5918
6068
  }
5919
6069
  async getScanner(skipStaleRescan = false) {
@@ -5932,7 +6082,7 @@ var StreamerServer = class {
5932
6082
  }
5933
6083
  this.scannerStale = false;
5934
6084
  const statCache = this.buildStatCache(this.scanner);
5935
- this.scanner = this.newScanner();
6085
+ this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
5936
6086
  this.allScanners.add(this.scanner);
5937
6087
  this.scannerReady = this.scanner.scan({
5938
6088
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5955,9 +6105,8 @@ var StreamerServer = class {
5955
6105
  this.scannerReady = null;
5956
6106
  return this.getScanner();
5957
6107
  }
5958
- // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
5959
- // survive, so classify() still skips unchanged files) and re-run its scan
5960
- // with fullRescan:true — the escape hatch that bypasses the scanner's
6108
+ // refresh=1's scan: reuse the WARM scanner and re-run its scan with
6109
+ // fullRescan:true the escape hatch that bypasses the scanner's
5961
6110
  // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
5962
6111
  // the "don't trust the gate, check disk for real" signal. Unlike
5963
6112
  // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
@@ -6345,7 +6494,8 @@ var StreamerServer = class {
6345
6494
  provider,
6346
6495
  projectPath,
6347
6496
  projectName: body.projectName,
6348
- branch: body.branch
6497
+ branch: body.branch,
6498
+ permissionMode: this.defaultPermissionMode
6349
6499
  });
6350
6500
  this.sessionStore.addManaged(session);
6351
6501
  void this.watchConversationFile(sessionId);
@@ -6669,7 +6819,8 @@ var StreamerServer = class {
6669
6819
  const session = await this.ptyManager.start(convId, {
6670
6820
  projectPath,
6671
6821
  projectName,
6672
- branch
6822
+ branch,
6823
+ permissionMode: this.defaultPermissionMode
6673
6824
  });
6674
6825
  this.sessionStore.addManaged(session);
6675
6826
  void this.watchConversationFile(session.id);
@@ -6739,10 +6890,35 @@ var StreamerServer = class {
6739
6890
  provider,
6740
6891
  projectPath: resolvedPath,
6741
6892
  projectName: body.projectName,
6742
- systemPrompt: systemPromptParts.join("\n")
6893
+ systemPrompt: systemPromptParts.join("\n"),
6894
+ permissionMode: this.defaultPermissionMode
6743
6895
  });
6744
6896
  this.sessionStore.addManaged(session);
6745
- json(res, 202, { id: session.id, status: "pending" });
6897
+ const readyOrFailed = new Promise((resolve2) => {
6898
+ const handler = (status) => {
6899
+ if (status === "waiting_input" || status === "idle") {
6900
+ this.sessionStatusBus.off(`status:${session.id}`, handler);
6901
+ resolve2(status === "waiting_input" ? "ready" : "failed");
6902
+ }
6903
+ };
6904
+ this.sessionStatusBus.on(`status:${session.id}`, handler);
6905
+ });
6906
+ const timeoutPromise = new Promise(
6907
+ (resolve2) => setTimeout(() => resolve2("timeout"), START_READY_TIMEOUT_MS)
6908
+ );
6909
+ const outcome = await Promise.race([readyOrFailed, timeoutPromise]);
6910
+ const current = this.sessionStore.get(session.id, this.ptyAttachedIds());
6911
+ if (outcome === "ready" && current) {
6912
+ json(res, 200, { session: current });
6913
+ } else if (outcome === "failed" && current) {
6914
+ json(res, 502, {
6915
+ id: session.id,
6916
+ status: "idle",
6917
+ error: current.failureReason ?? "Session exited before becoming ready"
6918
+ });
6919
+ } else {
6920
+ json(res, 202, { id: session.id, status: "pending" });
6921
+ }
6746
6922
  if (provider === CODEX_CLI_PROVIDER) {
6747
6923
  this.watchForCodexRollout(session.id, resolvedPath);
6748
6924
  } else {