@threadbase-sh/streamer 1.32.0 → 1.34.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 +2042 -2606
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1264 -205
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +242 -11
- package/dist/index.d.ts +242 -11
- package/dist/index.js +1259 -200
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -1783,6 +1783,10 @@ var PTYManager = class {
|
|
|
1783
1783
|
options.permissionMode ?? "acceptEdits",
|
|
1784
1784
|
"--settings",
|
|
1785
1785
|
'{"spinnerTipsEnabled":false}',
|
|
1786
|
+
"--model",
|
|
1787
|
+
options.model ?? "sonnet",
|
|
1788
|
+
"--effort",
|
|
1789
|
+
options.effort ?? "low",
|
|
1786
1790
|
"--resume",
|
|
1787
1791
|
sessionId
|
|
1788
1792
|
],
|
|
@@ -1833,6 +1837,10 @@ var PTYManager = class {
|
|
|
1833
1837
|
options.permissionMode ?? "acceptEdits",
|
|
1834
1838
|
"--settings",
|
|
1835
1839
|
'{"spinnerTipsEnabled":false}',
|
|
1840
|
+
"--model",
|
|
1841
|
+
options.model ?? "sonnet",
|
|
1842
|
+
"--effort",
|
|
1843
|
+
options.effort ?? "low",
|
|
1836
1844
|
"--session-id",
|
|
1837
1845
|
sessionId
|
|
1838
1846
|
];
|
|
@@ -2252,6 +2260,14 @@ var PTYManager = class {
|
|
|
2252
2260
|
if (session?.status !== "running") return;
|
|
2253
2261
|
if (this.pendingReady.has(sessionId)) {
|
|
2254
2262
|
this.markReady(sessionId, session, "quiet:timeout");
|
|
2263
|
+
} else {
|
|
2264
|
+
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2265
|
+
this.log.warn("[pty.ready] screen recheck failed", {
|
|
2266
|
+
event: "pty.ready_recheck_failed",
|
|
2267
|
+
sessionId,
|
|
2268
|
+
err
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2255
2271
|
}
|
|
2256
2272
|
this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
|
|
2257
2273
|
this.log.warn("[pty.prompt_detect] failed", {
|
|
@@ -2261,6 +2277,20 @@ var PTYManager = class {
|
|
|
2261
2277
|
});
|
|
2262
2278
|
});
|
|
2263
2279
|
}
|
|
2280
|
+
// Re-check the rendered screen (not just the last chunk) for a prompt
|
|
2281
|
+
// marker. Only meaningful once pendingReady is already clear — the boot
|
|
2282
|
+
// fallback above covers the first prompt after spawn/resume. Scoped to a
|
|
2283
|
+
// full viewport (PTY_ROWS), not just the last few lines: "on screen" means
|
|
2284
|
+
// whatever a user attached to this PTY would currently see.
|
|
2285
|
+
async recheckReadyFromScreen(sessionId) {
|
|
2286
|
+
const session = this.sessions.get(sessionId);
|
|
2287
|
+
if (session?.status !== "running") return;
|
|
2288
|
+
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2289
|
+
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2290
|
+
if (matchedMarker && session.status === "running") {
|
|
2291
|
+
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2264
2294
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2265
2295
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2266
2296
|
markReady(sessionId, session, reason) {
|
|
@@ -2433,6 +2463,44 @@ async function discoverClaudeProcesses() {
|
|
|
2433
2463
|
if ((0, import_os4.platform)() === "win32") return discoverWindows();
|
|
2434
2464
|
return discoverUnix();
|
|
2435
2465
|
}
|
|
2466
|
+
var CLAUDE_CLI_SCRIPT = /claude-code[\\/](?:cli|index)\.(?:js|mjs|cjs)$/i;
|
|
2467
|
+
var JS_RUNTIMES = /* @__PURE__ */ new Set(["node", "node.exe", "bun", "bun.exe", "deno", "deno.exe"]);
|
|
2468
|
+
function tokenizeCommandLine(commandLine) {
|
|
2469
|
+
const tokens = [];
|
|
2470
|
+
let current = "";
|
|
2471
|
+
let quoted = false;
|
|
2472
|
+
for (const ch of commandLine) {
|
|
2473
|
+
if (ch === '"') {
|
|
2474
|
+
quoted = !quoted;
|
|
2475
|
+
continue;
|
|
2476
|
+
}
|
|
2477
|
+
if (!quoted && (ch === " " || ch === " ")) {
|
|
2478
|
+
if (current) tokens.push(current);
|
|
2479
|
+
current = "";
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
current += ch;
|
|
2483
|
+
}
|
|
2484
|
+
if (current) tokens.push(current);
|
|
2485
|
+
return tokens;
|
|
2486
|
+
}
|
|
2487
|
+
function exeBaseName(token) {
|
|
2488
|
+
const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
|
|
2489
|
+
return cut === -1 ? token : token.slice(cut + 1);
|
|
2490
|
+
}
|
|
2491
|
+
function looksLikeClaudeProcess(commandLine) {
|
|
2492
|
+
const tokens = tokenizeCommandLine(commandLine);
|
|
2493
|
+
if (tokens.length === 0) return false;
|
|
2494
|
+
const exe = exeBaseName(tokens[0]).toLowerCase();
|
|
2495
|
+
if (exe === "claude" || exe === "claude.exe") return true;
|
|
2496
|
+
if (JS_RUNTIMES.has(exe)) {
|
|
2497
|
+
for (const raw of tokens.slice(1)) {
|
|
2498
|
+
if (raw.startsWith("-")) continue;
|
|
2499
|
+
return CLAUDE_CLI_SCRIPT.test(raw);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2436
2504
|
async function discoverUnix() {
|
|
2437
2505
|
const pids = await getPidsUnix();
|
|
2438
2506
|
const results = await Promise.all(
|
|
@@ -2460,6 +2528,8 @@ async function discoverUnix() {
|
|
|
2460
2528
|
return results.filter((r) => r !== null);
|
|
2461
2529
|
}
|
|
2462
2530
|
async function discoverWindows() {
|
|
2531
|
+
const viaCim = await discoverWindowsViaCim();
|
|
2532
|
+
if (viaCim) return viaCim;
|
|
2463
2533
|
const pids = await getPidsWindows();
|
|
2464
2534
|
const results = await Promise.all(
|
|
2465
2535
|
pids.map(async (pid) => {
|
|
@@ -2494,7 +2564,24 @@ function run(cmd, args, opts = {}) {
|
|
|
2494
2564
|
);
|
|
2495
2565
|
});
|
|
2496
2566
|
}
|
|
2567
|
+
function parsePsOutput(stdout) {
|
|
2568
|
+
const pids = [];
|
|
2569
|
+
for (const line of stdout.split("\n")) {
|
|
2570
|
+
const trimmed = line.trim();
|
|
2571
|
+
if (!trimmed) continue;
|
|
2572
|
+
const match = trimmed.match(/^(\d+)\s+(.*)$/);
|
|
2573
|
+
if (!match) continue;
|
|
2574
|
+
const pid = Number.parseInt(match[1], 10);
|
|
2575
|
+
if (!(pid > 0)) continue;
|
|
2576
|
+
if (looksLikeClaudeProcess(match[2])) pids.push(pid);
|
|
2577
|
+
}
|
|
2578
|
+
return pids;
|
|
2579
|
+
}
|
|
2497
2580
|
async function getPidsUnix() {
|
|
2581
|
+
try {
|
|
2582
|
+
return parsePsOutput(await run("ps", ["-eo", "pid=,args="]));
|
|
2583
|
+
} catch {
|
|
2584
|
+
}
|
|
2498
2585
|
try {
|
|
2499
2586
|
const output = await run("pgrep", ["-x", "claude"]);
|
|
2500
2587
|
return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
|
|
@@ -2515,6 +2602,54 @@ async function getProcessStartTimeUnix(pid) {
|
|
|
2515
2602
|
const d = new Date(raw);
|
|
2516
2603
|
return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
|
|
2517
2604
|
}
|
|
2605
|
+
function parseCimProcesses(stdout) {
|
|
2606
|
+
const trimmed = stdout.trim();
|
|
2607
|
+
if (!trimmed) return [];
|
|
2608
|
+
const parsed = JSON.parse(trimmed);
|
|
2609
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
2610
|
+
}
|
|
2611
|
+
function parseCimDate(value) {
|
|
2612
|
+
if (value) {
|
|
2613
|
+
const epoch = value.match(/\/Date\((\d+)\)\//);
|
|
2614
|
+
if (epoch) return new Date(Number(epoch[1]));
|
|
2615
|
+
const d = new Date(value);
|
|
2616
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
2617
|
+
}
|
|
2618
|
+
return /* @__PURE__ */ new Date();
|
|
2619
|
+
}
|
|
2620
|
+
async function discoverWindowsViaCim() {
|
|
2621
|
+
let stdout;
|
|
2622
|
+
try {
|
|
2623
|
+
stdout = await run("powershell.exe", [
|
|
2624
|
+
"-NoProfile",
|
|
2625
|
+
"-NonInteractive",
|
|
2626
|
+
"-Command",
|
|
2627
|
+
"Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
|
|
2628
|
+
]);
|
|
2629
|
+
} catch {
|
|
2630
|
+
return null;
|
|
2631
|
+
}
|
|
2632
|
+
let rows;
|
|
2633
|
+
try {
|
|
2634
|
+
rows = parseCimProcesses(stdout);
|
|
2635
|
+
} catch {
|
|
2636
|
+
return null;
|
|
2637
|
+
}
|
|
2638
|
+
const results = [];
|
|
2639
|
+
for (const row of rows) {
|
|
2640
|
+
const commandLine = row.CommandLine ?? "";
|
|
2641
|
+
if (!commandLine || !looksLikeClaudeProcess(commandLine)) continue;
|
|
2642
|
+
results.push({
|
|
2643
|
+
pid: row.ProcessId,
|
|
2644
|
+
projectPath: "",
|
|
2645
|
+
projectName: "",
|
|
2646
|
+
branch: "",
|
|
2647
|
+
conversationId: extractResumeId(commandLine),
|
|
2648
|
+
startedAt: parseCimDate(row.CreationDate)
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
return results;
|
|
2652
|
+
}
|
|
2518
2653
|
async function getPidsWindows() {
|
|
2519
2654
|
try {
|
|
2520
2655
|
const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
|
|
@@ -2557,10 +2692,15 @@ async function getProcessInfoWindows(pid) {
|
|
|
2557
2692
|
}
|
|
2558
2693
|
}
|
|
2559
2694
|
function extractResumeId(args) {
|
|
2560
|
-
const
|
|
2561
|
-
|
|
2695
|
+
const eq = args.match(/(?:--resume|-r)=(\S+)/);
|
|
2696
|
+
if (eq?.[1] && !eq[1].startsWith("-")) return eq[1];
|
|
2697
|
+
const spaced = args.match(/(?:--resume|-r)\s+(\S+)/);
|
|
2698
|
+
const candidate = spaced?.[1];
|
|
2699
|
+
if (!candidate || candidate.startsWith("-")) return null;
|
|
2700
|
+
return candidate;
|
|
2562
2701
|
}
|
|
2563
2702
|
async function readGitBranch(dir) {
|
|
2703
|
+
if (!dir) return "";
|
|
2564
2704
|
try {
|
|
2565
2705
|
return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
|
|
2566
2706
|
} catch {
|
|
@@ -2573,11 +2713,11 @@ var import_node_ws = require("@hono/node-ws");
|
|
|
2573
2713
|
var import_client = require("@temporalio/client");
|
|
2574
2714
|
var import_scanner3 = require("@threadbase-sh/scanner");
|
|
2575
2715
|
var import_events = require("events");
|
|
2576
|
-
var
|
|
2716
|
+
var import_fs17 = require("fs");
|
|
2577
2717
|
var import_promises7 = require("fs/promises");
|
|
2578
2718
|
var import_http = require("http");
|
|
2579
|
-
var
|
|
2580
|
-
var
|
|
2719
|
+
var import_os8 = require("os");
|
|
2720
|
+
var import_path17 = require("path");
|
|
2581
2721
|
var import_readline = require("readline");
|
|
2582
2722
|
|
|
2583
2723
|
// node_modules/nanoid/index.js
|
|
@@ -2836,7 +2976,7 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2836
2976
|
}
|
|
2837
2977
|
|
|
2838
2978
|
// src/api/app.ts
|
|
2839
|
-
var
|
|
2979
|
+
var import_hono13 = require("hono");
|
|
2840
2980
|
|
|
2841
2981
|
// src/api/middleware/auth.middleware.ts
|
|
2842
2982
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2958,12 +3098,72 @@ var createBrowseRoutes = (deps) => {
|
|
|
2958
3098
|
return app;
|
|
2959
3099
|
};
|
|
2960
3100
|
|
|
2961
|
-
// src/api/routes/
|
|
3101
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
2962
3102
|
var import_hono3 = require("hono");
|
|
3103
|
+
|
|
3104
|
+
// src/schemas/cacheAlert.schema.ts
|
|
3105
|
+
var import_zod = require("zod");
|
|
3106
|
+
var ResolveCacheAlertSchema = import_zod.z.object({
|
|
3107
|
+
fingerprint: import_zod.z.string(),
|
|
3108
|
+
action: import_zod.z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
|
|
3109
|
+
ids: import_zod.z.array(import_zod.z.string()).optional()
|
|
3110
|
+
}).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
|
|
3111
|
+
message: "prune_selected requires a non-empty ids array",
|
|
3112
|
+
path: ["ids"]
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
3116
|
+
function readRawBody2(req) {
|
|
3117
|
+
return new Promise((resolve2, reject) => {
|
|
3118
|
+
const chunks = [];
|
|
3119
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3120
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3121
|
+
req.on("error", reject);
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
var createCacheAlertRoutes = (deps) => {
|
|
3125
|
+
const app = new import_hono3.Hono();
|
|
3126
|
+
app.get("/", (c) => {
|
|
3127
|
+
const monitor = deps.cacheMonitor();
|
|
3128
|
+
return c.json({ pending: monitor?.pending ?? null });
|
|
3129
|
+
});
|
|
3130
|
+
app.post("/resolve", async (c) => {
|
|
3131
|
+
let body;
|
|
3132
|
+
try {
|
|
3133
|
+
const incoming = c.env?.incoming;
|
|
3134
|
+
const raw = incoming ? await readRawBody2(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3135
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3136
|
+
} catch {
|
|
3137
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3138
|
+
}
|
|
3139
|
+
const parsed = ResolveCacheAlertSchema.safeParse(body);
|
|
3140
|
+
if (!parsed.success) {
|
|
3141
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3142
|
+
}
|
|
3143
|
+
const monitor = deps.cacheMonitor();
|
|
3144
|
+
if (!monitor) return c.json({ ok: true, alreadyResolved: true });
|
|
3145
|
+
const { fingerprint, action, ids } = parsed.data;
|
|
3146
|
+
const result = await monitor.resolve(fingerprint, action, ids);
|
|
3147
|
+
if ("conflict" in result) {
|
|
3148
|
+
return c.json(
|
|
3149
|
+
{ error: "fingerprint_mismatch", currentFingerprint: result.currentFingerprint },
|
|
3150
|
+
409
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
if ("alreadyResolved" in result) {
|
|
3154
|
+
return c.json({ ok: true, alreadyResolved: true });
|
|
3155
|
+
}
|
|
3156
|
+
return c.json(result);
|
|
3157
|
+
});
|
|
3158
|
+
return app;
|
|
3159
|
+
};
|
|
3160
|
+
|
|
3161
|
+
// src/api/routes/conversations.routes.ts
|
|
3162
|
+
var import_hono4 = require("hono");
|
|
2963
3163
|
var ALREADY_HANDLED2 = 597;
|
|
2964
3164
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
2965
3165
|
var createConversationRoutes = (deps) => {
|
|
2966
|
-
const app = new
|
|
3166
|
+
const app = new import_hono4.Hono();
|
|
2967
3167
|
app.get("/count", async (c) => {
|
|
2968
3168
|
const url = new URL(c.req.url);
|
|
2969
3169
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -2990,7 +3190,7 @@ var createConversationRoutes = (deps) => {
|
|
|
2990
3190
|
};
|
|
2991
3191
|
|
|
2992
3192
|
// src/api/routes/health.routes.ts
|
|
2993
|
-
var
|
|
3193
|
+
var import_hono5 = require("hono");
|
|
2994
3194
|
|
|
2995
3195
|
// src/version.ts
|
|
2996
3196
|
var import_node_fs2 = require("fs");
|
|
@@ -3026,16 +3226,19 @@ function resolveVersion() {
|
|
|
3026
3226
|
}
|
|
3027
3227
|
|
|
3028
3228
|
// src/api/routes/health.routes.ts
|
|
3029
|
-
var createHealthRoutes = () => {
|
|
3030
|
-
const app = new
|
|
3031
|
-
app.get("/", (c) =>
|
|
3229
|
+
var createHealthRoutes = (deps) => {
|
|
3230
|
+
const app = new import_hono5.Hono();
|
|
3231
|
+
app.get("/", (c) => {
|
|
3232
|
+
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3233
|
+
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
3234
|
+
});
|
|
3032
3235
|
return app;
|
|
3033
3236
|
};
|
|
3034
3237
|
|
|
3035
3238
|
// src/api/routes/logs.routes.ts
|
|
3036
3239
|
var import_node_fs3 = require("fs");
|
|
3037
3240
|
var import_node_path5 = require("path");
|
|
3038
|
-
var
|
|
3241
|
+
var import_hono6 = require("hono");
|
|
3039
3242
|
|
|
3040
3243
|
// src/lifecycle/constants.ts
|
|
3041
3244
|
var import_node_os = require("os");
|
|
@@ -3093,7 +3296,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3093
3296
|
}
|
|
3094
3297
|
}
|
|
3095
3298
|
function createLogsRoutes() {
|
|
3096
|
-
const app = new
|
|
3299
|
+
const app = new import_hono6.Hono();
|
|
3097
3300
|
app.get("/", (c) => {
|
|
3098
3301
|
try {
|
|
3099
3302
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3164,7 +3367,7 @@ function createLogsRoutes() {
|
|
|
3164
3367
|
// src/api/routes/misc.routes.ts
|
|
3165
3368
|
var import_node_child_process = require("child_process");
|
|
3166
3369
|
var import_node_crypto2 = require("crypto");
|
|
3167
|
-
var
|
|
3370
|
+
var import_hono7 = require("hono");
|
|
3168
3371
|
var import_os5 = require("os");
|
|
3169
3372
|
|
|
3170
3373
|
// src/config/update-config.ts
|
|
@@ -3174,15 +3377,15 @@ var import_node_path6 = require("path");
|
|
|
3174
3377
|
var import_yaml = require("yaml");
|
|
3175
3378
|
|
|
3176
3379
|
// src/schemas/updateConfig.schema.ts
|
|
3177
|
-
var
|
|
3178
|
-
var UpdateConfigSchema =
|
|
3179
|
-
auto_update:
|
|
3180
|
-
channel:
|
|
3181
|
-
allow:
|
|
3182
|
-
poll_interval_minutes:
|
|
3183
|
-
defer_if_active_sessions:
|
|
3184
|
-
github_repo:
|
|
3185
|
-
webhook_secret:
|
|
3380
|
+
var import_zod2 = require("zod");
|
|
3381
|
+
var UpdateConfigSchema = import_zod2.z.object({
|
|
3382
|
+
auto_update: import_zod2.z.boolean().default(false),
|
|
3383
|
+
channel: import_zod2.z.enum(["stable", "next"]).default("stable"),
|
|
3384
|
+
allow: import_zod2.z.array(import_zod2.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
3385
|
+
poll_interval_minutes: import_zod2.z.number().int().min(0).default(1440),
|
|
3386
|
+
defer_if_active_sessions: import_zod2.z.boolean().default(true),
|
|
3387
|
+
github_repo: import_zod2.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
3388
|
+
webhook_secret: import_zod2.z.string().min(1).nullable().default(null)
|
|
3186
3389
|
}).strict();
|
|
3187
3390
|
|
|
3188
3391
|
// src/config/update-config.ts
|
|
@@ -3219,7 +3422,7 @@ function readJsonBody(req) {
|
|
|
3219
3422
|
req.on("error", reject);
|
|
3220
3423
|
});
|
|
3221
3424
|
}
|
|
3222
|
-
function
|
|
3425
|
+
function readRawBody3(req) {
|
|
3223
3426
|
return new Promise((resolve2, reject) => {
|
|
3224
3427
|
const chunks = [];
|
|
3225
3428
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3238,7 +3441,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3238
3441
|
}
|
|
3239
3442
|
var clientLog = getLogger("client");
|
|
3240
3443
|
var createMiscRoutes = (deps) => {
|
|
3241
|
-
const app = new
|
|
3444
|
+
const app = new import_hono7.Hono();
|
|
3242
3445
|
app.get("/api/info", (c) => {
|
|
3243
3446
|
const ptyIds = deps.ptyAttachedIds();
|
|
3244
3447
|
return c.json({
|
|
@@ -3271,7 +3474,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3271
3474
|
}
|
|
3272
3475
|
let body;
|
|
3273
3476
|
try {
|
|
3274
|
-
body = await
|
|
3477
|
+
body = await readRawBody3(c.env.incoming);
|
|
3275
3478
|
} catch {
|
|
3276
3479
|
return c.json({ error: "could not read body" }, 400);
|
|
3277
3480
|
}
|
|
@@ -3314,11 +3517,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3314
3517
|
};
|
|
3315
3518
|
|
|
3316
3519
|
// src/api/routes/pair.routes.ts
|
|
3317
|
-
var
|
|
3520
|
+
var import_hono8 = require("hono");
|
|
3318
3521
|
var ALREADY_HANDLED3 = 597;
|
|
3319
3522
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3320
3523
|
var createPairRoutes = (deps) => {
|
|
3321
|
-
const app = new
|
|
3524
|
+
const app = new import_hono8.Hono();
|
|
3322
3525
|
app.post("/start", (c) => {
|
|
3323
3526
|
deps.handlePairStart(c.env.outgoing);
|
|
3324
3527
|
return alreadyHandled3();
|
|
@@ -3331,11 +3534,11 @@ var createPairRoutes = (deps) => {
|
|
|
3331
3534
|
};
|
|
3332
3535
|
|
|
3333
3536
|
// src/api/routes/projects.routes.ts
|
|
3334
|
-
var
|
|
3537
|
+
var import_hono9 = require("hono");
|
|
3335
3538
|
var ALREADY_HANDLED4 = 597;
|
|
3336
3539
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3337
3540
|
var createProjectRoutes = (deps) => {
|
|
3338
|
-
const app = new
|
|
3541
|
+
const app = new import_hono9.Hono();
|
|
3339
3542
|
app.get("/", (c) => {
|
|
3340
3543
|
const url = new URL(c.req.url);
|
|
3341
3544
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3350,11 +3553,11 @@ var createProjectRoutes = (deps) => {
|
|
|
3350
3553
|
};
|
|
3351
3554
|
|
|
3352
3555
|
// src/api/routes/scanner.routes.ts
|
|
3353
|
-
var
|
|
3556
|
+
var import_hono10 = require("hono");
|
|
3354
3557
|
var ALREADY_HANDLED5 = 597;
|
|
3355
3558
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3356
3559
|
var createScannerRoutes = (deps) => {
|
|
3357
|
-
const app = new
|
|
3560
|
+
const app = new import_hono10.Hono();
|
|
3358
3561
|
app.get("/api/search", async (c) => {
|
|
3359
3562
|
const url = new URL(c.req.url);
|
|
3360
3563
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3364,11 +3567,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3364
3567
|
};
|
|
3365
3568
|
|
|
3366
3569
|
// src/api/routes/sessions.routes.ts
|
|
3367
|
-
var
|
|
3570
|
+
var import_hono11 = require("hono");
|
|
3368
3571
|
var ALREADY_HANDLED6 = 597;
|
|
3369
3572
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3370
3573
|
var createSessionRoutes = (deps) => {
|
|
3371
|
-
const app = new
|
|
3574
|
+
const app = new import_hono11.Hono();
|
|
3372
3575
|
app.get("/count", (c) => {
|
|
3373
3576
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3374
3577
|
return alreadyHandled6();
|
|
@@ -3435,9 +3638,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3435
3638
|
};
|
|
3436
3639
|
|
|
3437
3640
|
// src/api/routes/ws.routes.ts
|
|
3438
|
-
var
|
|
3641
|
+
var import_hono12 = require("hono");
|
|
3439
3642
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3440
|
-
const app = new
|
|
3643
|
+
const app = new import_hono12.Hono();
|
|
3441
3644
|
app.get(
|
|
3442
3645
|
"/ws",
|
|
3443
3646
|
upgradeWebSocket(() => {
|
|
@@ -3463,7 +3666,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3463
3666
|
|
|
3464
3667
|
// src/api/app.ts
|
|
3465
3668
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3466
|
-
const app = new
|
|
3669
|
+
const app = new import_hono13.Hono();
|
|
3467
3670
|
const httpLog = getLogger("http");
|
|
3468
3671
|
app.use("*", async (c, next) => {
|
|
3469
3672
|
const start = Date.now();
|
|
@@ -3483,10 +3686,11 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3483
3686
|
app.use("*", corsMiddleware(deps.browserCors));
|
|
3484
3687
|
app.use("*", authMiddleware(deps));
|
|
3485
3688
|
app.onError(errorMiddleware);
|
|
3486
|
-
app.route("/healthz", createHealthRoutes());
|
|
3689
|
+
app.route("/healthz", createHealthRoutes(deps));
|
|
3487
3690
|
app.route("/", createMiscRoutes(deps));
|
|
3488
3691
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3489
3692
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3693
|
+
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
3490
3694
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
3491
3695
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3492
3696
|
app.route("/api", createBrowseRoutes(deps));
|
|
@@ -3555,7 +3759,7 @@ var import_scanner2 = require("@threadbase-sh/scanner");
|
|
|
3555
3759
|
var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
|
|
3556
3760
|
var import_fs9 = require("fs");
|
|
3557
3761
|
var import_promises3 = require("fs/promises");
|
|
3558
|
-
var
|
|
3762
|
+
var import_path12 = require("path");
|
|
3559
3763
|
var import_promises4 = require("timers/promises");
|
|
3560
3764
|
|
|
3561
3765
|
// src/db/sqlite-migrate.ts
|
|
@@ -3669,6 +3873,31 @@ function parseAgentEntrypointsEnv(raw) {
|
|
|
3669
3873
|
return new Set(parts);
|
|
3670
3874
|
}
|
|
3671
3875
|
|
|
3876
|
+
// src/utils/canonicalizeFilePath.ts
|
|
3877
|
+
var import_path11 = require("path");
|
|
3878
|
+
function canonicalizeFilePath(filePath) {
|
|
3879
|
+
return filePath.trim().replace(/\\/g, "/");
|
|
3880
|
+
}
|
|
3881
|
+
function toNativeFilePath(filePath) {
|
|
3882
|
+
return (0, import_path11.normalize)(filePath.trim());
|
|
3883
|
+
}
|
|
3884
|
+
function canonicalLivePathSet(metas) {
|
|
3885
|
+
const live = /* @__PURE__ */ new Set();
|
|
3886
|
+
for (const meta of metas) {
|
|
3887
|
+
if (meta.filePath) live.add(canonicalizeFilePath(meta.filePath));
|
|
3888
|
+
}
|
|
3889
|
+
return live;
|
|
3890
|
+
}
|
|
3891
|
+
function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
3892
|
+
const joined = /* @__PURE__ */ new Map();
|
|
3893
|
+
for (const meta of metas) {
|
|
3894
|
+
if (!meta.filePath) continue;
|
|
3895
|
+
const stat3 = canonicalStats.get(canonicalizeFilePath(meta.filePath));
|
|
3896
|
+
if (stat3) joined.set(meta.filePath, { stat: stat3, meta });
|
|
3897
|
+
}
|
|
3898
|
+
return joined;
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3672
3901
|
// src/utils/fileIdentity.ts
|
|
3673
3902
|
var import_crypto5 = require("crypto");
|
|
3674
3903
|
function fileIdentity(stat3, headBytes) {
|
|
@@ -3781,8 +4010,19 @@ var ConversationCache = class _ConversationCache {
|
|
|
3781
4010
|
),
|
|
3782
4011
|
// Batch equivalent of updateMeta: bumps message_count by N in one write
|
|
3783
4012
|
// (used by updateFromLines so a burst of appended lines is one UPDATE).
|
|
4013
|
+
// last_activity/last_message only move FORWARD: a batch whose newest line
|
|
4014
|
+
// predates the stored last_activity (interleaved writers appending an older
|
|
4015
|
+
// line) must not drag the metadata backward. message_count and updated_at
|
|
4016
|
+
// still advance — a real message was appended and the row did change.
|
|
3784
4017
|
updateMetaBatch: db.prepare(
|
|
3785
|
-
|
|
4018
|
+
`UPDATE conversation_meta SET
|
|
4019
|
+
message_count = message_count + @inc,
|
|
4020
|
+
last_activity = CASE WHEN @last_activity > IFNULL(last_activity, -1)
|
|
4021
|
+
THEN @last_activity ELSE last_activity END,
|
|
4022
|
+
last_message = CASE WHEN @last_activity > IFNULL(last_activity, -1)
|
|
4023
|
+
THEN @last_message ELSE last_message END,
|
|
4024
|
+
updated_at = @updated_at
|
|
4025
|
+
WHERE id = @id`
|
|
3786
4026
|
),
|
|
3787
4027
|
insertSkeleton: db.prepare(
|
|
3788
4028
|
"INSERT OR IGNORE INTO conversation_meta (id, file_path, message_count, updated_at) VALUES (?, ?, 1, ?)"
|
|
@@ -3864,6 +4104,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
3864
4104
|
"SELECT provider FROM conversation_meta WHERE file_path = ?"
|
|
3865
4105
|
),
|
|
3866
4106
|
allFilePaths: db.prepare("SELECT id, file_path FROM conversation_meta"),
|
|
4107
|
+
allFilePathsWithTitle: db.prepare("SELECT id, file_path, title FROM conversation_meta"),
|
|
3867
4108
|
allFileStats: db.prepare(
|
|
3868
4109
|
"SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
|
|
3869
4110
|
),
|
|
@@ -4016,7 +4257,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4016
4257
|
// excluded from the index entirely; the scanner serves them (it routes each
|
|
4017
4258
|
// provider to its own parser).
|
|
4018
4259
|
isIndexableFile(filePath) {
|
|
4019
|
-
const row = this.stmts.getProviderByFilePath.get(filePath);
|
|
4260
|
+
const row = this.stmts.getProviderByFilePath.get(canonicalizeFilePath(filePath));
|
|
4020
4261
|
if (!row) return false;
|
|
4021
4262
|
return (row.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
|
|
4022
4263
|
}
|
|
@@ -4263,7 +4504,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4263
4504
|
return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
|
|
4264
4505
|
}
|
|
4265
4506
|
static open(dbPath, tailSize = 10, migrationsDir, options) {
|
|
4266
|
-
(0, import_fs9.mkdirSync)((0,
|
|
4507
|
+
(0, import_fs9.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
|
|
4267
4508
|
const db = new import_better_sqlite3.default(dbPath);
|
|
4268
4509
|
db.pragma("journal_mode = WAL");
|
|
4269
4510
|
db.pragma("foreign_keys = ON");
|
|
@@ -4284,7 +4525,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4284
4525
|
if (this.fileIndexLoaded) return;
|
|
4285
4526
|
const rows = this.stmts.allFilePaths.all();
|
|
4286
4527
|
for (const row of rows) {
|
|
4287
|
-
this.fileIndex.set(row.file_path, row.id);
|
|
4528
|
+
this.fileIndex.set(canonicalizeFilePath(row.file_path), row.id);
|
|
4288
4529
|
}
|
|
4289
4530
|
this.fileIndexLoaded = true;
|
|
4290
4531
|
}
|
|
@@ -4303,12 +4544,13 @@ var ConversationCache = class _ConversationCache {
|
|
|
4303
4544
|
const role = line.role ?? line.type;
|
|
4304
4545
|
const isMessage = role === "user" || role === "assistant";
|
|
4305
4546
|
this.ensureFileIndex();
|
|
4547
|
+
const key = canonicalizeFilePath(filePath);
|
|
4306
4548
|
if (!isMessage && !line.cwd && !line.slug) return;
|
|
4307
|
-
let convId = this.fileIndex.get(
|
|
4549
|
+
let convId = this.fileIndex.get(key);
|
|
4308
4550
|
if (!convId) {
|
|
4309
|
-
const pseudoId =
|
|
4310
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4311
|
-
this.fileIndex.set(
|
|
4551
|
+
const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
|
|
4552
|
+
this.stmts.insertSkeleton.run(pseudoId, key, 0);
|
|
4553
|
+
this.fileIndex.set(key, pseudoId);
|
|
4312
4554
|
convId = pseudoId;
|
|
4313
4555
|
}
|
|
4314
4556
|
if (line.cwd || line.slug) {
|
|
@@ -4323,18 +4565,18 @@ var ConversationCache = class _ConversationCache {
|
|
|
4323
4565
|
});
|
|
4324
4566
|
}
|
|
4325
4567
|
if (!isMessage) return;
|
|
4326
|
-
const
|
|
4327
|
-
const activityMs = new Date(
|
|
4568
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4569
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4328
4570
|
if (Number.isNaN(activityMs)) return;
|
|
4329
4571
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4330
4572
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4331
|
-
const lastMessage = JSON.stringify({ role, timestamp, text });
|
|
4573
|
+
const lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4332
4574
|
const seq = ++this.tailSeq;
|
|
4333
4575
|
const result = this.stmts.updateMeta.run(activityMs, lastMessage, seq, convId);
|
|
4334
4576
|
if (result.changes === 0) return;
|
|
4335
4577
|
const tailRow = this.stmts.getTail.get(convId);
|
|
4336
4578
|
const msgs = tailRow ? JSON.parse(tailRow.messages_json) : [];
|
|
4337
|
-
msgs.push({ role, timestamp, text, content: contentBlocks });
|
|
4579
|
+
msgs.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4338
4580
|
if (msgs.length > this.tailSize) msgs.splice(0, msgs.length - this.tailSize);
|
|
4339
4581
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, seq);
|
|
4340
4582
|
}
|
|
@@ -4346,7 +4588,8 @@ var ConversationCache = class _ConversationCache {
|
|
|
4346
4588
|
* updateFromLine in order: the agent filter short-circuits the whole batch,
|
|
4347
4589
|
* project context is backfilled last-wins, message_count increases by the
|
|
4348
4590
|
* number of surviving message lines, and last_activity/last_message reflect
|
|
4349
|
-
* the
|
|
4591
|
+
* the newest message line by timestamp (a monotonic guard keeps them from
|
|
4592
|
+
* moving backward when an interleaved writer appends an older line — P0.3).
|
|
4350
4593
|
*/
|
|
4351
4594
|
updateFromLines(filePath, rawLines) {
|
|
4352
4595
|
let sawProjectContext = false;
|
|
@@ -4382,23 +4625,26 @@ var ConversationCache = class _ConversationCache {
|
|
|
4382
4625
|
backfillTitle ??= lineTitle;
|
|
4383
4626
|
}
|
|
4384
4627
|
if (!isMessage) continue;
|
|
4385
|
-
const
|
|
4386
|
-
const activityMs = new Date(
|
|
4628
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4629
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4387
4630
|
if (Number.isNaN(activityMs)) continue;
|
|
4388
4631
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4389
4632
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4390
4633
|
msgCount += 1;
|
|
4391
|
-
lastActivityMs
|
|
4392
|
-
|
|
4393
|
-
|
|
4634
|
+
if (lastActivityMs === null || activityMs > lastActivityMs) {
|
|
4635
|
+
lastActivityMs = activityMs;
|
|
4636
|
+
lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4637
|
+
}
|
|
4638
|
+
newTail.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4394
4639
|
}
|
|
4395
4640
|
if (!sawProjectContext && msgCount === 0) return;
|
|
4396
4641
|
this.ensureFileIndex();
|
|
4397
|
-
|
|
4642
|
+
const key = canonicalizeFilePath(filePath);
|
|
4643
|
+
let convId = this.fileIndex.get(key);
|
|
4398
4644
|
if (!convId) {
|
|
4399
|
-
const pseudoId =
|
|
4400
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4401
|
-
this.fileIndex.set(
|
|
4645
|
+
const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
|
|
4646
|
+
this.stmts.insertSkeleton.run(pseudoId, key, 0);
|
|
4647
|
+
this.fileIndex.set(key, pseudoId);
|
|
4402
4648
|
convId = pseudoId;
|
|
4403
4649
|
}
|
|
4404
4650
|
const id = convId;
|
|
@@ -4441,6 +4687,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4441
4687
|
for (const m of items) {
|
|
4442
4688
|
const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
|
|
4443
4689
|
const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
|
|
4690
|
+
const canonicalPath = canonicalizeFilePath(m.filePath);
|
|
4444
4691
|
let mtimeMs = null;
|
|
4445
4692
|
let fileSize = null;
|
|
4446
4693
|
try {
|
|
@@ -4456,10 +4703,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4456
4703
|
const scannerMetaJson = JSON.stringify(m);
|
|
4457
4704
|
this.stmts.upsertFull.run({
|
|
4458
4705
|
id,
|
|
4459
|
-
file_path:
|
|
4706
|
+
file_path: canonicalPath,
|
|
4460
4707
|
project_path: m.projectPath ?? null,
|
|
4461
4708
|
project_name: m.projectName ?? null,
|
|
4462
|
-
title: m.title ?? m.projectName ?? null,
|
|
4709
|
+
title: m.title ?? m.sessionName ?? m.projectName ?? null,
|
|
4463
4710
|
model: m.model ?? null,
|
|
4464
4711
|
account: m.account ?? null,
|
|
4465
4712
|
branch: m.gitBranch ?? null,
|
|
@@ -4475,7 +4722,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4475
4722
|
scanner_meta_json: scannerMetaJson
|
|
4476
4723
|
});
|
|
4477
4724
|
this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
|
|
4478
|
-
if (this.fileIndexLoaded) this.fileIndex.set(
|
|
4725
|
+
if (this.fileIndexLoaded) this.fileIndex.set(canonicalPath, id);
|
|
4479
4726
|
upsertedIds.push(id);
|
|
4480
4727
|
}
|
|
4481
4728
|
});
|
|
@@ -4486,11 +4733,12 @@ var ConversationCache = class _ConversationCache {
|
|
|
4486
4733
|
// by updateFromLine when a previously-cached file turns out to be an agent
|
|
4487
4734
|
// JSONL.
|
|
4488
4735
|
deleteByFilePath(filePath) {
|
|
4489
|
-
const
|
|
4736
|
+
const key = canonicalizeFilePath(filePath);
|
|
4737
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4490
4738
|
if (!row) return false;
|
|
4491
4739
|
this.stmts.deleteTailById.run(row.id);
|
|
4492
4740
|
const result = this.stmts.deleteById.run(row.id);
|
|
4493
|
-
this.fileIndex.delete(
|
|
4741
|
+
this.fileIndex.delete(key);
|
|
4494
4742
|
return result.changes > 0;
|
|
4495
4743
|
}
|
|
4496
4744
|
// Reads the last `tailSize` qualifying lines from a JSONL file and writes them
|
|
@@ -4542,10 +4790,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4542
4790
|
}
|
|
4543
4791
|
const role = parsed.role ?? parsed.type;
|
|
4544
4792
|
if (!role) continue;
|
|
4545
|
-
const
|
|
4793
|
+
const timestamp2 = parsed.timestamp ?? "";
|
|
4546
4794
|
const contentBlocks = normalizeContent(parsed.message?.content ?? parsed.content);
|
|
4547
4795
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4548
|
-
msgs.unshift({ role, timestamp, text, content: contentBlocks });
|
|
4796
|
+
msgs.unshift({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4549
4797
|
}
|
|
4550
4798
|
if (msgs.length === 0) return false;
|
|
4551
4799
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, 0);
|
|
@@ -4613,6 +4861,16 @@ var ConversationCache = class _ConversationCache {
|
|
|
4613
4861
|
}
|
|
4614
4862
|
return map;
|
|
4615
4863
|
}
|
|
4864
|
+
/**
|
|
4865
|
+
* Conversation id for a JSONL path, or null when no row exists yet. Resolves
|
|
4866
|
+
* by file_path (NOT conversationIdForFile) so codex rollout files — named
|
|
4867
|
+
* rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
|
|
4868
|
+
*/
|
|
4869
|
+
getIdByFilePath(filePath) {
|
|
4870
|
+
const key = canonicalizeFilePath(filePath);
|
|
4871
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4872
|
+
return row?.id ?? null;
|
|
4873
|
+
}
|
|
4616
4874
|
getMetaById(id) {
|
|
4617
4875
|
const row = this.stmts.getFullById.get(id);
|
|
4618
4876
|
if (!row) return null;
|
|
@@ -4705,22 +4963,23 @@ var ConversationCache = class _ConversationCache {
|
|
|
4705
4963
|
/**
|
|
4706
4964
|
* Drop the cached row for a file. Two callers with opposite intent:
|
|
4707
4965
|
* - a directory-watch "change" event (the file was appended to) — pass
|
|
4708
|
-
* `skipIfTailed: true
|
|
4709
|
-
*
|
|
4710
|
-
*
|
|
4711
|
-
*
|
|
4712
|
-
*
|
|
4713
|
-
*
|
|
4714
|
-
*
|
|
4715
|
-
*
|
|
4716
|
-
* nothing.
|
|
4966
|
+
* `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
|
|
4967
|
+
* event fires on every external append; deleting here flickers the
|
|
4968
|
+
* conversation out of /api/conversations — whether it's a live-tailed row
|
|
4969
|
+
* the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
|
|
4970
|
+
* fire on the same append with no ordering guarantee) OR a refresh-created
|
|
4971
|
+
* untailed row (a ?refresh=1 upsert never populates a tail, so the old
|
|
4972
|
+
* "delete when untailed" behavior made it vanish on its next append with no
|
|
4973
|
+
* client action). The live-tail path owns the row's content and the
|
|
4974
|
+
* debounced rescan re-derives metadata, so leaving the row loses nothing.
|
|
4717
4975
|
* - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
|
|
4718
4976
|
* row is always removed, otherwise a deleted session ghosts in the cache.
|
|
4719
4977
|
*/
|
|
4720
4978
|
invalidateByFilePath(filePath, opts) {
|
|
4721
|
-
const
|
|
4979
|
+
const key = canonicalizeFilePath(filePath);
|
|
4980
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4722
4981
|
if (!row) return null;
|
|
4723
|
-
if (opts?.skipIfTailed
|
|
4982
|
+
if (opts?.skipIfTailed) return null;
|
|
4724
4983
|
this.invalidate(row.id);
|
|
4725
4984
|
return row.id;
|
|
4726
4985
|
}
|
|
@@ -4814,6 +5073,68 @@ var ConversationCache = class _ConversationCache {
|
|
|
4814
5073
|
}
|
|
4815
5074
|
return removed;
|
|
4816
5075
|
}
|
|
5076
|
+
/**
|
|
5077
|
+
* Read-only: list cached rows whose `file_path` no longer exists on disk.
|
|
5078
|
+
* Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
|
|
5079
|
+
* reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
|
|
5080
|
+
* rows that still have cached history (which pruneGhostFiles would keep).
|
|
5081
|
+
*/
|
|
5082
|
+
listMissingFiles(exists = import_fs9.existsSync) {
|
|
5083
|
+
const rows = this.stmts.allFilePathsWithTitle.all();
|
|
5084
|
+
const missing = [];
|
|
5085
|
+
for (const row of rows) {
|
|
5086
|
+
if (exists(row.file_path)) continue;
|
|
5087
|
+
missing.push({
|
|
5088
|
+
id: row.id,
|
|
5089
|
+
filePath: row.file_path,
|
|
5090
|
+
title: row.title,
|
|
5091
|
+
tailed: !!this.stmts.hasTail.get(row.id)
|
|
5092
|
+
});
|
|
5093
|
+
}
|
|
5094
|
+
return missing;
|
|
5095
|
+
}
|
|
5096
|
+
/**
|
|
5097
|
+
* Drop the given conversation ids outright — main row, tail, and message
|
|
5098
|
+
* index — regardless of whether they have a tail. Used by the cache-integrity
|
|
5099
|
+
* resolution actions (prune_all / prune_selected). Returns the count dropped.
|
|
5100
|
+
*/
|
|
5101
|
+
dropRowsById(ids) {
|
|
5102
|
+
if (ids.length === 0) return 0;
|
|
5103
|
+
const drop = this.db.transaction((toDrop) => {
|
|
5104
|
+
let n = 0;
|
|
5105
|
+
for (const id of toDrop) {
|
|
5106
|
+
this.stmts.deleteTailById.run(id);
|
|
5107
|
+
this.stmts.deleteMessageIndex.run(id);
|
|
5108
|
+
n += this.stmts.deleteById.run(id).changes;
|
|
5109
|
+
}
|
|
5110
|
+
return n;
|
|
5111
|
+
});
|
|
5112
|
+
const dropped = drop(ids);
|
|
5113
|
+
if (this.fileIndexLoaded) {
|
|
5114
|
+
for (const id of ids) {
|
|
5115
|
+
for (const [fp, cid] of this.fileIndex) {
|
|
5116
|
+
if (cid === id) {
|
|
5117
|
+
this.fileIndex.delete(fp);
|
|
5118
|
+
break;
|
|
5119
|
+
}
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
return dropped;
|
|
5124
|
+
}
|
|
5125
|
+
/**
|
|
5126
|
+
* Wipe all cached conversation state — meta, tails, and message index — and
|
|
5127
|
+
* reset the in-memory file index. Only called by the `reset_rescan`
|
|
5128
|
+
* resolution action, which repopulates from a fresh disk scan afterward.
|
|
5129
|
+
*/
|
|
5130
|
+
clearAll() {
|
|
5131
|
+
this.db.transaction(() => {
|
|
5132
|
+
this.stmts.deleteTailAll.run();
|
|
5133
|
+
this.stmts.deleteAll.run();
|
|
5134
|
+
this.db.exec("DELETE FROM conversation_message_index");
|
|
5135
|
+
})();
|
|
5136
|
+
this.fileIndex = /* @__PURE__ */ new Map();
|
|
5137
|
+
}
|
|
4817
5138
|
};
|
|
4818
5139
|
|
|
4819
5140
|
// src/db/repositories/cacheMetadata.repository.ts
|
|
@@ -5016,18 +5337,18 @@ async function recordUpload(pool2, instanceId, row) {
|
|
|
5016
5337
|
// src/handlers/handleListProjects.ts
|
|
5017
5338
|
var import_fs10 = require("fs");
|
|
5018
5339
|
var import_os6 = require("os");
|
|
5019
|
-
var
|
|
5340
|
+
var import_path13 = require("path");
|
|
5020
5341
|
function decodeProjectPath(dirName) {
|
|
5021
5342
|
return dirName.replace(/-/g, "/");
|
|
5022
5343
|
}
|
|
5023
5344
|
function handleListProjects(url, res) {
|
|
5024
5345
|
const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
|
|
5025
5346
|
const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
|
|
5026
|
-
const projectsDir = (0,
|
|
5347
|
+
const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
|
|
5027
5348
|
let entries;
|
|
5028
5349
|
try {
|
|
5029
5350
|
entries = (0, import_fs10.readdirSync)(projectsDir).map((dirName) => {
|
|
5030
|
-
const fullPath = (0,
|
|
5351
|
+
const fullPath = (0, import_path13.join)(projectsDir, dirName);
|
|
5031
5352
|
let mtime = 0;
|
|
5032
5353
|
try {
|
|
5033
5354
|
mtime = (0, import_fs10.statSync)(fullPath).mtimeMs;
|
|
@@ -5123,9 +5444,316 @@ function seal(plaintext, recipientPublicKeyBase64) {
|
|
|
5123
5444
|
};
|
|
5124
5445
|
}
|
|
5125
5446
|
|
|
5447
|
+
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5448
|
+
var import_crypto8 = require("crypto");
|
|
5449
|
+
var import_fs13 = require("fs");
|
|
5450
|
+
|
|
5451
|
+
// src/services/cache-integrity/alertStore.ts
|
|
5452
|
+
var import_fs11 = require("fs");
|
|
5453
|
+
var import_os7 = require("os");
|
|
5454
|
+
var import_path14 = require("path");
|
|
5455
|
+
function alertStatePath() {
|
|
5456
|
+
const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase");
|
|
5457
|
+
return (0, import_path14.join)(dir, "cache-alert.json");
|
|
5458
|
+
}
|
|
5459
|
+
function loadAlertState() {
|
|
5460
|
+
try {
|
|
5461
|
+
const parsed = JSON.parse((0, import_fs11.readFileSync)(alertStatePath(), "utf-8"));
|
|
5462
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
5463
|
+
} catch {
|
|
5464
|
+
return {};
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5467
|
+
function saveAlertState(state) {
|
|
5468
|
+
const path = alertStatePath();
|
|
5469
|
+
(0, import_fs11.mkdirSync)((0, import_path14.dirname)(path), { recursive: true });
|
|
5470
|
+
(0, import_fs11.writeFileSync)(path, `${JSON.stringify(state, null, 2)}
|
|
5471
|
+
`);
|
|
5472
|
+
}
|
|
5473
|
+
|
|
5474
|
+
// src/services/cache-integrity/backup.ts
|
|
5475
|
+
var import_fs12 = require("fs");
|
|
5476
|
+
var import_path15 = require("path");
|
|
5477
|
+
var DEFAULT_RETAIN = 3;
|
|
5478
|
+
function retainCount() {
|
|
5479
|
+
const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
|
|
5480
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RETAIN;
|
|
5481
|
+
}
|
|
5482
|
+
function timestamp(d) {
|
|
5483
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
5484
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
5485
|
+
}
|
|
5486
|
+
async function backupCacheDb(db, cacheDir) {
|
|
5487
|
+
const backupsDir = (0, import_path15.join)(cacheDir, "backups");
|
|
5488
|
+
(0, import_fs12.mkdirSync)(backupsDir, { recursive: true });
|
|
5489
|
+
const destPath = (0, import_path15.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
|
|
5490
|
+
await db.backup(destPath);
|
|
5491
|
+
const retain = retainCount();
|
|
5492
|
+
const backups = (0, import_fs12.readdirSync)(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
|
|
5493
|
+
const full = (0, import_path15.join)(backupsDir, f);
|
|
5494
|
+
return { full, mtime: (0, import_fs12.statSync)(full).mtimeMs };
|
|
5495
|
+
}).sort((a, b) => b.mtime - a.mtime);
|
|
5496
|
+
for (const stale of backups.slice(retain)) {
|
|
5497
|
+
if ((0, import_fs12.existsSync)(stale.full)) (0, import_fs12.unlinkSync)(stale.full);
|
|
5498
|
+
}
|
|
5499
|
+
return destPath;
|
|
5500
|
+
}
|
|
5501
|
+
|
|
5502
|
+
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5503
|
+
var MAX_MISSING_PERSISTED = 1e3;
|
|
5504
|
+
var SAMPLE_SIZE = 20;
|
|
5505
|
+
var STORM_WINDOW_MS = 3e4;
|
|
5506
|
+
var STORM_THRESHOLD = 10;
|
|
5507
|
+
function envInt(name, fallback) {
|
|
5508
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
5509
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
5510
|
+
}
|
|
5511
|
+
function fingerprintOf(ids) {
|
|
5512
|
+
const sorted = [...ids].sort();
|
|
5513
|
+
return `sha256:${(0, import_crypto8.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5514
|
+
}
|
|
5515
|
+
var CacheIntegrityMonitor = class {
|
|
5516
|
+
constructor(cache, wsHub, log3, cacheDir, rescan) {
|
|
5517
|
+
this.cache = cache;
|
|
5518
|
+
this.wsHub = wsHub;
|
|
5519
|
+
this.log = log3;
|
|
5520
|
+
this.cacheDir = cacheDir;
|
|
5521
|
+
this.rescan = rescan;
|
|
5522
|
+
const state = loadAlertState();
|
|
5523
|
+
this._pending = state.pending ?? null;
|
|
5524
|
+
this.ignoredIds = new Set(state.ignoredIds ?? []);
|
|
5525
|
+
}
|
|
5526
|
+
cache;
|
|
5527
|
+
wsHub;
|
|
5528
|
+
log;
|
|
5529
|
+
cacheDir;
|
|
5530
|
+
rescan;
|
|
5531
|
+
_pending;
|
|
5532
|
+
ignoredIds;
|
|
5533
|
+
deferredUnlinks = [];
|
|
5534
|
+
unlinkTimes = [];
|
|
5535
|
+
get pending() {
|
|
5536
|
+
return this._pending;
|
|
5537
|
+
}
|
|
5538
|
+
persist() {
|
|
5539
|
+
const state = {};
|
|
5540
|
+
if (this._pending) {
|
|
5541
|
+
state.pending = {
|
|
5542
|
+
...this._pending,
|
|
5543
|
+
missing: this._pending.missing.slice(0, MAX_MISSING_PERSISTED)
|
|
5544
|
+
};
|
|
5545
|
+
}
|
|
5546
|
+
if (this.ignoredIds.size > 0) state.ignoredIds = [...this.ignoredIds];
|
|
5547
|
+
saveAlertState(state);
|
|
5548
|
+
}
|
|
5549
|
+
classifySeverity(missingCount, totalRows) {
|
|
5550
|
+
const minMissing = envInt("THREADBASE_CACHE_ALERT_MIN_MISSING", 20);
|
|
5551
|
+
const minRatio = Number.parseFloat(process.env.THREADBASE_CACHE_ALERT_MIN_RATIO ?? "0.20");
|
|
5552
|
+
const ratio = totalRows > 0 ? missingCount / totalRows : 0;
|
|
5553
|
+
const ratioThreshold = Number.isFinite(minRatio) ? minRatio : 0.2;
|
|
5554
|
+
return missingCount >= minMissing && ratio >= ratioThreshold ? "high" : "low";
|
|
5555
|
+
}
|
|
5556
|
+
sampleOf(missing) {
|
|
5557
|
+
return missing.slice(0, SAMPLE_SIZE).map((m) => ({
|
|
5558
|
+
id: m.id,
|
|
5559
|
+
...m.title != null ? { title: m.title } : {}
|
|
5560
|
+
}));
|
|
5561
|
+
}
|
|
5562
|
+
buildWsMessage(pending) {
|
|
5563
|
+
return {
|
|
5564
|
+
type: "cache_alert",
|
|
5565
|
+
fingerprint: pending.fingerprint,
|
|
5566
|
+
severity: pending.severity,
|
|
5567
|
+
missingCount: pending.missingCount,
|
|
5568
|
+
totalRows: pending.totalRows,
|
|
5569
|
+
detectedAt: pending.detectedAt,
|
|
5570
|
+
sample: this.sampleOf(pending.missing)
|
|
5571
|
+
};
|
|
5572
|
+
}
|
|
5573
|
+
wsMessage() {
|
|
5574
|
+
return this._pending ? this.buildWsMessage(this._pending) : null;
|
|
5575
|
+
}
|
|
5576
|
+
healthzField() {
|
|
5577
|
+
if (!this._pending) return void 0;
|
|
5578
|
+
return {
|
|
5579
|
+
severity: this._pending.severity,
|
|
5580
|
+
missingCount: this._pending.missingCount,
|
|
5581
|
+
fingerprint: this._pending.fingerprint,
|
|
5582
|
+
detectedAt: this._pending.detectedAt
|
|
5583
|
+
};
|
|
5584
|
+
}
|
|
5585
|
+
/**
|
|
5586
|
+
* Scan the cache for rows whose file is gone, excluding ids the user chose to
|
|
5587
|
+
* ignore. If none remain, clear any stale pending alert and return (the caller
|
|
5588
|
+
* decides whether to run pruneGhostFiles). Otherwise classify severity, persist
|
|
5589
|
+
* the pending record, back up on high severity, and broadcast the alert.
|
|
5590
|
+
*/
|
|
5591
|
+
async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
5592
|
+
const all = this.cache.listMissingFiles(import_fs13.existsSync);
|
|
5593
|
+
const missing = all.filter((m) => !this.ignoredIds.has(m.id));
|
|
5594
|
+
if (missing.length === 0) {
|
|
5595
|
+
if (this._pending) {
|
|
5596
|
+
this._pending = null;
|
|
5597
|
+
this.persist();
|
|
5598
|
+
}
|
|
5599
|
+
return;
|
|
5600
|
+
}
|
|
5601
|
+
const totalRows = this.cache.listConversations({ limit: 0, offset: 0 }).total;
|
|
5602
|
+
const fingerprint = fingerprintOf(missing.map((m) => m.id));
|
|
5603
|
+
const severity = this.classifySeverity(missing.length, totalRows);
|
|
5604
|
+
const pending = {
|
|
5605
|
+
fingerprint,
|
|
5606
|
+
severity,
|
|
5607
|
+
detectedAt,
|
|
5608
|
+
missingCount: missing.length,
|
|
5609
|
+
totalRows,
|
|
5610
|
+
missing
|
|
5611
|
+
};
|
|
5612
|
+
if (severity === "high") {
|
|
5613
|
+
try {
|
|
5614
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5615
|
+
} catch (err) {
|
|
5616
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5617
|
+
event: "cache_integrity.backup_failed",
|
|
5618
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5619
|
+
});
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
this._pending = pending;
|
|
5623
|
+
this.persist();
|
|
5624
|
+
this.log.warn("cache integrity drift detected", {
|
|
5625
|
+
event: "cache_integrity.detected",
|
|
5626
|
+
severity,
|
|
5627
|
+
missingCount: missing.length,
|
|
5628
|
+
totalRows,
|
|
5629
|
+
fingerprint
|
|
5630
|
+
});
|
|
5631
|
+
this.wsHub.broadcast(this.buildWsMessage(pending));
|
|
5632
|
+
}
|
|
5633
|
+
/** Queue an unlink while an alert is pending — the row is not invalidated. */
|
|
5634
|
+
deferUnlink(filePath) {
|
|
5635
|
+
this.deferredUnlinks.push(filePath);
|
|
5636
|
+
}
|
|
5637
|
+
/**
|
|
5638
|
+
* Record a live unlink while NO alert is pending. Crossing the storm threshold
|
|
5639
|
+
* (>= 10 unlinks within 30s) re-triggers detection.
|
|
5640
|
+
*/
|
|
5641
|
+
recordUnlink(filePath) {
|
|
5642
|
+
const now = Date.now();
|
|
5643
|
+
this.unlinkTimes.push(now);
|
|
5644
|
+
this.unlinkTimes = this.unlinkTimes.filter((t) => now - t < STORM_WINDOW_MS);
|
|
5645
|
+
if (this.unlinkTimes.length >= STORM_THRESHOLD) {
|
|
5646
|
+
this.unlinkTimes = [];
|
|
5647
|
+
void this.runDetection().catch((err) => {
|
|
5648
|
+
this.log.error("cache-integrity storm detection failed", {
|
|
5649
|
+
event: "cache_integrity.storm_detection_failed",
|
|
5650
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5651
|
+
filePath
|
|
5652
|
+
});
|
|
5653
|
+
});
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5656
|
+
async ensureBackup(pending) {
|
|
5657
|
+
if (pending.backupPath) return pending.backupPath;
|
|
5658
|
+
try {
|
|
5659
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5660
|
+
} catch (err) {
|
|
5661
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5662
|
+
event: "cache_integrity.backup_failed",
|
|
5663
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5664
|
+
});
|
|
5665
|
+
}
|
|
5666
|
+
return pending.backupPath;
|
|
5667
|
+
}
|
|
5668
|
+
clearPending() {
|
|
5669
|
+
this._pending = null;
|
|
5670
|
+
this.deferredUnlinks = [];
|
|
5671
|
+
this.persist();
|
|
5672
|
+
}
|
|
5673
|
+
applyDeferredUnlinks() {
|
|
5674
|
+
for (const fp of this.deferredUnlinks) this.cache.invalidateByFilePath(fp);
|
|
5675
|
+
this.deferredUnlinks = [];
|
|
5676
|
+
}
|
|
5677
|
+
broadcastResolved(fingerprint, action) {
|
|
5678
|
+
this.wsHub.broadcast({ type: "cache_alert_resolved", fingerprint, action });
|
|
5679
|
+
}
|
|
5680
|
+
/**
|
|
5681
|
+
* Apply the human's chosen resolution. Idempotent per fingerprint: no pending
|
|
5682
|
+
* alert → alreadyResolved; a different fingerprint → conflict. See the spec's
|
|
5683
|
+
* four-action semantics.
|
|
5684
|
+
*/
|
|
5685
|
+
async resolve(fingerprint, action, ids) {
|
|
5686
|
+
const pending = this._pending;
|
|
5687
|
+
if (!pending) return { alreadyResolved: true };
|
|
5688
|
+
if (pending.fingerprint !== fingerprint) {
|
|
5689
|
+
return { conflict: true, currentFingerprint: pending.fingerprint };
|
|
5690
|
+
}
|
|
5691
|
+
this._pending = null;
|
|
5692
|
+
switch (action) {
|
|
5693
|
+
case "prune_all": {
|
|
5694
|
+
await this.ensureBackup(pending);
|
|
5695
|
+
const backupPath = pending.backupPath;
|
|
5696
|
+
const stillMissing = pending.missing.filter((m) => !(0, import_fs13.existsSync)(m.filePath)).map((m) => m.id);
|
|
5697
|
+
const pruned = this.cache.dropRowsById(stillMissing);
|
|
5698
|
+
this.applyDeferredUnlinks();
|
|
5699
|
+
this.clearPending();
|
|
5700
|
+
this.broadcastResolved(fingerprint, action);
|
|
5701
|
+
return { ok: true, action, pruned, backupPath };
|
|
5702
|
+
}
|
|
5703
|
+
case "prune_selected": {
|
|
5704
|
+
const requested = new Set(ids ?? []);
|
|
5705
|
+
const pendingIds = new Set(pending.missing.map((m) => m.id));
|
|
5706
|
+
const toDrop = [...requested].filter((id) => pendingIds.has(id));
|
|
5707
|
+
await this.ensureBackup(pending);
|
|
5708
|
+
const backupPath = pending.backupPath;
|
|
5709
|
+
const pruned = this.cache.dropRowsById(toDrop);
|
|
5710
|
+
const prunedPaths = new Set(
|
|
5711
|
+
pending.missing.filter((m) => toDrop.includes(m.id)).map((m) => m.filePath)
|
|
5712
|
+
);
|
|
5713
|
+
this.deferredUnlinks = this.deferredUnlinks.filter((fp) => {
|
|
5714
|
+
if (prunedPaths.has(fp)) {
|
|
5715
|
+
this.cache.invalidateByFilePath(fp);
|
|
5716
|
+
return false;
|
|
5717
|
+
}
|
|
5718
|
+
return true;
|
|
5719
|
+
});
|
|
5720
|
+
this.persist();
|
|
5721
|
+
await this.runDetection();
|
|
5722
|
+
this.broadcastResolved(fingerprint, action);
|
|
5723
|
+
return { ok: true, action, pruned, backupPath };
|
|
5724
|
+
}
|
|
5725
|
+
case "ignore": {
|
|
5726
|
+
for (const m of pending.missing) this.ignoredIds.add(m.id);
|
|
5727
|
+
this.deferredUnlinks = [];
|
|
5728
|
+
this.clearPending();
|
|
5729
|
+
this.broadcastResolved(fingerprint, action);
|
|
5730
|
+
return { ok: true, action };
|
|
5731
|
+
}
|
|
5732
|
+
case "reset_rescan": {
|
|
5733
|
+
const backupPath = await this.ensureBackup(pending);
|
|
5734
|
+
this.cache.clearAll();
|
|
5735
|
+
if (this.rescan) {
|
|
5736
|
+
try {
|
|
5737
|
+
const metas = await this.rescan();
|
|
5738
|
+
this.cache.upsertFromScannerMeta(metas);
|
|
5739
|
+
} catch (err) {
|
|
5740
|
+
this.log.error("cache-integrity reset rescan failed", {
|
|
5741
|
+
event: "cache_integrity.reset_rescan_failed",
|
|
5742
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5743
|
+
});
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5746
|
+
this.clearPending();
|
|
5747
|
+
this.broadcastResolved(fingerprint, action);
|
|
5748
|
+
return { ok: true, action, backupPath };
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
}
|
|
5752
|
+
};
|
|
5753
|
+
|
|
5126
5754
|
// src/services/conversations/conversationWatcher.ts
|
|
5127
5755
|
var import_chokidar = __toESM(require("chokidar"), 1);
|
|
5128
|
-
var
|
|
5756
|
+
var import_fs14 = require("fs");
|
|
5129
5757
|
var import_promises5 = require("fs/promises");
|
|
5130
5758
|
var ConversationWatcher = class {
|
|
5131
5759
|
files = /* @__PURE__ */ new Map();
|
|
@@ -5135,6 +5763,7 @@ var ConversationWatcher = class {
|
|
|
5135
5763
|
onNewLineSpans;
|
|
5136
5764
|
onConversationChanged;
|
|
5137
5765
|
onFileDeleted;
|
|
5766
|
+
onTruncated;
|
|
5138
5767
|
onError;
|
|
5139
5768
|
constructor(events = {}) {
|
|
5140
5769
|
this.onNewLine = events.onNewLine;
|
|
@@ -5142,13 +5771,15 @@ var ConversationWatcher = class {
|
|
|
5142
5771
|
this.onNewLineSpans = events.onNewLineSpans;
|
|
5143
5772
|
this.onConversationChanged = events.onConversationChanged;
|
|
5144
5773
|
this.onFileDeleted = events.onFileDeleted;
|
|
5774
|
+
this.onTruncated = events.onTruncated;
|
|
5145
5775
|
this.onError = events.onError;
|
|
5146
5776
|
}
|
|
5147
5777
|
watch(filePath) {
|
|
5148
|
-
|
|
5778
|
+
const key = canonicalizeFilePath(filePath);
|
|
5779
|
+
if (this.files.has(key)) return;
|
|
5149
5780
|
let offset;
|
|
5150
5781
|
try {
|
|
5151
|
-
offset = (0,
|
|
5782
|
+
offset = (0, import_fs14.statSync)(filePath).size;
|
|
5152
5783
|
} catch {
|
|
5153
5784
|
offset = 0;
|
|
5154
5785
|
}
|
|
@@ -5157,23 +5788,24 @@ var ConversationWatcher = class {
|
|
|
5157
5788
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
|
|
5158
5789
|
});
|
|
5159
5790
|
watcher.on("change", () => {
|
|
5160
|
-
void this.readNewLines(
|
|
5791
|
+
void this.readNewLines(key);
|
|
5161
5792
|
});
|
|
5162
5793
|
watcher.on("add", () => {
|
|
5163
|
-
void this.readNewLines(
|
|
5794
|
+
void this.readNewLines(key);
|
|
5164
5795
|
});
|
|
5165
5796
|
watcher.on("unlink", () => this.onFileDeleted?.(filePath));
|
|
5166
5797
|
watcher.on("error", (err) => {
|
|
5167
5798
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
5168
5799
|
this.onError?.(filePath, error);
|
|
5169
5800
|
});
|
|
5170
|
-
this.files.set(
|
|
5801
|
+
this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
|
|
5171
5802
|
}
|
|
5172
5803
|
unwatch(filePath) {
|
|
5173
|
-
const
|
|
5804
|
+
const key = canonicalizeFilePath(filePath);
|
|
5805
|
+
const entry = this.files.get(key);
|
|
5174
5806
|
if (!entry) return;
|
|
5175
5807
|
void entry.watcher.close();
|
|
5176
|
-
this.files.delete(
|
|
5808
|
+
this.files.delete(key);
|
|
5177
5809
|
}
|
|
5178
5810
|
/**
|
|
5179
5811
|
* Re-drive the tail read for a file that's already being tailed. A per-file
|
|
@@ -5184,8 +5816,9 @@ var ConversationWatcher = class {
|
|
|
5184
5816
|
* event is a cheap stat + no-op. Returns false for untailed paths.
|
|
5185
5817
|
*/
|
|
5186
5818
|
poke(filePath) {
|
|
5187
|
-
|
|
5188
|
-
|
|
5819
|
+
const key = canonicalizeFilePath(filePath);
|
|
5820
|
+
if (!this.files.has(key)) return false;
|
|
5821
|
+
void this.readNewLines(key);
|
|
5189
5822
|
return true;
|
|
5190
5823
|
}
|
|
5191
5824
|
/**
|
|
@@ -5222,9 +5855,10 @@ var ConversationWatcher = class {
|
|
|
5222
5855
|
for (const [path] of this.files) this.unwatch(path);
|
|
5223
5856
|
for (const [dir] of this.directories) this.unwatchDirectory(dir);
|
|
5224
5857
|
}
|
|
5225
|
-
async readNewLines(
|
|
5226
|
-
const entry = this.files.get(
|
|
5858
|
+
async readNewLines(key) {
|
|
5859
|
+
const entry = this.files.get(key);
|
|
5227
5860
|
if (!entry) return;
|
|
5861
|
+
const filePath = entry.path;
|
|
5228
5862
|
if (entry.reading) {
|
|
5229
5863
|
entry.pending = true;
|
|
5230
5864
|
return;
|
|
@@ -5233,6 +5867,10 @@ var ConversationWatcher = class {
|
|
|
5233
5867
|
try {
|
|
5234
5868
|
for (; ; ) {
|
|
5235
5869
|
const st = await (0, import_promises5.stat)(filePath);
|
|
5870
|
+
if (st.size < entry.offset) {
|
|
5871
|
+
entry.offset = 0;
|
|
5872
|
+
this.onTruncated?.(filePath);
|
|
5873
|
+
}
|
|
5236
5874
|
if (st.size <= entry.offset) break;
|
|
5237
5875
|
const readFrom = entry.offset;
|
|
5238
5876
|
const bytesToRead = st.size - readFrom;
|
|
@@ -5245,7 +5883,7 @@ var ConversationWatcher = class {
|
|
|
5245
5883
|
}
|
|
5246
5884
|
const { spans, consumed } = splitCompleteLines(buf, readFrom);
|
|
5247
5885
|
entry.offset = readFrom + consumed;
|
|
5248
|
-
if (!this.files.has(
|
|
5886
|
+
if (!this.files.has(key)) return;
|
|
5249
5887
|
const lines = spans.map((s) => s.text);
|
|
5250
5888
|
if (spans.length > 0) {
|
|
5251
5889
|
this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
|
|
@@ -5265,9 +5903,9 @@ var ConversationWatcher = class {
|
|
|
5265
5903
|
this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
|
|
5266
5904
|
} finally {
|
|
5267
5905
|
entry.reading = false;
|
|
5268
|
-
if (entry.pending && this.files.has(
|
|
5906
|
+
if (entry.pending && this.files.has(key)) {
|
|
5269
5907
|
entry.pending = false;
|
|
5270
|
-
void this.readNewLines(
|
|
5908
|
+
void this.readNewLines(key);
|
|
5271
5909
|
}
|
|
5272
5910
|
}
|
|
5273
5911
|
}
|
|
@@ -5323,14 +5961,14 @@ function findSearchTarget(messages, query) {
|
|
|
5323
5961
|
}
|
|
5324
5962
|
|
|
5325
5963
|
// src/services/conversations/pruneAgentConversations.ts
|
|
5326
|
-
var
|
|
5964
|
+
var import_fs15 = require("fs");
|
|
5327
5965
|
function pruneAgentConversations(cache) {
|
|
5328
5966
|
const db = cache.getDatabase();
|
|
5329
5967
|
const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
|
|
5330
5968
|
let pruned = 0;
|
|
5331
5969
|
let missing = 0;
|
|
5332
5970
|
for (const row of rows) {
|
|
5333
|
-
if (!(0,
|
|
5971
|
+
if (!(0, import_fs15.existsSync)(row.file_path)) {
|
|
5334
5972
|
missing += 1;
|
|
5335
5973
|
continue;
|
|
5336
5974
|
}
|
|
@@ -5476,6 +6114,52 @@ function resolveAnswer(pending, body) {
|
|
|
5476
6114
|
}
|
|
5477
6115
|
}
|
|
5478
6116
|
|
|
6117
|
+
// src/services/sessions/conversationBusy.ts
|
|
6118
|
+
var import_fs16 = require("fs");
|
|
6119
|
+
var RESUME_BUSY_WINDOW_MS = 12e4;
|
|
6120
|
+
function resolveResumeBusyWindowMs(env = process.env) {
|
|
6121
|
+
const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
|
|
6122
|
+
if (raw === void 0) return RESUME_BUSY_WINDOW_MS;
|
|
6123
|
+
const n = Number.parseInt(raw, 10);
|
|
6124
|
+
return Number.isFinite(n) && n >= 0 ? n : RESUME_BUSY_WINDOW_MS;
|
|
6125
|
+
}
|
|
6126
|
+
var SELF_ACTIVITY_SKEW_MS = 5e3;
|
|
6127
|
+
function conversationBusy(input) {
|
|
6128
|
+
const now = input.now ?? Date.now();
|
|
6129
|
+
const windowMs = input.windowMs ?? RESUME_BUSY_WINDOW_MS;
|
|
6130
|
+
const platform3 = input.platform ?? process.platform;
|
|
6131
|
+
const detectedBy = [];
|
|
6132
|
+
let lastActivityMs = null;
|
|
6133
|
+
if (input.jsonlPath) {
|
|
6134
|
+
try {
|
|
6135
|
+
const mtimeMs = (0, import_fs16.statSync)(input.jsonlPath).mtimeMs;
|
|
6136
|
+
const age = now - mtimeMs;
|
|
6137
|
+
lastActivityMs = Math.max(0, age);
|
|
6138
|
+
const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
|
|
6139
|
+
if (age <= windowMs && !isSelfEcho) detectedBy.push("jsonl_mtime");
|
|
6140
|
+
} catch {
|
|
6141
|
+
}
|
|
6142
|
+
}
|
|
6143
|
+
const argvMatch = input.discovered.some((p) => p.conversationId === input.conversationId);
|
|
6144
|
+
if (argvMatch) detectedBy.push("process_argv");
|
|
6145
|
+
let cwdMatch = false;
|
|
6146
|
+
if (platform3 !== "win32" && input.projectPath) {
|
|
6147
|
+
const target = canonicalizeProjectPath(input.projectPath);
|
|
6148
|
+
cwdMatch = input.discovered.some(
|
|
6149
|
+
(p) => !!p.projectPath && canonicalizeProjectPath(p.projectPath) === target
|
|
6150
|
+
);
|
|
6151
|
+
if (cwdMatch) detectedBy.push("process_cwd");
|
|
6152
|
+
}
|
|
6153
|
+
return {
|
|
6154
|
+
busy: detectedBy.length > 0,
|
|
6155
|
+
detectedBy,
|
|
6156
|
+
lastActivityMs,
|
|
6157
|
+
// A matched process is a concrete external owner; a lone mtime hit could be
|
|
6158
|
+
// an editor, a crashed process, or a process we could not enumerate.
|
|
6159
|
+
likelyOwner: argvMatch || cwdMatch ? "external" : "unknown"
|
|
6160
|
+
};
|
|
6161
|
+
}
|
|
6162
|
+
|
|
5479
6163
|
// src/session-store.ts
|
|
5480
6164
|
var SessionStore = class {
|
|
5481
6165
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -5615,6 +6299,9 @@ function managedToResponse(s, ptyAttached) {
|
|
|
5615
6299
|
conversationId: s.id,
|
|
5616
6300
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
5617
6301
|
status: s.status,
|
|
6302
|
+
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6303
|
+
// `activity` is attached for managed sessions.
|
|
6304
|
+
ownership: "managed",
|
|
5618
6305
|
projectPath: s.projectPath,
|
|
5619
6306
|
projectName: s.projectName,
|
|
5620
6307
|
branch: s.branch,
|
|
@@ -5648,7 +6335,14 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5648
6335
|
id: conversationId,
|
|
5649
6336
|
conversationId,
|
|
5650
6337
|
provider: CLAUDE_CODE_PROVIDER,
|
|
6338
|
+
// Stays "idle" deliberately: we cannot see this process's prompt state, and
|
|
6339
|
+
// reporting `running` would route mobile to the destructive Overtake screen.
|
|
6340
|
+
// Liveness travels in the additive fields below instead.
|
|
5651
6341
|
status: "idle",
|
|
6342
|
+
ownership: "external",
|
|
6343
|
+
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6344
|
+
// report "gone" here — a vanished process simply stops being listed.
|
|
6345
|
+
processLiveness: "alive",
|
|
5652
6346
|
projectPath: d.projectPath,
|
|
5653
6347
|
projectName: d.projectName,
|
|
5654
6348
|
branch: d.branch,
|
|
@@ -5663,10 +6357,10 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5663
6357
|
}
|
|
5664
6358
|
|
|
5665
6359
|
// src/uploads.ts
|
|
5666
|
-
var
|
|
6360
|
+
var import_crypto9 = require("crypto");
|
|
5667
6361
|
var import_promises6 = require("fs/promises");
|
|
5668
6362
|
var import_heic_convert = __toESM(require("heic-convert"), 1);
|
|
5669
|
-
var
|
|
6363
|
+
var import_path16 = require("path");
|
|
5670
6364
|
var UPLOAD_DIR_NAME = ".threadbase-uploads";
|
|
5671
6365
|
var MAX_BYTES = 25 * 1024 * 1024;
|
|
5672
6366
|
var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
@@ -5697,11 +6391,11 @@ async function saveUploadFile(input) {
|
|
|
5697
6391
|
mimeType = "image/jpeg";
|
|
5698
6392
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
5699
6393
|
}
|
|
5700
|
-
const id = `up_${(0,
|
|
6394
|
+
const id = `up_${(0, import_crypto9.randomBytes)(8).toString("hex")}`;
|
|
5701
6395
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
5702
|
-
const dir = (0,
|
|
6396
|
+
const dir = (0, import_path16.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
5703
6397
|
await (0, import_promises6.mkdir)(dir, { recursive: true });
|
|
5704
|
-
const filePath = (0,
|
|
6398
|
+
const filePath = (0, import_path16.join)(dir, `${Date.now()}-${id}-${safeName}`);
|
|
5705
6399
|
await (0, import_promises6.writeFile)(filePath, buffer);
|
|
5706
6400
|
return {
|
|
5707
6401
|
id,
|
|
@@ -5713,7 +6407,7 @@ async function saveUploadFile(input) {
|
|
|
5713
6407
|
}
|
|
5714
6408
|
function sanitizeFilename(name) {
|
|
5715
6409
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
5716
|
-
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("");
|
|
6410
|
+
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("").replace(/[\s@"'`$\\]/g, "_");
|
|
5717
6411
|
return cleaned;
|
|
5718
6412
|
}
|
|
5719
6413
|
|
|
@@ -5746,12 +6440,12 @@ function normalizeCodexLineToClaudeShape(line) {
|
|
|
5746
6440
|
const text = extractCodexText(payload.content);
|
|
5747
6441
|
if (!text) return null;
|
|
5748
6442
|
if (role === "user" && isCodexInjectedContext(text)) return null;
|
|
5749
|
-
const
|
|
5750
|
-
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${
|
|
6443
|
+
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6444
|
+
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
5751
6445
|
return JSON.stringify({
|
|
5752
6446
|
type: role,
|
|
5753
6447
|
uuid,
|
|
5754
|
-
timestamp,
|
|
6448
|
+
timestamp: timestamp2,
|
|
5755
6449
|
message: {
|
|
5756
6450
|
role,
|
|
5757
6451
|
content: [{ type: "text", text }]
|
|
@@ -5805,9 +6499,9 @@ var import_node_crypto3 = require("crypto");
|
|
|
5805
6499
|
function computeConversationEtag({
|
|
5806
6500
|
filePath,
|
|
5807
6501
|
messageCount,
|
|
5808
|
-
timestamp
|
|
6502
|
+
timestamp: timestamp2
|
|
5809
6503
|
}) {
|
|
5810
|
-
const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${
|
|
6504
|
+
const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
5811
6505
|
return `"${digest}"`;
|
|
5812
6506
|
}
|
|
5813
6507
|
|
|
@@ -5957,8 +6651,17 @@ var WSHub = class {
|
|
|
5957
6651
|
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.`;
|
|
5958
6652
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
5959
6653
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6654
|
+
var GRACE_MAX_DEFERS = 4;
|
|
6655
|
+
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6656
|
+
var DISCOVERY_TTL_MS = 15e3;
|
|
6657
|
+
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
6658
|
+
var ADOPT_KILL_POLL_MS = 100;
|
|
5960
6659
|
var REFRESH_TTL_MS = 2e3;
|
|
5961
6660
|
var START_READY_TIMEOUT_MS = 1e4;
|
|
6661
|
+
var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
|
|
6662
|
+
var EXTERNAL_TAIL_MAX = 32;
|
|
6663
|
+
var EXTERNAL_TAIL_IDLE_MS = 3e5;
|
|
6664
|
+
var EXTERNAL_ACTIVE_WRITING_MS = 3e4;
|
|
5962
6665
|
function parseIncludeAgentsEnv(raw) {
|
|
5963
6666
|
if (raw === void 0) return false;
|
|
5964
6667
|
const v = raw.trim().toLowerCase();
|
|
@@ -5972,11 +6675,30 @@ var StreamerServer = class {
|
|
|
5972
6675
|
fileWatcher;
|
|
5973
6676
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
5974
6677
|
// sessionId → JSONL filePath
|
|
6678
|
+
// canonical JSONL path → live tail on a file NO PTY session owns (an external
|
|
6679
|
+
// agent is writing it). Deliberately separate from sessionFileMap so managed
|
|
6680
|
+
// session semantics — terminal_output, session_update, question cards — are
|
|
6681
|
+
// untouched: an external tail only ever pushes transcript lines.
|
|
6682
|
+
externalTails = /* @__PURE__ */ new Map();
|
|
5975
6683
|
// Per-file seq assignments from the most recent onNewLineSpans (offset index),
|
|
5976
6684
|
// handed to the immediately-following onNewLines so it can stamp WS `seq` on
|
|
5977
6685
|
// the matching conversation_events entries. Same read → same lines order.
|
|
5978
6686
|
pendingLineSeqs = /* @__PURE__ */ new Map();
|
|
6687
|
+
// `origin` records whether the pending question came from the live PTY-screen
|
|
6688
|
+
// path (handleLiveQuestion) or a JSONL flush. A JSONL-derived question must
|
|
6689
|
+
// never clobber a PTY-originated one for a DIFFERENT question — an external
|
|
6690
|
+
// agent appending an AskUserQuestion into a shared conversation would
|
|
6691
|
+
// otherwise misroute the answer into this streamer's PTY.
|
|
5979
6692
|
pendingQuestions = /* @__PURE__ */ new Map();
|
|
6693
|
+
// Sessions resumed past a detected collision (busy probe said busy, caller
|
|
6694
|
+
// forced). JSONL-derived actionable question cards are suppressed for these
|
|
6695
|
+
// because a line in the shared file may have been written by the other owner.
|
|
6696
|
+
contendedSessions = /* @__PURE__ */ new Set();
|
|
6697
|
+
// conversationId → ms epoch when THIS streamer's PTY for it last went idle.
|
|
6698
|
+
// Lets the resume collision probe tell our own trailing JSONL writes (a
|
|
6699
|
+
// hold → resume round trip) apart from another owner's. Pruned on write so it
|
|
6700
|
+
// cannot grow without bound across a long-lived process.
|
|
6701
|
+
selfPtyEndedAt = /* @__PURE__ */ new Map();
|
|
5980
6702
|
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
5981
6703
|
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
5982
6704
|
// the screen detection fires first, the later JSONL flush of the same question
|
|
@@ -6039,8 +6761,13 @@ var StreamerServer = class {
|
|
|
6039
6761
|
ptyGracePeriodMs;
|
|
6040
6762
|
defaultSystemPrompt;
|
|
6041
6763
|
defaultPermissionMode;
|
|
6764
|
+
defaultModel;
|
|
6765
|
+
defaultEffort;
|
|
6042
6766
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6043
6767
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6768
|
+
// Consecutive grace-timer defers for a still-`running` session (see
|
|
6769
|
+
// GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
|
|
6770
|
+
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6044
6771
|
// Map of sessionId → set of subscribed WS clients
|
|
6045
6772
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
6046
6773
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -6048,6 +6775,7 @@ var StreamerServer = class {
|
|
|
6048
6775
|
// Reverse map for cleanup on close
|
|
6049
6776
|
wsToClientId = /* @__PURE__ */ new Map();
|
|
6050
6777
|
cache = null;
|
|
6778
|
+
cacheMonitor = null;
|
|
6051
6779
|
projectsRepo = null;
|
|
6052
6780
|
conversationsRepo = null;
|
|
6053
6781
|
sessionsRepo = null;
|
|
@@ -6084,11 +6812,13 @@ var StreamerServer = class {
|
|
|
6084
6812
|
this.disableDb = config.disableDb ?? false;
|
|
6085
6813
|
this.scannerPersistenceDisabled = config.scannerPersistent === false;
|
|
6086
6814
|
this.scanProfiles = config.scanProfiles;
|
|
6087
|
-
this.codexRoots = config.codexRoots ?? [(0,
|
|
6815
|
+
this.codexRoots = config.codexRoots ?? [(0, import_path17.join)((0, import_os8.homedir)(), ".codex", "sessions")];
|
|
6088
6816
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
6089
6817
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
6090
6818
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6091
|
-
this.
|
|
6819
|
+
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6820
|
+
this.defaultEffort = config.defaultEffort ?? "low";
|
|
6821
|
+
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path17.join)((0, import_os8.homedir)(), ".threadbase", "cache");
|
|
6092
6822
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6093
6823
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
6094
6824
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -6129,7 +6859,7 @@ var StreamerServer = class {
|
|
|
6129
6859
|
const seqs = cache.extendMessageIndex(
|
|
6130
6860
|
filePath,
|
|
6131
6861
|
spans,
|
|
6132
|
-
(0,
|
|
6862
|
+
(0, import_fs17.statSync)(filePath),
|
|
6133
6863
|
readFrom,
|
|
6134
6864
|
endOffset
|
|
6135
6865
|
);
|
|
@@ -6159,39 +6889,25 @@ var StreamerServer = class {
|
|
|
6159
6889
|
},
|
|
6160
6890
|
onNewLines: (filePath, lines) => {
|
|
6161
6891
|
this.cache?.updateFromLines(filePath, lines);
|
|
6892
|
+
let managed = false;
|
|
6162
6893
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
6163
6894
|
if (watchedPath === filePath) {
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
for (const p of pending) {
|
|
6167
|
-
this.pendingQuestions.set(sessionId, p);
|
|
6168
|
-
const t = setTimeout(() => {
|
|
6169
|
-
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
6170
|
-
this.cancelPendingQuestion(sessionId);
|
|
6171
|
-
}
|
|
6172
|
-
}, 6e4);
|
|
6173
|
-
t.unref();
|
|
6174
|
-
}
|
|
6175
|
-
for (const m of messages) {
|
|
6176
|
-
const key = questionContentKey(m.questions);
|
|
6177
|
-
const broadcast = shouldBroadcastQuestion({
|
|
6178
|
-
newContentKey: key,
|
|
6179
|
-
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
6180
|
-
newToolUseId: m.toolUseId,
|
|
6181
|
-
priorToolUseId
|
|
6182
|
-
});
|
|
6183
|
-
this.pendingQuestionKey.set(sessionId, key);
|
|
6184
|
-
if (broadcast) this.wsHub.broadcast(m);
|
|
6185
|
-
}
|
|
6895
|
+
managed = true;
|
|
6896
|
+
this.processJsonlQuestions(sessionId, lines);
|
|
6186
6897
|
const seqs = this.pendingLineSeqs.get(filePath);
|
|
6187
6898
|
this.broadcastConversationLines(sessionId, lines, seqs);
|
|
6188
6899
|
break;
|
|
6189
6900
|
}
|
|
6190
6901
|
}
|
|
6902
|
+
if (!managed) {
|
|
6903
|
+
this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
|
|
6904
|
+
}
|
|
6191
6905
|
this.pendingLineSeqs.delete(filePath);
|
|
6192
6906
|
},
|
|
6193
6907
|
onConversationChanged: (filePath) => {
|
|
6194
|
-
this.fileWatcher.poke(filePath);
|
|
6908
|
+
const tailed = this.fileWatcher.poke(filePath);
|
|
6909
|
+
if (!tailed) this.maybeAttachExternalTail(filePath);
|
|
6910
|
+
this.sweepIdleExternalTails();
|
|
6195
6911
|
this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
|
|
6196
6912
|
this.markScannerStaleDebounced();
|
|
6197
6913
|
this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
|
|
@@ -6199,7 +6915,20 @@ var StreamerServer = class {
|
|
|
6199
6915
|
event: "cache.directory_change"
|
|
6200
6916
|
});
|
|
6201
6917
|
},
|
|
6918
|
+
onTruncated: (filePath) => {
|
|
6919
|
+
this.cache?.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
|
|
6920
|
+
this.cache?.clearIndexParseState(filePath);
|
|
6921
|
+
this.log.warn(`JSONL truncated/replaced; offset index dropped: ${filePath}`, {
|
|
6922
|
+
filePath,
|
|
6923
|
+
event: "tail.truncated"
|
|
6924
|
+
});
|
|
6925
|
+
},
|
|
6202
6926
|
onFileDeleted: (filePath) => {
|
|
6927
|
+
this.detachExternalTail(canonicalizeFilePath(filePath));
|
|
6928
|
+
if (this.cacheMonitor?.pending) {
|
|
6929
|
+
this.cacheMonitor.deferUnlink(filePath);
|
|
6930
|
+
return;
|
|
6931
|
+
}
|
|
6203
6932
|
const id = this.cache?.invalidateByFilePath(filePath);
|
|
6204
6933
|
if (id)
|
|
6205
6934
|
this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
|
|
@@ -6207,6 +6936,7 @@ var StreamerServer = class {
|
|
|
6207
6936
|
filePath,
|
|
6208
6937
|
event: "cache.invalidate_on_unlink"
|
|
6209
6938
|
});
|
|
6939
|
+
this.cacheMonitor?.recordUnlink(filePath);
|
|
6210
6940
|
}
|
|
6211
6941
|
});
|
|
6212
6942
|
this.ptyManager = new LiveSessionManager({
|
|
@@ -6270,6 +7000,8 @@ var StreamerServer = class {
|
|
|
6270
7000
|
this.cancelPendingQuestion(session.id);
|
|
6271
7001
|
}
|
|
6272
7002
|
this.pendingPermission.delete(session.id);
|
|
7003
|
+
this.contendedSessions.delete(session.id);
|
|
7004
|
+
this.rememberSelfPtyEnded(session.id);
|
|
6273
7005
|
}
|
|
6274
7006
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
6275
7007
|
if (resp) {
|
|
@@ -6293,7 +7025,7 @@ var StreamerServer = class {
|
|
|
6293
7025
|
temporalClient,
|
|
6294
7026
|
taskQueue: agentConfig.temporal.taskQueue
|
|
6295
7027
|
});
|
|
6296
|
-
const conversationsBaseDir = agentConfig.conversationsDir || (0,
|
|
7028
|
+
const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path17.join)((0, import_path17.dirname)(this.cacheDir), "conversations");
|
|
6297
7029
|
conversationWriter = createConversationWriter({
|
|
6298
7030
|
baseDir: conversationsBaseDir
|
|
6299
7031
|
});
|
|
@@ -6315,6 +7047,7 @@ var StreamerServer = class {
|
|
|
6315
7047
|
sessionStore: this.sessionStore,
|
|
6316
7048
|
wsHub: this.wsHub,
|
|
6317
7049
|
cache: () => this.cache,
|
|
7050
|
+
cacheMonitor: () => this.cacheMonitor,
|
|
6318
7051
|
projectsRepo: () => this.projectsRepo,
|
|
6319
7052
|
conversationsRepo: () => this.conversationsRepo,
|
|
6320
7053
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -6353,6 +7086,8 @@ var StreamerServer = class {
|
|
|
6353
7086
|
if (this.cacheReady) {
|
|
6354
7087
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
6355
7088
|
}
|
|
7089
|
+
const alertMsg = this.cacheMonitor?.wsMessage();
|
|
7090
|
+
if (alertMsg) this.wsHub.unicast(ws, alertMsg);
|
|
6356
7091
|
},
|
|
6357
7092
|
handleWsMessage: async (ws, raw) => {
|
|
6358
7093
|
try {
|
|
@@ -6468,6 +7203,17 @@ var StreamerServer = class {
|
|
|
6468
7203
|
ptyAttachedIds() {
|
|
6469
7204
|
return new Set(this.ptyManager.listSessions().map((s) => s.id));
|
|
6470
7205
|
}
|
|
7206
|
+
// Record that our own PTY for `conversationId` just ended. Entries older than
|
|
7207
|
+
// the busy window can never change a verdict, so drop them as we go rather
|
|
7208
|
+
// than accumulating one per conversation for the process's lifetime.
|
|
7209
|
+
rememberSelfPtyEnded(conversationId) {
|
|
7210
|
+
const now = Date.now();
|
|
7211
|
+
const cutoff = now - resolveResumeBusyWindowMs();
|
|
7212
|
+
for (const [id, at] of this.selfPtyEndedAt) {
|
|
7213
|
+
if (at < cutoff) this.selfPtyEndedAt.delete(id);
|
|
7214
|
+
}
|
|
7215
|
+
this.selfPtyEndedAt.set(conversationId, now);
|
|
7216
|
+
}
|
|
6471
7217
|
/**
|
|
6472
7218
|
* Send a session_list to only the client that triggered this HTTP request
|
|
6473
7219
|
* (identified by X-Client-Id header → registered WS socket). Falls back to
|
|
@@ -6498,6 +7244,7 @@ var StreamerServer = class {
|
|
|
6498
7244
|
clearTimeout(existing);
|
|
6499
7245
|
this.ptyGraceTimers.delete(sessionId);
|
|
6500
7246
|
}
|
|
7247
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6501
7248
|
}
|
|
6502
7249
|
startGraceTimer(sessionId, delayMs) {
|
|
6503
7250
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
@@ -6507,14 +7254,24 @@ var StreamerServer = class {
|
|
|
6507
7254
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
6508
7255
|
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6509
7256
|
if (resp?.status === "running") {
|
|
6510
|
-
this.
|
|
6511
|
-
|
|
6512
|
-
|
|
7257
|
+
const defers = (this.ptyGraceDeferCounts.get(sessionId) ?? 0) + 1;
|
|
7258
|
+
if (defers <= GRACE_MAX_DEFERS) {
|
|
7259
|
+
this.ptyGraceDeferCounts.set(sessionId, defers);
|
|
7260
|
+
this.log.info(
|
|
7261
|
+
`[grace] session ${sessionId} still running, deferring hold (${defers}/${GRACE_MAX_DEFERS})`,
|
|
7262
|
+
{ sessionId, event: "pty.grace_defer", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
7263
|
+
"pino"
|
|
7264
|
+
);
|
|
7265
|
+
this.startGraceTimer(sessionId, delayMs);
|
|
7266
|
+
return;
|
|
7267
|
+
}
|
|
7268
|
+
this.log.warn(
|
|
7269
|
+
`[grace] session ${sessionId} exceeded ${GRACE_MAX_DEFERS} defers, holding anyway`,
|
|
7270
|
+
{ sessionId, event: "pty.grace_defer_cap", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
6513
7271
|
"pino"
|
|
6514
7272
|
);
|
|
6515
|
-
this.startGraceTimer(sessionId, delayMs);
|
|
6516
|
-
return;
|
|
6517
7273
|
}
|
|
7274
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6518
7275
|
this.sessionSubscribers.delete(sessionId);
|
|
6519
7276
|
this.log.info(
|
|
6520
7277
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
@@ -6525,6 +7282,7 @@ var StreamerServer = class {
|
|
|
6525
7282
|
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6526
7283
|
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
6527
7284
|
} else {
|
|
7285
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6528
7286
|
this.sessionSubscribers.delete(sessionId);
|
|
6529
7287
|
}
|
|
6530
7288
|
}, delayMs);
|
|
@@ -6557,13 +7315,16 @@ var StreamerServer = class {
|
|
|
6557
7315
|
});
|
|
6558
7316
|
try {
|
|
6559
7317
|
this.cache = ConversationCache.open(
|
|
6560
|
-
(0,
|
|
7318
|
+
(0, import_path17.join)(this.cacheDir, "cache.db"),
|
|
6561
7319
|
this.tailSize,
|
|
6562
7320
|
void 0,
|
|
6563
7321
|
{
|
|
6564
7322
|
filterAgentConversations: !this.includeAgents,
|
|
6565
7323
|
agentEntrypoints: this.agentEntrypoints,
|
|
6566
|
-
onAgentFileDetected: (fp) =>
|
|
7324
|
+
onAgentFileDetected: (fp) => {
|
|
7325
|
+
this.fileWatcher.unwatch(fp);
|
|
7326
|
+
this.externalTails.delete(canonicalizeFilePath(fp));
|
|
7327
|
+
}
|
|
6567
7328
|
}
|
|
6568
7329
|
);
|
|
6569
7330
|
if (!this.includeAgents) {
|
|
@@ -6580,9 +7341,23 @@ var StreamerServer = class {
|
|
|
6580
7341
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
6581
7342
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
6582
7343
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
7344
|
+
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7345
|
+
this.cache,
|
|
7346
|
+
this.wsHub,
|
|
7347
|
+
this.log,
|
|
7348
|
+
this.cacheDir,
|
|
7349
|
+
async () => {
|
|
7350
|
+
const scanner = await this.rescanForRefresh();
|
|
7351
|
+
return [...scanner.getMetadataCache().values()];
|
|
7352
|
+
}
|
|
7353
|
+
);
|
|
6583
7354
|
for (const dir of this.projectsDirs()) {
|
|
6584
7355
|
this.fileWatcher.watchDirectory(dir);
|
|
6585
7356
|
}
|
|
7357
|
+
for (const dir of this.codexRoots) {
|
|
7358
|
+
if (!(0, import_fs17.existsSync)(dir)) continue;
|
|
7359
|
+
this.fileWatcher.watchDirectory(dir);
|
|
7360
|
+
}
|
|
6586
7361
|
} catch (err) {
|
|
6587
7362
|
const message = err instanceof Error ? err.message : String(err);
|
|
6588
7363
|
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
@@ -6649,11 +7424,19 @@ var StreamerServer = class {
|
|
|
6649
7424
|
}
|
|
6650
7425
|
);
|
|
6651
7426
|
}
|
|
6652
|
-
|
|
6653
|
-
this.
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
7427
|
+
await this.cacheMonitor?.runDetection();
|
|
7428
|
+
if (this.cacheMonitor?.pending) {
|
|
7429
|
+
this.log.warn("Startup ghost prune skipped \u2014 cache integrity alert pending", {
|
|
7430
|
+
fingerprint: this.cacheMonitor.pending.fingerprint,
|
|
7431
|
+
event: "cache.prune_ghosts_frozen"
|
|
7432
|
+
});
|
|
7433
|
+
} else {
|
|
7434
|
+
const pruned = this.cache.pruneGhostFiles();
|
|
7435
|
+
this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
|
|
7436
|
+
count: pruned.length,
|
|
7437
|
+
event: "cache.prune_ghosts"
|
|
7438
|
+
});
|
|
7439
|
+
}
|
|
6657
7440
|
}).catch((err) => {
|
|
6658
7441
|
const message = err instanceof Error ? err.message : String(err);
|
|
6659
7442
|
this.log.warn(`Startup cache warm-up failed: ${message}`, {
|
|
@@ -6765,6 +7548,7 @@ var StreamerServer = class {
|
|
|
6765
7548
|
this.cache?.close();
|
|
6766
7549
|
this.ptyManager.dispose();
|
|
6767
7550
|
this.fileWatcher.dispose();
|
|
7551
|
+
this.externalTails.clear();
|
|
6768
7552
|
this.wsHub.dispose();
|
|
6769
7553
|
this.pairTokens.dispose();
|
|
6770
7554
|
if (this.dbPool) {
|
|
@@ -6904,10 +7688,9 @@ var StreamerServer = class {
|
|
|
6904
7688
|
const metas2 = [...scanner2.getMetadataCache().values()];
|
|
6905
7689
|
try {
|
|
6906
7690
|
this.cache.upsertFromScannerMeta(metas2);
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
this.cache.reconcileDeletions(livePaths);
|
|
7691
|
+
if (!this.cacheMonitor?.pending) {
|
|
7692
|
+
this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
|
|
7693
|
+
}
|
|
6911
7694
|
} catch (err) {
|
|
6912
7695
|
this.log.warn(
|
|
6913
7696
|
`refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -7038,6 +7821,7 @@ var StreamerServer = class {
|
|
|
7038
7821
|
type: "conversation",
|
|
7039
7822
|
id: c.id,
|
|
7040
7823
|
status: "idle",
|
|
7824
|
+
ownership: "historical",
|
|
7041
7825
|
ptyAttached: false,
|
|
7042
7826
|
projectId: c.projectId ?? void 0,
|
|
7043
7827
|
projectPath: c.projectPath ?? "",
|
|
@@ -7064,21 +7848,19 @@ var StreamerServer = class {
|
|
|
7064
7848
|
if (!this.cache) return void 0;
|
|
7065
7849
|
if (!previousScanner) {
|
|
7066
7850
|
const persisted = this.cache.getScannerStatCache();
|
|
7067
|
-
|
|
7851
|
+
if (persisted.size === 0) return void 0;
|
|
7852
|
+
const nativeKeyed = /* @__PURE__ */ new Map();
|
|
7853
|
+
for (const [canonicalPath, entry] of persisted) {
|
|
7854
|
+
nativeKeyed.set(toNativeFilePath(canonicalPath), entry);
|
|
7855
|
+
}
|
|
7856
|
+
return nativeKeyed;
|
|
7068
7857
|
}
|
|
7069
7858
|
const dbStats = this.cache.getFileStats();
|
|
7070
7859
|
if (dbStats.size === 0) return void 0;
|
|
7071
|
-
const
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
}
|
|
7076
|
-
}
|
|
7077
|
-
const statCache = /* @__PURE__ */ new Map();
|
|
7078
|
-
for (const [filePath, stat3] of dbStats) {
|
|
7079
|
-
const meta = metaByPath.get(filePath);
|
|
7080
|
-
if (meta) statCache.set(filePath, { stat: stat3, meta });
|
|
7081
|
-
}
|
|
7860
|
+
const statCache = joinStatCacheByNativePath(
|
|
7861
|
+
previousScanner.getMetadataCache().values(),
|
|
7862
|
+
dbStats
|
|
7863
|
+
);
|
|
7082
7864
|
return statCache.size > 0 ? statCache : void 0;
|
|
7083
7865
|
}
|
|
7084
7866
|
// Returns the provider + codexRoots fragment to spread into every scan()/search() call.
|
|
@@ -7172,22 +7954,22 @@ var StreamerServer = class {
|
|
|
7172
7954
|
*/
|
|
7173
7955
|
projectsDirs() {
|
|
7174
7956
|
if (this.scanProfiles && this.scanProfiles.length > 0) {
|
|
7175
|
-
return this.scanProfiles.filter((p) => p.enabled).map((p) => (0,
|
|
7957
|
+
return this.scanProfiles.filter((p) => p.enabled).map((p) => (0, import_path17.join)(p.configDir, "projects"));
|
|
7176
7958
|
}
|
|
7177
|
-
return [(0,
|
|
7959
|
+
return [(0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects")];
|
|
7178
7960
|
}
|
|
7179
7961
|
findJsonlPath(uuid) {
|
|
7180
7962
|
const filename = `${uuid}.jsonl`;
|
|
7181
7963
|
for (const projectsDir of this.projectsDirs()) {
|
|
7182
|
-
if (!(0,
|
|
7183
|
-
for (const dir of (0,
|
|
7184
|
-
const fp = (0,
|
|
7185
|
-
if ((0,
|
|
7186
|
-
const projectDir = (0,
|
|
7964
|
+
if (!(0, import_fs17.existsSync)(projectsDir)) continue;
|
|
7965
|
+
for (const dir of (0, import_fs17.readdirSync)(projectsDir)) {
|
|
7966
|
+
const fp = (0, import_path17.join)(projectsDir, dir, filename);
|
|
7967
|
+
if ((0, import_fs17.existsSync)(fp)) return fp;
|
|
7968
|
+
const projectDir = (0, import_path17.join)(projectsDir, dir);
|
|
7187
7969
|
try {
|
|
7188
|
-
for (const sub of (0,
|
|
7189
|
-
const subagentPath = (0,
|
|
7190
|
-
if ((0,
|
|
7970
|
+
for (const sub of (0, import_fs17.readdirSync)(projectDir)) {
|
|
7971
|
+
const subagentPath = (0, import_path17.join)(projectDir, sub, "subagents", filename);
|
|
7972
|
+
if ((0, import_fs17.existsSync)(subagentPath)) return subagentPath;
|
|
7191
7973
|
}
|
|
7192
7974
|
} catch {
|
|
7193
7975
|
}
|
|
@@ -7197,7 +7979,7 @@ var StreamerServer = class {
|
|
|
7197
7979
|
}
|
|
7198
7980
|
async readCwdFromJsonl(filePath) {
|
|
7199
7981
|
return new Promise((resolve2) => {
|
|
7200
|
-
const rl = (0, import_readline.createInterface)({ input: (0,
|
|
7982
|
+
const rl = (0, import_readline.createInterface)({ input: (0, import_fs17.createReadStream)(filePath), crlfDelay: Infinity });
|
|
7201
7983
|
let found = false;
|
|
7202
7984
|
rl.on("line", (line) => {
|
|
7203
7985
|
if (found) return;
|
|
@@ -7267,6 +8049,140 @@ var StreamerServer = class {
|
|
|
7267
8049
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7268
8050
|
}
|
|
7269
8051
|
}
|
|
8052
|
+
// ─── External (non-PTY) live tails ───────────────────────────────
|
|
8053
|
+
/** True when a managed (PTY) session owns the tail for this canonical path. */
|
|
8054
|
+
isManagedTailPath(key) {
|
|
8055
|
+
for (const watchedPath of this.sessionFileMap.values()) {
|
|
8056
|
+
if (canonicalizeFilePath(watchedPath) === key) return true;
|
|
8057
|
+
}
|
|
8058
|
+
return false;
|
|
8059
|
+
}
|
|
8060
|
+
/**
|
|
8061
|
+
* Attach a live tail to a JSONL nobody is tailing yet, when it was touched
|
|
8062
|
+
* recently enough to look actively written by an external agent. Capped at
|
|
8063
|
+
* EXTERNAL_TAIL_MAX with LRU eviction.
|
|
8064
|
+
*/
|
|
8065
|
+
maybeAttachExternalTail(filePath) {
|
|
8066
|
+
if (!filePath.endsWith(".jsonl")) return;
|
|
8067
|
+
const key = canonicalizeFilePath(filePath);
|
|
8068
|
+
if (this.externalTails.has(key)) return;
|
|
8069
|
+
if (this.isManagedTailPath(key)) return;
|
|
8070
|
+
let mtimeMs;
|
|
8071
|
+
try {
|
|
8072
|
+
mtimeMs = (0, import_fs17.statSync)(filePath).mtimeMs;
|
|
8073
|
+
} catch {
|
|
8074
|
+
return;
|
|
8075
|
+
}
|
|
8076
|
+
const now = Date.now();
|
|
8077
|
+
if (now - mtimeMs > EXTERNAL_TAIL_RECENCY_MS) return;
|
|
8078
|
+
this.evictExternalTailsIfNeeded();
|
|
8079
|
+
this.externalTails.set(key, {
|
|
8080
|
+
conversationId: ConversationCache.conversationIdForFile(key),
|
|
8081
|
+
lastActivityAt: now
|
|
8082
|
+
});
|
|
8083
|
+
this.fileWatcher.watch(filePath);
|
|
8084
|
+
this.log.debug?.(`External tail attached: ${filePath}`, {
|
|
8085
|
+
filePath,
|
|
8086
|
+
tails: this.externalTails.size,
|
|
8087
|
+
event: "external_tail.attach"
|
|
8088
|
+
});
|
|
8089
|
+
}
|
|
8090
|
+
/** Stop tailing an external file and drop its bookkeeping. */
|
|
8091
|
+
detachExternalTail(key) {
|
|
8092
|
+
if (!this.externalTails.delete(key)) return;
|
|
8093
|
+
this.fileWatcher.unwatch(key);
|
|
8094
|
+
this.log.debug?.(`External tail detached: ${key}`, {
|
|
8095
|
+
filePath: key,
|
|
8096
|
+
event: "external_tail.detach"
|
|
8097
|
+
});
|
|
8098
|
+
}
|
|
8099
|
+
/** Make room for one more tail by evicting the least recently active ones. */
|
|
8100
|
+
evictExternalTailsIfNeeded() {
|
|
8101
|
+
while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
|
|
8102
|
+
let lruKey = null;
|
|
8103
|
+
let lruAt = Number.POSITIVE_INFINITY;
|
|
8104
|
+
for (const [key, entry] of this.externalTails) {
|
|
8105
|
+
if (this.isManagedTailPath(key)) {
|
|
8106
|
+
this.externalTails.delete(key);
|
|
8107
|
+
return;
|
|
8108
|
+
}
|
|
8109
|
+
if (entry.lastActivityAt < lruAt) {
|
|
8110
|
+
lruAt = entry.lastActivityAt;
|
|
8111
|
+
lruKey = key;
|
|
8112
|
+
}
|
|
8113
|
+
}
|
|
8114
|
+
if (!lruKey) return;
|
|
8115
|
+
this.detachExternalTail(lruKey);
|
|
8116
|
+
}
|
|
8117
|
+
}
|
|
8118
|
+
/**
|
|
8119
|
+
* INFERRED activity for an externally-owned conversation, derived purely from
|
|
8120
|
+
* how recently its JSONL grew (the external tail's bookkeeping). Returns
|
|
8121
|
+
* undefined when we hold no tail for it, so a session we know nothing about
|
|
8122
|
+
* reports no activity rather than a fabricated "quiet".
|
|
8123
|
+
*
|
|
8124
|
+
* This can never distinguish a generating agent from one blocked on a
|
|
8125
|
+
* permission gate — gates render on the PTY screen and never reach the JSONL —
|
|
8126
|
+
* which is why it is a separate field and not folded into `status`.
|
|
8127
|
+
*/
|
|
8128
|
+
externalActivityFor(conversationId, now = Date.now()) {
|
|
8129
|
+
for (const entry of this.externalTails.values()) {
|
|
8130
|
+
if (entry.conversationId !== conversationId) continue;
|
|
8131
|
+
return {
|
|
8132
|
+
state: now - entry.lastActivityAt <= EXTERNAL_ACTIVE_WRITING_MS ? "active_writing" : "quiet",
|
|
8133
|
+
lastEventAt: new Date(entry.lastActivityAt).toISOString(),
|
|
8134
|
+
source: "jsonl"
|
|
8135
|
+
};
|
|
8136
|
+
}
|
|
8137
|
+
return void 0;
|
|
8138
|
+
}
|
|
8139
|
+
/** Attach inferred `activity` to externally-owned sessions in a response set. */
|
|
8140
|
+
withExternalActivity(sessions) {
|
|
8141
|
+
if (this.externalTails.size === 0) return sessions;
|
|
8142
|
+
const now = Date.now();
|
|
8143
|
+
return sessions.map((s) => {
|
|
8144
|
+
if (s.ownership !== "external") return s;
|
|
8145
|
+
const activity = this.externalActivityFor(s.conversationId ?? s.id, now);
|
|
8146
|
+
return activity ? { ...s, activity } : s;
|
|
8147
|
+
});
|
|
8148
|
+
}
|
|
8149
|
+
/** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
|
|
8150
|
+
sweepIdleExternalTails(now = Date.now()) {
|
|
8151
|
+
for (const [key, entry] of [...this.externalTails]) {
|
|
8152
|
+
if (this.isManagedTailPath(key)) {
|
|
8153
|
+
this.externalTails.delete(key);
|
|
8154
|
+
continue;
|
|
8155
|
+
}
|
|
8156
|
+
if (now - entry.lastActivityAt > EXTERNAL_TAIL_IDLE_MS) this.detachExternalTail(key);
|
|
8157
|
+
}
|
|
8158
|
+
}
|
|
8159
|
+
/**
|
|
8160
|
+
* Push appended lines from an externally-owned conversation. Reuses the exact
|
|
8161
|
+
* conversation_events / conversation_event shapes mobile already consumes,
|
|
8162
|
+
* keyed by the conversation UUID — an external session has no PTY, so it must
|
|
8163
|
+
* never produce terminal_output / terminal_replay / session_ready, and never a
|
|
8164
|
+
* session_update whose session.id is a conversation UUID (that would mint a
|
|
8165
|
+
* phantom session row in the mobile cache). Question cards are likewise never
|
|
8166
|
+
* derived here: with no PTY there is nothing that could deliver an answer.
|
|
8167
|
+
*/
|
|
8168
|
+
broadcastExternalTailLines(filePath, lines, seqs) {
|
|
8169
|
+
const key = canonicalizeFilePath(filePath);
|
|
8170
|
+
const entry = this.externalTails.get(key);
|
|
8171
|
+
if (!entry) return;
|
|
8172
|
+
entry.lastActivityAt = Date.now();
|
|
8173
|
+
const conversationId = this.cache?.getIdByFilePath(key);
|
|
8174
|
+
if (!conversationId) return;
|
|
8175
|
+
entry.conversationId = conversationId;
|
|
8176
|
+
this.broadcastConversationLines(conversationId, lines, seqs);
|
|
8177
|
+
const meta = this.cache?.getMetaById(conversationId);
|
|
8178
|
+
this.wsHub.broadcast({
|
|
8179
|
+
type: "conversation_updated",
|
|
8180
|
+
conversationId,
|
|
8181
|
+
messageCount: meta?.messageCount ?? 0,
|
|
8182
|
+
lastActivity: meta?.lastActivity ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
8183
|
+
ownership: "external"
|
|
8184
|
+
});
|
|
8185
|
+
}
|
|
7270
8186
|
async findConversationByUuid(uuid) {
|
|
7271
8187
|
const lookupId = this.resolveConversationLookupId(uuid);
|
|
7272
8188
|
if (!this.scannerReady && !this.scanProfiles) {
|
|
@@ -7335,7 +8251,7 @@ var StreamerServer = class {
|
|
|
7335
8251
|
if (!conv.filePath) return false;
|
|
7336
8252
|
let mtimeMs = null;
|
|
7337
8253
|
try {
|
|
7338
|
-
mtimeMs = (0,
|
|
8254
|
+
mtimeMs = (0, import_fs17.statSync)(conv.filePath).mtimeMs;
|
|
7339
8255
|
} catch {
|
|
7340
8256
|
return false;
|
|
7341
8257
|
}
|
|
@@ -7669,7 +8585,6 @@ var StreamerServer = class {
|
|
|
7669
8585
|
});
|
|
7670
8586
|
}
|
|
7671
8587
|
async handleListSessions(url, res) {
|
|
7672
|
-
const DISCOVERY_TTL_MS = 15e3;
|
|
7673
8588
|
const now = Date.now();
|
|
7674
8589
|
if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
|
|
7675
8590
|
try {
|
|
@@ -7681,7 +8596,7 @@ var StreamerServer = class {
|
|
|
7681
8596
|
}
|
|
7682
8597
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
7683
8598
|
if (!hasPaginationParams) {
|
|
7684
|
-
json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
|
|
8599
|
+
json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
|
|
7685
8600
|
return;
|
|
7686
8601
|
}
|
|
7687
8602
|
const parsed = parseSessionListQuery(url);
|
|
@@ -7691,6 +8606,7 @@ var StreamerServer = class {
|
|
|
7691
8606
|
}
|
|
7692
8607
|
try {
|
|
7693
8608
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8609
|
+
page.sessions = this.withExternalActivity(page.sessions);
|
|
7694
8610
|
json(res, 200, page);
|
|
7695
8611
|
} catch (err) {
|
|
7696
8612
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -7703,7 +8619,7 @@ var StreamerServer = class {
|
|
|
7703
8619
|
handleGetSession(sessionId, res) {
|
|
7704
8620
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
7705
8621
|
if (session) {
|
|
7706
|
-
if (!(0,
|
|
8622
|
+
if (!(0, import_fs17.existsSync)(session.projectPath)) {
|
|
7707
8623
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
7708
8624
|
}
|
|
7709
8625
|
json(res, 200, session);
|
|
@@ -7717,7 +8633,6 @@ var StreamerServer = class {
|
|
|
7717
8633
|
json(res, 404, { error: "Session not found" });
|
|
7718
8634
|
}
|
|
7719
8635
|
async handleResume(req, res) {
|
|
7720
|
-
this.discoveryCache = null;
|
|
7721
8636
|
const body = await readBody(req);
|
|
7722
8637
|
const sessionId = body.sessionId ?? body.conversationId;
|
|
7723
8638
|
if (!sessionId) {
|
|
@@ -7746,14 +8661,56 @@ var StreamerServer = class {
|
|
|
7746
8661
|
json(res, 400, { error: "Could not determine project path" });
|
|
7747
8662
|
return;
|
|
7748
8663
|
}
|
|
8664
|
+
let discovered = [];
|
|
8665
|
+
const cached2 = this.discoveryCache;
|
|
8666
|
+
if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
|
|
8667
|
+
discovered = cached2.entries;
|
|
8668
|
+
} else {
|
|
8669
|
+
try {
|
|
8670
|
+
discovered = await Promise.race([
|
|
8671
|
+
discoverClaudeProcesses(),
|
|
8672
|
+
new Promise(
|
|
8673
|
+
(resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
|
|
8674
|
+
)
|
|
8675
|
+
]);
|
|
8676
|
+
if (discovered.length > 0) {
|
|
8677
|
+
this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
|
|
8678
|
+
}
|
|
8679
|
+
} catch {
|
|
8680
|
+
}
|
|
8681
|
+
}
|
|
8682
|
+
const busy = conversationBusy({
|
|
8683
|
+
conversationId: sessionId,
|
|
8684
|
+
projectPath,
|
|
8685
|
+
jsonlPath,
|
|
8686
|
+
discovered,
|
|
8687
|
+
windowMs: resolveResumeBusyWindowMs(),
|
|
8688
|
+
selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
|
|
8689
|
+
});
|
|
8690
|
+
if (busy.busy && body.force !== true) {
|
|
8691
|
+
json(res, 409, {
|
|
8692
|
+
error: "This conversation looks active in another session",
|
|
8693
|
+
code: "CONVERSATION_BUSY",
|
|
8694
|
+
detectedBy: busy.detectedBy,
|
|
8695
|
+
lastActivityMs: busy.lastActivityMs,
|
|
8696
|
+
likelyOwner: busy.likelyOwner
|
|
8697
|
+
});
|
|
8698
|
+
return;
|
|
8699
|
+
}
|
|
8700
|
+
if (busy.busy) {
|
|
8701
|
+
this.contendedSessions.add(sessionId);
|
|
8702
|
+
}
|
|
7749
8703
|
const cachedConvMeta = this.cache?.getMetaById(sessionId);
|
|
7750
8704
|
const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
|
|
8705
|
+
this.discoveryCache = null;
|
|
7751
8706
|
const session = await this.ptyManager.start(sessionId, {
|
|
7752
8707
|
provider,
|
|
7753
8708
|
projectPath,
|
|
7754
8709
|
projectName: body.projectName,
|
|
7755
8710
|
branch: body.branch,
|
|
7756
|
-
permissionMode: this.defaultPermissionMode
|
|
8711
|
+
permissionMode: this.defaultPermissionMode,
|
|
8712
|
+
model: this.defaultModel,
|
|
8713
|
+
effort: this.defaultEffort
|
|
7757
8714
|
});
|
|
7758
8715
|
this.sessionStore.addManaged(session);
|
|
7759
8716
|
void this.watchConversationFile(sessionId);
|
|
@@ -7879,6 +8836,45 @@ var StreamerServer = class {
|
|
|
7879
8836
|
json(res, 400, { error: message });
|
|
7880
8837
|
}
|
|
7881
8838
|
}
|
|
8839
|
+
// Store + broadcast AskUserQuestion cards found in a JSONL batch for a watched
|
|
8840
|
+
// session. Two P0 safety guards on top of the screen/JSONL de-dupe:
|
|
8841
|
+
// (a) contended file → suppress JSONL-derived cards entirely (a line may be
|
|
8842
|
+
// the OTHER owner's question); the streamer's own PTY questions still
|
|
8843
|
+
// arrive via the live-screen path (handleLiveQuestion), not suppressed.
|
|
8844
|
+
// (b) a JSONL question must never clobber a PTY-screen question that is a
|
|
8845
|
+
// DIFFERENT question — answering it would type into this streamer's PTY.
|
|
8846
|
+
// Same-content re-syncs (screen synthetic id → real toolUseId) still pass.
|
|
8847
|
+
processJsonlQuestions(sessionId, lines) {
|
|
8848
|
+
const priorPending = this.pendingQuestions.get(sessionId);
|
|
8849
|
+
const priorToolUseId = priorPending?.toolUseId;
|
|
8850
|
+
const contended = this.contendedSessions.has(sessionId);
|
|
8851
|
+
const priorPtyKey = priorPending?.origin === "pty" ? questionContentKey(priorPending.questions) : null;
|
|
8852
|
+
const foreignVsPty = (questions) => priorPtyKey !== null && questionContentKey(questions) !== priorPtyKey;
|
|
8853
|
+
const { messages, pending } = questionsFromLines(sessionId, lines);
|
|
8854
|
+
for (const p of pending) {
|
|
8855
|
+
if (contended || foreignVsPty(p.questions)) continue;
|
|
8856
|
+
const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
|
|
8857
|
+
this.pendingQuestions.set(sessionId, { ...p, origin });
|
|
8858
|
+
const t = setTimeout(() => {
|
|
8859
|
+
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
8860
|
+
this.cancelPendingQuestion(sessionId);
|
|
8861
|
+
}
|
|
8862
|
+
}, 6e4);
|
|
8863
|
+
t.unref();
|
|
8864
|
+
}
|
|
8865
|
+
for (const m of messages) {
|
|
8866
|
+
if (contended || foreignVsPty(m.questions)) continue;
|
|
8867
|
+
const key = questionContentKey(m.questions);
|
|
8868
|
+
const broadcast = shouldBroadcastQuestion({
|
|
8869
|
+
newContentKey: key,
|
|
8870
|
+
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
8871
|
+
newToolUseId: m.toolUseId,
|
|
8872
|
+
priorToolUseId
|
|
8873
|
+
});
|
|
8874
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
8875
|
+
if (broadcast) this.wsHub.broadcast(m);
|
|
8876
|
+
}
|
|
8877
|
+
}
|
|
7882
8878
|
cancelPendingQuestion(sessionId) {
|
|
7883
8879
|
const pq = this.pendingQuestions.get(sessionId);
|
|
7884
8880
|
if (!pq) return;
|
|
@@ -7895,7 +8891,7 @@ var StreamerServer = class {
|
|
|
7895
8891
|
const key = questionContentKey(questions);
|
|
7896
8892
|
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
7897
8893
|
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
7898
|
-
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
8894
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
|
|
7899
8895
|
this.pendingQuestionKey.set(sessionId, key);
|
|
7900
8896
|
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
7901
8897
|
}
|
|
@@ -8078,12 +9074,40 @@ var StreamerServer = class {
|
|
|
8078
9074
|
json(res, 400, { error: "Session has no known PID" });
|
|
8079
9075
|
return;
|
|
8080
9076
|
}
|
|
9077
|
+
if (!projectPath) {
|
|
9078
|
+
this.log.warn("adopt: refusing, working directory unknown", {
|
|
9079
|
+
event: "adopt.no_project_path",
|
|
9080
|
+
sessionId,
|
|
9081
|
+
pid: discSession.pid
|
|
9082
|
+
});
|
|
9083
|
+
json(res, 400, {
|
|
9084
|
+
error: "Cannot take over this session: its working directory could not be determined on this platform",
|
|
9085
|
+
code: "ADOPT_NO_PROJECT_PATH"
|
|
9086
|
+
});
|
|
9087
|
+
return;
|
|
9088
|
+
}
|
|
8081
9089
|
this.ptyManager.killPid(discSession.pid);
|
|
9090
|
+
const exited = await waitForProcessExit(discSession.pid, ADOPT_KILL_TIMEOUT_MS);
|
|
9091
|
+
if (!exited) {
|
|
9092
|
+
this.log.warn("adopt: external process did not exit; refusing to double-write", {
|
|
9093
|
+
event: "adopt.kill_timeout",
|
|
9094
|
+
sessionId,
|
|
9095
|
+
pid: discSession.pid
|
|
9096
|
+
});
|
|
9097
|
+
json(res, 409, {
|
|
9098
|
+
error: "The existing process did not exit; not starting a second agent on this conversation",
|
|
9099
|
+
code: "ADOPT_KILL_TIMEOUT",
|
|
9100
|
+
pid: discSession.pid
|
|
9101
|
+
});
|
|
9102
|
+
return;
|
|
9103
|
+
}
|
|
8082
9104
|
const session = await this.ptyManager.start(convId, {
|
|
8083
9105
|
projectPath,
|
|
8084
9106
|
projectName,
|
|
8085
9107
|
branch,
|
|
8086
|
-
permissionMode: this.defaultPermissionMode
|
|
9108
|
+
permissionMode: this.defaultPermissionMode,
|
|
9109
|
+
model: this.defaultModel,
|
|
9110
|
+
effort: this.defaultEffort
|
|
8087
9111
|
});
|
|
8088
9112
|
this.sessionStore.addManaged(session);
|
|
8089
9113
|
void this.watchConversationFile(session.id);
|
|
@@ -8107,7 +9131,7 @@ var StreamerServer = class {
|
|
|
8107
9131
|
sessionStore: this.sessionStore,
|
|
8108
9132
|
// biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
|
|
8109
9133
|
agentClient: this.agentClient,
|
|
8110
|
-
conversationsDir: this.cacheDir ? (0,
|
|
9134
|
+
conversationsDir: this.cacheDir ? (0, import_path17.join)((0, import_path17.dirname)(this.cacheDir), "conversations") : "",
|
|
8111
9135
|
agentConfig: this.agentConfig
|
|
8112
9136
|
});
|
|
8113
9137
|
json(res, result.status, result.body);
|
|
@@ -8154,7 +9178,9 @@ var StreamerServer = class {
|
|
|
8154
9178
|
projectPath: resolvedPath,
|
|
8155
9179
|
projectName: body.projectName,
|
|
8156
9180
|
systemPrompt: systemPromptParts.join("\n"),
|
|
8157
|
-
permissionMode: this.defaultPermissionMode
|
|
9181
|
+
permissionMode: this.defaultPermissionMode,
|
|
9182
|
+
model: this.defaultModel,
|
|
9183
|
+
effort: this.defaultEffort
|
|
8158
9184
|
});
|
|
8159
9185
|
this.sessionStore.addManaged(session);
|
|
8160
9186
|
const readyOrFailed = new Promise((resolve2) => {
|
|
@@ -8243,14 +9269,30 @@ var StreamerServer = class {
|
|
|
8243
9269
|
} catch {
|
|
8244
9270
|
}
|
|
8245
9271
|
}
|
|
9272
|
+
// Read just the `sessionId` field from a JSONL's first line, used by the
|
|
9273
|
+
// watchForJsonl fallback to confirm a candidate file's identity before
|
|
9274
|
+
// binding it. Reads only up to the first newline so a large actively-written
|
|
9275
|
+
// file isn't slurped in full.
|
|
9276
|
+
readFirstLineSessionId(filePath) {
|
|
9277
|
+
try {
|
|
9278
|
+
const content = (0, import_fs17.readFileSync)(filePath, "utf8");
|
|
9279
|
+
const nl = content.indexOf("\n");
|
|
9280
|
+
const firstLine = nl === -1 ? content : content.slice(0, nl);
|
|
9281
|
+
if (!firstLine.trim()) return null;
|
|
9282
|
+
const obj = JSON.parse(firstLine);
|
|
9283
|
+
return typeof obj.sessionId === "string" ? obj.sessionId : null;
|
|
9284
|
+
} catch {
|
|
9285
|
+
return null;
|
|
9286
|
+
}
|
|
9287
|
+
}
|
|
8246
9288
|
// Watch the project directory for the JSONL file Claude creates for sessionId.
|
|
8247
9289
|
// Once found, wire up structured event streaming. No rekeying needed — the UUID
|
|
8248
9290
|
// was passed to Claude via --session-id so the filename matches from the start.
|
|
8249
9291
|
watchForJsonl(sessionId, projectPath) {
|
|
8250
9292
|
const encoded = projectPath.replace(/[/\\:.]/g, "-");
|
|
8251
|
-
const projectsDir = (0,
|
|
9293
|
+
const projectsDir = (0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects", encoded);
|
|
8252
9294
|
const expectedFile = `${sessionId}.jsonl`;
|
|
8253
|
-
const filePath = (0,
|
|
9295
|
+
const filePath = (0, import_path17.join)(projectsDir, expectedFile);
|
|
8254
9296
|
const deadline = Date.now() + 12e4;
|
|
8255
9297
|
let watcher = null;
|
|
8256
9298
|
const cleanup = () => {
|
|
@@ -8268,26 +9310,28 @@ var StreamerServer = class {
|
|
|
8268
9310
|
cleanup();
|
|
8269
9311
|
return;
|
|
8270
9312
|
}
|
|
8271
|
-
let resolvedFilePath = (0,
|
|
8272
|
-
if (!resolvedFilePath && (0,
|
|
9313
|
+
let resolvedFilePath = (0, import_fs17.existsSync)(filePath) ? filePath : null;
|
|
9314
|
+
if (!resolvedFilePath && (0, import_fs17.existsSync)(projectsDir)) {
|
|
8273
9315
|
try {
|
|
8274
9316
|
const now = Date.now();
|
|
8275
|
-
const
|
|
8276
|
-
|
|
9317
|
+
const match = (0, import_fs17.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs17.statSync)((0, import_path17.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
|
|
9318
|
+
({ f }) => (0, import_path17.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path17.join)(projectsDir, f)) === sessionId
|
|
9319
|
+
).sort((a, b) => b.mtime - a.mtime)[0];
|
|
9320
|
+
if (match) resolvedFilePath = (0, import_path17.join)(projectsDir, match.f);
|
|
8277
9321
|
} catch {
|
|
8278
9322
|
}
|
|
8279
9323
|
}
|
|
8280
9324
|
if (!resolvedFilePath) return;
|
|
8281
9325
|
cleanup();
|
|
8282
9326
|
this.sessionFileMap.set(sessionId, resolvedFilePath);
|
|
8283
|
-
this.fileWatcher.watch(resolvedFilePath);
|
|
8284
9327
|
try {
|
|
8285
|
-
const existing = (0,
|
|
9328
|
+
const existing = (0, import_fs17.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
8286
9329
|
if (existing.length > 0) {
|
|
8287
9330
|
this.broadcastConversationLines(sessionId, existing);
|
|
8288
9331
|
}
|
|
8289
9332
|
} catch {
|
|
8290
9333
|
}
|
|
9334
|
+
this.fileWatcher.watch(resolvedFilePath);
|
|
8291
9335
|
if (this.scannerReady) {
|
|
8292
9336
|
this.scannerStale = true;
|
|
8293
9337
|
} else {
|
|
@@ -8305,7 +9349,7 @@ var StreamerServer = class {
|
|
|
8305
9349
|
if (this.sessionFileMap.has(sessionId)) return;
|
|
8306
9350
|
try {
|
|
8307
9351
|
require("fs").mkdirSync(projectsDir, { recursive: true });
|
|
8308
|
-
watcher = (0,
|
|
9352
|
+
watcher = (0, import_fs17.watch)(projectsDir, tryWire);
|
|
8309
9353
|
watcher.on("error", cleanup);
|
|
8310
9354
|
} catch {
|
|
8311
9355
|
}
|
|
@@ -8321,7 +9365,7 @@ var StreamerServer = class {
|
|
|
8321
9365
|
watchForCodexRollout(sessionId, projectPath) {
|
|
8322
9366
|
const deadline = Date.now() + 12e4;
|
|
8323
9367
|
const now = /* @__PURE__ */ new Date();
|
|
8324
|
-
const dateDir = (0,
|
|
9368
|
+
const dateDir = (0, import_path17.join)(
|
|
8325
9369
|
String(now.getFullYear()),
|
|
8326
9370
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
8327
9371
|
String(now.getDate()).padStart(2, "0")
|
|
@@ -8334,7 +9378,7 @@ var StreamerServer = class {
|
|
|
8334
9378
|
};
|
|
8335
9379
|
const matchesProjectPath = (candidatePath) => {
|
|
8336
9380
|
try {
|
|
8337
|
-
const firstLine = (0,
|
|
9381
|
+
const firstLine = (0, import_fs17.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
|
|
8338
9382
|
if (!firstLine) return null;
|
|
8339
9383
|
const parsed = JSON.parse(firstLine);
|
|
8340
9384
|
if (parsed?.type !== "session_meta") return null;
|
|
@@ -8362,18 +9406,18 @@ var StreamerServer = class {
|
|
|
8362
9406
|
this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
|
|
8363
9407
|
);
|
|
8364
9408
|
for (const root of this.codexRoots) {
|
|
8365
|
-
const sessionsDir = (0,
|
|
8366
|
-
if (!(0,
|
|
9409
|
+
const sessionsDir = (0, import_path17.join)(root, dateDir);
|
|
9410
|
+
if (!(0, import_fs17.existsSync)(sessionsDir)) continue;
|
|
8367
9411
|
let candidateFiles;
|
|
8368
9412
|
try {
|
|
8369
|
-
candidateFiles = (0,
|
|
9413
|
+
candidateFiles = (0, import_fs17.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
8370
9414
|
} catch {
|
|
8371
9415
|
continue;
|
|
8372
9416
|
}
|
|
8373
9417
|
const nowMs = Date.now();
|
|
8374
|
-
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0,
|
|
9418
|
+
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs17.statSync)((0, import_path17.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
|
|
8375
9419
|
for (const { f } of recentCandidates) {
|
|
8376
|
-
const candidatePath = (0,
|
|
9420
|
+
const candidatePath = (0, import_path17.join)(sessionsDir, f);
|
|
8377
9421
|
const match = matchesProjectPath(candidatePath);
|
|
8378
9422
|
if (!match) continue;
|
|
8379
9423
|
if (boundElsewhere.has(match.id)) continue;
|
|
@@ -8383,7 +9427,7 @@ var StreamerServer = class {
|
|
|
8383
9427
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
8384
9428
|
this.fileWatcher.watch(candidatePath);
|
|
8385
9429
|
try {
|
|
8386
|
-
const existing = (0,
|
|
9430
|
+
const existing = (0, import_fs17.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
8387
9431
|
if (existing.length > 0) {
|
|
8388
9432
|
this.broadcastConversationLines(sessionId, existing);
|
|
8389
9433
|
}
|
|
@@ -8503,9 +9547,21 @@ var StreamerServer = class {
|
|
|
8503
9547
|
json(res, 200, this.cache.listSessionNames());
|
|
8504
9548
|
}
|
|
8505
9549
|
};
|
|
9550
|
+
async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
|
|
9551
|
+
const deadline = Date.now() + timeoutMs;
|
|
9552
|
+
for (; ; ) {
|
|
9553
|
+
try {
|
|
9554
|
+
process.kill(pid, 0);
|
|
9555
|
+
} catch {
|
|
9556
|
+
return true;
|
|
9557
|
+
}
|
|
9558
|
+
if (Date.now() >= deadline) return false;
|
|
9559
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollMs));
|
|
9560
|
+
}
|
|
9561
|
+
}
|
|
8506
9562
|
function classifyResumability(cwd) {
|
|
8507
9563
|
if (!cwd) return { resumable: true };
|
|
8508
|
-
if ((0,
|
|
9564
|
+
if ((0, import_fs17.existsSync)(cwd)) return { resumable: true };
|
|
8509
9565
|
const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
|
|
8510
9566
|
return {
|
|
8511
9567
|
resumable: false,
|
|
@@ -8520,6 +9576,9 @@ function conversationToResumableSession(c) {
|
|
|
8520
9576
|
id: c.id,
|
|
8521
9577
|
conversationId: c.id,
|
|
8522
9578
|
status: "on_hold",
|
|
9579
|
+
// A cached conversation with no process behind it. Distinguishes "nobody is
|
|
9580
|
+
// running this" from an external session that IS live (ownership "external").
|
|
9581
|
+
ownership: "historical",
|
|
8523
9582
|
ptyAttached: false,
|
|
8524
9583
|
projectId: c.projectId ?? void 0,
|
|
8525
9584
|
projectPath: c.projectPath ?? "",
|