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