@threadbase-sh/streamer 1.33.0 → 1.35.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 +2087 -2612
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1325 -210
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +248 -12
- package/dist/index.d.ts +248 -12
- package/dist/index.js +1320 -205
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2208,6 +2208,14 @@ var PTYManager = class {
|
|
|
2208
2208
|
if (session?.status !== "running") return;
|
|
2209
2209
|
if (this.pendingReady.has(sessionId)) {
|
|
2210
2210
|
this.markReady(sessionId, session, "quiet:timeout");
|
|
2211
|
+
} else {
|
|
2212
|
+
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2213
|
+
this.log.warn("[pty.ready] screen recheck failed", {
|
|
2214
|
+
event: "pty.ready_recheck_failed",
|
|
2215
|
+
sessionId,
|
|
2216
|
+
err
|
|
2217
|
+
});
|
|
2218
|
+
});
|
|
2211
2219
|
}
|
|
2212
2220
|
this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
|
|
2213
2221
|
this.log.warn("[pty.prompt_detect] failed", {
|
|
@@ -2217,6 +2225,20 @@ var PTYManager = class {
|
|
|
2217
2225
|
});
|
|
2218
2226
|
});
|
|
2219
2227
|
}
|
|
2228
|
+
// Re-check the rendered screen (not just the last chunk) for a prompt
|
|
2229
|
+
// marker. Only meaningful once pendingReady is already clear — the boot
|
|
2230
|
+
// fallback above covers the first prompt after spawn/resume. Scoped to a
|
|
2231
|
+
// full viewport (PTY_ROWS), not just the last few lines: "on screen" means
|
|
2232
|
+
// whatever a user attached to this PTY would currently see.
|
|
2233
|
+
async recheckReadyFromScreen(sessionId) {
|
|
2234
|
+
const session = this.sessions.get(sessionId);
|
|
2235
|
+
if (session?.status !== "running") return;
|
|
2236
|
+
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2237
|
+
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2238
|
+
if (matchedMarker && session.status === "running") {
|
|
2239
|
+
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2220
2242
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2221
2243
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2222
2244
|
markReady(sessionId, session, reason) {
|
|
@@ -2389,6 +2411,44 @@ async function discoverClaudeProcesses() {
|
|
|
2389
2411
|
if (platform2() === "win32") return discoverWindows();
|
|
2390
2412
|
return discoverUnix();
|
|
2391
2413
|
}
|
|
2414
|
+
var CLAUDE_CLI_SCRIPT = /claude-code[\\/](?:cli|index)\.(?:js|mjs|cjs)$/i;
|
|
2415
|
+
var JS_RUNTIMES = /* @__PURE__ */ new Set(["node", "node.exe", "bun", "bun.exe", "deno", "deno.exe"]);
|
|
2416
|
+
function tokenizeCommandLine(commandLine) {
|
|
2417
|
+
const tokens = [];
|
|
2418
|
+
let current = "";
|
|
2419
|
+
let quoted = false;
|
|
2420
|
+
for (const ch of commandLine) {
|
|
2421
|
+
if (ch === '"') {
|
|
2422
|
+
quoted = !quoted;
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
if (!quoted && (ch === " " || ch === " ")) {
|
|
2426
|
+
if (current) tokens.push(current);
|
|
2427
|
+
current = "";
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
current += ch;
|
|
2431
|
+
}
|
|
2432
|
+
if (current) tokens.push(current);
|
|
2433
|
+
return tokens;
|
|
2434
|
+
}
|
|
2435
|
+
function exeBaseName(token) {
|
|
2436
|
+
const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
|
|
2437
|
+
return cut === -1 ? token : token.slice(cut + 1);
|
|
2438
|
+
}
|
|
2439
|
+
function looksLikeClaudeProcess(commandLine) {
|
|
2440
|
+
const tokens = tokenizeCommandLine(commandLine);
|
|
2441
|
+
if (tokens.length === 0) return false;
|
|
2442
|
+
const exe = exeBaseName(tokens[0]).toLowerCase();
|
|
2443
|
+
if (exe === "claude" || exe === "claude.exe") return true;
|
|
2444
|
+
if (JS_RUNTIMES.has(exe)) {
|
|
2445
|
+
for (const raw of tokens.slice(1)) {
|
|
2446
|
+
if (raw.startsWith("-")) continue;
|
|
2447
|
+
return CLAUDE_CLI_SCRIPT.test(raw);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
return false;
|
|
2451
|
+
}
|
|
2392
2452
|
async function discoverUnix() {
|
|
2393
2453
|
const pids = await getPidsUnix();
|
|
2394
2454
|
const results = await Promise.all(
|
|
@@ -2416,6 +2476,8 @@ async function discoverUnix() {
|
|
|
2416
2476
|
return results.filter((r) => r !== null);
|
|
2417
2477
|
}
|
|
2418
2478
|
async function discoverWindows() {
|
|
2479
|
+
const viaCim = await discoverWindowsViaCim();
|
|
2480
|
+
if (viaCim) return viaCim;
|
|
2419
2481
|
const pids = await getPidsWindows();
|
|
2420
2482
|
const results = await Promise.all(
|
|
2421
2483
|
pids.map(async (pid) => {
|
|
@@ -2450,7 +2512,24 @@ function run(cmd, args, opts = {}) {
|
|
|
2450
2512
|
);
|
|
2451
2513
|
});
|
|
2452
2514
|
}
|
|
2515
|
+
function parsePsOutput(stdout) {
|
|
2516
|
+
const pids = [];
|
|
2517
|
+
for (const line of stdout.split("\n")) {
|
|
2518
|
+
const trimmed = line.trim();
|
|
2519
|
+
if (!trimmed) continue;
|
|
2520
|
+
const match = trimmed.match(/^(\d+)\s+(.*)$/);
|
|
2521
|
+
if (!match) continue;
|
|
2522
|
+
const pid = Number.parseInt(match[1], 10);
|
|
2523
|
+
if (!(pid > 0)) continue;
|
|
2524
|
+
if (looksLikeClaudeProcess(match[2])) pids.push(pid);
|
|
2525
|
+
}
|
|
2526
|
+
return pids;
|
|
2527
|
+
}
|
|
2453
2528
|
async function getPidsUnix() {
|
|
2529
|
+
try {
|
|
2530
|
+
return parsePsOutput(await run("ps", ["-eo", "pid=,args="]));
|
|
2531
|
+
} catch {
|
|
2532
|
+
}
|
|
2454
2533
|
try {
|
|
2455
2534
|
const output = await run("pgrep", ["-x", "claude"]);
|
|
2456
2535
|
return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
|
|
@@ -2471,6 +2550,54 @@ async function getProcessStartTimeUnix(pid) {
|
|
|
2471
2550
|
const d = new Date(raw);
|
|
2472
2551
|
return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
|
|
2473
2552
|
}
|
|
2553
|
+
function parseCimProcesses(stdout) {
|
|
2554
|
+
const trimmed = stdout.trim();
|
|
2555
|
+
if (!trimmed) return [];
|
|
2556
|
+
const parsed = JSON.parse(trimmed);
|
|
2557
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
2558
|
+
}
|
|
2559
|
+
function parseCimDate(value) {
|
|
2560
|
+
if (value) {
|
|
2561
|
+
const epoch = value.match(/\/Date\((\d+)\)\//);
|
|
2562
|
+
if (epoch) return new Date(Number(epoch[1]));
|
|
2563
|
+
const d = new Date(value);
|
|
2564
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
2565
|
+
}
|
|
2566
|
+
return /* @__PURE__ */ new Date();
|
|
2567
|
+
}
|
|
2568
|
+
async function discoverWindowsViaCim() {
|
|
2569
|
+
let stdout;
|
|
2570
|
+
try {
|
|
2571
|
+
stdout = await run("powershell.exe", [
|
|
2572
|
+
"-NoProfile",
|
|
2573
|
+
"-NonInteractive",
|
|
2574
|
+
"-Command",
|
|
2575
|
+
"Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
|
|
2576
|
+
]);
|
|
2577
|
+
} catch {
|
|
2578
|
+
return null;
|
|
2579
|
+
}
|
|
2580
|
+
let rows;
|
|
2581
|
+
try {
|
|
2582
|
+
rows = parseCimProcesses(stdout);
|
|
2583
|
+
} catch {
|
|
2584
|
+
return null;
|
|
2585
|
+
}
|
|
2586
|
+
const results = [];
|
|
2587
|
+
for (const row of rows) {
|
|
2588
|
+
const commandLine = row.CommandLine ?? "";
|
|
2589
|
+
if (!commandLine || !looksLikeClaudeProcess(commandLine)) continue;
|
|
2590
|
+
results.push({
|
|
2591
|
+
pid: row.ProcessId,
|
|
2592
|
+
projectPath: "",
|
|
2593
|
+
projectName: "",
|
|
2594
|
+
branch: "",
|
|
2595
|
+
conversationId: extractResumeId(commandLine),
|
|
2596
|
+
startedAt: parseCimDate(row.CreationDate)
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
return results;
|
|
2600
|
+
}
|
|
2474
2601
|
async function getPidsWindows() {
|
|
2475
2602
|
try {
|
|
2476
2603
|
const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
|
|
@@ -2513,10 +2640,15 @@ async function getProcessInfoWindows(pid) {
|
|
|
2513
2640
|
}
|
|
2514
2641
|
}
|
|
2515
2642
|
function extractResumeId(args) {
|
|
2516
|
-
const
|
|
2517
|
-
|
|
2643
|
+
const eq = args.match(/(?:--resume|-r)=(\S+)/);
|
|
2644
|
+
if (eq?.[1] && !eq[1].startsWith("-")) return eq[1];
|
|
2645
|
+
const spaced = args.match(/(?:--resume|-r)\s+(\S+)/);
|
|
2646
|
+
const candidate = spaced?.[1];
|
|
2647
|
+
if (!candidate || candidate.startsWith("-")) return null;
|
|
2648
|
+
return candidate;
|
|
2518
2649
|
}
|
|
2519
2650
|
async function readGitBranch(dir) {
|
|
2651
|
+
if (!dir) return "";
|
|
2520
2652
|
try {
|
|
2521
2653
|
return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
|
|
2522
2654
|
} catch {
|
|
@@ -2538,16 +2670,16 @@ import {
|
|
|
2538
2670
|
import { EventEmitter } from "events";
|
|
2539
2671
|
import {
|
|
2540
2672
|
createReadStream,
|
|
2541
|
-
existsSync as
|
|
2673
|
+
existsSync as existsSync10,
|
|
2542
2674
|
watch as fsWatch,
|
|
2543
|
-
readdirSync as
|
|
2544
|
-
readFileSync as
|
|
2545
|
-
statSync as
|
|
2675
|
+
readdirSync as readdirSync5,
|
|
2676
|
+
readFileSync as readFileSync8,
|
|
2677
|
+
statSync as statSync8
|
|
2546
2678
|
} from "fs";
|
|
2547
2679
|
import { realpath as realpath2 } from "fs/promises";
|
|
2548
2680
|
import { createServer } from "http";
|
|
2549
|
-
import { homedir as
|
|
2550
|
-
import { dirname as
|
|
2681
|
+
import { homedir as homedir8 } from "os";
|
|
2682
|
+
import { basename as basename5, dirname as dirname9, join as join17 } from "path";
|
|
2551
2683
|
import { createInterface } from "readline";
|
|
2552
2684
|
|
|
2553
2685
|
// node_modules/nanoid/index.js
|
|
@@ -2806,7 +2938,7 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2806
2938
|
}
|
|
2807
2939
|
|
|
2808
2940
|
// src/api/app.ts
|
|
2809
|
-
import { Hono as
|
|
2941
|
+
import { Hono as Hono13 } from "hono";
|
|
2810
2942
|
|
|
2811
2943
|
// src/api/middleware/auth.middleware.ts
|
|
2812
2944
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2928,12 +3060,72 @@ var createBrowseRoutes = (deps) => {
|
|
|
2928
3060
|
return app;
|
|
2929
3061
|
};
|
|
2930
3062
|
|
|
2931
|
-
// src/api/routes/
|
|
3063
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
2932
3064
|
import { Hono as Hono3 } from "hono";
|
|
3065
|
+
|
|
3066
|
+
// src/schemas/cacheAlert.schema.ts
|
|
3067
|
+
import { z } from "zod";
|
|
3068
|
+
var ResolveCacheAlertSchema = z.object({
|
|
3069
|
+
fingerprint: z.string(),
|
|
3070
|
+
action: z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
|
|
3071
|
+
ids: z.array(z.string()).optional()
|
|
3072
|
+
}).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
|
|
3073
|
+
message: "prune_selected requires a non-empty ids array",
|
|
3074
|
+
path: ["ids"]
|
|
3075
|
+
});
|
|
3076
|
+
|
|
3077
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
3078
|
+
function readRawBody2(req) {
|
|
3079
|
+
return new Promise((resolve2, reject) => {
|
|
3080
|
+
const chunks = [];
|
|
3081
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3082
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3083
|
+
req.on("error", reject);
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
var createCacheAlertRoutes = (deps) => {
|
|
3087
|
+
const app = new Hono3();
|
|
3088
|
+
app.get("/", (c) => {
|
|
3089
|
+
const monitor = deps.cacheMonitor();
|
|
3090
|
+
return c.json({ pending: monitor?.pending ?? null });
|
|
3091
|
+
});
|
|
3092
|
+
app.post("/resolve", async (c) => {
|
|
3093
|
+
let body;
|
|
3094
|
+
try {
|
|
3095
|
+
const incoming = c.env?.incoming;
|
|
3096
|
+
const raw = incoming ? await readRawBody2(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3097
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3098
|
+
} catch {
|
|
3099
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3100
|
+
}
|
|
3101
|
+
const parsed = ResolveCacheAlertSchema.safeParse(body);
|
|
3102
|
+
if (!parsed.success) {
|
|
3103
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3104
|
+
}
|
|
3105
|
+
const monitor = deps.cacheMonitor();
|
|
3106
|
+
if (!monitor) return c.json({ ok: true, alreadyResolved: true });
|
|
3107
|
+
const { fingerprint, action, ids } = parsed.data;
|
|
3108
|
+
const result = await monitor.resolve(fingerprint, action, ids);
|
|
3109
|
+
if ("conflict" in result) {
|
|
3110
|
+
return c.json(
|
|
3111
|
+
{ error: "fingerprint_mismatch", currentFingerprint: result.currentFingerprint },
|
|
3112
|
+
409
|
|
3113
|
+
);
|
|
3114
|
+
}
|
|
3115
|
+
if ("alreadyResolved" in result) {
|
|
3116
|
+
return c.json({ ok: true, alreadyResolved: true });
|
|
3117
|
+
}
|
|
3118
|
+
return c.json(result);
|
|
3119
|
+
});
|
|
3120
|
+
return app;
|
|
3121
|
+
};
|
|
3122
|
+
|
|
3123
|
+
// src/api/routes/conversations.routes.ts
|
|
3124
|
+
import { Hono as Hono4 } from "hono";
|
|
2933
3125
|
var ALREADY_HANDLED2 = 597;
|
|
2934
3126
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
2935
3127
|
var createConversationRoutes = (deps) => {
|
|
2936
|
-
const app = new
|
|
3128
|
+
const app = new Hono4();
|
|
2937
3129
|
app.get("/count", async (c) => {
|
|
2938
3130
|
const url = new URL(c.req.url);
|
|
2939
3131
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -2960,7 +3152,7 @@ var createConversationRoutes = (deps) => {
|
|
|
2960
3152
|
};
|
|
2961
3153
|
|
|
2962
3154
|
// src/api/routes/health.routes.ts
|
|
2963
|
-
import { Hono as
|
|
3155
|
+
import { Hono as Hono5 } from "hono";
|
|
2964
3156
|
|
|
2965
3157
|
// src/version.ts
|
|
2966
3158
|
import { readFileSync as readFileSync4, realpathSync } from "fs";
|
|
@@ -2996,16 +3188,19 @@ function resolveVersion() {
|
|
|
2996
3188
|
}
|
|
2997
3189
|
|
|
2998
3190
|
// src/api/routes/health.routes.ts
|
|
2999
|
-
var createHealthRoutes = () => {
|
|
3000
|
-
const app = new
|
|
3001
|
-
app.get("/", (c) =>
|
|
3191
|
+
var createHealthRoutes = (deps) => {
|
|
3192
|
+
const app = new Hono5();
|
|
3193
|
+
app.get("/", (c) => {
|
|
3194
|
+
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3195
|
+
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
3196
|
+
});
|
|
3002
3197
|
return app;
|
|
3003
3198
|
};
|
|
3004
3199
|
|
|
3005
3200
|
// src/api/routes/logs.routes.ts
|
|
3006
3201
|
import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
|
|
3007
3202
|
import { join as join9 } from "path";
|
|
3008
|
-
import { Hono as
|
|
3203
|
+
import { Hono as Hono6 } from "hono";
|
|
3009
3204
|
|
|
3010
3205
|
// src/lifecycle/constants.ts
|
|
3011
3206
|
import { homedir as homedir4 } from "os";
|
|
@@ -3063,7 +3258,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3063
3258
|
}
|
|
3064
3259
|
}
|
|
3065
3260
|
function createLogsRoutes() {
|
|
3066
|
-
const app = new
|
|
3261
|
+
const app = new Hono6();
|
|
3067
3262
|
app.get("/", (c) => {
|
|
3068
3263
|
try {
|
|
3069
3264
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3134,7 +3329,7 @@ function createLogsRoutes() {
|
|
|
3134
3329
|
// src/api/routes/misc.routes.ts
|
|
3135
3330
|
import { spawn } from "child_process";
|
|
3136
3331
|
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3137
|
-
import { Hono as
|
|
3332
|
+
import { Hono as Hono7 } from "hono";
|
|
3138
3333
|
import { hostname } from "os";
|
|
3139
3334
|
|
|
3140
3335
|
// src/config/update-config.ts
|
|
@@ -3144,15 +3339,15 @@ import { join as join10 } from "path";
|
|
|
3144
3339
|
import { parse as parseYaml } from "yaml";
|
|
3145
3340
|
|
|
3146
3341
|
// src/schemas/updateConfig.schema.ts
|
|
3147
|
-
import { z } from "zod";
|
|
3148
|
-
var UpdateConfigSchema =
|
|
3149
|
-
auto_update:
|
|
3150
|
-
channel:
|
|
3151
|
-
allow:
|
|
3152
|
-
poll_interval_minutes:
|
|
3153
|
-
defer_if_active_sessions:
|
|
3154
|
-
github_repo:
|
|
3155
|
-
webhook_secret:
|
|
3342
|
+
import { z as z2 } from "zod";
|
|
3343
|
+
var UpdateConfigSchema = z2.object({
|
|
3344
|
+
auto_update: z2.boolean().default(false),
|
|
3345
|
+
channel: z2.enum(["stable", "next"]).default("stable"),
|
|
3346
|
+
allow: z2.array(z2.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
3347
|
+
poll_interval_minutes: z2.number().int().min(0).default(1440),
|
|
3348
|
+
defer_if_active_sessions: z2.boolean().default(true),
|
|
3349
|
+
github_repo: z2.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
3350
|
+
webhook_secret: z2.string().min(1).nullable().default(null)
|
|
3156
3351
|
}).strict();
|
|
3157
3352
|
|
|
3158
3353
|
// src/config/update-config.ts
|
|
@@ -3189,7 +3384,7 @@ function readJsonBody(req) {
|
|
|
3189
3384
|
req.on("error", reject);
|
|
3190
3385
|
});
|
|
3191
3386
|
}
|
|
3192
|
-
function
|
|
3387
|
+
function readRawBody3(req) {
|
|
3193
3388
|
return new Promise((resolve2, reject) => {
|
|
3194
3389
|
const chunks = [];
|
|
3195
3390
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3208,7 +3403,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3208
3403
|
}
|
|
3209
3404
|
var clientLog = getLogger("client");
|
|
3210
3405
|
var createMiscRoutes = (deps) => {
|
|
3211
|
-
const app = new
|
|
3406
|
+
const app = new Hono7();
|
|
3212
3407
|
app.get("/api/info", (c) => {
|
|
3213
3408
|
const ptyIds = deps.ptyAttachedIds();
|
|
3214
3409
|
return c.json({
|
|
@@ -3241,7 +3436,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3241
3436
|
}
|
|
3242
3437
|
let body;
|
|
3243
3438
|
try {
|
|
3244
|
-
body = await
|
|
3439
|
+
body = await readRawBody3(c.env.incoming);
|
|
3245
3440
|
} catch {
|
|
3246
3441
|
return c.json({ error: "could not read body" }, 400);
|
|
3247
3442
|
}
|
|
@@ -3284,11 +3479,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3284
3479
|
};
|
|
3285
3480
|
|
|
3286
3481
|
// src/api/routes/pair.routes.ts
|
|
3287
|
-
import { Hono as
|
|
3482
|
+
import { Hono as Hono8 } from "hono";
|
|
3288
3483
|
var ALREADY_HANDLED3 = 597;
|
|
3289
3484
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3290
3485
|
var createPairRoutes = (deps) => {
|
|
3291
|
-
const app = new
|
|
3486
|
+
const app = new Hono8();
|
|
3292
3487
|
app.post("/start", (c) => {
|
|
3293
3488
|
deps.handlePairStart(c.env.outgoing);
|
|
3294
3489
|
return alreadyHandled3();
|
|
@@ -3301,11 +3496,11 @@ var createPairRoutes = (deps) => {
|
|
|
3301
3496
|
};
|
|
3302
3497
|
|
|
3303
3498
|
// src/api/routes/projects.routes.ts
|
|
3304
|
-
import { Hono as
|
|
3499
|
+
import { Hono as Hono9 } from "hono";
|
|
3305
3500
|
var ALREADY_HANDLED4 = 597;
|
|
3306
3501
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3307
3502
|
var createProjectRoutes = (deps) => {
|
|
3308
|
-
const app = new
|
|
3503
|
+
const app = new Hono9();
|
|
3309
3504
|
app.get("/", (c) => {
|
|
3310
3505
|
const url = new URL(c.req.url);
|
|
3311
3506
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3320,11 +3515,11 @@ var createProjectRoutes = (deps) => {
|
|
|
3320
3515
|
};
|
|
3321
3516
|
|
|
3322
3517
|
// src/api/routes/scanner.routes.ts
|
|
3323
|
-
import { Hono as
|
|
3518
|
+
import { Hono as Hono10 } from "hono";
|
|
3324
3519
|
var ALREADY_HANDLED5 = 597;
|
|
3325
3520
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3326
3521
|
var createScannerRoutes = (deps) => {
|
|
3327
|
-
const app = new
|
|
3522
|
+
const app = new Hono10();
|
|
3328
3523
|
app.get("/api/search", async (c) => {
|
|
3329
3524
|
const url = new URL(c.req.url);
|
|
3330
3525
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3334,11 +3529,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3334
3529
|
};
|
|
3335
3530
|
|
|
3336
3531
|
// src/api/routes/sessions.routes.ts
|
|
3337
|
-
import { Hono as
|
|
3532
|
+
import { Hono as Hono11 } from "hono";
|
|
3338
3533
|
var ALREADY_HANDLED6 = 597;
|
|
3339
3534
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3340
3535
|
var createSessionRoutes = (deps) => {
|
|
3341
|
-
const app = new
|
|
3536
|
+
const app = new Hono11();
|
|
3342
3537
|
app.get("/count", (c) => {
|
|
3343
3538
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3344
3539
|
return alreadyHandled6();
|
|
@@ -3405,9 +3600,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3405
3600
|
};
|
|
3406
3601
|
|
|
3407
3602
|
// src/api/routes/ws.routes.ts
|
|
3408
|
-
import { Hono as
|
|
3603
|
+
import { Hono as Hono12 } from "hono";
|
|
3409
3604
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3410
|
-
const app = new
|
|
3605
|
+
const app = new Hono12();
|
|
3411
3606
|
app.get(
|
|
3412
3607
|
"/ws",
|
|
3413
3608
|
upgradeWebSocket(() => {
|
|
@@ -3433,7 +3628,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3433
3628
|
|
|
3434
3629
|
// src/api/app.ts
|
|
3435
3630
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3436
|
-
const app = new
|
|
3631
|
+
const app = new Hono13();
|
|
3437
3632
|
const httpLog = getLogger("http");
|
|
3438
3633
|
app.use("*", async (c, next) => {
|
|
3439
3634
|
const start = Date.now();
|
|
@@ -3453,10 +3648,11 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3453
3648
|
app.use("*", corsMiddleware(deps.browserCors));
|
|
3454
3649
|
app.use("*", authMiddleware(deps));
|
|
3455
3650
|
app.onError(errorMiddleware);
|
|
3456
|
-
app.route("/healthz", createHealthRoutes());
|
|
3651
|
+
app.route("/healthz", createHealthRoutes(deps));
|
|
3457
3652
|
app.route("/", createMiscRoutes(deps));
|
|
3458
3653
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3459
3654
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3655
|
+
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
3460
3656
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
3461
3657
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3462
3658
|
app.route("/api", createBrowseRoutes(deps));
|
|
@@ -3641,6 +3837,31 @@ function parseAgentEntrypointsEnv(raw) {
|
|
|
3641
3837
|
return new Set(parts);
|
|
3642
3838
|
}
|
|
3643
3839
|
|
|
3840
|
+
// src/utils/canonicalizeFilePath.ts
|
|
3841
|
+
import { normalize } from "path";
|
|
3842
|
+
function canonicalizeFilePath(filePath) {
|
|
3843
|
+
return filePath.trim().replace(/\\/g, "/");
|
|
3844
|
+
}
|
|
3845
|
+
function toNativeFilePath(filePath) {
|
|
3846
|
+
return normalize(filePath.trim());
|
|
3847
|
+
}
|
|
3848
|
+
function canonicalLivePathSet(metas) {
|
|
3849
|
+
const live = /* @__PURE__ */ new Set();
|
|
3850
|
+
for (const meta of metas) {
|
|
3851
|
+
if (meta.filePath) live.add(canonicalizeFilePath(meta.filePath));
|
|
3852
|
+
}
|
|
3853
|
+
return live;
|
|
3854
|
+
}
|
|
3855
|
+
function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
3856
|
+
const joined = /* @__PURE__ */ new Map();
|
|
3857
|
+
for (const meta of metas) {
|
|
3858
|
+
if (!meta.filePath) continue;
|
|
3859
|
+
const stat3 = canonicalStats.get(canonicalizeFilePath(meta.filePath));
|
|
3860
|
+
if (stat3) joined.set(meta.filePath, { stat: stat3, meta });
|
|
3861
|
+
}
|
|
3862
|
+
return joined;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3644
3865
|
// src/utils/fileIdentity.ts
|
|
3645
3866
|
import { createHash } from "crypto";
|
|
3646
3867
|
function fileIdentity(stat3, headBytes) {
|
|
@@ -3753,8 +3974,19 @@ var ConversationCache = class _ConversationCache {
|
|
|
3753
3974
|
),
|
|
3754
3975
|
// Batch equivalent of updateMeta: bumps message_count by N in one write
|
|
3755
3976
|
// (used by updateFromLines so a burst of appended lines is one UPDATE).
|
|
3977
|
+
// last_activity/last_message only move FORWARD: a batch whose newest line
|
|
3978
|
+
// predates the stored last_activity (interleaved writers appending an older
|
|
3979
|
+
// line) must not drag the metadata backward. message_count and updated_at
|
|
3980
|
+
// still advance — a real message was appended and the row did change.
|
|
3756
3981
|
updateMetaBatch: db.prepare(
|
|
3757
|
-
|
|
3982
|
+
`UPDATE conversation_meta SET
|
|
3983
|
+
message_count = message_count + @inc,
|
|
3984
|
+
last_activity = CASE WHEN @last_activity > IFNULL(last_activity, -1)
|
|
3985
|
+
THEN @last_activity ELSE last_activity END,
|
|
3986
|
+
last_message = CASE WHEN @last_activity > IFNULL(last_activity, -1)
|
|
3987
|
+
THEN @last_message ELSE last_message END,
|
|
3988
|
+
updated_at = @updated_at
|
|
3989
|
+
WHERE id = @id`
|
|
3758
3990
|
),
|
|
3759
3991
|
insertSkeleton: db.prepare(
|
|
3760
3992
|
"INSERT OR IGNORE INTO conversation_meta (id, file_path, message_count, updated_at) VALUES (?, ?, 1, ?)"
|
|
@@ -3836,6 +4068,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
3836
4068
|
"SELECT provider FROM conversation_meta WHERE file_path = ?"
|
|
3837
4069
|
),
|
|
3838
4070
|
allFilePaths: db.prepare("SELECT id, file_path FROM conversation_meta"),
|
|
4071
|
+
allFilePathsWithTitle: db.prepare("SELECT id, file_path, title FROM conversation_meta"),
|
|
3839
4072
|
allFileStats: db.prepare(
|
|
3840
4073
|
"SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
|
|
3841
4074
|
),
|
|
@@ -3988,7 +4221,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
3988
4221
|
// excluded from the index entirely; the scanner serves them (it routes each
|
|
3989
4222
|
// provider to its own parser).
|
|
3990
4223
|
isIndexableFile(filePath) {
|
|
3991
|
-
const row = this.stmts.getProviderByFilePath.get(filePath);
|
|
4224
|
+
const row = this.stmts.getProviderByFilePath.get(canonicalizeFilePath(filePath));
|
|
3992
4225
|
if (!row) return false;
|
|
3993
4226
|
return (row.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
|
|
3994
4227
|
}
|
|
@@ -4256,7 +4489,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4256
4489
|
if (this.fileIndexLoaded) return;
|
|
4257
4490
|
const rows = this.stmts.allFilePaths.all();
|
|
4258
4491
|
for (const row of rows) {
|
|
4259
|
-
this.fileIndex.set(row.file_path, row.id);
|
|
4492
|
+
this.fileIndex.set(canonicalizeFilePath(row.file_path), row.id);
|
|
4260
4493
|
}
|
|
4261
4494
|
this.fileIndexLoaded = true;
|
|
4262
4495
|
}
|
|
@@ -4275,12 +4508,13 @@ var ConversationCache = class _ConversationCache {
|
|
|
4275
4508
|
const role = line.role ?? line.type;
|
|
4276
4509
|
const isMessage = role === "user" || role === "assistant";
|
|
4277
4510
|
this.ensureFileIndex();
|
|
4511
|
+
const key = canonicalizeFilePath(filePath);
|
|
4278
4512
|
if (!isMessage && !line.cwd && !line.slug) return;
|
|
4279
|
-
let convId = this.fileIndex.get(
|
|
4513
|
+
let convId = this.fileIndex.get(key);
|
|
4280
4514
|
if (!convId) {
|
|
4281
|
-
const pseudoId =
|
|
4282
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4283
|
-
this.fileIndex.set(
|
|
4515
|
+
const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
|
|
4516
|
+
this.stmts.insertSkeleton.run(pseudoId, key, 0);
|
|
4517
|
+
this.fileIndex.set(key, pseudoId);
|
|
4284
4518
|
convId = pseudoId;
|
|
4285
4519
|
}
|
|
4286
4520
|
if (line.cwd || line.slug) {
|
|
@@ -4295,18 +4529,18 @@ var ConversationCache = class _ConversationCache {
|
|
|
4295
4529
|
});
|
|
4296
4530
|
}
|
|
4297
4531
|
if (!isMessage) return;
|
|
4298
|
-
const
|
|
4299
|
-
const activityMs = new Date(
|
|
4532
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4533
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4300
4534
|
if (Number.isNaN(activityMs)) return;
|
|
4301
4535
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4302
4536
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4303
|
-
const lastMessage = JSON.stringify({ role, timestamp, text });
|
|
4537
|
+
const lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4304
4538
|
const seq = ++this.tailSeq;
|
|
4305
4539
|
const result = this.stmts.updateMeta.run(activityMs, lastMessage, seq, convId);
|
|
4306
4540
|
if (result.changes === 0) return;
|
|
4307
4541
|
const tailRow = this.stmts.getTail.get(convId);
|
|
4308
4542
|
const msgs = tailRow ? JSON.parse(tailRow.messages_json) : [];
|
|
4309
|
-
msgs.push({ role, timestamp, text, content: contentBlocks });
|
|
4543
|
+
msgs.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4310
4544
|
if (msgs.length > this.tailSize) msgs.splice(0, msgs.length - this.tailSize);
|
|
4311
4545
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, seq);
|
|
4312
4546
|
}
|
|
@@ -4318,7 +4552,8 @@ var ConversationCache = class _ConversationCache {
|
|
|
4318
4552
|
* updateFromLine in order: the agent filter short-circuits the whole batch,
|
|
4319
4553
|
* project context is backfilled last-wins, message_count increases by the
|
|
4320
4554
|
* number of surviving message lines, and last_activity/last_message reflect
|
|
4321
|
-
* the
|
|
4555
|
+
* the newest message line by timestamp (a monotonic guard keeps them from
|
|
4556
|
+
* moving backward when an interleaved writer appends an older line — P0.3).
|
|
4322
4557
|
*/
|
|
4323
4558
|
updateFromLines(filePath, rawLines) {
|
|
4324
4559
|
let sawProjectContext = false;
|
|
@@ -4354,23 +4589,26 @@ var ConversationCache = class _ConversationCache {
|
|
|
4354
4589
|
backfillTitle ??= lineTitle;
|
|
4355
4590
|
}
|
|
4356
4591
|
if (!isMessage) continue;
|
|
4357
|
-
const
|
|
4358
|
-
const activityMs = new Date(
|
|
4592
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4593
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4359
4594
|
if (Number.isNaN(activityMs)) continue;
|
|
4360
4595
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4361
4596
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4362
4597
|
msgCount += 1;
|
|
4363
|
-
lastActivityMs
|
|
4364
|
-
|
|
4365
|
-
|
|
4598
|
+
if (lastActivityMs === null || activityMs > lastActivityMs) {
|
|
4599
|
+
lastActivityMs = activityMs;
|
|
4600
|
+
lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4601
|
+
}
|
|
4602
|
+
newTail.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4366
4603
|
}
|
|
4367
4604
|
if (!sawProjectContext && msgCount === 0) return;
|
|
4368
4605
|
this.ensureFileIndex();
|
|
4369
|
-
|
|
4606
|
+
const key = canonicalizeFilePath(filePath);
|
|
4607
|
+
let convId = this.fileIndex.get(key);
|
|
4370
4608
|
if (!convId) {
|
|
4371
|
-
const pseudoId =
|
|
4372
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4373
|
-
this.fileIndex.set(
|
|
4609
|
+
const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
|
|
4610
|
+
this.stmts.insertSkeleton.run(pseudoId, key, 0);
|
|
4611
|
+
this.fileIndex.set(key, pseudoId);
|
|
4374
4612
|
convId = pseudoId;
|
|
4375
4613
|
}
|
|
4376
4614
|
const id = convId;
|
|
@@ -4413,6 +4651,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4413
4651
|
for (const m of items) {
|
|
4414
4652
|
const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
|
|
4415
4653
|
const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
|
|
4654
|
+
const canonicalPath = canonicalizeFilePath(m.filePath);
|
|
4416
4655
|
let mtimeMs = null;
|
|
4417
4656
|
let fileSize = null;
|
|
4418
4657
|
try {
|
|
@@ -4428,10 +4667,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4428
4667
|
const scannerMetaJson = JSON.stringify(m);
|
|
4429
4668
|
this.stmts.upsertFull.run({
|
|
4430
4669
|
id,
|
|
4431
|
-
file_path:
|
|
4670
|
+
file_path: canonicalPath,
|
|
4432
4671
|
project_path: m.projectPath ?? null,
|
|
4433
4672
|
project_name: m.projectName ?? null,
|
|
4434
|
-
title: m.title ?? m.projectName ?? null,
|
|
4673
|
+
title: m.title ?? m.sessionName ?? m.projectName ?? null,
|
|
4435
4674
|
model: m.model ?? null,
|
|
4436
4675
|
account: m.account ?? null,
|
|
4437
4676
|
branch: m.gitBranch ?? null,
|
|
@@ -4447,7 +4686,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4447
4686
|
scanner_meta_json: scannerMetaJson
|
|
4448
4687
|
});
|
|
4449
4688
|
this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
|
|
4450
|
-
if (this.fileIndexLoaded) this.fileIndex.set(
|
|
4689
|
+
if (this.fileIndexLoaded) this.fileIndex.set(canonicalPath, id);
|
|
4451
4690
|
upsertedIds.push(id);
|
|
4452
4691
|
}
|
|
4453
4692
|
});
|
|
@@ -4458,11 +4697,12 @@ var ConversationCache = class _ConversationCache {
|
|
|
4458
4697
|
// by updateFromLine when a previously-cached file turns out to be an agent
|
|
4459
4698
|
// JSONL.
|
|
4460
4699
|
deleteByFilePath(filePath) {
|
|
4461
|
-
const
|
|
4700
|
+
const key = canonicalizeFilePath(filePath);
|
|
4701
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4462
4702
|
if (!row) return false;
|
|
4463
4703
|
this.stmts.deleteTailById.run(row.id);
|
|
4464
4704
|
const result = this.stmts.deleteById.run(row.id);
|
|
4465
|
-
this.fileIndex.delete(
|
|
4705
|
+
this.fileIndex.delete(key);
|
|
4466
4706
|
return result.changes > 0;
|
|
4467
4707
|
}
|
|
4468
4708
|
// Reads the last `tailSize` qualifying lines from a JSONL file and writes them
|
|
@@ -4514,10 +4754,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4514
4754
|
}
|
|
4515
4755
|
const role = parsed.role ?? parsed.type;
|
|
4516
4756
|
if (!role) continue;
|
|
4517
|
-
const
|
|
4757
|
+
const timestamp2 = parsed.timestamp ?? "";
|
|
4518
4758
|
const contentBlocks = normalizeContent(parsed.message?.content ?? parsed.content);
|
|
4519
4759
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4520
|
-
msgs.unshift({ role, timestamp, text, content: contentBlocks });
|
|
4760
|
+
msgs.unshift({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4521
4761
|
}
|
|
4522
4762
|
if (msgs.length === 0) return false;
|
|
4523
4763
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, 0);
|
|
@@ -4585,6 +4825,16 @@ var ConversationCache = class _ConversationCache {
|
|
|
4585
4825
|
}
|
|
4586
4826
|
return map;
|
|
4587
4827
|
}
|
|
4828
|
+
/**
|
|
4829
|
+
* Conversation id for a JSONL path, or null when no row exists yet. Resolves
|
|
4830
|
+
* by file_path (NOT conversationIdForFile) so codex rollout files — named
|
|
4831
|
+
* rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
|
|
4832
|
+
*/
|
|
4833
|
+
getIdByFilePath(filePath) {
|
|
4834
|
+
const key = canonicalizeFilePath(filePath);
|
|
4835
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4836
|
+
return row?.id ?? null;
|
|
4837
|
+
}
|
|
4588
4838
|
getMetaById(id) {
|
|
4589
4839
|
const row = this.stmts.getFullById.get(id);
|
|
4590
4840
|
if (!row) return null;
|
|
@@ -4677,22 +4927,23 @@ var ConversationCache = class _ConversationCache {
|
|
|
4677
4927
|
/**
|
|
4678
4928
|
* Drop the cached row for a file. Two callers with opposite intent:
|
|
4679
4929
|
* - a directory-watch "change" event (the file was appended to) — pass
|
|
4680
|
-
* `skipIfTailed: true
|
|
4681
|
-
*
|
|
4682
|
-
*
|
|
4683
|
-
*
|
|
4684
|
-
*
|
|
4685
|
-
*
|
|
4686
|
-
*
|
|
4687
|
-
*
|
|
4688
|
-
* nothing.
|
|
4930
|
+
* `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
|
|
4931
|
+
* event fires on every external append; deleting here flickers the
|
|
4932
|
+
* conversation out of /api/conversations — whether it's a live-tailed row
|
|
4933
|
+
* the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
|
|
4934
|
+
* fire on the same append with no ordering guarantee) OR a refresh-created
|
|
4935
|
+
* untailed row (a ?refresh=1 upsert never populates a tail, so the old
|
|
4936
|
+
* "delete when untailed" behavior made it vanish on its next append with no
|
|
4937
|
+
* client action). The live-tail path owns the row's content and the
|
|
4938
|
+
* debounced rescan re-derives metadata, so leaving the row loses nothing.
|
|
4689
4939
|
* - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
|
|
4690
4940
|
* row is always removed, otherwise a deleted session ghosts in the cache.
|
|
4691
4941
|
*/
|
|
4692
4942
|
invalidateByFilePath(filePath, opts) {
|
|
4693
|
-
const
|
|
4943
|
+
const key = canonicalizeFilePath(filePath);
|
|
4944
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4694
4945
|
if (!row) return null;
|
|
4695
|
-
if (opts?.skipIfTailed
|
|
4946
|
+
if (opts?.skipIfTailed) return null;
|
|
4696
4947
|
this.invalidate(row.id);
|
|
4697
4948
|
return row.id;
|
|
4698
4949
|
}
|
|
@@ -4786,6 +5037,68 @@ var ConversationCache = class _ConversationCache {
|
|
|
4786
5037
|
}
|
|
4787
5038
|
return removed;
|
|
4788
5039
|
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Read-only: list cached rows whose `file_path` no longer exists on disk.
|
|
5042
|
+
* Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
|
|
5043
|
+
* reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
|
|
5044
|
+
* rows that still have cached history (which pruneGhostFiles would keep).
|
|
5045
|
+
*/
|
|
5046
|
+
listMissingFiles(exists = existsSync6) {
|
|
5047
|
+
const rows = this.stmts.allFilePathsWithTitle.all();
|
|
5048
|
+
const missing = [];
|
|
5049
|
+
for (const row of rows) {
|
|
5050
|
+
if (exists(row.file_path)) continue;
|
|
5051
|
+
missing.push({
|
|
5052
|
+
id: row.id,
|
|
5053
|
+
filePath: row.file_path,
|
|
5054
|
+
title: row.title,
|
|
5055
|
+
tailed: !!this.stmts.hasTail.get(row.id)
|
|
5056
|
+
});
|
|
5057
|
+
}
|
|
5058
|
+
return missing;
|
|
5059
|
+
}
|
|
5060
|
+
/**
|
|
5061
|
+
* Drop the given conversation ids outright — main row, tail, and message
|
|
5062
|
+
* index — regardless of whether they have a tail. Used by the cache-integrity
|
|
5063
|
+
* resolution actions (prune_all / prune_selected). Returns the count dropped.
|
|
5064
|
+
*/
|
|
5065
|
+
dropRowsById(ids) {
|
|
5066
|
+
if (ids.length === 0) return 0;
|
|
5067
|
+
const drop = this.db.transaction((toDrop) => {
|
|
5068
|
+
let n = 0;
|
|
5069
|
+
for (const id of toDrop) {
|
|
5070
|
+
this.stmts.deleteTailById.run(id);
|
|
5071
|
+
this.stmts.deleteMessageIndex.run(id);
|
|
5072
|
+
n += this.stmts.deleteById.run(id).changes;
|
|
5073
|
+
}
|
|
5074
|
+
return n;
|
|
5075
|
+
});
|
|
5076
|
+
const dropped = drop(ids);
|
|
5077
|
+
if (this.fileIndexLoaded) {
|
|
5078
|
+
for (const id of ids) {
|
|
5079
|
+
for (const [fp, cid] of this.fileIndex) {
|
|
5080
|
+
if (cid === id) {
|
|
5081
|
+
this.fileIndex.delete(fp);
|
|
5082
|
+
break;
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
return dropped;
|
|
5088
|
+
}
|
|
5089
|
+
/**
|
|
5090
|
+
* Wipe all cached conversation state — meta, tails, and message index — and
|
|
5091
|
+
* reset the in-memory file index. Only called by the `reset_rescan`
|
|
5092
|
+
* resolution action, which repopulates from a fresh disk scan afterward.
|
|
5093
|
+
*/
|
|
5094
|
+
clearAll() {
|
|
5095
|
+
this.db.transaction(() => {
|
|
5096
|
+
this.stmts.deleteTailAll.run();
|
|
5097
|
+
this.stmts.deleteAll.run();
|
|
5098
|
+
this.db.exec("DELETE FROM conversation_message_index");
|
|
5099
|
+
})();
|
|
5100
|
+
this.fileIndex = /* @__PURE__ */ new Map();
|
|
5101
|
+
}
|
|
4789
5102
|
};
|
|
4790
5103
|
|
|
4791
5104
|
// src/db/repositories/cacheMetadata.repository.ts
|
|
@@ -5095,9 +5408,320 @@ function seal(plaintext, recipientPublicKeyBase64) {
|
|
|
5095
5408
|
};
|
|
5096
5409
|
}
|
|
5097
5410
|
|
|
5411
|
+
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5412
|
+
import { createHash as createHash2 } from "crypto";
|
|
5413
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5414
|
+
|
|
5415
|
+
// src/services/cache-integrity/alertStore.ts
|
|
5416
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
|
|
5417
|
+
import { homedir as homedir7 } from "os";
|
|
5418
|
+
import { dirname as dirname8, join as join14 } from "path";
|
|
5419
|
+
function alertStatePath() {
|
|
5420
|
+
const dir = process.env.THREADBASE_CONFIG_DIR ?? join14(homedir7(), ".threadbase");
|
|
5421
|
+
return join14(dir, "cache-alert.json");
|
|
5422
|
+
}
|
|
5423
|
+
function loadAlertState() {
|
|
5424
|
+
try {
|
|
5425
|
+
const parsed = JSON.parse(readFileSync7(alertStatePath(), "utf-8"));
|
|
5426
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
5427
|
+
} catch {
|
|
5428
|
+
return {};
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
function saveAlertState(state) {
|
|
5432
|
+
const path = alertStatePath();
|
|
5433
|
+
mkdirSync4(dirname8(path), { recursive: true });
|
|
5434
|
+
writeFileSync3(path, `${JSON.stringify(state, null, 2)}
|
|
5435
|
+
`);
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
// src/services/cache-integrity/backup.ts
|
|
5439
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
|
|
5440
|
+
import { join as join15 } from "path";
|
|
5441
|
+
var DEFAULT_RETAIN = 3;
|
|
5442
|
+
function retainCount() {
|
|
5443
|
+
const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
|
|
5444
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RETAIN;
|
|
5445
|
+
}
|
|
5446
|
+
function timestamp(d) {
|
|
5447
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
5448
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
5449
|
+
}
|
|
5450
|
+
async function backupCacheDb(db, cacheDir) {
|
|
5451
|
+
const backupsDir = join15(cacheDir, "backups");
|
|
5452
|
+
mkdirSync5(backupsDir, { recursive: true });
|
|
5453
|
+
const destPath = join15(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
|
|
5454
|
+
await db.backup(destPath);
|
|
5455
|
+
const retain = retainCount();
|
|
5456
|
+
const backups = readdirSync4(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
|
|
5457
|
+
const full = join15(backupsDir, f);
|
|
5458
|
+
return { full, mtime: statSync5(full).mtimeMs };
|
|
5459
|
+
}).sort((a, b) => b.mtime - a.mtime);
|
|
5460
|
+
for (const stale of backups.slice(retain)) {
|
|
5461
|
+
if (existsSync7(stale.full)) unlinkSync(stale.full);
|
|
5462
|
+
}
|
|
5463
|
+
return destPath;
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5467
|
+
var MAX_MISSING_PERSISTED = 1e3;
|
|
5468
|
+
var SAMPLE_SIZE = 20;
|
|
5469
|
+
var STORM_WINDOW_MS = 3e4;
|
|
5470
|
+
var STORM_THRESHOLD = 10;
|
|
5471
|
+
function envInt(name, fallback) {
|
|
5472
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
5473
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
5474
|
+
}
|
|
5475
|
+
function fingerprintOf(ids) {
|
|
5476
|
+
const sorted = [...ids].sort();
|
|
5477
|
+
return `sha256:${createHash2("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5478
|
+
}
|
|
5479
|
+
var CacheIntegrityMonitor = class {
|
|
5480
|
+
constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
|
|
5481
|
+
this.cache = cache;
|
|
5482
|
+
this.wsHub = wsHub;
|
|
5483
|
+
this.log = log3;
|
|
5484
|
+
this.cacheDir = cacheDir;
|
|
5485
|
+
this.rescan = rescan;
|
|
5486
|
+
this.runDuringReset = runDuringReset;
|
|
5487
|
+
const state = loadAlertState();
|
|
5488
|
+
this._pending = state.pending ?? null;
|
|
5489
|
+
this.ignoredIds = new Set(state.ignoredIds ?? []);
|
|
5490
|
+
}
|
|
5491
|
+
cache;
|
|
5492
|
+
wsHub;
|
|
5493
|
+
log;
|
|
5494
|
+
cacheDir;
|
|
5495
|
+
rescan;
|
|
5496
|
+
runDuringReset;
|
|
5497
|
+
_pending;
|
|
5498
|
+
ignoredIds;
|
|
5499
|
+
deferredUnlinks = [];
|
|
5500
|
+
unlinkTimes = [];
|
|
5501
|
+
get pending() {
|
|
5502
|
+
return this._pending;
|
|
5503
|
+
}
|
|
5504
|
+
persist() {
|
|
5505
|
+
const state = {};
|
|
5506
|
+
if (this._pending) {
|
|
5507
|
+
state.pending = {
|
|
5508
|
+
...this._pending,
|
|
5509
|
+
missing: this._pending.missing.slice(0, MAX_MISSING_PERSISTED)
|
|
5510
|
+
};
|
|
5511
|
+
}
|
|
5512
|
+
if (this.ignoredIds.size > 0) state.ignoredIds = [...this.ignoredIds];
|
|
5513
|
+
saveAlertState(state);
|
|
5514
|
+
}
|
|
5515
|
+
classifySeverity(missingCount, totalRows) {
|
|
5516
|
+
const minMissing = envInt("THREADBASE_CACHE_ALERT_MIN_MISSING", 20);
|
|
5517
|
+
const minRatio = Number.parseFloat(process.env.THREADBASE_CACHE_ALERT_MIN_RATIO ?? "0.20");
|
|
5518
|
+
const ratio = totalRows > 0 ? missingCount / totalRows : 0;
|
|
5519
|
+
const ratioThreshold = Number.isFinite(minRatio) ? minRatio : 0.2;
|
|
5520
|
+
return missingCount >= minMissing && ratio >= ratioThreshold ? "high" : "low";
|
|
5521
|
+
}
|
|
5522
|
+
sampleOf(missing) {
|
|
5523
|
+
return missing.slice(0, SAMPLE_SIZE).map((m) => ({
|
|
5524
|
+
id: m.id,
|
|
5525
|
+
...m.title != null ? { title: m.title } : {}
|
|
5526
|
+
}));
|
|
5527
|
+
}
|
|
5528
|
+
buildWsMessage(pending) {
|
|
5529
|
+
return {
|
|
5530
|
+
type: "cache_alert",
|
|
5531
|
+
fingerprint: pending.fingerprint,
|
|
5532
|
+
severity: pending.severity,
|
|
5533
|
+
missingCount: pending.missingCount,
|
|
5534
|
+
totalRows: pending.totalRows,
|
|
5535
|
+
detectedAt: pending.detectedAt,
|
|
5536
|
+
sample: this.sampleOf(pending.missing)
|
|
5537
|
+
};
|
|
5538
|
+
}
|
|
5539
|
+
wsMessage() {
|
|
5540
|
+
return this._pending ? this.buildWsMessage(this._pending) : null;
|
|
5541
|
+
}
|
|
5542
|
+
healthzField() {
|
|
5543
|
+
if (!this._pending) return void 0;
|
|
5544
|
+
return {
|
|
5545
|
+
severity: this._pending.severity,
|
|
5546
|
+
missingCount: this._pending.missingCount,
|
|
5547
|
+
fingerprint: this._pending.fingerprint,
|
|
5548
|
+
detectedAt: this._pending.detectedAt
|
|
5549
|
+
};
|
|
5550
|
+
}
|
|
5551
|
+
/**
|
|
5552
|
+
* Scan the cache for rows whose file is gone, excluding ids the user chose to
|
|
5553
|
+
* ignore. If none remain, clear any stale pending alert and return (the caller
|
|
5554
|
+
* decides whether to run pruneGhostFiles). Otherwise classify severity, persist
|
|
5555
|
+
* the pending record, back up on high severity, and broadcast the alert.
|
|
5556
|
+
*/
|
|
5557
|
+
async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
5558
|
+
const all = this.cache.listMissingFiles(existsSync8);
|
|
5559
|
+
const missing = all.filter((m) => !this.ignoredIds.has(m.id));
|
|
5560
|
+
if (missing.length === 0) {
|
|
5561
|
+
if (this._pending) {
|
|
5562
|
+
this._pending = null;
|
|
5563
|
+
this.persist();
|
|
5564
|
+
}
|
|
5565
|
+
return;
|
|
5566
|
+
}
|
|
5567
|
+
const totalRows = this.cache.listConversations({ limit: 0, offset: 0 }).total;
|
|
5568
|
+
const fingerprint = fingerprintOf(missing.map((m) => m.id));
|
|
5569
|
+
const severity = this.classifySeverity(missing.length, totalRows);
|
|
5570
|
+
const pending = {
|
|
5571
|
+
fingerprint,
|
|
5572
|
+
severity,
|
|
5573
|
+
detectedAt,
|
|
5574
|
+
missingCount: missing.length,
|
|
5575
|
+
totalRows,
|
|
5576
|
+
missing
|
|
5577
|
+
};
|
|
5578
|
+
if (severity === "high") {
|
|
5579
|
+
try {
|
|
5580
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5581
|
+
} catch (err) {
|
|
5582
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5583
|
+
event: "cache_integrity.backup_failed",
|
|
5584
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5587
|
+
}
|
|
5588
|
+
this._pending = pending;
|
|
5589
|
+
this.persist();
|
|
5590
|
+
this.log.warn("cache integrity drift detected", {
|
|
5591
|
+
event: "cache_integrity.detected",
|
|
5592
|
+
severity,
|
|
5593
|
+
missingCount: missing.length,
|
|
5594
|
+
totalRows,
|
|
5595
|
+
fingerprint
|
|
5596
|
+
});
|
|
5597
|
+
this.wsHub.broadcast(this.buildWsMessage(pending));
|
|
5598
|
+
}
|
|
5599
|
+
/** Queue an unlink while an alert is pending — the row is not invalidated. */
|
|
5600
|
+
deferUnlink(filePath) {
|
|
5601
|
+
this.deferredUnlinks.push(filePath);
|
|
5602
|
+
}
|
|
5603
|
+
/**
|
|
5604
|
+
* Record a live unlink while NO alert is pending. Crossing the storm threshold
|
|
5605
|
+
* (>= 10 unlinks within 30s) re-triggers detection.
|
|
5606
|
+
*/
|
|
5607
|
+
recordUnlink(filePath) {
|
|
5608
|
+
const now = Date.now();
|
|
5609
|
+
this.unlinkTimes.push(now);
|
|
5610
|
+
this.unlinkTimes = this.unlinkTimes.filter((t) => now - t < STORM_WINDOW_MS);
|
|
5611
|
+
if (this.unlinkTimes.length >= STORM_THRESHOLD) {
|
|
5612
|
+
this.unlinkTimes = [];
|
|
5613
|
+
void this.runDetection().catch((err) => {
|
|
5614
|
+
this.log.error("cache-integrity storm detection failed", {
|
|
5615
|
+
event: "cache_integrity.storm_detection_failed",
|
|
5616
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5617
|
+
filePath
|
|
5618
|
+
});
|
|
5619
|
+
});
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
async ensureBackup(pending) {
|
|
5623
|
+
if (pending.backupPath) return pending.backupPath;
|
|
5624
|
+
try {
|
|
5625
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5626
|
+
} catch (err) {
|
|
5627
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5628
|
+
event: "cache_integrity.backup_failed",
|
|
5629
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5630
|
+
});
|
|
5631
|
+
}
|
|
5632
|
+
return pending.backupPath;
|
|
5633
|
+
}
|
|
5634
|
+
clearPending() {
|
|
5635
|
+
this._pending = null;
|
|
5636
|
+
this.deferredUnlinks = [];
|
|
5637
|
+
this.persist();
|
|
5638
|
+
}
|
|
5639
|
+
applyDeferredUnlinks() {
|
|
5640
|
+
for (const fp of this.deferredUnlinks) this.cache.invalidateByFilePath(fp);
|
|
5641
|
+
this.deferredUnlinks = [];
|
|
5642
|
+
}
|
|
5643
|
+
broadcastResolved(fingerprint, action) {
|
|
5644
|
+
this.wsHub.broadcast({ type: "cache_alert_resolved", fingerprint, action });
|
|
5645
|
+
}
|
|
5646
|
+
/**
|
|
5647
|
+
* Apply the human's chosen resolution. Idempotent per fingerprint: no pending
|
|
5648
|
+
* alert → alreadyResolved; a different fingerprint → conflict. See the spec's
|
|
5649
|
+
* four-action semantics.
|
|
5650
|
+
*/
|
|
5651
|
+
async resolve(fingerprint, action, ids) {
|
|
5652
|
+
const pending = this._pending;
|
|
5653
|
+
if (!pending) return { alreadyResolved: true };
|
|
5654
|
+
if (pending.fingerprint !== fingerprint) {
|
|
5655
|
+
return { conflict: true, currentFingerprint: pending.fingerprint };
|
|
5656
|
+
}
|
|
5657
|
+
this._pending = null;
|
|
5658
|
+
switch (action) {
|
|
5659
|
+
case "prune_all": {
|
|
5660
|
+
await this.ensureBackup(pending);
|
|
5661
|
+
const backupPath = pending.backupPath;
|
|
5662
|
+
const stillMissing = pending.missing.filter((m) => !existsSync8(m.filePath)).map((m) => m.id);
|
|
5663
|
+
const pruned = this.cache.dropRowsById(stillMissing);
|
|
5664
|
+
this.applyDeferredUnlinks();
|
|
5665
|
+
this.clearPending();
|
|
5666
|
+
this.broadcastResolved(fingerprint, action);
|
|
5667
|
+
return { ok: true, action, pruned, backupPath };
|
|
5668
|
+
}
|
|
5669
|
+
case "prune_selected": {
|
|
5670
|
+
const requested = new Set(ids ?? []);
|
|
5671
|
+
const pendingIds = new Set(pending.missing.map((m) => m.id));
|
|
5672
|
+
const toDrop = [...requested].filter((id) => pendingIds.has(id));
|
|
5673
|
+
await this.ensureBackup(pending);
|
|
5674
|
+
const backupPath = pending.backupPath;
|
|
5675
|
+
const pruned = this.cache.dropRowsById(toDrop);
|
|
5676
|
+
const prunedPaths = new Set(
|
|
5677
|
+
pending.missing.filter((m) => toDrop.includes(m.id)).map((m) => m.filePath)
|
|
5678
|
+
);
|
|
5679
|
+
this.deferredUnlinks = this.deferredUnlinks.filter((fp) => {
|
|
5680
|
+
if (prunedPaths.has(fp)) {
|
|
5681
|
+
this.cache.invalidateByFilePath(fp);
|
|
5682
|
+
return false;
|
|
5683
|
+
}
|
|
5684
|
+
return true;
|
|
5685
|
+
});
|
|
5686
|
+
this.persist();
|
|
5687
|
+
await this.runDetection();
|
|
5688
|
+
this.broadcastResolved(fingerprint, action);
|
|
5689
|
+
return { ok: true, action, pruned, backupPath };
|
|
5690
|
+
}
|
|
5691
|
+
case "ignore": {
|
|
5692
|
+
for (const m of pending.missing) this.ignoredIds.add(m.id);
|
|
5693
|
+
this.deferredUnlinks = [];
|
|
5694
|
+
this.clearPending();
|
|
5695
|
+
this.broadcastResolved(fingerprint, action);
|
|
5696
|
+
return { ok: true, action };
|
|
5697
|
+
}
|
|
5698
|
+
case "reset_rescan": {
|
|
5699
|
+
const backupPath = await this.ensureBackup(pending);
|
|
5700
|
+
const reset = async () => {
|
|
5701
|
+
this.cache.clearAll();
|
|
5702
|
+
if (this.rescan) {
|
|
5703
|
+
const metas = await this.rescan();
|
|
5704
|
+
this.cache.upsertFromScannerMeta(metas);
|
|
5705
|
+
}
|
|
5706
|
+
};
|
|
5707
|
+
const resetPromise = this.runDuringReset ? this.runDuringReset(reset) : reset();
|
|
5708
|
+
void resetPromise.catch((err) => {
|
|
5709
|
+
this.log.error("cache-integrity reset rescan failed", {
|
|
5710
|
+
event: "cache_integrity.reset_rescan_failed",
|
|
5711
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5712
|
+
});
|
|
5713
|
+
});
|
|
5714
|
+
this.clearPending();
|
|
5715
|
+
this.broadcastResolved(fingerprint, action);
|
|
5716
|
+
return { ok: true, action, backupPath };
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
}
|
|
5720
|
+
};
|
|
5721
|
+
|
|
5098
5722
|
// src/services/conversations/conversationWatcher.ts
|
|
5099
5723
|
import chokidar from "chokidar";
|
|
5100
|
-
import { statSync as
|
|
5724
|
+
import { statSync as statSync6 } from "fs";
|
|
5101
5725
|
import { open, stat as stat2 } from "fs/promises";
|
|
5102
5726
|
var ConversationWatcher = class {
|
|
5103
5727
|
files = /* @__PURE__ */ new Map();
|
|
@@ -5107,6 +5731,7 @@ var ConversationWatcher = class {
|
|
|
5107
5731
|
onNewLineSpans;
|
|
5108
5732
|
onConversationChanged;
|
|
5109
5733
|
onFileDeleted;
|
|
5734
|
+
onTruncated;
|
|
5110
5735
|
onError;
|
|
5111
5736
|
constructor(events = {}) {
|
|
5112
5737
|
this.onNewLine = events.onNewLine;
|
|
@@ -5114,13 +5739,15 @@ var ConversationWatcher = class {
|
|
|
5114
5739
|
this.onNewLineSpans = events.onNewLineSpans;
|
|
5115
5740
|
this.onConversationChanged = events.onConversationChanged;
|
|
5116
5741
|
this.onFileDeleted = events.onFileDeleted;
|
|
5742
|
+
this.onTruncated = events.onTruncated;
|
|
5117
5743
|
this.onError = events.onError;
|
|
5118
5744
|
}
|
|
5119
5745
|
watch(filePath) {
|
|
5120
|
-
|
|
5746
|
+
const key = canonicalizeFilePath(filePath);
|
|
5747
|
+
if (this.files.has(key)) return;
|
|
5121
5748
|
let offset;
|
|
5122
5749
|
try {
|
|
5123
|
-
offset =
|
|
5750
|
+
offset = statSync6(filePath).size;
|
|
5124
5751
|
} catch {
|
|
5125
5752
|
offset = 0;
|
|
5126
5753
|
}
|
|
@@ -5129,23 +5756,24 @@ var ConversationWatcher = class {
|
|
|
5129
5756
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
|
|
5130
5757
|
});
|
|
5131
5758
|
watcher.on("change", () => {
|
|
5132
|
-
void this.readNewLines(
|
|
5759
|
+
void this.readNewLines(key);
|
|
5133
5760
|
});
|
|
5134
5761
|
watcher.on("add", () => {
|
|
5135
|
-
void this.readNewLines(
|
|
5762
|
+
void this.readNewLines(key);
|
|
5136
5763
|
});
|
|
5137
5764
|
watcher.on("unlink", () => this.onFileDeleted?.(filePath));
|
|
5138
5765
|
watcher.on("error", (err) => {
|
|
5139
5766
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
5140
5767
|
this.onError?.(filePath, error);
|
|
5141
5768
|
});
|
|
5142
|
-
this.files.set(
|
|
5769
|
+
this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
|
|
5143
5770
|
}
|
|
5144
5771
|
unwatch(filePath) {
|
|
5145
|
-
const
|
|
5772
|
+
const key = canonicalizeFilePath(filePath);
|
|
5773
|
+
const entry = this.files.get(key);
|
|
5146
5774
|
if (!entry) return;
|
|
5147
5775
|
void entry.watcher.close();
|
|
5148
|
-
this.files.delete(
|
|
5776
|
+
this.files.delete(key);
|
|
5149
5777
|
}
|
|
5150
5778
|
/**
|
|
5151
5779
|
* Re-drive the tail read for a file that's already being tailed. A per-file
|
|
@@ -5156,8 +5784,9 @@ var ConversationWatcher = class {
|
|
|
5156
5784
|
* event is a cheap stat + no-op. Returns false for untailed paths.
|
|
5157
5785
|
*/
|
|
5158
5786
|
poke(filePath) {
|
|
5159
|
-
|
|
5160
|
-
|
|
5787
|
+
const key = canonicalizeFilePath(filePath);
|
|
5788
|
+
if (!this.files.has(key)) return false;
|
|
5789
|
+
void this.readNewLines(key);
|
|
5161
5790
|
return true;
|
|
5162
5791
|
}
|
|
5163
5792
|
/**
|
|
@@ -5194,9 +5823,10 @@ var ConversationWatcher = class {
|
|
|
5194
5823
|
for (const [path] of this.files) this.unwatch(path);
|
|
5195
5824
|
for (const [dir] of this.directories) this.unwatchDirectory(dir);
|
|
5196
5825
|
}
|
|
5197
|
-
async readNewLines(
|
|
5198
|
-
const entry = this.files.get(
|
|
5826
|
+
async readNewLines(key) {
|
|
5827
|
+
const entry = this.files.get(key);
|
|
5199
5828
|
if (!entry) return;
|
|
5829
|
+
const filePath = entry.path;
|
|
5200
5830
|
if (entry.reading) {
|
|
5201
5831
|
entry.pending = true;
|
|
5202
5832
|
return;
|
|
@@ -5205,6 +5835,10 @@ var ConversationWatcher = class {
|
|
|
5205
5835
|
try {
|
|
5206
5836
|
for (; ; ) {
|
|
5207
5837
|
const st = await stat2(filePath);
|
|
5838
|
+
if (st.size < entry.offset) {
|
|
5839
|
+
entry.offset = 0;
|
|
5840
|
+
this.onTruncated?.(filePath);
|
|
5841
|
+
}
|
|
5208
5842
|
if (st.size <= entry.offset) break;
|
|
5209
5843
|
const readFrom = entry.offset;
|
|
5210
5844
|
const bytesToRead = st.size - readFrom;
|
|
@@ -5217,7 +5851,7 @@ var ConversationWatcher = class {
|
|
|
5217
5851
|
}
|
|
5218
5852
|
const { spans, consumed } = splitCompleteLines(buf, readFrom);
|
|
5219
5853
|
entry.offset = readFrom + consumed;
|
|
5220
|
-
if (!this.files.has(
|
|
5854
|
+
if (!this.files.has(key)) return;
|
|
5221
5855
|
const lines = spans.map((s) => s.text);
|
|
5222
5856
|
if (spans.length > 0) {
|
|
5223
5857
|
this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
|
|
@@ -5237,9 +5871,9 @@ var ConversationWatcher = class {
|
|
|
5237
5871
|
this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
|
|
5238
5872
|
} finally {
|
|
5239
5873
|
entry.reading = false;
|
|
5240
|
-
if (entry.pending && this.files.has(
|
|
5874
|
+
if (entry.pending && this.files.has(key)) {
|
|
5241
5875
|
entry.pending = false;
|
|
5242
|
-
void this.readNewLines(
|
|
5876
|
+
void this.readNewLines(key);
|
|
5243
5877
|
}
|
|
5244
5878
|
}
|
|
5245
5879
|
}
|
|
@@ -5295,14 +5929,14 @@ function findSearchTarget(messages, query) {
|
|
|
5295
5929
|
}
|
|
5296
5930
|
|
|
5297
5931
|
// src/services/conversations/pruneAgentConversations.ts
|
|
5298
|
-
import { existsSync as
|
|
5932
|
+
import { existsSync as existsSync9 } from "fs";
|
|
5299
5933
|
function pruneAgentConversations(cache) {
|
|
5300
5934
|
const db = cache.getDatabase();
|
|
5301
5935
|
const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
|
|
5302
5936
|
let pruned = 0;
|
|
5303
5937
|
let missing = 0;
|
|
5304
5938
|
for (const row of rows) {
|
|
5305
|
-
if (!
|
|
5939
|
+
if (!existsSync9(row.file_path)) {
|
|
5306
5940
|
missing += 1;
|
|
5307
5941
|
continue;
|
|
5308
5942
|
}
|
|
@@ -5448,6 +6082,52 @@ function resolveAnswer(pending, body) {
|
|
|
5448
6082
|
}
|
|
5449
6083
|
}
|
|
5450
6084
|
|
|
6085
|
+
// src/services/sessions/conversationBusy.ts
|
|
6086
|
+
import { statSync as statSync7 } from "fs";
|
|
6087
|
+
var RESUME_BUSY_WINDOW_MS = 12e4;
|
|
6088
|
+
function resolveResumeBusyWindowMs(env = process.env) {
|
|
6089
|
+
const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
|
|
6090
|
+
if (raw === void 0) return RESUME_BUSY_WINDOW_MS;
|
|
6091
|
+
const n = Number.parseInt(raw, 10);
|
|
6092
|
+
return Number.isFinite(n) && n >= 0 ? n : RESUME_BUSY_WINDOW_MS;
|
|
6093
|
+
}
|
|
6094
|
+
var SELF_ACTIVITY_SKEW_MS = 5e3;
|
|
6095
|
+
function conversationBusy(input) {
|
|
6096
|
+
const now = input.now ?? Date.now();
|
|
6097
|
+
const windowMs = input.windowMs ?? RESUME_BUSY_WINDOW_MS;
|
|
6098
|
+
const platform3 = input.platform ?? process.platform;
|
|
6099
|
+
const detectedBy = [];
|
|
6100
|
+
let lastActivityMs = null;
|
|
6101
|
+
if (input.jsonlPath) {
|
|
6102
|
+
try {
|
|
6103
|
+
const mtimeMs = statSync7(input.jsonlPath).mtimeMs;
|
|
6104
|
+
const age = now - mtimeMs;
|
|
6105
|
+
lastActivityMs = Math.max(0, age);
|
|
6106
|
+
const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
|
|
6107
|
+
if (age <= windowMs && !isSelfEcho) detectedBy.push("jsonl_mtime");
|
|
6108
|
+
} catch {
|
|
6109
|
+
}
|
|
6110
|
+
}
|
|
6111
|
+
const argvMatch = input.discovered.some((p) => p.conversationId === input.conversationId);
|
|
6112
|
+
if (argvMatch) detectedBy.push("process_argv");
|
|
6113
|
+
let cwdMatch = false;
|
|
6114
|
+
if (platform3 !== "win32" && input.projectPath) {
|
|
6115
|
+
const target = canonicalizeProjectPath(input.projectPath);
|
|
6116
|
+
cwdMatch = input.discovered.some(
|
|
6117
|
+
(p) => !!p.projectPath && canonicalizeProjectPath(p.projectPath) === target
|
|
6118
|
+
);
|
|
6119
|
+
if (cwdMatch) detectedBy.push("process_cwd");
|
|
6120
|
+
}
|
|
6121
|
+
return {
|
|
6122
|
+
busy: detectedBy.length > 0,
|
|
6123
|
+
detectedBy,
|
|
6124
|
+
lastActivityMs,
|
|
6125
|
+
// A matched process is a concrete external owner; a lone mtime hit could be
|
|
6126
|
+
// an editor, a crashed process, or a process we could not enumerate.
|
|
6127
|
+
likelyOwner: argvMatch || cwdMatch ? "external" : "unknown"
|
|
6128
|
+
};
|
|
6129
|
+
}
|
|
6130
|
+
|
|
5451
6131
|
// src/session-store.ts
|
|
5452
6132
|
var SessionStore = class {
|
|
5453
6133
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -5587,6 +6267,9 @@ function managedToResponse(s, ptyAttached) {
|
|
|
5587
6267
|
conversationId: s.id,
|
|
5588
6268
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
5589
6269
|
status: s.status,
|
|
6270
|
+
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6271
|
+
// `activity` is attached for managed sessions.
|
|
6272
|
+
ownership: "managed",
|
|
5590
6273
|
projectPath: s.projectPath,
|
|
5591
6274
|
projectName: s.projectName,
|
|
5592
6275
|
branch: s.branch,
|
|
@@ -5620,7 +6303,14 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5620
6303
|
id: conversationId,
|
|
5621
6304
|
conversationId,
|
|
5622
6305
|
provider: CLAUDE_CODE_PROVIDER,
|
|
6306
|
+
// Stays "idle" deliberately: we cannot see this process's prompt state, and
|
|
6307
|
+
// reporting `running` would route mobile to the destructive Overtake screen.
|
|
6308
|
+
// Liveness travels in the additive fields below instead.
|
|
5623
6309
|
status: "idle",
|
|
6310
|
+
ownership: "external",
|
|
6311
|
+
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6312
|
+
// report "gone" here — a vanished process simply stops being listed.
|
|
6313
|
+
processLiveness: "alive",
|
|
5624
6314
|
projectPath: d.projectPath,
|
|
5625
6315
|
projectName: d.projectName,
|
|
5626
6316
|
branch: d.branch,
|
|
@@ -5638,7 +6328,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5638
6328
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
5639
6329
|
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
5640
6330
|
import heicConvert from "heic-convert";
|
|
5641
|
-
import { join as
|
|
6331
|
+
import { join as join16 } from "path";
|
|
5642
6332
|
var UPLOAD_DIR_NAME = ".threadbase-uploads";
|
|
5643
6333
|
var MAX_BYTES = 25 * 1024 * 1024;
|
|
5644
6334
|
var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
@@ -5671,9 +6361,9 @@ async function saveUploadFile(input) {
|
|
|
5671
6361
|
}
|
|
5672
6362
|
const id = `up_${randomBytes3(8).toString("hex")}`;
|
|
5673
6363
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
5674
|
-
const dir =
|
|
6364
|
+
const dir = join16(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
5675
6365
|
await mkdir3(dir, { recursive: true });
|
|
5676
|
-
const filePath =
|
|
6366
|
+
const filePath = join16(dir, `${Date.now()}-${id}-${safeName}`);
|
|
5677
6367
|
await writeFile(filePath, buffer);
|
|
5678
6368
|
return {
|
|
5679
6369
|
id,
|
|
@@ -5685,7 +6375,7 @@ async function saveUploadFile(input) {
|
|
|
5685
6375
|
}
|
|
5686
6376
|
function sanitizeFilename(name) {
|
|
5687
6377
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
5688
|
-
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("");
|
|
6378
|
+
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("").replace(/[\s@"'`$\\]/g, "_");
|
|
5689
6379
|
return cleaned;
|
|
5690
6380
|
}
|
|
5691
6381
|
|
|
@@ -5718,12 +6408,12 @@ function normalizeCodexLineToClaudeShape(line) {
|
|
|
5718
6408
|
const text = extractCodexText(payload.content);
|
|
5719
6409
|
if (!text) return null;
|
|
5720
6410
|
if (role === "user" && isCodexInjectedContext(text)) return null;
|
|
5721
|
-
const
|
|
5722
|
-
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${
|
|
6411
|
+
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6412
|
+
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
5723
6413
|
return JSON.stringify({
|
|
5724
6414
|
type: role,
|
|
5725
6415
|
uuid,
|
|
5726
|
-
timestamp,
|
|
6416
|
+
timestamp: timestamp2,
|
|
5727
6417
|
message: {
|
|
5728
6418
|
role,
|
|
5729
6419
|
content: [{ type: "text", text }]
|
|
@@ -5773,13 +6463,13 @@ function hashPrefix(text) {
|
|
|
5773
6463
|
}
|
|
5774
6464
|
|
|
5775
6465
|
// src/utils/conversationEtag.ts
|
|
5776
|
-
import { createHash as
|
|
6466
|
+
import { createHash as createHash3 } from "crypto";
|
|
5777
6467
|
function computeConversationEtag({
|
|
5778
6468
|
filePath,
|
|
5779
6469
|
messageCount,
|
|
5780
|
-
timestamp
|
|
6470
|
+
timestamp: timestamp2
|
|
5781
6471
|
}) {
|
|
5782
|
-
const digest =
|
|
6472
|
+
const digest = createHash3("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
5783
6473
|
return `"${digest}"`;
|
|
5784
6474
|
}
|
|
5785
6475
|
|
|
@@ -5929,8 +6619,17 @@ var WSHub = class {
|
|
|
5929
6619
|
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.`;
|
|
5930
6620
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
5931
6621
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6622
|
+
var GRACE_MAX_DEFERS = 4;
|
|
6623
|
+
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6624
|
+
var DISCOVERY_TTL_MS = 15e3;
|
|
6625
|
+
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
6626
|
+
var ADOPT_KILL_POLL_MS = 100;
|
|
5932
6627
|
var REFRESH_TTL_MS = 2e3;
|
|
5933
6628
|
var START_READY_TIMEOUT_MS = 1e4;
|
|
6629
|
+
var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
|
|
6630
|
+
var EXTERNAL_TAIL_MAX = 32;
|
|
6631
|
+
var EXTERNAL_TAIL_IDLE_MS = 3e5;
|
|
6632
|
+
var EXTERNAL_ACTIVE_WRITING_MS = 3e4;
|
|
5934
6633
|
function parseIncludeAgentsEnv(raw) {
|
|
5935
6634
|
if (raw === void 0) return false;
|
|
5936
6635
|
const v = raw.trim().toLowerCase();
|
|
@@ -5944,11 +6643,30 @@ var StreamerServer = class {
|
|
|
5944
6643
|
fileWatcher;
|
|
5945
6644
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
5946
6645
|
// sessionId → JSONL filePath
|
|
6646
|
+
// canonical JSONL path → live tail on a file NO PTY session owns (an external
|
|
6647
|
+
// agent is writing it). Deliberately separate from sessionFileMap so managed
|
|
6648
|
+
// session semantics — terminal_output, session_update, question cards — are
|
|
6649
|
+
// untouched: an external tail only ever pushes transcript lines.
|
|
6650
|
+
externalTails = /* @__PURE__ */ new Map();
|
|
5947
6651
|
// Per-file seq assignments from the most recent onNewLineSpans (offset index),
|
|
5948
6652
|
// handed to the immediately-following onNewLines so it can stamp WS `seq` on
|
|
5949
6653
|
// the matching conversation_events entries. Same read → same lines order.
|
|
5950
6654
|
pendingLineSeqs = /* @__PURE__ */ new Map();
|
|
6655
|
+
// `origin` records whether the pending question came from the live PTY-screen
|
|
6656
|
+
// path (handleLiveQuestion) or a JSONL flush. A JSONL-derived question must
|
|
6657
|
+
// never clobber a PTY-originated one for a DIFFERENT question — an external
|
|
6658
|
+
// agent appending an AskUserQuestion into a shared conversation would
|
|
6659
|
+
// otherwise misroute the answer into this streamer's PTY.
|
|
5951
6660
|
pendingQuestions = /* @__PURE__ */ new Map();
|
|
6661
|
+
// Sessions resumed past a detected collision (busy probe said busy, caller
|
|
6662
|
+
// forced). JSONL-derived actionable question cards are suppressed for these
|
|
6663
|
+
// because a line in the shared file may have been written by the other owner.
|
|
6664
|
+
contendedSessions = /* @__PURE__ */ new Set();
|
|
6665
|
+
// conversationId → ms epoch when THIS streamer's PTY for it last went idle.
|
|
6666
|
+
// Lets the resume collision probe tell our own trailing JSONL writes (a
|
|
6667
|
+
// hold → resume round trip) apart from another owner's. Pruned on write so it
|
|
6668
|
+
// cannot grow without bound across a long-lived process.
|
|
6669
|
+
selfPtyEndedAt = /* @__PURE__ */ new Map();
|
|
5952
6670
|
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
5953
6671
|
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
5954
6672
|
// the screen detection fires first, the later JSONL flush of the same question
|
|
@@ -5983,7 +6701,8 @@ var StreamerServer = class {
|
|
|
5983
6701
|
// listener-level 'error' handler demotes EADDRINUSE to debug during this
|
|
5984
6702
|
// window so the self-healing kickstart-relaunch race doesn't spam warn.
|
|
5985
6703
|
binding = false;
|
|
5986
|
-
|
|
6704
|
+
activeWarmups = /* @__PURE__ */ new Map([[0, "startup"]]);
|
|
6705
|
+
nextWarmupId = 1;
|
|
5987
6706
|
// Every fire-and-forget task that runs a scan and then writes to this.cache
|
|
5988
6707
|
// in an async continuation (startup warm-up, background count refresh, …).
|
|
5989
6708
|
// close() awaits all of them before closing this.cache, so a scan's post-scan
|
|
@@ -6015,6 +6734,9 @@ var StreamerServer = class {
|
|
|
6015
6734
|
defaultEffort;
|
|
6016
6735
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6017
6736
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6737
|
+
// Consecutive grace-timer defers for a still-`running` session (see
|
|
6738
|
+
// GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
|
|
6739
|
+
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6018
6740
|
// Map of sessionId → set of subscribed WS clients
|
|
6019
6741
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
6020
6742
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -6022,6 +6744,7 @@ var StreamerServer = class {
|
|
|
6022
6744
|
// Reverse map for cleanup on close
|
|
6023
6745
|
wsToClientId = /* @__PURE__ */ new Map();
|
|
6024
6746
|
cache = null;
|
|
6747
|
+
cacheMonitor = null;
|
|
6025
6748
|
projectsRepo = null;
|
|
6026
6749
|
conversationsRepo = null;
|
|
6027
6750
|
sessionsRepo = null;
|
|
@@ -6058,13 +6781,13 @@ var StreamerServer = class {
|
|
|
6058
6781
|
this.disableDb = config.disableDb ?? false;
|
|
6059
6782
|
this.scannerPersistenceDisabled = config.scannerPersistent === false;
|
|
6060
6783
|
this.scanProfiles = config.scanProfiles;
|
|
6061
|
-
this.codexRoots = config.codexRoots ?? [
|
|
6784
|
+
this.codexRoots = config.codexRoots ?? [join17(homedir8(), ".codex", "sessions")];
|
|
6062
6785
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
6063
6786
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
6064
6787
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6065
6788
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6066
6789
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
6067
|
-
this.cacheDir = config.cacheDir ?? loadCacheDir() ??
|
|
6790
|
+
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join17(homedir8(), ".threadbase", "cache");
|
|
6068
6791
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6069
6792
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
6070
6793
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -6105,7 +6828,7 @@ var StreamerServer = class {
|
|
|
6105
6828
|
const seqs = cache.extendMessageIndex(
|
|
6106
6829
|
filePath,
|
|
6107
6830
|
spans,
|
|
6108
|
-
|
|
6831
|
+
statSync8(filePath),
|
|
6109
6832
|
readFrom,
|
|
6110
6833
|
endOffset
|
|
6111
6834
|
);
|
|
@@ -6135,39 +6858,25 @@ var StreamerServer = class {
|
|
|
6135
6858
|
},
|
|
6136
6859
|
onNewLines: (filePath, lines) => {
|
|
6137
6860
|
this.cache?.updateFromLines(filePath, lines);
|
|
6861
|
+
let managed = false;
|
|
6138
6862
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
6139
6863
|
if (watchedPath === filePath) {
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
for (const p of pending) {
|
|
6143
|
-
this.pendingQuestions.set(sessionId, p);
|
|
6144
|
-
const t = setTimeout(() => {
|
|
6145
|
-
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
6146
|
-
this.cancelPendingQuestion(sessionId);
|
|
6147
|
-
}
|
|
6148
|
-
}, 6e4);
|
|
6149
|
-
t.unref();
|
|
6150
|
-
}
|
|
6151
|
-
for (const m of messages) {
|
|
6152
|
-
const key = questionContentKey(m.questions);
|
|
6153
|
-
const broadcast = shouldBroadcastQuestion({
|
|
6154
|
-
newContentKey: key,
|
|
6155
|
-
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
6156
|
-
newToolUseId: m.toolUseId,
|
|
6157
|
-
priorToolUseId
|
|
6158
|
-
});
|
|
6159
|
-
this.pendingQuestionKey.set(sessionId, key);
|
|
6160
|
-
if (broadcast) this.wsHub.broadcast(m);
|
|
6161
|
-
}
|
|
6864
|
+
managed = true;
|
|
6865
|
+
this.processJsonlQuestions(sessionId, lines);
|
|
6162
6866
|
const seqs = this.pendingLineSeqs.get(filePath);
|
|
6163
6867
|
this.broadcastConversationLines(sessionId, lines, seqs);
|
|
6164
6868
|
break;
|
|
6165
6869
|
}
|
|
6166
6870
|
}
|
|
6871
|
+
if (!managed) {
|
|
6872
|
+
this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
|
|
6873
|
+
}
|
|
6167
6874
|
this.pendingLineSeqs.delete(filePath);
|
|
6168
6875
|
},
|
|
6169
6876
|
onConversationChanged: (filePath) => {
|
|
6170
|
-
this.fileWatcher.poke(filePath);
|
|
6877
|
+
const tailed = this.fileWatcher.poke(filePath);
|
|
6878
|
+
if (!tailed) this.maybeAttachExternalTail(filePath);
|
|
6879
|
+
this.sweepIdleExternalTails();
|
|
6171
6880
|
this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
|
|
6172
6881
|
this.markScannerStaleDebounced();
|
|
6173
6882
|
this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
|
|
@@ -6175,7 +6884,20 @@ var StreamerServer = class {
|
|
|
6175
6884
|
event: "cache.directory_change"
|
|
6176
6885
|
});
|
|
6177
6886
|
},
|
|
6887
|
+
onTruncated: (filePath) => {
|
|
6888
|
+
this.cache?.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
|
|
6889
|
+
this.cache?.clearIndexParseState(filePath);
|
|
6890
|
+
this.log.warn(`JSONL truncated/replaced; offset index dropped: ${filePath}`, {
|
|
6891
|
+
filePath,
|
|
6892
|
+
event: "tail.truncated"
|
|
6893
|
+
});
|
|
6894
|
+
},
|
|
6178
6895
|
onFileDeleted: (filePath) => {
|
|
6896
|
+
this.detachExternalTail(canonicalizeFilePath(filePath));
|
|
6897
|
+
if (this.cacheMonitor?.pending) {
|
|
6898
|
+
this.cacheMonitor.deferUnlink(filePath);
|
|
6899
|
+
return;
|
|
6900
|
+
}
|
|
6179
6901
|
const id = this.cache?.invalidateByFilePath(filePath);
|
|
6180
6902
|
if (id)
|
|
6181
6903
|
this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
|
|
@@ -6183,6 +6905,7 @@ var StreamerServer = class {
|
|
|
6183
6905
|
filePath,
|
|
6184
6906
|
event: "cache.invalidate_on_unlink"
|
|
6185
6907
|
});
|
|
6908
|
+
this.cacheMonitor?.recordUnlink(filePath);
|
|
6186
6909
|
}
|
|
6187
6910
|
});
|
|
6188
6911
|
this.ptyManager = new LiveSessionManager({
|
|
@@ -6246,6 +6969,8 @@ var StreamerServer = class {
|
|
|
6246
6969
|
this.cancelPendingQuestion(session.id);
|
|
6247
6970
|
}
|
|
6248
6971
|
this.pendingPermission.delete(session.id);
|
|
6972
|
+
this.contendedSessions.delete(session.id);
|
|
6973
|
+
this.rememberSelfPtyEnded(session.id);
|
|
6249
6974
|
}
|
|
6250
6975
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
6251
6976
|
if (resp) {
|
|
@@ -6269,7 +6994,7 @@ var StreamerServer = class {
|
|
|
6269
6994
|
temporalClient,
|
|
6270
6995
|
taskQueue: agentConfig.temporal.taskQueue
|
|
6271
6996
|
});
|
|
6272
|
-
const conversationsBaseDir = agentConfig.conversationsDir ||
|
|
6997
|
+
const conversationsBaseDir = agentConfig.conversationsDir || join17(dirname9(this.cacheDir), "conversations");
|
|
6273
6998
|
conversationWriter = createConversationWriter({
|
|
6274
6999
|
baseDir: conversationsBaseDir
|
|
6275
7000
|
});
|
|
@@ -6291,6 +7016,7 @@ var StreamerServer = class {
|
|
|
6291
7016
|
sessionStore: this.sessionStore,
|
|
6292
7017
|
wsHub: this.wsHub,
|
|
6293
7018
|
cache: () => this.cache,
|
|
7019
|
+
cacheMonitor: () => this.cacheMonitor,
|
|
6294
7020
|
projectsRepo: () => this.projectsRepo,
|
|
6295
7021
|
conversationsRepo: () => this.conversationsRepo,
|
|
6296
7022
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -6326,9 +7052,11 @@ var StreamerServer = class {
|
|
|
6326
7052
|
this.wsHub.addClient(ws);
|
|
6327
7053
|
const sessions = this.sessionStore.list(this.ptyAttachedIds());
|
|
6328
7054
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
6329
|
-
if (this.
|
|
7055
|
+
if (!this.currentWarmupState()) {
|
|
6330
7056
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
6331
7057
|
}
|
|
7058
|
+
const alertMsg = this.cacheMonitor?.wsMessage();
|
|
7059
|
+
if (alertMsg) this.wsHub.unicast(ws, alertMsg);
|
|
6332
7060
|
},
|
|
6333
7061
|
handleWsMessage: async (ws, raw) => {
|
|
6334
7062
|
try {
|
|
@@ -6444,6 +7172,17 @@ var StreamerServer = class {
|
|
|
6444
7172
|
ptyAttachedIds() {
|
|
6445
7173
|
return new Set(this.ptyManager.listSessions().map((s) => s.id));
|
|
6446
7174
|
}
|
|
7175
|
+
// Record that our own PTY for `conversationId` just ended. Entries older than
|
|
7176
|
+
// the busy window can never change a verdict, so drop them as we go rather
|
|
7177
|
+
// than accumulating one per conversation for the process's lifetime.
|
|
7178
|
+
rememberSelfPtyEnded(conversationId) {
|
|
7179
|
+
const now = Date.now();
|
|
7180
|
+
const cutoff = now - resolveResumeBusyWindowMs();
|
|
7181
|
+
for (const [id, at] of this.selfPtyEndedAt) {
|
|
7182
|
+
if (at < cutoff) this.selfPtyEndedAt.delete(id);
|
|
7183
|
+
}
|
|
7184
|
+
this.selfPtyEndedAt.set(conversationId, now);
|
|
7185
|
+
}
|
|
6447
7186
|
/**
|
|
6448
7187
|
* Send a session_list to only the client that triggered this HTTP request
|
|
6449
7188
|
* (identified by X-Client-Id header → registered WS socket). Falls back to
|
|
@@ -6474,6 +7213,7 @@ var StreamerServer = class {
|
|
|
6474
7213
|
clearTimeout(existing);
|
|
6475
7214
|
this.ptyGraceTimers.delete(sessionId);
|
|
6476
7215
|
}
|
|
7216
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6477
7217
|
}
|
|
6478
7218
|
startGraceTimer(sessionId, delayMs) {
|
|
6479
7219
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
@@ -6483,14 +7223,24 @@ var StreamerServer = class {
|
|
|
6483
7223
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
6484
7224
|
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6485
7225
|
if (resp?.status === "running") {
|
|
6486
|
-
this.
|
|
6487
|
-
|
|
6488
|
-
|
|
7226
|
+
const defers = (this.ptyGraceDeferCounts.get(sessionId) ?? 0) + 1;
|
|
7227
|
+
if (defers <= GRACE_MAX_DEFERS) {
|
|
7228
|
+
this.ptyGraceDeferCounts.set(sessionId, defers);
|
|
7229
|
+
this.log.info(
|
|
7230
|
+
`[grace] session ${sessionId} still running, deferring hold (${defers}/${GRACE_MAX_DEFERS})`,
|
|
7231
|
+
{ sessionId, event: "pty.grace_defer", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
7232
|
+
"pino"
|
|
7233
|
+
);
|
|
7234
|
+
this.startGraceTimer(sessionId, delayMs);
|
|
7235
|
+
return;
|
|
7236
|
+
}
|
|
7237
|
+
this.log.warn(
|
|
7238
|
+
`[grace] session ${sessionId} exceeded ${GRACE_MAX_DEFERS} defers, holding anyway`,
|
|
7239
|
+
{ sessionId, event: "pty.grace_defer_cap", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
6489
7240
|
"pino"
|
|
6490
7241
|
);
|
|
6491
|
-
this.startGraceTimer(sessionId, delayMs);
|
|
6492
|
-
return;
|
|
6493
7242
|
}
|
|
7243
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6494
7244
|
this.sessionSubscribers.delete(sessionId);
|
|
6495
7245
|
this.log.info(
|
|
6496
7246
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
@@ -6501,6 +7251,7 @@ var StreamerServer = class {
|
|
|
6501
7251
|
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6502
7252
|
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
6503
7253
|
} else {
|
|
7254
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6504
7255
|
this.sessionSubscribers.delete(sessionId);
|
|
6505
7256
|
}
|
|
6506
7257
|
}, delayMs);
|
|
@@ -6510,6 +7261,39 @@ var StreamerServer = class {
|
|
|
6510
7261
|
const addr = this.httpServer.address();
|
|
6511
7262
|
return typeof addr === "object" && addr ? addr.port : 0;
|
|
6512
7263
|
}
|
|
7264
|
+
currentWarmupState() {
|
|
7265
|
+
let current = null;
|
|
7266
|
+
for (const state of this.activeWarmups.values()) current = state;
|
|
7267
|
+
return current;
|
|
7268
|
+
}
|
|
7269
|
+
beginWarmup(state) {
|
|
7270
|
+
const id = this.nextWarmupId++;
|
|
7271
|
+
this.activeWarmups.set(id, state);
|
|
7272
|
+
return id;
|
|
7273
|
+
}
|
|
7274
|
+
finishWarmup(id) {
|
|
7275
|
+
if (!this.activeWarmups.delete(id) || this.activeWarmups.size > 0) return;
|
|
7276
|
+
this.wsHub.broadcast({ type: "cache_ready" });
|
|
7277
|
+
}
|
|
7278
|
+
async withWarmup(state, operation) {
|
|
7279
|
+
const id = this.beginWarmup(state);
|
|
7280
|
+
try {
|
|
7281
|
+
return await operation();
|
|
7282
|
+
} finally {
|
|
7283
|
+
this.finishWarmup(id);
|
|
7284
|
+
}
|
|
7285
|
+
}
|
|
7286
|
+
rejectIfWarmingUp(res) {
|
|
7287
|
+
const warmupState = this.currentWarmupState();
|
|
7288
|
+
if (!warmupState) return false;
|
|
7289
|
+
const body = {
|
|
7290
|
+
error: "Server is warming up",
|
|
7291
|
+
code: "SERVER_WARMING_UP",
|
|
7292
|
+
warmupState
|
|
7293
|
+
};
|
|
7294
|
+
json(res, 503, body);
|
|
7295
|
+
return true;
|
|
7296
|
+
}
|
|
6513
7297
|
async listen(port, opts) {
|
|
6514
7298
|
const dbConfig = this.disableDb ? null : getDbConfig();
|
|
6515
7299
|
if (dbConfig) {
|
|
@@ -6533,13 +7317,16 @@ var StreamerServer = class {
|
|
|
6533
7317
|
});
|
|
6534
7318
|
try {
|
|
6535
7319
|
this.cache = ConversationCache.open(
|
|
6536
|
-
|
|
7320
|
+
join17(this.cacheDir, "cache.db"),
|
|
6537
7321
|
this.tailSize,
|
|
6538
7322
|
void 0,
|
|
6539
7323
|
{
|
|
6540
7324
|
filterAgentConversations: !this.includeAgents,
|
|
6541
7325
|
agentEntrypoints: this.agentEntrypoints,
|
|
6542
|
-
onAgentFileDetected: (fp) =>
|
|
7326
|
+
onAgentFileDetected: (fp) => {
|
|
7327
|
+
this.fileWatcher.unwatch(fp);
|
|
7328
|
+
this.externalTails.delete(canonicalizeFilePath(fp));
|
|
7329
|
+
}
|
|
6543
7330
|
}
|
|
6544
7331
|
);
|
|
6545
7332
|
if (!this.includeAgents) {
|
|
@@ -6556,9 +7343,28 @@ var StreamerServer = class {
|
|
|
6556
7343
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
6557
7344
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
6558
7345
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
7346
|
+
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7347
|
+
this.cache,
|
|
7348
|
+
this.wsHub,
|
|
7349
|
+
this.log,
|
|
7350
|
+
this.cacheDir,
|
|
7351
|
+
async () => {
|
|
7352
|
+
const scanner = await this.rescanForRefresh();
|
|
7353
|
+
return [...scanner.getMetadataCache().values()];
|
|
7354
|
+
},
|
|
7355
|
+
(operation) => {
|
|
7356
|
+
const reset = this.withWarmup("cache_reset", operation);
|
|
7357
|
+
this.trackCacheWrite(reset);
|
|
7358
|
+
return reset;
|
|
7359
|
+
}
|
|
7360
|
+
);
|
|
6559
7361
|
for (const dir of this.projectsDirs()) {
|
|
6560
7362
|
this.fileWatcher.watchDirectory(dir);
|
|
6561
7363
|
}
|
|
7364
|
+
for (const dir of this.codexRoots) {
|
|
7365
|
+
if (!existsSync10(dir)) continue;
|
|
7366
|
+
this.fileWatcher.watchDirectory(dir);
|
|
7367
|
+
}
|
|
6562
7368
|
} catch (err) {
|
|
6563
7369
|
const message = err instanceof Error ? err.message : String(err);
|
|
6564
7370
|
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
@@ -6625,11 +7431,19 @@ var StreamerServer = class {
|
|
|
6625
7431
|
}
|
|
6626
7432
|
);
|
|
6627
7433
|
}
|
|
6628
|
-
|
|
6629
|
-
this.
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
7434
|
+
await this.cacheMonitor?.runDetection();
|
|
7435
|
+
if (this.cacheMonitor?.pending) {
|
|
7436
|
+
this.log.warn("Startup ghost prune skipped \u2014 cache integrity alert pending", {
|
|
7437
|
+
fingerprint: this.cacheMonitor.pending.fingerprint,
|
|
7438
|
+
event: "cache.prune_ghosts_frozen"
|
|
7439
|
+
});
|
|
7440
|
+
} else {
|
|
7441
|
+
const pruned = this.cache.pruneGhostFiles();
|
|
7442
|
+
this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
|
|
7443
|
+
count: pruned.length,
|
|
7444
|
+
event: "cache.prune_ghosts"
|
|
7445
|
+
});
|
|
7446
|
+
}
|
|
6633
7447
|
}).catch((err) => {
|
|
6634
7448
|
const message = err instanceof Error ? err.message : String(err);
|
|
6635
7449
|
this.log.warn(`Startup cache warm-up failed: ${message}`, {
|
|
@@ -6637,8 +7451,7 @@ var StreamerServer = class {
|
|
|
6637
7451
|
event: "cache.warmup_failed"
|
|
6638
7452
|
});
|
|
6639
7453
|
}).finally(() => {
|
|
6640
|
-
this.
|
|
6641
|
-
this.wsHub.broadcast({ type: "cache_ready" });
|
|
7454
|
+
this.finishWarmup(0);
|
|
6642
7455
|
resolveWarm();
|
|
6643
7456
|
});
|
|
6644
7457
|
}
|
|
@@ -6741,6 +7554,7 @@ var StreamerServer = class {
|
|
|
6741
7554
|
this.cache?.close();
|
|
6742
7555
|
this.ptyManager.dispose();
|
|
6743
7556
|
this.fileWatcher.dispose();
|
|
7557
|
+
this.externalTails.clear();
|
|
6744
7558
|
this.wsHub.dispose();
|
|
6745
7559
|
this.pairTokens.dispose();
|
|
6746
7560
|
if (this.dbPool) {
|
|
@@ -6869,6 +7683,7 @@ var StreamerServer = class {
|
|
|
6869
7683
|
return this.checkRateLimit(this.sessionInputAttempts, sessionId, 500, 6e4);
|
|
6870
7684
|
}
|
|
6871
7685
|
async handleListConversations(url, res) {
|
|
7686
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
6872
7687
|
const limit = intParam(url, "limit", 50);
|
|
6873
7688
|
const offset = intParam(url, "offset", 0);
|
|
6874
7689
|
const sort = url.searchParams.get("sort") ?? "recent";
|
|
@@ -6876,14 +7691,13 @@ var StreamerServer = class {
|
|
|
6876
7691
|
const providerFilter = url.searchParams.get("provider") ?? void 0;
|
|
6877
7692
|
const bustCache = url.searchParams.get("refresh") === "1";
|
|
6878
7693
|
if (bustCache && this.cache) {
|
|
6879
|
-
const scanner2 = await this.rescanForRefresh();
|
|
7694
|
+
const scanner2 = await this.withWarmup("conversation_refresh", () => this.rescanForRefresh());
|
|
6880
7695
|
const metas2 = [...scanner2.getMetadataCache().values()];
|
|
6881
7696
|
try {
|
|
6882
7697
|
this.cache.upsertFromScannerMeta(metas2);
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
this.cache.reconcileDeletions(livePaths);
|
|
7698
|
+
if (!this.cacheMonitor?.pending) {
|
|
7699
|
+
this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
|
|
7700
|
+
}
|
|
6887
7701
|
} catch (err) {
|
|
6888
7702
|
this.log.warn(
|
|
6889
7703
|
`refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -6958,6 +7772,7 @@ var StreamerServer = class {
|
|
|
6958
7772
|
json(res, 200, { conversations: adapted, hasMore: offset + limit < total, offset, total });
|
|
6959
7773
|
}
|
|
6960
7774
|
async handleConversationsCount(url, res) {
|
|
7775
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
6961
7776
|
const project = url.searchParams.get("project") ?? void 0;
|
|
6962
7777
|
const providerFilter = url.searchParams.get("provider") ?? void 0;
|
|
6963
7778
|
const bustCache = url.searchParams.get("refresh") === "1";
|
|
@@ -6985,7 +7800,7 @@ var StreamerServer = class {
|
|
|
6985
7800
|
// path — refresh=1 returns the cached total synchronously and this catches up.
|
|
6986
7801
|
refreshCountInBackground() {
|
|
6987
7802
|
this.trackCacheWrite(
|
|
6988
|
-
(async () => {
|
|
7803
|
+
this.withWarmup("conversation_refresh", async () => {
|
|
6989
7804
|
try {
|
|
6990
7805
|
const scanner = await this.getFreshScanner();
|
|
6991
7806
|
if (this.cache) {
|
|
@@ -6997,13 +7812,15 @@ var StreamerServer = class {
|
|
|
6997
7812
|
{ event: "count.refresh_failed" }
|
|
6998
7813
|
);
|
|
6999
7814
|
}
|
|
7000
|
-
})
|
|
7815
|
+
})
|
|
7001
7816
|
);
|
|
7002
7817
|
}
|
|
7003
7818
|
handleSessionsCount(res) {
|
|
7819
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7004
7820
|
json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
|
|
7005
7821
|
}
|
|
7006
7822
|
handleGetRecentSessions(url, res) {
|
|
7823
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7007
7824
|
const limit = intParam(url, "limit", 20);
|
|
7008
7825
|
if (!this.cache) {
|
|
7009
7826
|
json(res, 200, { sessions: [], total: 0 });
|
|
@@ -7014,6 +7831,7 @@ var StreamerServer = class {
|
|
|
7014
7831
|
type: "conversation",
|
|
7015
7832
|
id: c.id,
|
|
7016
7833
|
status: "idle",
|
|
7834
|
+
ownership: "historical",
|
|
7017
7835
|
ptyAttached: false,
|
|
7018
7836
|
projectId: c.projectId ?? void 0,
|
|
7019
7837
|
projectPath: c.projectPath ?? "",
|
|
@@ -7040,21 +7858,19 @@ var StreamerServer = class {
|
|
|
7040
7858
|
if (!this.cache) return void 0;
|
|
7041
7859
|
if (!previousScanner) {
|
|
7042
7860
|
const persisted = this.cache.getScannerStatCache();
|
|
7043
|
-
|
|
7861
|
+
if (persisted.size === 0) return void 0;
|
|
7862
|
+
const nativeKeyed = /* @__PURE__ */ new Map();
|
|
7863
|
+
for (const [canonicalPath, entry] of persisted) {
|
|
7864
|
+
nativeKeyed.set(toNativeFilePath(canonicalPath), entry);
|
|
7865
|
+
}
|
|
7866
|
+
return nativeKeyed;
|
|
7044
7867
|
}
|
|
7045
7868
|
const dbStats = this.cache.getFileStats();
|
|
7046
7869
|
if (dbStats.size === 0) return void 0;
|
|
7047
|
-
const
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
}
|
|
7052
|
-
}
|
|
7053
|
-
const statCache = /* @__PURE__ */ new Map();
|
|
7054
|
-
for (const [filePath, stat3] of dbStats) {
|
|
7055
|
-
const meta = metaByPath.get(filePath);
|
|
7056
|
-
if (meta) statCache.set(filePath, { stat: stat3, meta });
|
|
7057
|
-
}
|
|
7870
|
+
const statCache = joinStatCacheByNativePath(
|
|
7871
|
+
previousScanner.getMetadataCache().values(),
|
|
7872
|
+
dbStats
|
|
7873
|
+
);
|
|
7058
7874
|
return statCache.size > 0 ? statCache : void 0;
|
|
7059
7875
|
}
|
|
7060
7876
|
// Returns the provider + codexRoots fragment to spread into every scan()/search() call.
|
|
@@ -7148,22 +7964,22 @@ var StreamerServer = class {
|
|
|
7148
7964
|
*/
|
|
7149
7965
|
projectsDirs() {
|
|
7150
7966
|
if (this.scanProfiles && this.scanProfiles.length > 0) {
|
|
7151
|
-
return this.scanProfiles.filter((p) => p.enabled).map((p) =>
|
|
7967
|
+
return this.scanProfiles.filter((p) => p.enabled).map((p) => join17(p.configDir, "projects"));
|
|
7152
7968
|
}
|
|
7153
|
-
return [
|
|
7969
|
+
return [join17(homedir8(), ".claude", "projects")];
|
|
7154
7970
|
}
|
|
7155
7971
|
findJsonlPath(uuid) {
|
|
7156
7972
|
const filename = `${uuid}.jsonl`;
|
|
7157
7973
|
for (const projectsDir of this.projectsDirs()) {
|
|
7158
|
-
if (!
|
|
7159
|
-
for (const dir of
|
|
7160
|
-
const fp =
|
|
7161
|
-
if (
|
|
7162
|
-
const projectDir =
|
|
7974
|
+
if (!existsSync10(projectsDir)) continue;
|
|
7975
|
+
for (const dir of readdirSync5(projectsDir)) {
|
|
7976
|
+
const fp = join17(projectsDir, dir, filename);
|
|
7977
|
+
if (existsSync10(fp)) return fp;
|
|
7978
|
+
const projectDir = join17(projectsDir, dir);
|
|
7163
7979
|
try {
|
|
7164
|
-
for (const sub of
|
|
7165
|
-
const subagentPath =
|
|
7166
|
-
if (
|
|
7980
|
+
for (const sub of readdirSync5(projectDir)) {
|
|
7981
|
+
const subagentPath = join17(projectDir, sub, "subagents", filename);
|
|
7982
|
+
if (existsSync10(subagentPath)) return subagentPath;
|
|
7167
7983
|
}
|
|
7168
7984
|
} catch {
|
|
7169
7985
|
}
|
|
@@ -7243,6 +8059,140 @@ var StreamerServer = class {
|
|
|
7243
8059
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7244
8060
|
}
|
|
7245
8061
|
}
|
|
8062
|
+
// ─── External (non-PTY) live tails ───────────────────────────────
|
|
8063
|
+
/** True when a managed (PTY) session owns the tail for this canonical path. */
|
|
8064
|
+
isManagedTailPath(key) {
|
|
8065
|
+
for (const watchedPath of this.sessionFileMap.values()) {
|
|
8066
|
+
if (canonicalizeFilePath(watchedPath) === key) return true;
|
|
8067
|
+
}
|
|
8068
|
+
return false;
|
|
8069
|
+
}
|
|
8070
|
+
/**
|
|
8071
|
+
* Attach a live tail to a JSONL nobody is tailing yet, when it was touched
|
|
8072
|
+
* recently enough to look actively written by an external agent. Capped at
|
|
8073
|
+
* EXTERNAL_TAIL_MAX with LRU eviction.
|
|
8074
|
+
*/
|
|
8075
|
+
maybeAttachExternalTail(filePath) {
|
|
8076
|
+
if (!filePath.endsWith(".jsonl")) return;
|
|
8077
|
+
const key = canonicalizeFilePath(filePath);
|
|
8078
|
+
if (this.externalTails.has(key)) return;
|
|
8079
|
+
if (this.isManagedTailPath(key)) return;
|
|
8080
|
+
let mtimeMs;
|
|
8081
|
+
try {
|
|
8082
|
+
mtimeMs = statSync8(filePath).mtimeMs;
|
|
8083
|
+
} catch {
|
|
8084
|
+
return;
|
|
8085
|
+
}
|
|
8086
|
+
const now = Date.now();
|
|
8087
|
+
if (now - mtimeMs > EXTERNAL_TAIL_RECENCY_MS) return;
|
|
8088
|
+
this.evictExternalTailsIfNeeded();
|
|
8089
|
+
this.externalTails.set(key, {
|
|
8090
|
+
conversationId: ConversationCache.conversationIdForFile(key),
|
|
8091
|
+
lastActivityAt: now
|
|
8092
|
+
});
|
|
8093
|
+
this.fileWatcher.watch(filePath);
|
|
8094
|
+
this.log.debug?.(`External tail attached: ${filePath}`, {
|
|
8095
|
+
filePath,
|
|
8096
|
+
tails: this.externalTails.size,
|
|
8097
|
+
event: "external_tail.attach"
|
|
8098
|
+
});
|
|
8099
|
+
}
|
|
8100
|
+
/** Stop tailing an external file and drop its bookkeeping. */
|
|
8101
|
+
detachExternalTail(key) {
|
|
8102
|
+
if (!this.externalTails.delete(key)) return;
|
|
8103
|
+
this.fileWatcher.unwatch(key);
|
|
8104
|
+
this.log.debug?.(`External tail detached: ${key}`, {
|
|
8105
|
+
filePath: key,
|
|
8106
|
+
event: "external_tail.detach"
|
|
8107
|
+
});
|
|
8108
|
+
}
|
|
8109
|
+
/** Make room for one more tail by evicting the least recently active ones. */
|
|
8110
|
+
evictExternalTailsIfNeeded() {
|
|
8111
|
+
while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
|
|
8112
|
+
let lruKey = null;
|
|
8113
|
+
let lruAt = Number.POSITIVE_INFINITY;
|
|
8114
|
+
for (const [key, entry] of this.externalTails) {
|
|
8115
|
+
if (this.isManagedTailPath(key)) {
|
|
8116
|
+
this.externalTails.delete(key);
|
|
8117
|
+
return;
|
|
8118
|
+
}
|
|
8119
|
+
if (entry.lastActivityAt < lruAt) {
|
|
8120
|
+
lruAt = entry.lastActivityAt;
|
|
8121
|
+
lruKey = key;
|
|
8122
|
+
}
|
|
8123
|
+
}
|
|
8124
|
+
if (!lruKey) return;
|
|
8125
|
+
this.detachExternalTail(lruKey);
|
|
8126
|
+
}
|
|
8127
|
+
}
|
|
8128
|
+
/**
|
|
8129
|
+
* INFERRED activity for an externally-owned conversation, derived purely from
|
|
8130
|
+
* how recently its JSONL grew (the external tail's bookkeeping). Returns
|
|
8131
|
+
* undefined when we hold no tail for it, so a session we know nothing about
|
|
8132
|
+
* reports no activity rather than a fabricated "quiet".
|
|
8133
|
+
*
|
|
8134
|
+
* This can never distinguish a generating agent from one blocked on a
|
|
8135
|
+
* permission gate — gates render on the PTY screen and never reach the JSONL —
|
|
8136
|
+
* which is why it is a separate field and not folded into `status`.
|
|
8137
|
+
*/
|
|
8138
|
+
externalActivityFor(conversationId, now = Date.now()) {
|
|
8139
|
+
for (const entry of this.externalTails.values()) {
|
|
8140
|
+
if (entry.conversationId !== conversationId) continue;
|
|
8141
|
+
return {
|
|
8142
|
+
state: now - entry.lastActivityAt <= EXTERNAL_ACTIVE_WRITING_MS ? "active_writing" : "quiet",
|
|
8143
|
+
lastEventAt: new Date(entry.lastActivityAt).toISOString(),
|
|
8144
|
+
source: "jsonl"
|
|
8145
|
+
};
|
|
8146
|
+
}
|
|
8147
|
+
return void 0;
|
|
8148
|
+
}
|
|
8149
|
+
/** Attach inferred `activity` to externally-owned sessions in a response set. */
|
|
8150
|
+
withExternalActivity(sessions) {
|
|
8151
|
+
if (this.externalTails.size === 0) return sessions;
|
|
8152
|
+
const now = Date.now();
|
|
8153
|
+
return sessions.map((s) => {
|
|
8154
|
+
if (s.ownership !== "external") return s;
|
|
8155
|
+
const activity = this.externalActivityFor(s.conversationId ?? s.id, now);
|
|
8156
|
+
return activity ? { ...s, activity } : s;
|
|
8157
|
+
});
|
|
8158
|
+
}
|
|
8159
|
+
/** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
|
|
8160
|
+
sweepIdleExternalTails(now = Date.now()) {
|
|
8161
|
+
for (const [key, entry] of [...this.externalTails]) {
|
|
8162
|
+
if (this.isManagedTailPath(key)) {
|
|
8163
|
+
this.externalTails.delete(key);
|
|
8164
|
+
continue;
|
|
8165
|
+
}
|
|
8166
|
+
if (now - entry.lastActivityAt > EXTERNAL_TAIL_IDLE_MS) this.detachExternalTail(key);
|
|
8167
|
+
}
|
|
8168
|
+
}
|
|
8169
|
+
/**
|
|
8170
|
+
* Push appended lines from an externally-owned conversation. Reuses the exact
|
|
8171
|
+
* conversation_events / conversation_event shapes mobile already consumes,
|
|
8172
|
+
* keyed by the conversation UUID — an external session has no PTY, so it must
|
|
8173
|
+
* never produce terminal_output / terminal_replay / session_ready, and never a
|
|
8174
|
+
* session_update whose session.id is a conversation UUID (that would mint a
|
|
8175
|
+
* phantom session row in the mobile cache). Question cards are likewise never
|
|
8176
|
+
* derived here: with no PTY there is nothing that could deliver an answer.
|
|
8177
|
+
*/
|
|
8178
|
+
broadcastExternalTailLines(filePath, lines, seqs) {
|
|
8179
|
+
const key = canonicalizeFilePath(filePath);
|
|
8180
|
+
const entry = this.externalTails.get(key);
|
|
8181
|
+
if (!entry) return;
|
|
8182
|
+
entry.lastActivityAt = Date.now();
|
|
8183
|
+
const conversationId = this.cache?.getIdByFilePath(key);
|
|
8184
|
+
if (!conversationId) return;
|
|
8185
|
+
entry.conversationId = conversationId;
|
|
8186
|
+
this.broadcastConversationLines(conversationId, lines, seqs);
|
|
8187
|
+
const meta = this.cache?.getMetaById(conversationId);
|
|
8188
|
+
this.wsHub.broadcast({
|
|
8189
|
+
type: "conversation_updated",
|
|
8190
|
+
conversationId,
|
|
8191
|
+
messageCount: meta?.messageCount ?? 0,
|
|
8192
|
+
lastActivity: meta?.lastActivity ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
8193
|
+
ownership: "external"
|
|
8194
|
+
});
|
|
8195
|
+
}
|
|
7246
8196
|
async findConversationByUuid(uuid) {
|
|
7247
8197
|
const lookupId = this.resolveConversationLookupId(uuid);
|
|
7248
8198
|
if (!this.scannerReady && !this.scanProfiles) {
|
|
@@ -7311,13 +8261,14 @@ var StreamerServer = class {
|
|
|
7311
8261
|
if (!conv.filePath) return false;
|
|
7312
8262
|
let mtimeMs = null;
|
|
7313
8263
|
try {
|
|
7314
|
-
mtimeMs =
|
|
8264
|
+
mtimeMs = statSync8(conv.filePath).mtimeMs;
|
|
7315
8265
|
} catch {
|
|
7316
8266
|
return false;
|
|
7317
8267
|
}
|
|
7318
8268
|
return isScannedSnapshotStale(conv.timestamp, mtimeMs);
|
|
7319
8269
|
}
|
|
7320
8270
|
async handleGetConversation(id, url, res, ifNoneMatch) {
|
|
8271
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7321
8272
|
const conversation = await this.findConversationByUuid(id);
|
|
7322
8273
|
if (!conversation && this.cache) {
|
|
7323
8274
|
const isFirstLoad = !url.searchParams.has("before_index");
|
|
@@ -7645,7 +8596,7 @@ var StreamerServer = class {
|
|
|
7645
8596
|
});
|
|
7646
8597
|
}
|
|
7647
8598
|
async handleListSessions(url, res) {
|
|
7648
|
-
|
|
8599
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7649
8600
|
const now = Date.now();
|
|
7650
8601
|
if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
|
|
7651
8602
|
try {
|
|
@@ -7657,7 +8608,7 @@ var StreamerServer = class {
|
|
|
7657
8608
|
}
|
|
7658
8609
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
7659
8610
|
if (!hasPaginationParams) {
|
|
7660
|
-
json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
|
|
8611
|
+
json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
|
|
7661
8612
|
return;
|
|
7662
8613
|
}
|
|
7663
8614
|
const parsed = parseSessionListQuery(url);
|
|
@@ -7667,6 +8618,7 @@ var StreamerServer = class {
|
|
|
7667
8618
|
}
|
|
7668
8619
|
try {
|
|
7669
8620
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8621
|
+
page.sessions = this.withExternalActivity(page.sessions);
|
|
7670
8622
|
json(res, 200, page);
|
|
7671
8623
|
} catch (err) {
|
|
7672
8624
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -7677,9 +8629,10 @@ var StreamerServer = class {
|
|
|
7677
8629
|
}
|
|
7678
8630
|
}
|
|
7679
8631
|
handleGetSession(sessionId, res) {
|
|
8632
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7680
8633
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
7681
8634
|
if (session) {
|
|
7682
|
-
if (!
|
|
8635
|
+
if (!existsSync10(session.projectPath)) {
|
|
7683
8636
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
7684
8637
|
}
|
|
7685
8638
|
json(res, 200, session);
|
|
@@ -7693,7 +8646,6 @@ var StreamerServer = class {
|
|
|
7693
8646
|
json(res, 404, { error: "Session not found" });
|
|
7694
8647
|
}
|
|
7695
8648
|
async handleResume(req, res) {
|
|
7696
|
-
this.discoveryCache = null;
|
|
7697
8649
|
const body = await readBody(req);
|
|
7698
8650
|
const sessionId = body.sessionId ?? body.conversationId;
|
|
7699
8651
|
if (!sessionId) {
|
|
@@ -7722,8 +8674,48 @@ var StreamerServer = class {
|
|
|
7722
8674
|
json(res, 400, { error: "Could not determine project path" });
|
|
7723
8675
|
return;
|
|
7724
8676
|
}
|
|
8677
|
+
let discovered = [];
|
|
8678
|
+
const cached2 = this.discoveryCache;
|
|
8679
|
+
if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
|
|
8680
|
+
discovered = cached2.entries;
|
|
8681
|
+
} else {
|
|
8682
|
+
try {
|
|
8683
|
+
discovered = await Promise.race([
|
|
8684
|
+
discoverClaudeProcesses(),
|
|
8685
|
+
new Promise(
|
|
8686
|
+
(resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
|
|
8687
|
+
)
|
|
8688
|
+
]);
|
|
8689
|
+
if (discovered.length > 0) {
|
|
8690
|
+
this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
|
|
8691
|
+
}
|
|
8692
|
+
} catch {
|
|
8693
|
+
}
|
|
8694
|
+
}
|
|
8695
|
+
const busy = conversationBusy({
|
|
8696
|
+
conversationId: sessionId,
|
|
8697
|
+
projectPath,
|
|
8698
|
+
jsonlPath,
|
|
8699
|
+
discovered,
|
|
8700
|
+
windowMs: resolveResumeBusyWindowMs(),
|
|
8701
|
+
selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
|
|
8702
|
+
});
|
|
8703
|
+
if (busy.busy && body.force !== true) {
|
|
8704
|
+
json(res, 409, {
|
|
8705
|
+
error: "This conversation looks active in another session",
|
|
8706
|
+
code: "CONVERSATION_BUSY",
|
|
8707
|
+
detectedBy: busy.detectedBy,
|
|
8708
|
+
lastActivityMs: busy.lastActivityMs,
|
|
8709
|
+
likelyOwner: busy.likelyOwner
|
|
8710
|
+
});
|
|
8711
|
+
return;
|
|
8712
|
+
}
|
|
8713
|
+
if (busy.busy) {
|
|
8714
|
+
this.contendedSessions.add(sessionId);
|
|
8715
|
+
}
|
|
7725
8716
|
const cachedConvMeta = this.cache?.getMetaById(sessionId);
|
|
7726
8717
|
const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
|
|
8718
|
+
this.discoveryCache = null;
|
|
7727
8719
|
const session = await this.ptyManager.start(sessionId, {
|
|
7728
8720
|
provider,
|
|
7729
8721
|
projectPath,
|
|
@@ -7857,6 +8849,45 @@ var StreamerServer = class {
|
|
|
7857
8849
|
json(res, 400, { error: message });
|
|
7858
8850
|
}
|
|
7859
8851
|
}
|
|
8852
|
+
// Store + broadcast AskUserQuestion cards found in a JSONL batch for a watched
|
|
8853
|
+
// session. Two P0 safety guards on top of the screen/JSONL de-dupe:
|
|
8854
|
+
// (a) contended file → suppress JSONL-derived cards entirely (a line may be
|
|
8855
|
+
// the OTHER owner's question); the streamer's own PTY questions still
|
|
8856
|
+
// arrive via the live-screen path (handleLiveQuestion), not suppressed.
|
|
8857
|
+
// (b) a JSONL question must never clobber a PTY-screen question that is a
|
|
8858
|
+
// DIFFERENT question — answering it would type into this streamer's PTY.
|
|
8859
|
+
// Same-content re-syncs (screen synthetic id → real toolUseId) still pass.
|
|
8860
|
+
processJsonlQuestions(sessionId, lines) {
|
|
8861
|
+
const priorPending = this.pendingQuestions.get(sessionId);
|
|
8862
|
+
const priorToolUseId = priorPending?.toolUseId;
|
|
8863
|
+
const contended = this.contendedSessions.has(sessionId);
|
|
8864
|
+
const priorPtyKey = priorPending?.origin === "pty" ? questionContentKey(priorPending.questions) : null;
|
|
8865
|
+
const foreignVsPty = (questions) => priorPtyKey !== null && questionContentKey(questions) !== priorPtyKey;
|
|
8866
|
+
const { messages, pending } = questionsFromLines(sessionId, lines);
|
|
8867
|
+
for (const p of pending) {
|
|
8868
|
+
if (contended || foreignVsPty(p.questions)) continue;
|
|
8869
|
+
const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
|
|
8870
|
+
this.pendingQuestions.set(sessionId, { ...p, origin });
|
|
8871
|
+
const t = setTimeout(() => {
|
|
8872
|
+
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
8873
|
+
this.cancelPendingQuestion(sessionId);
|
|
8874
|
+
}
|
|
8875
|
+
}, 6e4);
|
|
8876
|
+
t.unref();
|
|
8877
|
+
}
|
|
8878
|
+
for (const m of messages) {
|
|
8879
|
+
if (contended || foreignVsPty(m.questions)) continue;
|
|
8880
|
+
const key = questionContentKey(m.questions);
|
|
8881
|
+
const broadcast = shouldBroadcastQuestion({
|
|
8882
|
+
newContentKey: key,
|
|
8883
|
+
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
8884
|
+
newToolUseId: m.toolUseId,
|
|
8885
|
+
priorToolUseId
|
|
8886
|
+
});
|
|
8887
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
8888
|
+
if (broadcast) this.wsHub.broadcast(m);
|
|
8889
|
+
}
|
|
8890
|
+
}
|
|
7860
8891
|
cancelPendingQuestion(sessionId) {
|
|
7861
8892
|
const pq = this.pendingQuestions.get(sessionId);
|
|
7862
8893
|
if (!pq) return;
|
|
@@ -7873,7 +8904,7 @@ var StreamerServer = class {
|
|
|
7873
8904
|
const key = questionContentKey(questions);
|
|
7874
8905
|
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
7875
8906
|
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
7876
|
-
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
8907
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
|
|
7877
8908
|
this.pendingQuestionKey.set(sessionId, key);
|
|
7878
8909
|
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
7879
8910
|
}
|
|
@@ -8050,13 +9081,64 @@ var StreamerServer = class {
|
|
|
8050
9081
|
json(res, 404, { error: "Discovered session not found" });
|
|
8051
9082
|
return;
|
|
8052
9083
|
}
|
|
8053
|
-
const {
|
|
9084
|
+
const { branch } = discSession;
|
|
9085
|
+
let { projectPath, projectName } = discSession;
|
|
8054
9086
|
const convId = discSession.id;
|
|
8055
9087
|
if (discSession.pid == null) {
|
|
8056
9088
|
json(res, 400, { error: "Session has no known PID" });
|
|
8057
9089
|
return;
|
|
8058
9090
|
}
|
|
9091
|
+
if (!projectPath) {
|
|
9092
|
+
const jsonlPath = this.findJsonlPath(convId);
|
|
9093
|
+
const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
|
|
9094
|
+
if (jsonlCwd) {
|
|
9095
|
+
projectPath = jsonlCwd;
|
|
9096
|
+
projectName = projectName || basename5(jsonlCwd);
|
|
9097
|
+
}
|
|
9098
|
+
}
|
|
9099
|
+
if (!projectPath) {
|
|
9100
|
+
this.log.warn("adopt: refusing, working directory unknown", {
|
|
9101
|
+
event: "adopt.no_project_path",
|
|
9102
|
+
sessionId,
|
|
9103
|
+
pid: discSession.pid
|
|
9104
|
+
});
|
|
9105
|
+
json(res, 400, {
|
|
9106
|
+
error: "Cannot take over this session: its working directory could not be determined on this platform",
|
|
9107
|
+
code: "ADOPT_NO_PROJECT_PATH"
|
|
9108
|
+
});
|
|
9109
|
+
return;
|
|
9110
|
+
}
|
|
9111
|
+
const availability = classifyResumability(projectPath);
|
|
9112
|
+
if (!availability.resumable) {
|
|
9113
|
+
this.log.warn("adopt: refusing, project directory no longer exists", {
|
|
9114
|
+
event: "adopt.project_path_missing",
|
|
9115
|
+
sessionId,
|
|
9116
|
+
pid: discSession.pid,
|
|
9117
|
+
projectPath,
|
|
9118
|
+
reason: availability.unavailable_reason
|
|
9119
|
+
});
|
|
9120
|
+
json(res, 400, {
|
|
9121
|
+
error: "Cannot take over this session: its project directory no longer exists",
|
|
9122
|
+
code: "ADOPT_PROJECT_PATH_MISSING",
|
|
9123
|
+
reason: availability.unavailable_reason
|
|
9124
|
+
});
|
|
9125
|
+
return;
|
|
9126
|
+
}
|
|
8059
9127
|
this.ptyManager.killPid(discSession.pid);
|
|
9128
|
+
const exited = await waitForProcessExit(discSession.pid, ADOPT_KILL_TIMEOUT_MS);
|
|
9129
|
+
if (!exited) {
|
|
9130
|
+
this.log.warn("adopt: external process did not exit; refusing to double-write", {
|
|
9131
|
+
event: "adopt.kill_timeout",
|
|
9132
|
+
sessionId,
|
|
9133
|
+
pid: discSession.pid
|
|
9134
|
+
});
|
|
9135
|
+
json(res, 409, {
|
|
9136
|
+
error: "The existing process did not exit; not starting a second agent on this conversation",
|
|
9137
|
+
code: "ADOPT_KILL_TIMEOUT",
|
|
9138
|
+
pid: discSession.pid
|
|
9139
|
+
});
|
|
9140
|
+
return;
|
|
9141
|
+
}
|
|
8060
9142
|
const session = await this.ptyManager.start(convId, {
|
|
8061
9143
|
projectPath,
|
|
8062
9144
|
projectName,
|
|
@@ -8087,7 +9169,7 @@ var StreamerServer = class {
|
|
|
8087
9169
|
sessionStore: this.sessionStore,
|
|
8088
9170
|
// biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
|
|
8089
9171
|
agentClient: this.agentClient,
|
|
8090
|
-
conversationsDir: this.cacheDir ?
|
|
9172
|
+
conversationsDir: this.cacheDir ? join17(dirname9(this.cacheDir), "conversations") : "",
|
|
8091
9173
|
agentConfig: this.agentConfig
|
|
8092
9174
|
});
|
|
8093
9175
|
json(res, result.status, result.body);
|
|
@@ -8225,14 +9307,30 @@ var StreamerServer = class {
|
|
|
8225
9307
|
} catch {
|
|
8226
9308
|
}
|
|
8227
9309
|
}
|
|
9310
|
+
// Read just the `sessionId` field from a JSONL's first line, used by the
|
|
9311
|
+
// watchForJsonl fallback to confirm a candidate file's identity before
|
|
9312
|
+
// binding it. Reads only up to the first newline so a large actively-written
|
|
9313
|
+
// file isn't slurped in full.
|
|
9314
|
+
readFirstLineSessionId(filePath) {
|
|
9315
|
+
try {
|
|
9316
|
+
const content = readFileSync8(filePath, "utf8");
|
|
9317
|
+
const nl = content.indexOf("\n");
|
|
9318
|
+
const firstLine = nl === -1 ? content : content.slice(0, nl);
|
|
9319
|
+
if (!firstLine.trim()) return null;
|
|
9320
|
+
const obj = JSON.parse(firstLine);
|
|
9321
|
+
return typeof obj.sessionId === "string" ? obj.sessionId : null;
|
|
9322
|
+
} catch {
|
|
9323
|
+
return null;
|
|
9324
|
+
}
|
|
9325
|
+
}
|
|
8228
9326
|
// Watch the project directory for the JSONL file Claude creates for sessionId.
|
|
8229
9327
|
// Once found, wire up structured event streaming. No rekeying needed — the UUID
|
|
8230
9328
|
// was passed to Claude via --session-id so the filename matches from the start.
|
|
8231
9329
|
watchForJsonl(sessionId, projectPath) {
|
|
8232
9330
|
const encoded = projectPath.replace(/[/\\:.]/g, "-");
|
|
8233
|
-
const projectsDir =
|
|
9331
|
+
const projectsDir = join17(homedir8(), ".claude", "projects", encoded);
|
|
8234
9332
|
const expectedFile = `${sessionId}.jsonl`;
|
|
8235
|
-
const filePath =
|
|
9333
|
+
const filePath = join17(projectsDir, expectedFile);
|
|
8236
9334
|
const deadline = Date.now() + 12e4;
|
|
8237
9335
|
let watcher = null;
|
|
8238
9336
|
const cleanup = () => {
|
|
@@ -8250,26 +9348,28 @@ var StreamerServer = class {
|
|
|
8250
9348
|
cleanup();
|
|
8251
9349
|
return;
|
|
8252
9350
|
}
|
|
8253
|
-
let resolvedFilePath =
|
|
8254
|
-
if (!resolvedFilePath &&
|
|
9351
|
+
let resolvedFilePath = existsSync10(filePath) ? filePath : null;
|
|
9352
|
+
if (!resolvedFilePath && existsSync10(projectsDir)) {
|
|
8255
9353
|
try {
|
|
8256
9354
|
const now = Date.now();
|
|
8257
|
-
const
|
|
8258
|
-
|
|
9355
|
+
const match = readdirSync5(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync8(join17(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
|
|
9356
|
+
({ f }) => basename5(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join17(projectsDir, f)) === sessionId
|
|
9357
|
+
).sort((a, b) => b.mtime - a.mtime)[0];
|
|
9358
|
+
if (match) resolvedFilePath = join17(projectsDir, match.f);
|
|
8259
9359
|
} catch {
|
|
8260
9360
|
}
|
|
8261
9361
|
}
|
|
8262
9362
|
if (!resolvedFilePath) return;
|
|
8263
9363
|
cleanup();
|
|
8264
9364
|
this.sessionFileMap.set(sessionId, resolvedFilePath);
|
|
8265
|
-
this.fileWatcher.watch(resolvedFilePath);
|
|
8266
9365
|
try {
|
|
8267
|
-
const existing =
|
|
9366
|
+
const existing = readFileSync8(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
8268
9367
|
if (existing.length > 0) {
|
|
8269
9368
|
this.broadcastConversationLines(sessionId, existing);
|
|
8270
9369
|
}
|
|
8271
9370
|
} catch {
|
|
8272
9371
|
}
|
|
9372
|
+
this.fileWatcher.watch(resolvedFilePath);
|
|
8273
9373
|
if (this.scannerReady) {
|
|
8274
9374
|
this.scannerStale = true;
|
|
8275
9375
|
} else {
|
|
@@ -8303,7 +9403,7 @@ var StreamerServer = class {
|
|
|
8303
9403
|
watchForCodexRollout(sessionId, projectPath) {
|
|
8304
9404
|
const deadline = Date.now() + 12e4;
|
|
8305
9405
|
const now = /* @__PURE__ */ new Date();
|
|
8306
|
-
const dateDir =
|
|
9406
|
+
const dateDir = join17(
|
|
8307
9407
|
String(now.getFullYear()),
|
|
8308
9408
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
8309
9409
|
String(now.getDate()).padStart(2, "0")
|
|
@@ -8316,7 +9416,7 @@ var StreamerServer = class {
|
|
|
8316
9416
|
};
|
|
8317
9417
|
const matchesProjectPath = (candidatePath) => {
|
|
8318
9418
|
try {
|
|
8319
|
-
const firstLine =
|
|
9419
|
+
const firstLine = readFileSync8(candidatePath, "utf8").split("\n", 1)[0];
|
|
8320
9420
|
if (!firstLine) return null;
|
|
8321
9421
|
const parsed = JSON.parse(firstLine);
|
|
8322
9422
|
if (parsed?.type !== "session_meta") return null;
|
|
@@ -8344,18 +9444,18 @@ var StreamerServer = class {
|
|
|
8344
9444
|
this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
|
|
8345
9445
|
);
|
|
8346
9446
|
for (const root of this.codexRoots) {
|
|
8347
|
-
const sessionsDir =
|
|
8348
|
-
if (!
|
|
9447
|
+
const sessionsDir = join17(root, dateDir);
|
|
9448
|
+
if (!existsSync10(sessionsDir)) continue;
|
|
8349
9449
|
let candidateFiles;
|
|
8350
9450
|
try {
|
|
8351
|
-
candidateFiles =
|
|
9451
|
+
candidateFiles = readdirSync5(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
8352
9452
|
} catch {
|
|
8353
9453
|
continue;
|
|
8354
9454
|
}
|
|
8355
9455
|
const nowMs = Date.now();
|
|
8356
|
-
const recentCandidates = candidateFiles.map((f) => ({ f, mtime:
|
|
9456
|
+
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync8(join17(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
|
|
8357
9457
|
for (const { f } of recentCandidates) {
|
|
8358
|
-
const candidatePath =
|
|
9458
|
+
const candidatePath = join17(sessionsDir, f);
|
|
8359
9459
|
const match = matchesProjectPath(candidatePath);
|
|
8360
9460
|
if (!match) continue;
|
|
8361
9461
|
if (boundElsewhere.has(match.id)) continue;
|
|
@@ -8365,7 +9465,7 @@ var StreamerServer = class {
|
|
|
8365
9465
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
8366
9466
|
this.fileWatcher.watch(candidatePath);
|
|
8367
9467
|
try {
|
|
8368
|
-
const existing =
|
|
9468
|
+
const existing = readFileSync8(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
8369
9469
|
if (existing.length > 0) {
|
|
8370
9470
|
this.broadcastConversationLines(sessionId, existing);
|
|
8371
9471
|
}
|
|
@@ -8485,9 +9585,21 @@ var StreamerServer = class {
|
|
|
8485
9585
|
json(res, 200, this.cache.listSessionNames());
|
|
8486
9586
|
}
|
|
8487
9587
|
};
|
|
9588
|
+
async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
|
|
9589
|
+
const deadline = Date.now() + timeoutMs;
|
|
9590
|
+
for (; ; ) {
|
|
9591
|
+
try {
|
|
9592
|
+
process.kill(pid, 0);
|
|
9593
|
+
} catch {
|
|
9594
|
+
return true;
|
|
9595
|
+
}
|
|
9596
|
+
if (Date.now() >= deadline) return false;
|
|
9597
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollMs));
|
|
9598
|
+
}
|
|
9599
|
+
}
|
|
8488
9600
|
function classifyResumability(cwd) {
|
|
8489
9601
|
if (!cwd) return { resumable: true };
|
|
8490
|
-
if (
|
|
9602
|
+
if (existsSync10(cwd)) return { resumable: true };
|
|
8491
9603
|
const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
|
|
8492
9604
|
return {
|
|
8493
9605
|
resumable: false,
|
|
@@ -8502,6 +9614,9 @@ function conversationToResumableSession(c) {
|
|
|
8502
9614
|
id: c.id,
|
|
8503
9615
|
conversationId: c.id,
|
|
8504
9616
|
status: "on_hold",
|
|
9617
|
+
// A cached conversation with no process behind it. Distinguishes "nobody is
|
|
9618
|
+
// running this" from an external session that IS live (ownership "external").
|
|
9619
|
+
ownership: "historical",
|
|
8505
9620
|
ptyAttached: false,
|
|
8506
9621
|
projectId: c.projectId ?? void 0,
|
|
8507
9622
|
projectPath: c.projectPath ?? "",
|