@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.js CHANGED
@@ -342,6 +342,16 @@ function loadTailSize() {
342
342
  }
343
343
  return void 0;
344
344
  }
345
+ function loadDefaultPermissionMode() {
346
+ try {
347
+ const content = readFileSync(configFile(), "utf-8");
348
+ const match = content.match(/default_permission_mode:\s*(\S+)/);
349
+ const value = match?.[1]?.trim();
350
+ if (value === "acceptEdits" || value === "manual") return value;
351
+ } catch {
352
+ }
353
+ return void 0;
354
+ }
345
355
  function validatePublicUrl(raw) {
346
356
  let parsed;
347
357
  try {
@@ -1301,6 +1311,37 @@ function detectShellPrompt(lines) {
1301
1311
  return null;
1302
1312
  }
1303
1313
 
1314
+ // src/utils/debounce.ts
1315
+ function debounce(fn, waitMs) {
1316
+ let timer = null;
1317
+ let lastArgs = null;
1318
+ const run2 = () => {
1319
+ timer = null;
1320
+ if (lastArgs) {
1321
+ const args = lastArgs;
1322
+ lastArgs = null;
1323
+ fn(...args);
1324
+ }
1325
+ };
1326
+ const debounced = (...args) => {
1327
+ lastArgs = args;
1328
+ if (timer) clearTimeout(timer);
1329
+ timer = setTimeout(run2, waitMs);
1330
+ };
1331
+ debounced.cancel = () => {
1332
+ if (timer) clearTimeout(timer);
1333
+ timer = null;
1334
+ lastArgs = null;
1335
+ };
1336
+ debounced.flush = () => {
1337
+ if (timer) {
1338
+ clearTimeout(timer);
1339
+ run2();
1340
+ }
1341
+ };
1342
+ return debounced;
1343
+ }
1344
+
1304
1345
  // src/pty-manager.ts
1305
1346
  var OUTPUT_BUFFER_MAX2 = 65536;
1306
1347
  var PTY_COLS2 = 120;
@@ -1308,11 +1349,13 @@ var PTY_ROWS2 = 40;
1308
1349
  var SCREEN_SCROLLBACK2 = 1e3;
1309
1350
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1310
1351
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
1352
+ var QUIET_DETECT_MS = 500;
1311
1353
  function buildPasteBytes(input) {
1312
1354
  return `\x1B[200~${input}\x1B[201~`;
1313
1355
  }
1314
1356
  var SUBMIT_BYTES2 = "\r";
1315
1357
  var SUBMIT_DELAY_MS = 16;
1358
+ var SUBMIT_MAX_WAIT_MS = 500;
1316
1359
  function digestBytes2(s) {
1317
1360
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1318
1361
  if (escaped.length <= 200) return escaped;
@@ -1389,6 +1432,10 @@ var PTYManager = class {
1389
1432
  // to a given input or fell silent. Reset on dispose().
1390
1433
  chunkIndex = /* @__PURE__ */ new Map();
1391
1434
  lastChunkAt = /* @__PURE__ */ new Map();
1435
+ // Per-session debounced "went quiet" checker, re-armed on every chunk. Fires
1436
+ // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
1437
+ // wait for another chunk that may never arrive (Claude blocked on input).
1438
+ quietCheckers = /* @__PURE__ */ new Map();
1392
1439
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1393
1440
  // concurrent resume for the same session (double-tap, client retry) awaits
1394
1441
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -1404,16 +1451,19 @@ var PTYManager = class {
1404
1451
  }
1405
1452
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
1406
1453
  //
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
1454
+ // options.permissionMode defaults to `acceptEdits` rather than
1455
+ // `--dangerously-skip-permissions`/`bypassPermissions`. Both suppress
1456
+ // file-edit prompts, but in an interactive (TUI) launch the skip-permissions
1457
+ // flag renders a blocking "Bypass Permissions mode" warning menu on every
1458
+ // boot that no known ~/.claude.json flag suppressed (as of Claude CLI
1459
+ // v2.1.x) the session never reaches a usable prompt, so the mobile app
1460
+ // shows an empty/stuck screen. `acceptEdits` auto-approves file edits
1413
1461
  // without that warning gate, while still prompting for shell commands.
1462
+ // `manual` (prompt for everything) is the only other mode callers may pass;
1463
+ // `bypassPermissions`/`plan`/`dontAsk`/`auto` are not supported here.
1414
1464
  // (The other first-run gates — onboarding/theme, workspace trust,
1415
1465
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1416
- // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
1466
+ // docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
1417
1467
  async start(sessionId, options) {
1418
1468
  const existing = this.sessions.get(sessionId);
1419
1469
  if (existing) return toPublicSession2(existing);
@@ -1432,7 +1482,7 @@ var PTYManager = class {
1432
1482
  resolveClaudeExe(),
1433
1483
  [
1434
1484
  "--permission-mode",
1435
- "acceptEdits",
1485
+ options.permissionMode ?? "acceptEdits",
1436
1486
  "--settings",
1437
1487
  '{"spinnerTipsEnabled":false}',
1438
1488
  "--resume",
@@ -1481,7 +1531,7 @@ var PTYManager = class {
1481
1531
  const projectName = options.projectName ?? basename2(options.projectPath);
1482
1532
  const args = [
1483
1533
  "--permission-mode",
1484
- "acceptEdits",
1534
+ options.permissionMode ?? "acceptEdits",
1485
1535
  "--settings",
1486
1536
  '{"spinnerTipsEnabled":false}',
1487
1537
  "--session-id",
@@ -1575,9 +1625,20 @@ var PTYManager = class {
1575
1625
  session.promptCount++;
1576
1626
  return session.promptCount;
1577
1627
  }
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.
1628
+ // Two-step paste-then-submit. Writes the bracketed-paste body, then waits
1629
+ // for the PTY to go quiet before writing \r. See buildPasteBytes() for why
1630
+ // the split matters.
1631
+ //
1632
+ // The wait is quiescence-based, not a flat delay: a fixed SUBMIT_DELAY_MS
1633
+ // timer (the original fix) still races a TUI that's mid-redraw of its own
1634
+ // output (e.g. re-painting right after posting a question) when the paste
1635
+ // lands — the timer can elapse and fire \r while the TUI is still busy,
1636
+ // and that \r gets absorbed by the redraw instead of submitting (see
1637
+ // 2026-07 session 14dda340: a "Yes" reply was accepted into the input line
1638
+ // but never landed as a submitted JSONL turn). Polling in SUBMIT_DELAY_MS
1639
+ // steps and only submitting once lastChunkAt hasn't advanced for a full
1640
+ // step gives the TUI as many extra ticks as it needs, capped at
1641
+ // SUBMIT_MAX_WAIT_MS so a silent/wedged PTY still gets its \r eventually.
1581
1642
  writeSubmit(sessionId, session, input, path, promptCount) {
1582
1643
  const pasteBytes = buildPasteBytes(input);
1583
1644
  this.log.info(
@@ -1592,12 +1653,21 @@ var PTYManager = class {
1592
1653
  phase: "paste"
1593
1654
  }
1594
1655
  );
1656
+ const pasteAt = Date.now();
1595
1657
  session.process.write(pasteBytes);
1596
- setTimeout(() => {
1658
+ const trySubmit = () => {
1597
1659
  const current = this.sessions.get(sessionId);
1598
1660
  if (!current || current !== session) return;
1661
+ const now = Date.now();
1662
+ const lastChunk = this.lastChunkAt.get(sessionId) ?? pasteAt;
1663
+ const quiet = now - lastChunk >= SUBMIT_DELAY_MS;
1664
+ const timedOut = now - pasteAt >= SUBMIT_MAX_WAIT_MS;
1665
+ if (!quiet && !timedOut) {
1666
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1667
+ return;
1668
+ }
1599
1669
  this.log.info(
1600
- `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
1670
+ `[pty.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r waitedMs=${now - pasteAt} timedOut=${timedOut}`,
1601
1671
  {
1602
1672
  event: "pty.input_write",
1603
1673
  sessionId,
@@ -1605,11 +1675,14 @@ var PTYManager = class {
1605
1675
  byteLen: SUBMIT_BYTES2.length,
1606
1676
  digest: "\\r",
1607
1677
  path,
1608
- phase: "submit"
1678
+ phase: "submit",
1679
+ waitedMs: now - pasteAt,
1680
+ timedOut
1609
1681
  }
1610
1682
  );
1611
1683
  current.process.write(SUBMIT_BYTES2);
1612
- }, SUBMIT_DELAY_MS);
1684
+ };
1685
+ setTimeout(trySubmit, SUBMIT_DELAY_MS);
1613
1686
  }
1614
1687
  // Drain any inputs that were sent while the session was still pendingReady,
1615
1688
  // writing them in arrival order now that Claude is at its prompt.
@@ -1658,6 +1731,8 @@ var PTYManager = class {
1658
1731
  this.permissionOpen.delete(sessionId);
1659
1732
  this.lastScreenQuestionKey.delete(sessionId);
1660
1733
  this.shellPromptOpen.delete(sessionId);
1734
+ this.quietCheckers.get(sessionId)?.cancel();
1735
+ this.quietCheckers.delete(sessionId);
1661
1736
  try {
1662
1737
  session.process.kill("SIGINT");
1663
1738
  } catch {
@@ -1717,6 +1792,8 @@ var PTYManager = class {
1717
1792
  this.firstChunkAt.clear();
1718
1793
  this.chunkIndex.clear();
1719
1794
  this.lastChunkAt.clear();
1795
+ for (const quiet of this.quietCheckers.values()) quiet.cancel();
1796
+ this.quietCheckers.clear();
1720
1797
  this.permissionOpen.clear();
1721
1798
  this.lastScreenQuestionKey.clear();
1722
1799
  this.shellPromptOpen.clear();
@@ -1770,6 +1847,12 @@ var PTYManager = class {
1770
1847
  err
1771
1848
  });
1772
1849
  });
1850
+ let quiet = this.quietCheckers.get(sessionId);
1851
+ if (!quiet) {
1852
+ quiet = debounce(() => this.handleQuiet(sessionId), QUIET_DETECT_MS);
1853
+ this.quietCheckers.set(sessionId, quiet);
1854
+ }
1855
+ quiet();
1773
1856
  }
1774
1857
  // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
1775
1858
  // menus from the rendered screen, firing the additive callbacks. Async because
@@ -1845,6 +1928,24 @@ var PTYManager = class {
1845
1928
  }
1846
1929
  }
1847
1930
  }
1931
+ // Fired QUIET_DETECT_MS after the last PTY chunk. Re-runs the same
1932
+ // ready/prompt detection handleOutput() runs per-chunk, using the last
1933
+ // rendered output — a session blocked on a prompt (or an unmarked boot
1934
+ // screen) may never produce another chunk to trigger detection otherwise.
1935
+ handleQuiet(sessionId) {
1936
+ const session = this.sessions.get(sessionId);
1937
+ if (session?.status !== "running") return;
1938
+ if (this.pendingReady.has(sessionId)) {
1939
+ this.markReady(sessionId, session, "quiet:timeout");
1940
+ }
1941
+ this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
1942
+ this.log.warn("[pty.prompt_detect] failed", {
1943
+ event: "pty.prompt_detect_failed",
1944
+ sessionId,
1945
+ err
1946
+ });
1947
+ });
1948
+ }
1848
1949
  // Transition a session from "running" to "waiting_input", clear pendingReady,
1849
1950
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
1850
1951
  markReady(sessionId, session, reason) {
@@ -1885,6 +1986,8 @@ var PTYManager = class {
1885
1986
  this.permissionOpen.delete(sessionId);
1886
1987
  this.lastScreenQuestionKey.delete(sessionId);
1887
1988
  this.shellPromptOpen.delete(sessionId);
1989
+ this.quietCheckers.get(sessionId)?.cancel();
1990
+ this.quietCheckers.delete(sessionId);
1888
1991
  }
1889
1992
  };
1890
1993
  function toPublicSession2(s) {
@@ -3186,11 +3289,11 @@ var ConversationCache = class _ConversationCache {
3186
3289
  INSERT INTO conversation_meta
3187
3290
  (id, file_path, project_path, project_name, title, model, account, branch,
3188
3291
  message_count, last_activity, first_message, last_message, preview, updated_at,
3189
- mtime_ms, file_size, provider)
3292
+ mtime_ms, file_size, provider, scanner_meta_json)
3190
3293
  VALUES
3191
3294
  (@id, @file_path, @project_path, @project_name, @title, @model, @account, @branch,
3192
3295
  @message_count, @last_activity, @first_message, @last_message, @preview, @updated_at,
3193
- @mtime_ms, @file_size, @provider)
3296
+ @mtime_ms, @file_size, @provider, @scanner_meta_json)
3194
3297
  ON CONFLICT(id) DO UPDATE SET
3195
3298
  file_path = excluded.file_path,
3196
3299
  project_path = excluded.project_path,
@@ -3210,7 +3313,8 @@ var ConversationCache = class _ConversationCache {
3210
3313
  updated_at = excluded.updated_at,
3211
3314
  mtime_ms = excluded.mtime_ms,
3212
3315
  file_size = excluded.file_size,
3213
- provider = excluded.provider
3316
+ provider = excluded.provider,
3317
+ scanner_meta_json = excluded.scanner_meta_json
3214
3318
  WHERE conversation_meta.updated_at < excluded.updated_at
3215
3319
  `),
3216
3320
  getTail: db.prepare("SELECT * FROM conversation_tail WHERE conversation_id = ?"),
@@ -3247,6 +3351,27 @@ var ConversationCache = class _ConversationCache {
3247
3351
  allFileStats: db.prepare(
3248
3352
  "SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
3249
3353
  ),
3354
+ allScannerStatCacheRows: db.prepare(
3355
+ "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"
3356
+ ),
3357
+ updateScannerCache: db.prepare(
3358
+ "UPDATE conversation_meta SET mtime_ms = ?, file_size = ?, scanner_meta_json = ? WHERE id = ?"
3359
+ ),
3360
+ getFileMetadata: db.prepare(
3361
+ "SELECT mtime_ms, file_size, is_agent, agent_entrypoints_key FROM conversation_file_metadata WHERE file_path = ?"
3362
+ ),
3363
+ upsertFileMetadata: db.prepare(`
3364
+ INSERT INTO conversation_file_metadata
3365
+ (file_path, mtime_ms, file_size, is_agent, agent_entrypoints_key, updated_at)
3366
+ VALUES
3367
+ (@file_path, @mtime_ms, @file_size, @is_agent, @agent_entrypoints_key, @updated_at)
3368
+ ON CONFLICT(file_path) DO UPDATE SET
3369
+ mtime_ms = excluded.mtime_ms,
3370
+ file_size = excluded.file_size,
3371
+ is_agent = excluded.is_agent,
3372
+ agent_entrypoints_key = excluded.agent_entrypoints_key,
3373
+ updated_at = excluded.updated_at
3374
+ `),
3250
3375
  upsertSessionName: db.prepare(`
3251
3376
  INSERT INTO session_names (session_id, name, updated_at)
3252
3377
  VALUES (?, ?, ?)
@@ -3287,8 +3412,35 @@ var ConversationCache = class _ConversationCache {
3287
3412
  getDatabase() {
3288
3413
  return this.db;
3289
3414
  }
3290
- getAgentEntrypoints() {
3291
- return this.agentEntrypoints;
3415
+ agentEntrypointsKey() {
3416
+ return [...this.agentEntrypoints].sort().join(",");
3417
+ }
3418
+ classifyAgentFile(filePath, mtimeMs, fileSize) {
3419
+ if (this.agentEntrypoints.size === 0) return false;
3420
+ const entrypointsKey = this.agentEntrypointsKey();
3421
+ const cached2 = this.stmts.getFileMetadata.get(filePath);
3422
+ if (cached2 && cached2.mtime_ms === mtimeMs && cached2.file_size === fileSize && cached2.agent_entrypoints_key === entrypointsKey) {
3423
+ return cached2.is_agent === 1;
3424
+ }
3425
+ const isAgent = isAgentFile(filePath, this.agentEntrypoints);
3426
+ this.stmts.upsertFileMetadata.run({
3427
+ file_path: filePath,
3428
+ mtime_ms: mtimeMs,
3429
+ file_size: fileSize,
3430
+ is_agent: isAgent ? 1 : 0,
3431
+ agent_entrypoints_key: entrypointsKey,
3432
+ updated_at: Date.now()
3433
+ });
3434
+ return isAgent;
3435
+ }
3436
+ isAgentFileCached(filePath) {
3437
+ let s;
3438
+ try {
3439
+ s = statSync2(filePath);
3440
+ } catch {
3441
+ return false;
3442
+ }
3443
+ return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
3292
3444
  }
3293
3445
  static open(dbPath, tailSize = 10, migrationsDir, options) {
3294
3446
  mkdirSync2(dirname6(dbPath), { recursive: true });
@@ -3464,11 +3616,9 @@ var ConversationCache = class _ConversationCache {
3464
3616
  // in conversation-cache.test.ts).
3465
3617
  upsertFromScannerMeta(metas) {
3466
3618
  const filter = this.filterAgentConversations;
3467
- const entrypoints = this.agentEntrypoints;
3468
3619
  const upsertedIds = [];
3469
3620
  const run2 = this.db.transaction((items) => {
3470
3621
  for (const m of items) {
3471
- if (filter && isAgentFile(m.filePath, entrypoints)) continue;
3472
3622
  const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
3473
3623
  const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
3474
3624
  let mtimeMs = null;
@@ -3480,6 +3630,10 @@ var ConversationCache = class _ConversationCache {
3480
3630
  } catch {
3481
3631
  }
3482
3632
  const seq = ++this.tailSeq;
3633
+ if (filter && mtimeMs !== null && fileSize !== null && this.classifyAgentFile(m.filePath, mtimeMs, fileSize)) {
3634
+ continue;
3635
+ }
3636
+ const scannerMetaJson = JSON.stringify(m);
3483
3637
  this.stmts.upsertFull.run({
3484
3638
  id,
3485
3639
  file_path: m.filePath,
@@ -3497,8 +3651,10 @@ var ConversationCache = class _ConversationCache {
3497
3651
  updated_at: seq,
3498
3652
  mtime_ms: mtimeMs,
3499
3653
  file_size: fileSize,
3500
- provider: m.provider ?? CLAUDE_CODE_PROVIDER
3654
+ provider: m.provider ?? CLAUDE_CODE_PROVIDER,
3655
+ scanner_meta_json: scannerMetaJson
3501
3656
  });
3657
+ this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
3502
3658
  if (this.fileIndexLoaded) this.fileIndex.set(m.filePath, id);
3503
3659
  upsertedIds.push(id);
3504
3660
  }
@@ -3622,6 +3778,21 @@ var ConversationCache = class _ConversationCache {
3622
3778
  }
3623
3779
  return map;
3624
3780
  }
3781
+ getScannerStatCache() {
3782
+ const rows = this.stmts.allScannerStatCacheRows.all();
3783
+ const map = /* @__PURE__ */ new Map();
3784
+ for (const r of rows) {
3785
+ try {
3786
+ const meta = JSON.parse(r.scanner_meta_json);
3787
+ map.set(r.file_path, {
3788
+ stat: { mtimeMs: r.mtime_ms, size: r.file_size },
3789
+ meta
3790
+ });
3791
+ } catch {
3792
+ }
3793
+ }
3794
+ return map;
3795
+ }
3625
3796
  getMetaById(id) {
3626
3797
  const row = this.stmts.getFullById.get(id);
3627
3798
  if (!row) return null;
@@ -4288,7 +4459,7 @@ function pruneAgentConversations(cache) {
4288
4459
  missing += 1;
4289
4460
  continue;
4290
4461
  }
4291
- if (isAgentFile(row.file_path, cache.getAgentEntrypoints())) {
4462
+ if (cache.isAgentFileCached(row.file_path)) {
4292
4463
  cache.deleteByFilePath(row.file_path);
4293
4464
  pruned += 1;
4294
4465
  }
@@ -4682,37 +4853,6 @@ function computeConversationEtag({
4682
4853
  return `"${digest}"`;
4683
4854
  }
4684
4855
 
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
4856
  // src/utils/dates.ts
4717
4857
  import { compareDesc, isValid, parseISO } from "date-fns";
4718
4858
  function parseIsoDateOrNull(value) {
@@ -4859,6 +4999,7 @@ var WSHub = class {
4859
4999
  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
5000
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
4861
5001
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5002
+ var START_READY_TIMEOUT_MS = 15e3;
4862
5003
  function parseIncludeAgentsEnv(raw) {
4863
5004
  if (raw === void 0) return false;
4864
5005
  const v = raw.trim().toLowerCase();
@@ -4884,7 +5025,9 @@ var StreamerServer = class {
4884
5025
  pendingPermission = /* @__PURE__ */ new Map();
4885
5026
  scanner = null;
4886
5027
  // Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
4887
- // ConversationCache.open throw). All scanners are then built with
5028
+ // ConversationCache.open throw), or when config.scannerPersistent is false
5029
+ // (test isolation — the scanner's default SQLite index is a single shared
5030
+ // file unscoped by scanProfiles). All scanners are then built with
4888
5031
  // persistent: false so requests serve from disk instead of 500ing on
4889
5032
  // every touch of the scanner's own SQLite index.
4890
5033
  scannerPersistenceDisabled = false;
@@ -4925,6 +5068,7 @@ var StreamerServer = class {
4925
5068
  sessionInputAttempts = /* @__PURE__ */ new Map();
4926
5069
  ptyGracePeriodMs;
4927
5070
  defaultSystemPrompt;
5071
+ defaultPermissionMode;
4928
5072
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
4929
5073
  ptyGraceTimers = /* @__PURE__ */ new Map();
4930
5074
  // Map of sessionId → set of subscribed WS clients
@@ -4968,10 +5112,12 @@ var StreamerServer = class {
4968
5112
  }
4969
5113
  this.verbose = config.verbose ?? false;
4970
5114
  this.disableDb = config.disableDb ?? false;
5115
+ this.scannerPersistenceDisabled = config.scannerPersistent === false;
4971
5116
  this.scanProfiles = config.scanProfiles;
4972
5117
  this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
4973
5118
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4974
5119
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5120
+ this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
4975
5121
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
4976
5122
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4977
5123
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -5401,9 +5547,9 @@ var StreamerServer = class {
5401
5547
  );
5402
5548
  this.scannerPersistenceDisabled = true;
5403
5549
  }
5404
- const warmupScanner = this.newScanner();
5405
- this.allScanners.add(warmupScanner);
5406
5550
  const warmupStatCache = this.buildStatCache(null);
5551
+ const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
5552
+ this.allScanners.add(warmupScanner);
5407
5553
  const shouldEmitProgress = createScanProgressThrottle();
5408
5554
  const scanOpts = {
5409
5555
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5843,6 +5989,10 @@ var StreamerServer = class {
5843
5989
  }
5844
5990
  buildStatCache(previousScanner) {
5845
5991
  if (!this.cache) return void 0;
5992
+ if (!previousScanner) {
5993
+ const persisted = this.cache.getScannerStatCache();
5994
+ return persisted.size > 0 ? persisted : void 0;
5995
+ }
5846
5996
  const dbStats = this.cache.getFileStats();
5847
5997
  if (dbStats.size === 0) return void 0;
5848
5998
  const metaByPath = /* @__PURE__ */ new Map();
@@ -5872,9 +6022,9 @@ var StreamerServer = class {
5872
6022
  // this — its per-file refreshFile (in findConversationByUuid) already
5873
6023
  // reconciles the one conversation being requested, so paying a full-tree
5874
6024
  // rescan just because some OTHER file changed is the stall this avoids.
5875
- newScanner() {
6025
+ newScanner(options) {
5876
6026
  return new ConversationScanner(
5877
- this.scannerPersistenceDisabled ? { persistent: false } : void 0
6027
+ options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
5878
6028
  );
5879
6029
  }
5880
6030
  async getScanner(skipStaleRescan = false) {
@@ -5893,7 +6043,7 @@ var StreamerServer = class {
5893
6043
  }
5894
6044
  this.scannerStale = false;
5895
6045
  const statCache = this.buildStatCache(this.scanner);
5896
- this.scanner = this.newScanner();
6046
+ this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
5897
6047
  this.allScanners.add(this.scanner);
5898
6048
  this.scannerReady = this.scanner.scan({
5899
6049
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
@@ -5916,9 +6066,8 @@ var StreamerServer = class {
5916
6066
  this.scannerReady = null;
5917
6067
  return this.getScanner();
5918
6068
  }
5919
- // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
5920
- // survive, so classify() still skips unchanged files) and re-run its scan
5921
- // with fullRescan:true — the escape hatch that bypasses the scanner's
6069
+ // refresh=1's scan: reuse the WARM scanner and re-run its scan with
6070
+ // fullRescan:true the escape hatch that bypasses the scanner's
5922
6071
  // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
5923
6072
  // the "don't trust the gate, check disk for real" signal. Unlike
5924
6073
  // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
@@ -6306,7 +6455,8 @@ var StreamerServer = class {
6306
6455
  provider,
6307
6456
  projectPath,
6308
6457
  projectName: body.projectName,
6309
- branch: body.branch
6458
+ branch: body.branch,
6459
+ permissionMode: this.defaultPermissionMode
6310
6460
  });
6311
6461
  this.sessionStore.addManaged(session);
6312
6462
  void this.watchConversationFile(sessionId);
@@ -6630,7 +6780,8 @@ var StreamerServer = class {
6630
6780
  const session = await this.ptyManager.start(convId, {
6631
6781
  projectPath,
6632
6782
  projectName,
6633
- branch
6783
+ branch,
6784
+ permissionMode: this.defaultPermissionMode
6634
6785
  });
6635
6786
  this.sessionStore.addManaged(session);
6636
6787
  void this.watchConversationFile(session.id);
@@ -6700,10 +6851,35 @@ var StreamerServer = class {
6700
6851
  provider,
6701
6852
  projectPath: resolvedPath,
6702
6853
  projectName: body.projectName,
6703
- systemPrompt: systemPromptParts.join("\n")
6854
+ systemPrompt: systemPromptParts.join("\n"),
6855
+ permissionMode: this.defaultPermissionMode
6704
6856
  });
6705
6857
  this.sessionStore.addManaged(session);
6706
- json(res, 202, { id: session.id, status: "pending" });
6858
+ const readyOrFailed = new Promise((resolve2) => {
6859
+ const handler = (status) => {
6860
+ if (status === "waiting_input" || status === "idle") {
6861
+ this.sessionStatusBus.off(`status:${session.id}`, handler);
6862
+ resolve2(status === "waiting_input" ? "ready" : "failed");
6863
+ }
6864
+ };
6865
+ this.sessionStatusBus.on(`status:${session.id}`, handler);
6866
+ });
6867
+ const timeoutPromise = new Promise(
6868
+ (resolve2) => setTimeout(() => resolve2("timeout"), START_READY_TIMEOUT_MS)
6869
+ );
6870
+ const outcome = await Promise.race([readyOrFailed, timeoutPromise]);
6871
+ const current = this.sessionStore.get(session.id, this.ptyAttachedIds());
6872
+ if (outcome === "ready" && current) {
6873
+ json(res, 200, { session: current });
6874
+ } else if (outcome === "failed" && current) {
6875
+ json(res, 502, {
6876
+ id: session.id,
6877
+ status: "idle",
6878
+ error: current.failureReason ?? "Session exited before becoming ready"
6879
+ });
6880
+ } else {
6881
+ json(res, 202, { id: session.id, status: "pending" });
6882
+ }
6707
6883
  if (provider === CODEX_CLI_PROVIDER) {
6708
6884
  this.watchForCodexRollout(session.id, resolvedPath);
6709
6885
  } else {