@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/cli.cjs +478 -145
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +300 -85
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +300 -85
- package/dist/index.js.map +1 -1
- package/dist/migrations/007_add_scanner_warmup_cache.sql +15 -0
- package/dist/seed-claude-config.cjs.map +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
1460
|
-
// Both suppress
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
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
|
|
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,
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
2624
|
+
var DEFAULT_DEV_ORIGINS = [
|
|
2513
2625
|
"http://localhost:8081",
|
|
2514
2626
|
"http://localhost:19006",
|
|
2515
2627
|
"http://localhost:3000"
|
|
2516
|
-
]
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
5962
|
-
//
|
|
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
|
-
|
|
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 {
|