@threadbase-sh/streamer 1.33.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +2004 -2603
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1243 -202
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +234 -11
- package/dist/index.d.ts +234 -11
- package/dist/index.js +1238 -197
- 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,316 @@ 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) {
|
|
5481
|
+
this.cache = cache;
|
|
5482
|
+
this.wsHub = wsHub;
|
|
5483
|
+
this.log = log3;
|
|
5484
|
+
this.cacheDir = cacheDir;
|
|
5485
|
+
this.rescan = rescan;
|
|
5486
|
+
const state = loadAlertState();
|
|
5487
|
+
this._pending = state.pending ?? null;
|
|
5488
|
+
this.ignoredIds = new Set(state.ignoredIds ?? []);
|
|
5489
|
+
}
|
|
5490
|
+
cache;
|
|
5491
|
+
wsHub;
|
|
5492
|
+
log;
|
|
5493
|
+
cacheDir;
|
|
5494
|
+
rescan;
|
|
5495
|
+
_pending;
|
|
5496
|
+
ignoredIds;
|
|
5497
|
+
deferredUnlinks = [];
|
|
5498
|
+
unlinkTimes = [];
|
|
5499
|
+
get pending() {
|
|
5500
|
+
return this._pending;
|
|
5501
|
+
}
|
|
5502
|
+
persist() {
|
|
5503
|
+
const state = {};
|
|
5504
|
+
if (this._pending) {
|
|
5505
|
+
state.pending = {
|
|
5506
|
+
...this._pending,
|
|
5507
|
+
missing: this._pending.missing.slice(0, MAX_MISSING_PERSISTED)
|
|
5508
|
+
};
|
|
5509
|
+
}
|
|
5510
|
+
if (this.ignoredIds.size > 0) state.ignoredIds = [...this.ignoredIds];
|
|
5511
|
+
saveAlertState(state);
|
|
5512
|
+
}
|
|
5513
|
+
classifySeverity(missingCount, totalRows) {
|
|
5514
|
+
const minMissing = envInt("THREADBASE_CACHE_ALERT_MIN_MISSING", 20);
|
|
5515
|
+
const minRatio = Number.parseFloat(process.env.THREADBASE_CACHE_ALERT_MIN_RATIO ?? "0.20");
|
|
5516
|
+
const ratio = totalRows > 0 ? missingCount / totalRows : 0;
|
|
5517
|
+
const ratioThreshold = Number.isFinite(minRatio) ? minRatio : 0.2;
|
|
5518
|
+
return missingCount >= minMissing && ratio >= ratioThreshold ? "high" : "low";
|
|
5519
|
+
}
|
|
5520
|
+
sampleOf(missing) {
|
|
5521
|
+
return missing.slice(0, SAMPLE_SIZE).map((m) => ({
|
|
5522
|
+
id: m.id,
|
|
5523
|
+
...m.title != null ? { title: m.title } : {}
|
|
5524
|
+
}));
|
|
5525
|
+
}
|
|
5526
|
+
buildWsMessage(pending) {
|
|
5527
|
+
return {
|
|
5528
|
+
type: "cache_alert",
|
|
5529
|
+
fingerprint: pending.fingerprint,
|
|
5530
|
+
severity: pending.severity,
|
|
5531
|
+
missingCount: pending.missingCount,
|
|
5532
|
+
totalRows: pending.totalRows,
|
|
5533
|
+
detectedAt: pending.detectedAt,
|
|
5534
|
+
sample: this.sampleOf(pending.missing)
|
|
5535
|
+
};
|
|
5536
|
+
}
|
|
5537
|
+
wsMessage() {
|
|
5538
|
+
return this._pending ? this.buildWsMessage(this._pending) : null;
|
|
5539
|
+
}
|
|
5540
|
+
healthzField() {
|
|
5541
|
+
if (!this._pending) return void 0;
|
|
5542
|
+
return {
|
|
5543
|
+
severity: this._pending.severity,
|
|
5544
|
+
missingCount: this._pending.missingCount,
|
|
5545
|
+
fingerprint: this._pending.fingerprint,
|
|
5546
|
+
detectedAt: this._pending.detectedAt
|
|
5547
|
+
};
|
|
5548
|
+
}
|
|
5549
|
+
/**
|
|
5550
|
+
* Scan the cache for rows whose file is gone, excluding ids the user chose to
|
|
5551
|
+
* ignore. If none remain, clear any stale pending alert and return (the caller
|
|
5552
|
+
* decides whether to run pruneGhostFiles). Otherwise classify severity, persist
|
|
5553
|
+
* the pending record, back up on high severity, and broadcast the alert.
|
|
5554
|
+
*/
|
|
5555
|
+
async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
5556
|
+
const all = this.cache.listMissingFiles(existsSync8);
|
|
5557
|
+
const missing = all.filter((m) => !this.ignoredIds.has(m.id));
|
|
5558
|
+
if (missing.length === 0) {
|
|
5559
|
+
if (this._pending) {
|
|
5560
|
+
this._pending = null;
|
|
5561
|
+
this.persist();
|
|
5562
|
+
}
|
|
5563
|
+
return;
|
|
5564
|
+
}
|
|
5565
|
+
const totalRows = this.cache.listConversations({ limit: 0, offset: 0 }).total;
|
|
5566
|
+
const fingerprint = fingerprintOf(missing.map((m) => m.id));
|
|
5567
|
+
const severity = this.classifySeverity(missing.length, totalRows);
|
|
5568
|
+
const pending = {
|
|
5569
|
+
fingerprint,
|
|
5570
|
+
severity,
|
|
5571
|
+
detectedAt,
|
|
5572
|
+
missingCount: missing.length,
|
|
5573
|
+
totalRows,
|
|
5574
|
+
missing
|
|
5575
|
+
};
|
|
5576
|
+
if (severity === "high") {
|
|
5577
|
+
try {
|
|
5578
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5579
|
+
} catch (err) {
|
|
5580
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5581
|
+
event: "cache_integrity.backup_failed",
|
|
5582
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5583
|
+
});
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
5586
|
+
this._pending = pending;
|
|
5587
|
+
this.persist();
|
|
5588
|
+
this.log.warn("cache integrity drift detected", {
|
|
5589
|
+
event: "cache_integrity.detected",
|
|
5590
|
+
severity,
|
|
5591
|
+
missingCount: missing.length,
|
|
5592
|
+
totalRows,
|
|
5593
|
+
fingerprint
|
|
5594
|
+
});
|
|
5595
|
+
this.wsHub.broadcast(this.buildWsMessage(pending));
|
|
5596
|
+
}
|
|
5597
|
+
/** Queue an unlink while an alert is pending — the row is not invalidated. */
|
|
5598
|
+
deferUnlink(filePath) {
|
|
5599
|
+
this.deferredUnlinks.push(filePath);
|
|
5600
|
+
}
|
|
5601
|
+
/**
|
|
5602
|
+
* Record a live unlink while NO alert is pending. Crossing the storm threshold
|
|
5603
|
+
* (>= 10 unlinks within 30s) re-triggers detection.
|
|
5604
|
+
*/
|
|
5605
|
+
recordUnlink(filePath) {
|
|
5606
|
+
const now = Date.now();
|
|
5607
|
+
this.unlinkTimes.push(now);
|
|
5608
|
+
this.unlinkTimes = this.unlinkTimes.filter((t) => now - t < STORM_WINDOW_MS);
|
|
5609
|
+
if (this.unlinkTimes.length >= STORM_THRESHOLD) {
|
|
5610
|
+
this.unlinkTimes = [];
|
|
5611
|
+
void this.runDetection().catch((err) => {
|
|
5612
|
+
this.log.error("cache-integrity storm detection failed", {
|
|
5613
|
+
event: "cache_integrity.storm_detection_failed",
|
|
5614
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5615
|
+
filePath
|
|
5616
|
+
});
|
|
5617
|
+
});
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
async ensureBackup(pending) {
|
|
5621
|
+
if (pending.backupPath) return pending.backupPath;
|
|
5622
|
+
try {
|
|
5623
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5624
|
+
} catch (err) {
|
|
5625
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5626
|
+
event: "cache_integrity.backup_failed",
|
|
5627
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5628
|
+
});
|
|
5629
|
+
}
|
|
5630
|
+
return pending.backupPath;
|
|
5631
|
+
}
|
|
5632
|
+
clearPending() {
|
|
5633
|
+
this._pending = null;
|
|
5634
|
+
this.deferredUnlinks = [];
|
|
5635
|
+
this.persist();
|
|
5636
|
+
}
|
|
5637
|
+
applyDeferredUnlinks() {
|
|
5638
|
+
for (const fp of this.deferredUnlinks) this.cache.invalidateByFilePath(fp);
|
|
5639
|
+
this.deferredUnlinks = [];
|
|
5640
|
+
}
|
|
5641
|
+
broadcastResolved(fingerprint, action) {
|
|
5642
|
+
this.wsHub.broadcast({ type: "cache_alert_resolved", fingerprint, action });
|
|
5643
|
+
}
|
|
5644
|
+
/**
|
|
5645
|
+
* Apply the human's chosen resolution. Idempotent per fingerprint: no pending
|
|
5646
|
+
* alert → alreadyResolved; a different fingerprint → conflict. See the spec's
|
|
5647
|
+
* four-action semantics.
|
|
5648
|
+
*/
|
|
5649
|
+
async resolve(fingerprint, action, ids) {
|
|
5650
|
+
const pending = this._pending;
|
|
5651
|
+
if (!pending) return { alreadyResolved: true };
|
|
5652
|
+
if (pending.fingerprint !== fingerprint) {
|
|
5653
|
+
return { conflict: true, currentFingerprint: pending.fingerprint };
|
|
5654
|
+
}
|
|
5655
|
+
this._pending = null;
|
|
5656
|
+
switch (action) {
|
|
5657
|
+
case "prune_all": {
|
|
5658
|
+
await this.ensureBackup(pending);
|
|
5659
|
+
const backupPath = pending.backupPath;
|
|
5660
|
+
const stillMissing = pending.missing.filter((m) => !existsSync8(m.filePath)).map((m) => m.id);
|
|
5661
|
+
const pruned = this.cache.dropRowsById(stillMissing);
|
|
5662
|
+
this.applyDeferredUnlinks();
|
|
5663
|
+
this.clearPending();
|
|
5664
|
+
this.broadcastResolved(fingerprint, action);
|
|
5665
|
+
return { ok: true, action, pruned, backupPath };
|
|
5666
|
+
}
|
|
5667
|
+
case "prune_selected": {
|
|
5668
|
+
const requested = new Set(ids ?? []);
|
|
5669
|
+
const pendingIds = new Set(pending.missing.map((m) => m.id));
|
|
5670
|
+
const toDrop = [...requested].filter((id) => pendingIds.has(id));
|
|
5671
|
+
await this.ensureBackup(pending);
|
|
5672
|
+
const backupPath = pending.backupPath;
|
|
5673
|
+
const pruned = this.cache.dropRowsById(toDrop);
|
|
5674
|
+
const prunedPaths = new Set(
|
|
5675
|
+
pending.missing.filter((m) => toDrop.includes(m.id)).map((m) => m.filePath)
|
|
5676
|
+
);
|
|
5677
|
+
this.deferredUnlinks = this.deferredUnlinks.filter((fp) => {
|
|
5678
|
+
if (prunedPaths.has(fp)) {
|
|
5679
|
+
this.cache.invalidateByFilePath(fp);
|
|
5680
|
+
return false;
|
|
5681
|
+
}
|
|
5682
|
+
return true;
|
|
5683
|
+
});
|
|
5684
|
+
this.persist();
|
|
5685
|
+
await this.runDetection();
|
|
5686
|
+
this.broadcastResolved(fingerprint, action);
|
|
5687
|
+
return { ok: true, action, pruned, backupPath };
|
|
5688
|
+
}
|
|
5689
|
+
case "ignore": {
|
|
5690
|
+
for (const m of pending.missing) this.ignoredIds.add(m.id);
|
|
5691
|
+
this.deferredUnlinks = [];
|
|
5692
|
+
this.clearPending();
|
|
5693
|
+
this.broadcastResolved(fingerprint, action);
|
|
5694
|
+
return { ok: true, action };
|
|
5695
|
+
}
|
|
5696
|
+
case "reset_rescan": {
|
|
5697
|
+
const backupPath = await this.ensureBackup(pending);
|
|
5698
|
+
this.cache.clearAll();
|
|
5699
|
+
if (this.rescan) {
|
|
5700
|
+
try {
|
|
5701
|
+
const metas = await this.rescan();
|
|
5702
|
+
this.cache.upsertFromScannerMeta(metas);
|
|
5703
|
+
} catch (err) {
|
|
5704
|
+
this.log.error("cache-integrity reset rescan failed", {
|
|
5705
|
+
event: "cache_integrity.reset_rescan_failed",
|
|
5706
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5707
|
+
});
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
this.clearPending();
|
|
5711
|
+
this.broadcastResolved(fingerprint, action);
|
|
5712
|
+
return { ok: true, action, backupPath };
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
5716
|
+
};
|
|
5717
|
+
|
|
5098
5718
|
// src/services/conversations/conversationWatcher.ts
|
|
5099
5719
|
import chokidar from "chokidar";
|
|
5100
|
-
import { statSync as
|
|
5720
|
+
import { statSync as statSync6 } from "fs";
|
|
5101
5721
|
import { open, stat as stat2 } from "fs/promises";
|
|
5102
5722
|
var ConversationWatcher = class {
|
|
5103
5723
|
files = /* @__PURE__ */ new Map();
|
|
@@ -5107,6 +5727,7 @@ var ConversationWatcher = class {
|
|
|
5107
5727
|
onNewLineSpans;
|
|
5108
5728
|
onConversationChanged;
|
|
5109
5729
|
onFileDeleted;
|
|
5730
|
+
onTruncated;
|
|
5110
5731
|
onError;
|
|
5111
5732
|
constructor(events = {}) {
|
|
5112
5733
|
this.onNewLine = events.onNewLine;
|
|
@@ -5114,13 +5735,15 @@ var ConversationWatcher = class {
|
|
|
5114
5735
|
this.onNewLineSpans = events.onNewLineSpans;
|
|
5115
5736
|
this.onConversationChanged = events.onConversationChanged;
|
|
5116
5737
|
this.onFileDeleted = events.onFileDeleted;
|
|
5738
|
+
this.onTruncated = events.onTruncated;
|
|
5117
5739
|
this.onError = events.onError;
|
|
5118
5740
|
}
|
|
5119
5741
|
watch(filePath) {
|
|
5120
|
-
|
|
5742
|
+
const key = canonicalizeFilePath(filePath);
|
|
5743
|
+
if (this.files.has(key)) return;
|
|
5121
5744
|
let offset;
|
|
5122
5745
|
try {
|
|
5123
|
-
offset =
|
|
5746
|
+
offset = statSync6(filePath).size;
|
|
5124
5747
|
} catch {
|
|
5125
5748
|
offset = 0;
|
|
5126
5749
|
}
|
|
@@ -5129,23 +5752,24 @@ var ConversationWatcher = class {
|
|
|
5129
5752
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
|
|
5130
5753
|
});
|
|
5131
5754
|
watcher.on("change", () => {
|
|
5132
|
-
void this.readNewLines(
|
|
5755
|
+
void this.readNewLines(key);
|
|
5133
5756
|
});
|
|
5134
5757
|
watcher.on("add", () => {
|
|
5135
|
-
void this.readNewLines(
|
|
5758
|
+
void this.readNewLines(key);
|
|
5136
5759
|
});
|
|
5137
5760
|
watcher.on("unlink", () => this.onFileDeleted?.(filePath));
|
|
5138
5761
|
watcher.on("error", (err) => {
|
|
5139
5762
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
5140
5763
|
this.onError?.(filePath, error);
|
|
5141
5764
|
});
|
|
5142
|
-
this.files.set(
|
|
5765
|
+
this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
|
|
5143
5766
|
}
|
|
5144
5767
|
unwatch(filePath) {
|
|
5145
|
-
const
|
|
5768
|
+
const key = canonicalizeFilePath(filePath);
|
|
5769
|
+
const entry = this.files.get(key);
|
|
5146
5770
|
if (!entry) return;
|
|
5147
5771
|
void entry.watcher.close();
|
|
5148
|
-
this.files.delete(
|
|
5772
|
+
this.files.delete(key);
|
|
5149
5773
|
}
|
|
5150
5774
|
/**
|
|
5151
5775
|
* Re-drive the tail read for a file that's already being tailed. A per-file
|
|
@@ -5156,8 +5780,9 @@ var ConversationWatcher = class {
|
|
|
5156
5780
|
* event is a cheap stat + no-op. Returns false for untailed paths.
|
|
5157
5781
|
*/
|
|
5158
5782
|
poke(filePath) {
|
|
5159
|
-
|
|
5160
|
-
|
|
5783
|
+
const key = canonicalizeFilePath(filePath);
|
|
5784
|
+
if (!this.files.has(key)) return false;
|
|
5785
|
+
void this.readNewLines(key);
|
|
5161
5786
|
return true;
|
|
5162
5787
|
}
|
|
5163
5788
|
/**
|
|
@@ -5194,9 +5819,10 @@ var ConversationWatcher = class {
|
|
|
5194
5819
|
for (const [path] of this.files) this.unwatch(path);
|
|
5195
5820
|
for (const [dir] of this.directories) this.unwatchDirectory(dir);
|
|
5196
5821
|
}
|
|
5197
|
-
async readNewLines(
|
|
5198
|
-
const entry = this.files.get(
|
|
5822
|
+
async readNewLines(key) {
|
|
5823
|
+
const entry = this.files.get(key);
|
|
5199
5824
|
if (!entry) return;
|
|
5825
|
+
const filePath = entry.path;
|
|
5200
5826
|
if (entry.reading) {
|
|
5201
5827
|
entry.pending = true;
|
|
5202
5828
|
return;
|
|
@@ -5205,6 +5831,10 @@ var ConversationWatcher = class {
|
|
|
5205
5831
|
try {
|
|
5206
5832
|
for (; ; ) {
|
|
5207
5833
|
const st = await stat2(filePath);
|
|
5834
|
+
if (st.size < entry.offset) {
|
|
5835
|
+
entry.offset = 0;
|
|
5836
|
+
this.onTruncated?.(filePath);
|
|
5837
|
+
}
|
|
5208
5838
|
if (st.size <= entry.offset) break;
|
|
5209
5839
|
const readFrom = entry.offset;
|
|
5210
5840
|
const bytesToRead = st.size - readFrom;
|
|
@@ -5217,7 +5847,7 @@ var ConversationWatcher = class {
|
|
|
5217
5847
|
}
|
|
5218
5848
|
const { spans, consumed } = splitCompleteLines(buf, readFrom);
|
|
5219
5849
|
entry.offset = readFrom + consumed;
|
|
5220
|
-
if (!this.files.has(
|
|
5850
|
+
if (!this.files.has(key)) return;
|
|
5221
5851
|
const lines = spans.map((s) => s.text);
|
|
5222
5852
|
if (spans.length > 0) {
|
|
5223
5853
|
this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
|
|
@@ -5237,9 +5867,9 @@ var ConversationWatcher = class {
|
|
|
5237
5867
|
this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
|
|
5238
5868
|
} finally {
|
|
5239
5869
|
entry.reading = false;
|
|
5240
|
-
if (entry.pending && this.files.has(
|
|
5870
|
+
if (entry.pending && this.files.has(key)) {
|
|
5241
5871
|
entry.pending = false;
|
|
5242
|
-
void this.readNewLines(
|
|
5872
|
+
void this.readNewLines(key);
|
|
5243
5873
|
}
|
|
5244
5874
|
}
|
|
5245
5875
|
}
|
|
@@ -5295,14 +5925,14 @@ function findSearchTarget(messages, query) {
|
|
|
5295
5925
|
}
|
|
5296
5926
|
|
|
5297
5927
|
// src/services/conversations/pruneAgentConversations.ts
|
|
5298
|
-
import { existsSync as
|
|
5928
|
+
import { existsSync as existsSync9 } from "fs";
|
|
5299
5929
|
function pruneAgentConversations(cache) {
|
|
5300
5930
|
const db = cache.getDatabase();
|
|
5301
5931
|
const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
|
|
5302
5932
|
let pruned = 0;
|
|
5303
5933
|
let missing = 0;
|
|
5304
5934
|
for (const row of rows) {
|
|
5305
|
-
if (!
|
|
5935
|
+
if (!existsSync9(row.file_path)) {
|
|
5306
5936
|
missing += 1;
|
|
5307
5937
|
continue;
|
|
5308
5938
|
}
|
|
@@ -5448,6 +6078,52 @@ function resolveAnswer(pending, body) {
|
|
|
5448
6078
|
}
|
|
5449
6079
|
}
|
|
5450
6080
|
|
|
6081
|
+
// src/services/sessions/conversationBusy.ts
|
|
6082
|
+
import { statSync as statSync7 } from "fs";
|
|
6083
|
+
var RESUME_BUSY_WINDOW_MS = 12e4;
|
|
6084
|
+
function resolveResumeBusyWindowMs(env = process.env) {
|
|
6085
|
+
const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
|
|
6086
|
+
if (raw === void 0) return RESUME_BUSY_WINDOW_MS;
|
|
6087
|
+
const n = Number.parseInt(raw, 10);
|
|
6088
|
+
return Number.isFinite(n) && n >= 0 ? n : RESUME_BUSY_WINDOW_MS;
|
|
6089
|
+
}
|
|
6090
|
+
var SELF_ACTIVITY_SKEW_MS = 5e3;
|
|
6091
|
+
function conversationBusy(input) {
|
|
6092
|
+
const now = input.now ?? Date.now();
|
|
6093
|
+
const windowMs = input.windowMs ?? RESUME_BUSY_WINDOW_MS;
|
|
6094
|
+
const platform3 = input.platform ?? process.platform;
|
|
6095
|
+
const detectedBy = [];
|
|
6096
|
+
let lastActivityMs = null;
|
|
6097
|
+
if (input.jsonlPath) {
|
|
6098
|
+
try {
|
|
6099
|
+
const mtimeMs = statSync7(input.jsonlPath).mtimeMs;
|
|
6100
|
+
const age = now - mtimeMs;
|
|
6101
|
+
lastActivityMs = Math.max(0, age);
|
|
6102
|
+
const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
|
|
6103
|
+
if (age <= windowMs && !isSelfEcho) detectedBy.push("jsonl_mtime");
|
|
6104
|
+
} catch {
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
6107
|
+
const argvMatch = input.discovered.some((p) => p.conversationId === input.conversationId);
|
|
6108
|
+
if (argvMatch) detectedBy.push("process_argv");
|
|
6109
|
+
let cwdMatch = false;
|
|
6110
|
+
if (platform3 !== "win32" && input.projectPath) {
|
|
6111
|
+
const target = canonicalizeProjectPath(input.projectPath);
|
|
6112
|
+
cwdMatch = input.discovered.some(
|
|
6113
|
+
(p) => !!p.projectPath && canonicalizeProjectPath(p.projectPath) === target
|
|
6114
|
+
);
|
|
6115
|
+
if (cwdMatch) detectedBy.push("process_cwd");
|
|
6116
|
+
}
|
|
6117
|
+
return {
|
|
6118
|
+
busy: detectedBy.length > 0,
|
|
6119
|
+
detectedBy,
|
|
6120
|
+
lastActivityMs,
|
|
6121
|
+
// A matched process is a concrete external owner; a lone mtime hit could be
|
|
6122
|
+
// an editor, a crashed process, or a process we could not enumerate.
|
|
6123
|
+
likelyOwner: argvMatch || cwdMatch ? "external" : "unknown"
|
|
6124
|
+
};
|
|
6125
|
+
}
|
|
6126
|
+
|
|
5451
6127
|
// src/session-store.ts
|
|
5452
6128
|
var SessionStore = class {
|
|
5453
6129
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -5587,6 +6263,9 @@ function managedToResponse(s, ptyAttached) {
|
|
|
5587
6263
|
conversationId: s.id,
|
|
5588
6264
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
5589
6265
|
status: s.status,
|
|
6266
|
+
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6267
|
+
// `activity` is attached for managed sessions.
|
|
6268
|
+
ownership: "managed",
|
|
5590
6269
|
projectPath: s.projectPath,
|
|
5591
6270
|
projectName: s.projectName,
|
|
5592
6271
|
branch: s.branch,
|
|
@@ -5620,7 +6299,14 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5620
6299
|
id: conversationId,
|
|
5621
6300
|
conversationId,
|
|
5622
6301
|
provider: CLAUDE_CODE_PROVIDER,
|
|
6302
|
+
// Stays "idle" deliberately: we cannot see this process's prompt state, and
|
|
6303
|
+
// reporting `running` would route mobile to the destructive Overtake screen.
|
|
6304
|
+
// Liveness travels in the additive fields below instead.
|
|
5623
6305
|
status: "idle",
|
|
6306
|
+
ownership: "external",
|
|
6307
|
+
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6308
|
+
// report "gone" here — a vanished process simply stops being listed.
|
|
6309
|
+
processLiveness: "alive",
|
|
5624
6310
|
projectPath: d.projectPath,
|
|
5625
6311
|
projectName: d.projectName,
|
|
5626
6312
|
branch: d.branch,
|
|
@@ -5638,7 +6324,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5638
6324
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
5639
6325
|
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
5640
6326
|
import heicConvert from "heic-convert";
|
|
5641
|
-
import { join as
|
|
6327
|
+
import { join as join16 } from "path";
|
|
5642
6328
|
var UPLOAD_DIR_NAME = ".threadbase-uploads";
|
|
5643
6329
|
var MAX_BYTES = 25 * 1024 * 1024;
|
|
5644
6330
|
var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
@@ -5671,9 +6357,9 @@ async function saveUploadFile(input) {
|
|
|
5671
6357
|
}
|
|
5672
6358
|
const id = `up_${randomBytes3(8).toString("hex")}`;
|
|
5673
6359
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
5674
|
-
const dir =
|
|
6360
|
+
const dir = join16(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
5675
6361
|
await mkdir3(dir, { recursive: true });
|
|
5676
|
-
const filePath =
|
|
6362
|
+
const filePath = join16(dir, `${Date.now()}-${id}-${safeName}`);
|
|
5677
6363
|
await writeFile(filePath, buffer);
|
|
5678
6364
|
return {
|
|
5679
6365
|
id,
|
|
@@ -5685,7 +6371,7 @@ async function saveUploadFile(input) {
|
|
|
5685
6371
|
}
|
|
5686
6372
|
function sanitizeFilename(name) {
|
|
5687
6373
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
5688
|
-
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("");
|
|
6374
|
+
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("").replace(/[\s@"'`$\\]/g, "_");
|
|
5689
6375
|
return cleaned;
|
|
5690
6376
|
}
|
|
5691
6377
|
|
|
@@ -5718,12 +6404,12 @@ function normalizeCodexLineToClaudeShape(line) {
|
|
|
5718
6404
|
const text = extractCodexText(payload.content);
|
|
5719
6405
|
if (!text) return null;
|
|
5720
6406
|
if (role === "user" && isCodexInjectedContext(text)) return null;
|
|
5721
|
-
const
|
|
5722
|
-
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${
|
|
6407
|
+
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6408
|
+
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
5723
6409
|
return JSON.stringify({
|
|
5724
6410
|
type: role,
|
|
5725
6411
|
uuid,
|
|
5726
|
-
timestamp,
|
|
6412
|
+
timestamp: timestamp2,
|
|
5727
6413
|
message: {
|
|
5728
6414
|
role,
|
|
5729
6415
|
content: [{ type: "text", text }]
|
|
@@ -5773,13 +6459,13 @@ function hashPrefix(text) {
|
|
|
5773
6459
|
}
|
|
5774
6460
|
|
|
5775
6461
|
// src/utils/conversationEtag.ts
|
|
5776
|
-
import { createHash as
|
|
6462
|
+
import { createHash as createHash3 } from "crypto";
|
|
5777
6463
|
function computeConversationEtag({
|
|
5778
6464
|
filePath,
|
|
5779
6465
|
messageCount,
|
|
5780
|
-
timestamp
|
|
6466
|
+
timestamp: timestamp2
|
|
5781
6467
|
}) {
|
|
5782
|
-
const digest =
|
|
6468
|
+
const digest = createHash3("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
5783
6469
|
return `"${digest}"`;
|
|
5784
6470
|
}
|
|
5785
6471
|
|
|
@@ -5929,8 +6615,17 @@ var WSHub = class {
|
|
|
5929
6615
|
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
6616
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
5931
6617
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6618
|
+
var GRACE_MAX_DEFERS = 4;
|
|
6619
|
+
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6620
|
+
var DISCOVERY_TTL_MS = 15e3;
|
|
6621
|
+
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
6622
|
+
var ADOPT_KILL_POLL_MS = 100;
|
|
5932
6623
|
var REFRESH_TTL_MS = 2e3;
|
|
5933
6624
|
var START_READY_TIMEOUT_MS = 1e4;
|
|
6625
|
+
var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
|
|
6626
|
+
var EXTERNAL_TAIL_MAX = 32;
|
|
6627
|
+
var EXTERNAL_TAIL_IDLE_MS = 3e5;
|
|
6628
|
+
var EXTERNAL_ACTIVE_WRITING_MS = 3e4;
|
|
5934
6629
|
function parseIncludeAgentsEnv(raw) {
|
|
5935
6630
|
if (raw === void 0) return false;
|
|
5936
6631
|
const v = raw.trim().toLowerCase();
|
|
@@ -5944,11 +6639,30 @@ var StreamerServer = class {
|
|
|
5944
6639
|
fileWatcher;
|
|
5945
6640
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
5946
6641
|
// sessionId → JSONL filePath
|
|
6642
|
+
// canonical JSONL path → live tail on a file NO PTY session owns (an external
|
|
6643
|
+
// agent is writing it). Deliberately separate from sessionFileMap so managed
|
|
6644
|
+
// session semantics — terminal_output, session_update, question cards — are
|
|
6645
|
+
// untouched: an external tail only ever pushes transcript lines.
|
|
6646
|
+
externalTails = /* @__PURE__ */ new Map();
|
|
5947
6647
|
// Per-file seq assignments from the most recent onNewLineSpans (offset index),
|
|
5948
6648
|
// handed to the immediately-following onNewLines so it can stamp WS `seq` on
|
|
5949
6649
|
// the matching conversation_events entries. Same read → same lines order.
|
|
5950
6650
|
pendingLineSeqs = /* @__PURE__ */ new Map();
|
|
6651
|
+
// `origin` records whether the pending question came from the live PTY-screen
|
|
6652
|
+
// path (handleLiveQuestion) or a JSONL flush. A JSONL-derived question must
|
|
6653
|
+
// never clobber a PTY-originated one for a DIFFERENT question — an external
|
|
6654
|
+
// agent appending an AskUserQuestion into a shared conversation would
|
|
6655
|
+
// otherwise misroute the answer into this streamer's PTY.
|
|
5951
6656
|
pendingQuestions = /* @__PURE__ */ new Map();
|
|
6657
|
+
// Sessions resumed past a detected collision (busy probe said busy, caller
|
|
6658
|
+
// forced). JSONL-derived actionable question cards are suppressed for these
|
|
6659
|
+
// because a line in the shared file may have been written by the other owner.
|
|
6660
|
+
contendedSessions = /* @__PURE__ */ new Set();
|
|
6661
|
+
// conversationId → ms epoch when THIS streamer's PTY for it last went idle.
|
|
6662
|
+
// Lets the resume collision probe tell our own trailing JSONL writes (a
|
|
6663
|
+
// hold → resume round trip) apart from another owner's. Pruned on write so it
|
|
6664
|
+
// cannot grow without bound across a long-lived process.
|
|
6665
|
+
selfPtyEndedAt = /* @__PURE__ */ new Map();
|
|
5952
6666
|
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
5953
6667
|
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
5954
6668
|
// the screen detection fires first, the later JSONL flush of the same question
|
|
@@ -6015,6 +6729,9 @@ var StreamerServer = class {
|
|
|
6015
6729
|
defaultEffort;
|
|
6016
6730
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6017
6731
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6732
|
+
// Consecutive grace-timer defers for a still-`running` session (see
|
|
6733
|
+
// GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
|
|
6734
|
+
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6018
6735
|
// Map of sessionId → set of subscribed WS clients
|
|
6019
6736
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
6020
6737
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -6022,6 +6739,7 @@ var StreamerServer = class {
|
|
|
6022
6739
|
// Reverse map for cleanup on close
|
|
6023
6740
|
wsToClientId = /* @__PURE__ */ new Map();
|
|
6024
6741
|
cache = null;
|
|
6742
|
+
cacheMonitor = null;
|
|
6025
6743
|
projectsRepo = null;
|
|
6026
6744
|
conversationsRepo = null;
|
|
6027
6745
|
sessionsRepo = null;
|
|
@@ -6058,13 +6776,13 @@ var StreamerServer = class {
|
|
|
6058
6776
|
this.disableDb = config.disableDb ?? false;
|
|
6059
6777
|
this.scannerPersistenceDisabled = config.scannerPersistent === false;
|
|
6060
6778
|
this.scanProfiles = config.scanProfiles;
|
|
6061
|
-
this.codexRoots = config.codexRoots ?? [
|
|
6779
|
+
this.codexRoots = config.codexRoots ?? [join17(homedir8(), ".codex", "sessions")];
|
|
6062
6780
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
6063
6781
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
6064
6782
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6065
6783
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6066
6784
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
6067
|
-
this.cacheDir = config.cacheDir ?? loadCacheDir() ??
|
|
6785
|
+
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join17(homedir8(), ".threadbase", "cache");
|
|
6068
6786
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6069
6787
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
6070
6788
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -6105,7 +6823,7 @@ var StreamerServer = class {
|
|
|
6105
6823
|
const seqs = cache.extendMessageIndex(
|
|
6106
6824
|
filePath,
|
|
6107
6825
|
spans,
|
|
6108
|
-
|
|
6826
|
+
statSync8(filePath),
|
|
6109
6827
|
readFrom,
|
|
6110
6828
|
endOffset
|
|
6111
6829
|
);
|
|
@@ -6135,39 +6853,25 @@ var StreamerServer = class {
|
|
|
6135
6853
|
},
|
|
6136
6854
|
onNewLines: (filePath, lines) => {
|
|
6137
6855
|
this.cache?.updateFromLines(filePath, lines);
|
|
6856
|
+
let managed = false;
|
|
6138
6857
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
6139
6858
|
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
|
-
}
|
|
6859
|
+
managed = true;
|
|
6860
|
+
this.processJsonlQuestions(sessionId, lines);
|
|
6162
6861
|
const seqs = this.pendingLineSeqs.get(filePath);
|
|
6163
6862
|
this.broadcastConversationLines(sessionId, lines, seqs);
|
|
6164
6863
|
break;
|
|
6165
6864
|
}
|
|
6166
6865
|
}
|
|
6866
|
+
if (!managed) {
|
|
6867
|
+
this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
|
|
6868
|
+
}
|
|
6167
6869
|
this.pendingLineSeqs.delete(filePath);
|
|
6168
6870
|
},
|
|
6169
6871
|
onConversationChanged: (filePath) => {
|
|
6170
|
-
this.fileWatcher.poke(filePath);
|
|
6872
|
+
const tailed = this.fileWatcher.poke(filePath);
|
|
6873
|
+
if (!tailed) this.maybeAttachExternalTail(filePath);
|
|
6874
|
+
this.sweepIdleExternalTails();
|
|
6171
6875
|
this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
|
|
6172
6876
|
this.markScannerStaleDebounced();
|
|
6173
6877
|
this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
|
|
@@ -6175,7 +6879,20 @@ var StreamerServer = class {
|
|
|
6175
6879
|
event: "cache.directory_change"
|
|
6176
6880
|
});
|
|
6177
6881
|
},
|
|
6882
|
+
onTruncated: (filePath) => {
|
|
6883
|
+
this.cache?.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
|
|
6884
|
+
this.cache?.clearIndexParseState(filePath);
|
|
6885
|
+
this.log.warn(`JSONL truncated/replaced; offset index dropped: ${filePath}`, {
|
|
6886
|
+
filePath,
|
|
6887
|
+
event: "tail.truncated"
|
|
6888
|
+
});
|
|
6889
|
+
},
|
|
6178
6890
|
onFileDeleted: (filePath) => {
|
|
6891
|
+
this.detachExternalTail(canonicalizeFilePath(filePath));
|
|
6892
|
+
if (this.cacheMonitor?.pending) {
|
|
6893
|
+
this.cacheMonitor.deferUnlink(filePath);
|
|
6894
|
+
return;
|
|
6895
|
+
}
|
|
6179
6896
|
const id = this.cache?.invalidateByFilePath(filePath);
|
|
6180
6897
|
if (id)
|
|
6181
6898
|
this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
|
|
@@ -6183,6 +6900,7 @@ var StreamerServer = class {
|
|
|
6183
6900
|
filePath,
|
|
6184
6901
|
event: "cache.invalidate_on_unlink"
|
|
6185
6902
|
});
|
|
6903
|
+
this.cacheMonitor?.recordUnlink(filePath);
|
|
6186
6904
|
}
|
|
6187
6905
|
});
|
|
6188
6906
|
this.ptyManager = new LiveSessionManager({
|
|
@@ -6246,6 +6964,8 @@ var StreamerServer = class {
|
|
|
6246
6964
|
this.cancelPendingQuestion(session.id);
|
|
6247
6965
|
}
|
|
6248
6966
|
this.pendingPermission.delete(session.id);
|
|
6967
|
+
this.contendedSessions.delete(session.id);
|
|
6968
|
+
this.rememberSelfPtyEnded(session.id);
|
|
6249
6969
|
}
|
|
6250
6970
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
6251
6971
|
if (resp) {
|
|
@@ -6269,7 +6989,7 @@ var StreamerServer = class {
|
|
|
6269
6989
|
temporalClient,
|
|
6270
6990
|
taskQueue: agentConfig.temporal.taskQueue
|
|
6271
6991
|
});
|
|
6272
|
-
const conversationsBaseDir = agentConfig.conversationsDir ||
|
|
6992
|
+
const conversationsBaseDir = agentConfig.conversationsDir || join17(dirname9(this.cacheDir), "conversations");
|
|
6273
6993
|
conversationWriter = createConversationWriter({
|
|
6274
6994
|
baseDir: conversationsBaseDir
|
|
6275
6995
|
});
|
|
@@ -6291,6 +7011,7 @@ var StreamerServer = class {
|
|
|
6291
7011
|
sessionStore: this.sessionStore,
|
|
6292
7012
|
wsHub: this.wsHub,
|
|
6293
7013
|
cache: () => this.cache,
|
|
7014
|
+
cacheMonitor: () => this.cacheMonitor,
|
|
6294
7015
|
projectsRepo: () => this.projectsRepo,
|
|
6295
7016
|
conversationsRepo: () => this.conversationsRepo,
|
|
6296
7017
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -6329,6 +7050,8 @@ var StreamerServer = class {
|
|
|
6329
7050
|
if (this.cacheReady) {
|
|
6330
7051
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
6331
7052
|
}
|
|
7053
|
+
const alertMsg = this.cacheMonitor?.wsMessage();
|
|
7054
|
+
if (alertMsg) this.wsHub.unicast(ws, alertMsg);
|
|
6332
7055
|
},
|
|
6333
7056
|
handleWsMessage: async (ws, raw) => {
|
|
6334
7057
|
try {
|
|
@@ -6444,6 +7167,17 @@ var StreamerServer = class {
|
|
|
6444
7167
|
ptyAttachedIds() {
|
|
6445
7168
|
return new Set(this.ptyManager.listSessions().map((s) => s.id));
|
|
6446
7169
|
}
|
|
7170
|
+
// Record that our own PTY for `conversationId` just ended. Entries older than
|
|
7171
|
+
// the busy window can never change a verdict, so drop them as we go rather
|
|
7172
|
+
// than accumulating one per conversation for the process's lifetime.
|
|
7173
|
+
rememberSelfPtyEnded(conversationId) {
|
|
7174
|
+
const now = Date.now();
|
|
7175
|
+
const cutoff = now - resolveResumeBusyWindowMs();
|
|
7176
|
+
for (const [id, at] of this.selfPtyEndedAt) {
|
|
7177
|
+
if (at < cutoff) this.selfPtyEndedAt.delete(id);
|
|
7178
|
+
}
|
|
7179
|
+
this.selfPtyEndedAt.set(conversationId, now);
|
|
7180
|
+
}
|
|
6447
7181
|
/**
|
|
6448
7182
|
* Send a session_list to only the client that triggered this HTTP request
|
|
6449
7183
|
* (identified by X-Client-Id header → registered WS socket). Falls back to
|
|
@@ -6474,6 +7208,7 @@ var StreamerServer = class {
|
|
|
6474
7208
|
clearTimeout(existing);
|
|
6475
7209
|
this.ptyGraceTimers.delete(sessionId);
|
|
6476
7210
|
}
|
|
7211
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6477
7212
|
}
|
|
6478
7213
|
startGraceTimer(sessionId, delayMs) {
|
|
6479
7214
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
@@ -6483,14 +7218,24 @@ var StreamerServer = class {
|
|
|
6483
7218
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
6484
7219
|
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6485
7220
|
if (resp?.status === "running") {
|
|
6486
|
-
this.
|
|
6487
|
-
|
|
6488
|
-
|
|
7221
|
+
const defers = (this.ptyGraceDeferCounts.get(sessionId) ?? 0) + 1;
|
|
7222
|
+
if (defers <= GRACE_MAX_DEFERS) {
|
|
7223
|
+
this.ptyGraceDeferCounts.set(sessionId, defers);
|
|
7224
|
+
this.log.info(
|
|
7225
|
+
`[grace] session ${sessionId} still running, deferring hold (${defers}/${GRACE_MAX_DEFERS})`,
|
|
7226
|
+
{ sessionId, event: "pty.grace_defer", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
7227
|
+
"pino"
|
|
7228
|
+
);
|
|
7229
|
+
this.startGraceTimer(sessionId, delayMs);
|
|
7230
|
+
return;
|
|
7231
|
+
}
|
|
7232
|
+
this.log.warn(
|
|
7233
|
+
`[grace] session ${sessionId} exceeded ${GRACE_MAX_DEFERS} defers, holding anyway`,
|
|
7234
|
+
{ sessionId, event: "pty.grace_defer_cap", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
6489
7235
|
"pino"
|
|
6490
7236
|
);
|
|
6491
|
-
this.startGraceTimer(sessionId, delayMs);
|
|
6492
|
-
return;
|
|
6493
7237
|
}
|
|
7238
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6494
7239
|
this.sessionSubscribers.delete(sessionId);
|
|
6495
7240
|
this.log.info(
|
|
6496
7241
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
@@ -6501,6 +7246,7 @@ var StreamerServer = class {
|
|
|
6501
7246
|
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6502
7247
|
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
6503
7248
|
} else {
|
|
7249
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6504
7250
|
this.sessionSubscribers.delete(sessionId);
|
|
6505
7251
|
}
|
|
6506
7252
|
}, delayMs);
|
|
@@ -6533,13 +7279,16 @@ var StreamerServer = class {
|
|
|
6533
7279
|
});
|
|
6534
7280
|
try {
|
|
6535
7281
|
this.cache = ConversationCache.open(
|
|
6536
|
-
|
|
7282
|
+
join17(this.cacheDir, "cache.db"),
|
|
6537
7283
|
this.tailSize,
|
|
6538
7284
|
void 0,
|
|
6539
7285
|
{
|
|
6540
7286
|
filterAgentConversations: !this.includeAgents,
|
|
6541
7287
|
agentEntrypoints: this.agentEntrypoints,
|
|
6542
|
-
onAgentFileDetected: (fp) =>
|
|
7288
|
+
onAgentFileDetected: (fp) => {
|
|
7289
|
+
this.fileWatcher.unwatch(fp);
|
|
7290
|
+
this.externalTails.delete(canonicalizeFilePath(fp));
|
|
7291
|
+
}
|
|
6543
7292
|
}
|
|
6544
7293
|
);
|
|
6545
7294
|
if (!this.includeAgents) {
|
|
@@ -6556,9 +7305,23 @@ var StreamerServer = class {
|
|
|
6556
7305
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
6557
7306
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
6558
7307
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
7308
|
+
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7309
|
+
this.cache,
|
|
7310
|
+
this.wsHub,
|
|
7311
|
+
this.log,
|
|
7312
|
+
this.cacheDir,
|
|
7313
|
+
async () => {
|
|
7314
|
+
const scanner = await this.rescanForRefresh();
|
|
7315
|
+
return [...scanner.getMetadataCache().values()];
|
|
7316
|
+
}
|
|
7317
|
+
);
|
|
6559
7318
|
for (const dir of this.projectsDirs()) {
|
|
6560
7319
|
this.fileWatcher.watchDirectory(dir);
|
|
6561
7320
|
}
|
|
7321
|
+
for (const dir of this.codexRoots) {
|
|
7322
|
+
if (!existsSync10(dir)) continue;
|
|
7323
|
+
this.fileWatcher.watchDirectory(dir);
|
|
7324
|
+
}
|
|
6562
7325
|
} catch (err) {
|
|
6563
7326
|
const message = err instanceof Error ? err.message : String(err);
|
|
6564
7327
|
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
@@ -6625,11 +7388,19 @@ var StreamerServer = class {
|
|
|
6625
7388
|
}
|
|
6626
7389
|
);
|
|
6627
7390
|
}
|
|
6628
|
-
|
|
6629
|
-
this.
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
7391
|
+
await this.cacheMonitor?.runDetection();
|
|
7392
|
+
if (this.cacheMonitor?.pending) {
|
|
7393
|
+
this.log.warn("Startup ghost prune skipped \u2014 cache integrity alert pending", {
|
|
7394
|
+
fingerprint: this.cacheMonitor.pending.fingerprint,
|
|
7395
|
+
event: "cache.prune_ghosts_frozen"
|
|
7396
|
+
});
|
|
7397
|
+
} else {
|
|
7398
|
+
const pruned = this.cache.pruneGhostFiles();
|
|
7399
|
+
this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
|
|
7400
|
+
count: pruned.length,
|
|
7401
|
+
event: "cache.prune_ghosts"
|
|
7402
|
+
});
|
|
7403
|
+
}
|
|
6633
7404
|
}).catch((err) => {
|
|
6634
7405
|
const message = err instanceof Error ? err.message : String(err);
|
|
6635
7406
|
this.log.warn(`Startup cache warm-up failed: ${message}`, {
|
|
@@ -6741,6 +7512,7 @@ var StreamerServer = class {
|
|
|
6741
7512
|
this.cache?.close();
|
|
6742
7513
|
this.ptyManager.dispose();
|
|
6743
7514
|
this.fileWatcher.dispose();
|
|
7515
|
+
this.externalTails.clear();
|
|
6744
7516
|
this.wsHub.dispose();
|
|
6745
7517
|
this.pairTokens.dispose();
|
|
6746
7518
|
if (this.dbPool) {
|
|
@@ -6880,10 +7652,9 @@ var StreamerServer = class {
|
|
|
6880
7652
|
const metas2 = [...scanner2.getMetadataCache().values()];
|
|
6881
7653
|
try {
|
|
6882
7654
|
this.cache.upsertFromScannerMeta(metas2);
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
this.cache.reconcileDeletions(livePaths);
|
|
7655
|
+
if (!this.cacheMonitor?.pending) {
|
|
7656
|
+
this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
|
|
7657
|
+
}
|
|
6887
7658
|
} catch (err) {
|
|
6888
7659
|
this.log.warn(
|
|
6889
7660
|
`refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -7014,6 +7785,7 @@ var StreamerServer = class {
|
|
|
7014
7785
|
type: "conversation",
|
|
7015
7786
|
id: c.id,
|
|
7016
7787
|
status: "idle",
|
|
7788
|
+
ownership: "historical",
|
|
7017
7789
|
ptyAttached: false,
|
|
7018
7790
|
projectId: c.projectId ?? void 0,
|
|
7019
7791
|
projectPath: c.projectPath ?? "",
|
|
@@ -7040,21 +7812,19 @@ var StreamerServer = class {
|
|
|
7040
7812
|
if (!this.cache) return void 0;
|
|
7041
7813
|
if (!previousScanner) {
|
|
7042
7814
|
const persisted = this.cache.getScannerStatCache();
|
|
7043
|
-
|
|
7815
|
+
if (persisted.size === 0) return void 0;
|
|
7816
|
+
const nativeKeyed = /* @__PURE__ */ new Map();
|
|
7817
|
+
for (const [canonicalPath, entry] of persisted) {
|
|
7818
|
+
nativeKeyed.set(toNativeFilePath(canonicalPath), entry);
|
|
7819
|
+
}
|
|
7820
|
+
return nativeKeyed;
|
|
7044
7821
|
}
|
|
7045
7822
|
const dbStats = this.cache.getFileStats();
|
|
7046
7823
|
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
|
-
}
|
|
7824
|
+
const statCache = joinStatCacheByNativePath(
|
|
7825
|
+
previousScanner.getMetadataCache().values(),
|
|
7826
|
+
dbStats
|
|
7827
|
+
);
|
|
7058
7828
|
return statCache.size > 0 ? statCache : void 0;
|
|
7059
7829
|
}
|
|
7060
7830
|
// Returns the provider + codexRoots fragment to spread into every scan()/search() call.
|
|
@@ -7148,22 +7918,22 @@ var StreamerServer = class {
|
|
|
7148
7918
|
*/
|
|
7149
7919
|
projectsDirs() {
|
|
7150
7920
|
if (this.scanProfiles && this.scanProfiles.length > 0) {
|
|
7151
|
-
return this.scanProfiles.filter((p) => p.enabled).map((p) =>
|
|
7921
|
+
return this.scanProfiles.filter((p) => p.enabled).map((p) => join17(p.configDir, "projects"));
|
|
7152
7922
|
}
|
|
7153
|
-
return [
|
|
7923
|
+
return [join17(homedir8(), ".claude", "projects")];
|
|
7154
7924
|
}
|
|
7155
7925
|
findJsonlPath(uuid) {
|
|
7156
7926
|
const filename = `${uuid}.jsonl`;
|
|
7157
7927
|
for (const projectsDir of this.projectsDirs()) {
|
|
7158
|
-
if (!
|
|
7159
|
-
for (const dir of
|
|
7160
|
-
const fp =
|
|
7161
|
-
if (
|
|
7162
|
-
const projectDir =
|
|
7928
|
+
if (!existsSync10(projectsDir)) continue;
|
|
7929
|
+
for (const dir of readdirSync5(projectsDir)) {
|
|
7930
|
+
const fp = join17(projectsDir, dir, filename);
|
|
7931
|
+
if (existsSync10(fp)) return fp;
|
|
7932
|
+
const projectDir = join17(projectsDir, dir);
|
|
7163
7933
|
try {
|
|
7164
|
-
for (const sub of
|
|
7165
|
-
const subagentPath =
|
|
7166
|
-
if (
|
|
7934
|
+
for (const sub of readdirSync5(projectDir)) {
|
|
7935
|
+
const subagentPath = join17(projectDir, sub, "subagents", filename);
|
|
7936
|
+
if (existsSync10(subagentPath)) return subagentPath;
|
|
7167
7937
|
}
|
|
7168
7938
|
} catch {
|
|
7169
7939
|
}
|
|
@@ -7243,6 +8013,140 @@ var StreamerServer = class {
|
|
|
7243
8013
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7244
8014
|
}
|
|
7245
8015
|
}
|
|
8016
|
+
// ─── External (non-PTY) live tails ───────────────────────────────
|
|
8017
|
+
/** True when a managed (PTY) session owns the tail for this canonical path. */
|
|
8018
|
+
isManagedTailPath(key) {
|
|
8019
|
+
for (const watchedPath of this.sessionFileMap.values()) {
|
|
8020
|
+
if (canonicalizeFilePath(watchedPath) === key) return true;
|
|
8021
|
+
}
|
|
8022
|
+
return false;
|
|
8023
|
+
}
|
|
8024
|
+
/**
|
|
8025
|
+
* Attach a live tail to a JSONL nobody is tailing yet, when it was touched
|
|
8026
|
+
* recently enough to look actively written by an external agent. Capped at
|
|
8027
|
+
* EXTERNAL_TAIL_MAX with LRU eviction.
|
|
8028
|
+
*/
|
|
8029
|
+
maybeAttachExternalTail(filePath) {
|
|
8030
|
+
if (!filePath.endsWith(".jsonl")) return;
|
|
8031
|
+
const key = canonicalizeFilePath(filePath);
|
|
8032
|
+
if (this.externalTails.has(key)) return;
|
|
8033
|
+
if (this.isManagedTailPath(key)) return;
|
|
8034
|
+
let mtimeMs;
|
|
8035
|
+
try {
|
|
8036
|
+
mtimeMs = statSync8(filePath).mtimeMs;
|
|
8037
|
+
} catch {
|
|
8038
|
+
return;
|
|
8039
|
+
}
|
|
8040
|
+
const now = Date.now();
|
|
8041
|
+
if (now - mtimeMs > EXTERNAL_TAIL_RECENCY_MS) return;
|
|
8042
|
+
this.evictExternalTailsIfNeeded();
|
|
8043
|
+
this.externalTails.set(key, {
|
|
8044
|
+
conversationId: ConversationCache.conversationIdForFile(key),
|
|
8045
|
+
lastActivityAt: now
|
|
8046
|
+
});
|
|
8047
|
+
this.fileWatcher.watch(filePath);
|
|
8048
|
+
this.log.debug?.(`External tail attached: ${filePath}`, {
|
|
8049
|
+
filePath,
|
|
8050
|
+
tails: this.externalTails.size,
|
|
8051
|
+
event: "external_tail.attach"
|
|
8052
|
+
});
|
|
8053
|
+
}
|
|
8054
|
+
/** Stop tailing an external file and drop its bookkeeping. */
|
|
8055
|
+
detachExternalTail(key) {
|
|
8056
|
+
if (!this.externalTails.delete(key)) return;
|
|
8057
|
+
this.fileWatcher.unwatch(key);
|
|
8058
|
+
this.log.debug?.(`External tail detached: ${key}`, {
|
|
8059
|
+
filePath: key,
|
|
8060
|
+
event: "external_tail.detach"
|
|
8061
|
+
});
|
|
8062
|
+
}
|
|
8063
|
+
/** Make room for one more tail by evicting the least recently active ones. */
|
|
8064
|
+
evictExternalTailsIfNeeded() {
|
|
8065
|
+
while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
|
|
8066
|
+
let lruKey = null;
|
|
8067
|
+
let lruAt = Number.POSITIVE_INFINITY;
|
|
8068
|
+
for (const [key, entry] of this.externalTails) {
|
|
8069
|
+
if (this.isManagedTailPath(key)) {
|
|
8070
|
+
this.externalTails.delete(key);
|
|
8071
|
+
return;
|
|
8072
|
+
}
|
|
8073
|
+
if (entry.lastActivityAt < lruAt) {
|
|
8074
|
+
lruAt = entry.lastActivityAt;
|
|
8075
|
+
lruKey = key;
|
|
8076
|
+
}
|
|
8077
|
+
}
|
|
8078
|
+
if (!lruKey) return;
|
|
8079
|
+
this.detachExternalTail(lruKey);
|
|
8080
|
+
}
|
|
8081
|
+
}
|
|
8082
|
+
/**
|
|
8083
|
+
* INFERRED activity for an externally-owned conversation, derived purely from
|
|
8084
|
+
* how recently its JSONL grew (the external tail's bookkeeping). Returns
|
|
8085
|
+
* undefined when we hold no tail for it, so a session we know nothing about
|
|
8086
|
+
* reports no activity rather than a fabricated "quiet".
|
|
8087
|
+
*
|
|
8088
|
+
* This can never distinguish a generating agent from one blocked on a
|
|
8089
|
+
* permission gate — gates render on the PTY screen and never reach the JSONL —
|
|
8090
|
+
* which is why it is a separate field and not folded into `status`.
|
|
8091
|
+
*/
|
|
8092
|
+
externalActivityFor(conversationId, now = Date.now()) {
|
|
8093
|
+
for (const entry of this.externalTails.values()) {
|
|
8094
|
+
if (entry.conversationId !== conversationId) continue;
|
|
8095
|
+
return {
|
|
8096
|
+
state: now - entry.lastActivityAt <= EXTERNAL_ACTIVE_WRITING_MS ? "active_writing" : "quiet",
|
|
8097
|
+
lastEventAt: new Date(entry.lastActivityAt).toISOString(),
|
|
8098
|
+
source: "jsonl"
|
|
8099
|
+
};
|
|
8100
|
+
}
|
|
8101
|
+
return void 0;
|
|
8102
|
+
}
|
|
8103
|
+
/** Attach inferred `activity` to externally-owned sessions in a response set. */
|
|
8104
|
+
withExternalActivity(sessions) {
|
|
8105
|
+
if (this.externalTails.size === 0) return sessions;
|
|
8106
|
+
const now = Date.now();
|
|
8107
|
+
return sessions.map((s) => {
|
|
8108
|
+
if (s.ownership !== "external") return s;
|
|
8109
|
+
const activity = this.externalActivityFor(s.conversationId ?? s.id, now);
|
|
8110
|
+
return activity ? { ...s, activity } : s;
|
|
8111
|
+
});
|
|
8112
|
+
}
|
|
8113
|
+
/** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
|
|
8114
|
+
sweepIdleExternalTails(now = Date.now()) {
|
|
8115
|
+
for (const [key, entry] of [...this.externalTails]) {
|
|
8116
|
+
if (this.isManagedTailPath(key)) {
|
|
8117
|
+
this.externalTails.delete(key);
|
|
8118
|
+
continue;
|
|
8119
|
+
}
|
|
8120
|
+
if (now - entry.lastActivityAt > EXTERNAL_TAIL_IDLE_MS) this.detachExternalTail(key);
|
|
8121
|
+
}
|
|
8122
|
+
}
|
|
8123
|
+
/**
|
|
8124
|
+
* Push appended lines from an externally-owned conversation. Reuses the exact
|
|
8125
|
+
* conversation_events / conversation_event shapes mobile already consumes,
|
|
8126
|
+
* keyed by the conversation UUID — an external session has no PTY, so it must
|
|
8127
|
+
* never produce terminal_output / terminal_replay / session_ready, and never a
|
|
8128
|
+
* session_update whose session.id is a conversation UUID (that would mint a
|
|
8129
|
+
* phantom session row in the mobile cache). Question cards are likewise never
|
|
8130
|
+
* derived here: with no PTY there is nothing that could deliver an answer.
|
|
8131
|
+
*/
|
|
8132
|
+
broadcastExternalTailLines(filePath, lines, seqs) {
|
|
8133
|
+
const key = canonicalizeFilePath(filePath);
|
|
8134
|
+
const entry = this.externalTails.get(key);
|
|
8135
|
+
if (!entry) return;
|
|
8136
|
+
entry.lastActivityAt = Date.now();
|
|
8137
|
+
const conversationId = this.cache?.getIdByFilePath(key);
|
|
8138
|
+
if (!conversationId) return;
|
|
8139
|
+
entry.conversationId = conversationId;
|
|
8140
|
+
this.broadcastConversationLines(conversationId, lines, seqs);
|
|
8141
|
+
const meta = this.cache?.getMetaById(conversationId);
|
|
8142
|
+
this.wsHub.broadcast({
|
|
8143
|
+
type: "conversation_updated",
|
|
8144
|
+
conversationId,
|
|
8145
|
+
messageCount: meta?.messageCount ?? 0,
|
|
8146
|
+
lastActivity: meta?.lastActivity ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
8147
|
+
ownership: "external"
|
|
8148
|
+
});
|
|
8149
|
+
}
|
|
7246
8150
|
async findConversationByUuid(uuid) {
|
|
7247
8151
|
const lookupId = this.resolveConversationLookupId(uuid);
|
|
7248
8152
|
if (!this.scannerReady && !this.scanProfiles) {
|
|
@@ -7311,7 +8215,7 @@ var StreamerServer = class {
|
|
|
7311
8215
|
if (!conv.filePath) return false;
|
|
7312
8216
|
let mtimeMs = null;
|
|
7313
8217
|
try {
|
|
7314
|
-
mtimeMs =
|
|
8218
|
+
mtimeMs = statSync8(conv.filePath).mtimeMs;
|
|
7315
8219
|
} catch {
|
|
7316
8220
|
return false;
|
|
7317
8221
|
}
|
|
@@ -7645,7 +8549,6 @@ var StreamerServer = class {
|
|
|
7645
8549
|
});
|
|
7646
8550
|
}
|
|
7647
8551
|
async handleListSessions(url, res) {
|
|
7648
|
-
const DISCOVERY_TTL_MS = 15e3;
|
|
7649
8552
|
const now = Date.now();
|
|
7650
8553
|
if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
|
|
7651
8554
|
try {
|
|
@@ -7657,7 +8560,7 @@ var StreamerServer = class {
|
|
|
7657
8560
|
}
|
|
7658
8561
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
7659
8562
|
if (!hasPaginationParams) {
|
|
7660
|
-
json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
|
|
8563
|
+
json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
|
|
7661
8564
|
return;
|
|
7662
8565
|
}
|
|
7663
8566
|
const parsed = parseSessionListQuery(url);
|
|
@@ -7667,6 +8570,7 @@ var StreamerServer = class {
|
|
|
7667
8570
|
}
|
|
7668
8571
|
try {
|
|
7669
8572
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8573
|
+
page.sessions = this.withExternalActivity(page.sessions);
|
|
7670
8574
|
json(res, 200, page);
|
|
7671
8575
|
} catch (err) {
|
|
7672
8576
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -7679,7 +8583,7 @@ var StreamerServer = class {
|
|
|
7679
8583
|
handleGetSession(sessionId, res) {
|
|
7680
8584
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
7681
8585
|
if (session) {
|
|
7682
|
-
if (!
|
|
8586
|
+
if (!existsSync10(session.projectPath)) {
|
|
7683
8587
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
7684
8588
|
}
|
|
7685
8589
|
json(res, 200, session);
|
|
@@ -7693,7 +8597,6 @@ var StreamerServer = class {
|
|
|
7693
8597
|
json(res, 404, { error: "Session not found" });
|
|
7694
8598
|
}
|
|
7695
8599
|
async handleResume(req, res) {
|
|
7696
|
-
this.discoveryCache = null;
|
|
7697
8600
|
const body = await readBody(req);
|
|
7698
8601
|
const sessionId = body.sessionId ?? body.conversationId;
|
|
7699
8602
|
if (!sessionId) {
|
|
@@ -7722,8 +8625,48 @@ var StreamerServer = class {
|
|
|
7722
8625
|
json(res, 400, { error: "Could not determine project path" });
|
|
7723
8626
|
return;
|
|
7724
8627
|
}
|
|
8628
|
+
let discovered = [];
|
|
8629
|
+
const cached2 = this.discoveryCache;
|
|
8630
|
+
if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
|
|
8631
|
+
discovered = cached2.entries;
|
|
8632
|
+
} else {
|
|
8633
|
+
try {
|
|
8634
|
+
discovered = await Promise.race([
|
|
8635
|
+
discoverClaudeProcesses(),
|
|
8636
|
+
new Promise(
|
|
8637
|
+
(resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
|
|
8638
|
+
)
|
|
8639
|
+
]);
|
|
8640
|
+
if (discovered.length > 0) {
|
|
8641
|
+
this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
|
|
8642
|
+
}
|
|
8643
|
+
} catch {
|
|
8644
|
+
}
|
|
8645
|
+
}
|
|
8646
|
+
const busy = conversationBusy({
|
|
8647
|
+
conversationId: sessionId,
|
|
8648
|
+
projectPath,
|
|
8649
|
+
jsonlPath,
|
|
8650
|
+
discovered,
|
|
8651
|
+
windowMs: resolveResumeBusyWindowMs(),
|
|
8652
|
+
selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
|
|
8653
|
+
});
|
|
8654
|
+
if (busy.busy && body.force !== true) {
|
|
8655
|
+
json(res, 409, {
|
|
8656
|
+
error: "This conversation looks active in another session",
|
|
8657
|
+
code: "CONVERSATION_BUSY",
|
|
8658
|
+
detectedBy: busy.detectedBy,
|
|
8659
|
+
lastActivityMs: busy.lastActivityMs,
|
|
8660
|
+
likelyOwner: busy.likelyOwner
|
|
8661
|
+
});
|
|
8662
|
+
return;
|
|
8663
|
+
}
|
|
8664
|
+
if (busy.busy) {
|
|
8665
|
+
this.contendedSessions.add(sessionId);
|
|
8666
|
+
}
|
|
7725
8667
|
const cachedConvMeta = this.cache?.getMetaById(sessionId);
|
|
7726
8668
|
const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
|
|
8669
|
+
this.discoveryCache = null;
|
|
7727
8670
|
const session = await this.ptyManager.start(sessionId, {
|
|
7728
8671
|
provider,
|
|
7729
8672
|
projectPath,
|
|
@@ -7857,6 +8800,45 @@ var StreamerServer = class {
|
|
|
7857
8800
|
json(res, 400, { error: message });
|
|
7858
8801
|
}
|
|
7859
8802
|
}
|
|
8803
|
+
// Store + broadcast AskUserQuestion cards found in a JSONL batch for a watched
|
|
8804
|
+
// session. Two P0 safety guards on top of the screen/JSONL de-dupe:
|
|
8805
|
+
// (a) contended file → suppress JSONL-derived cards entirely (a line may be
|
|
8806
|
+
// the OTHER owner's question); the streamer's own PTY questions still
|
|
8807
|
+
// arrive via the live-screen path (handleLiveQuestion), not suppressed.
|
|
8808
|
+
// (b) a JSONL question must never clobber a PTY-screen question that is a
|
|
8809
|
+
// DIFFERENT question — answering it would type into this streamer's PTY.
|
|
8810
|
+
// Same-content re-syncs (screen synthetic id → real toolUseId) still pass.
|
|
8811
|
+
processJsonlQuestions(sessionId, lines) {
|
|
8812
|
+
const priorPending = this.pendingQuestions.get(sessionId);
|
|
8813
|
+
const priorToolUseId = priorPending?.toolUseId;
|
|
8814
|
+
const contended = this.contendedSessions.has(sessionId);
|
|
8815
|
+
const priorPtyKey = priorPending?.origin === "pty" ? questionContentKey(priorPending.questions) : null;
|
|
8816
|
+
const foreignVsPty = (questions) => priorPtyKey !== null && questionContentKey(questions) !== priorPtyKey;
|
|
8817
|
+
const { messages, pending } = questionsFromLines(sessionId, lines);
|
|
8818
|
+
for (const p of pending) {
|
|
8819
|
+
if (contended || foreignVsPty(p.questions)) continue;
|
|
8820
|
+
const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
|
|
8821
|
+
this.pendingQuestions.set(sessionId, { ...p, origin });
|
|
8822
|
+
const t = setTimeout(() => {
|
|
8823
|
+
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
8824
|
+
this.cancelPendingQuestion(sessionId);
|
|
8825
|
+
}
|
|
8826
|
+
}, 6e4);
|
|
8827
|
+
t.unref();
|
|
8828
|
+
}
|
|
8829
|
+
for (const m of messages) {
|
|
8830
|
+
if (contended || foreignVsPty(m.questions)) continue;
|
|
8831
|
+
const key = questionContentKey(m.questions);
|
|
8832
|
+
const broadcast = shouldBroadcastQuestion({
|
|
8833
|
+
newContentKey: key,
|
|
8834
|
+
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
8835
|
+
newToolUseId: m.toolUseId,
|
|
8836
|
+
priorToolUseId
|
|
8837
|
+
});
|
|
8838
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
8839
|
+
if (broadcast) this.wsHub.broadcast(m);
|
|
8840
|
+
}
|
|
8841
|
+
}
|
|
7860
8842
|
cancelPendingQuestion(sessionId) {
|
|
7861
8843
|
const pq = this.pendingQuestions.get(sessionId);
|
|
7862
8844
|
if (!pq) return;
|
|
@@ -7873,7 +8855,7 @@ var StreamerServer = class {
|
|
|
7873
8855
|
const key = questionContentKey(questions);
|
|
7874
8856
|
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
7875
8857
|
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
7876
|
-
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
8858
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
|
|
7877
8859
|
this.pendingQuestionKey.set(sessionId, key);
|
|
7878
8860
|
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
7879
8861
|
}
|
|
@@ -8056,7 +9038,33 @@ var StreamerServer = class {
|
|
|
8056
9038
|
json(res, 400, { error: "Session has no known PID" });
|
|
8057
9039
|
return;
|
|
8058
9040
|
}
|
|
9041
|
+
if (!projectPath) {
|
|
9042
|
+
this.log.warn("adopt: refusing, working directory unknown", {
|
|
9043
|
+
event: "adopt.no_project_path",
|
|
9044
|
+
sessionId,
|
|
9045
|
+
pid: discSession.pid
|
|
9046
|
+
});
|
|
9047
|
+
json(res, 400, {
|
|
9048
|
+
error: "Cannot take over this session: its working directory could not be determined on this platform",
|
|
9049
|
+
code: "ADOPT_NO_PROJECT_PATH"
|
|
9050
|
+
});
|
|
9051
|
+
return;
|
|
9052
|
+
}
|
|
8059
9053
|
this.ptyManager.killPid(discSession.pid);
|
|
9054
|
+
const exited = await waitForProcessExit(discSession.pid, ADOPT_KILL_TIMEOUT_MS);
|
|
9055
|
+
if (!exited) {
|
|
9056
|
+
this.log.warn("adopt: external process did not exit; refusing to double-write", {
|
|
9057
|
+
event: "adopt.kill_timeout",
|
|
9058
|
+
sessionId,
|
|
9059
|
+
pid: discSession.pid
|
|
9060
|
+
});
|
|
9061
|
+
json(res, 409, {
|
|
9062
|
+
error: "The existing process did not exit; not starting a second agent on this conversation",
|
|
9063
|
+
code: "ADOPT_KILL_TIMEOUT",
|
|
9064
|
+
pid: discSession.pid
|
|
9065
|
+
});
|
|
9066
|
+
return;
|
|
9067
|
+
}
|
|
8060
9068
|
const session = await this.ptyManager.start(convId, {
|
|
8061
9069
|
projectPath,
|
|
8062
9070
|
projectName,
|
|
@@ -8087,7 +9095,7 @@ var StreamerServer = class {
|
|
|
8087
9095
|
sessionStore: this.sessionStore,
|
|
8088
9096
|
// biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
|
|
8089
9097
|
agentClient: this.agentClient,
|
|
8090
|
-
conversationsDir: this.cacheDir ?
|
|
9098
|
+
conversationsDir: this.cacheDir ? join17(dirname9(this.cacheDir), "conversations") : "",
|
|
8091
9099
|
agentConfig: this.agentConfig
|
|
8092
9100
|
});
|
|
8093
9101
|
json(res, result.status, result.body);
|
|
@@ -8225,14 +9233,30 @@ var StreamerServer = class {
|
|
|
8225
9233
|
} catch {
|
|
8226
9234
|
}
|
|
8227
9235
|
}
|
|
9236
|
+
// Read just the `sessionId` field from a JSONL's first line, used by the
|
|
9237
|
+
// watchForJsonl fallback to confirm a candidate file's identity before
|
|
9238
|
+
// binding it. Reads only up to the first newline so a large actively-written
|
|
9239
|
+
// file isn't slurped in full.
|
|
9240
|
+
readFirstLineSessionId(filePath) {
|
|
9241
|
+
try {
|
|
9242
|
+
const content = readFileSync8(filePath, "utf8");
|
|
9243
|
+
const nl = content.indexOf("\n");
|
|
9244
|
+
const firstLine = nl === -1 ? content : content.slice(0, nl);
|
|
9245
|
+
if (!firstLine.trim()) return null;
|
|
9246
|
+
const obj = JSON.parse(firstLine);
|
|
9247
|
+
return typeof obj.sessionId === "string" ? obj.sessionId : null;
|
|
9248
|
+
} catch {
|
|
9249
|
+
return null;
|
|
9250
|
+
}
|
|
9251
|
+
}
|
|
8228
9252
|
// Watch the project directory for the JSONL file Claude creates for sessionId.
|
|
8229
9253
|
// Once found, wire up structured event streaming. No rekeying needed — the UUID
|
|
8230
9254
|
// was passed to Claude via --session-id so the filename matches from the start.
|
|
8231
9255
|
watchForJsonl(sessionId, projectPath) {
|
|
8232
9256
|
const encoded = projectPath.replace(/[/\\:.]/g, "-");
|
|
8233
|
-
const projectsDir =
|
|
9257
|
+
const projectsDir = join17(homedir8(), ".claude", "projects", encoded);
|
|
8234
9258
|
const expectedFile = `${sessionId}.jsonl`;
|
|
8235
|
-
const filePath =
|
|
9259
|
+
const filePath = join17(projectsDir, expectedFile);
|
|
8236
9260
|
const deadline = Date.now() + 12e4;
|
|
8237
9261
|
let watcher = null;
|
|
8238
9262
|
const cleanup = () => {
|
|
@@ -8250,26 +9274,28 @@ var StreamerServer = class {
|
|
|
8250
9274
|
cleanup();
|
|
8251
9275
|
return;
|
|
8252
9276
|
}
|
|
8253
|
-
let resolvedFilePath =
|
|
8254
|
-
if (!resolvedFilePath &&
|
|
9277
|
+
let resolvedFilePath = existsSync10(filePath) ? filePath : null;
|
|
9278
|
+
if (!resolvedFilePath && existsSync10(projectsDir)) {
|
|
8255
9279
|
try {
|
|
8256
9280
|
const now = Date.now();
|
|
8257
|
-
const
|
|
8258
|
-
|
|
9281
|
+
const match = readdirSync5(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync8(join17(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
|
|
9282
|
+
({ f }) => basename5(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join17(projectsDir, f)) === sessionId
|
|
9283
|
+
).sort((a, b) => b.mtime - a.mtime)[0];
|
|
9284
|
+
if (match) resolvedFilePath = join17(projectsDir, match.f);
|
|
8259
9285
|
} catch {
|
|
8260
9286
|
}
|
|
8261
9287
|
}
|
|
8262
9288
|
if (!resolvedFilePath) return;
|
|
8263
9289
|
cleanup();
|
|
8264
9290
|
this.sessionFileMap.set(sessionId, resolvedFilePath);
|
|
8265
|
-
this.fileWatcher.watch(resolvedFilePath);
|
|
8266
9291
|
try {
|
|
8267
|
-
const existing =
|
|
9292
|
+
const existing = readFileSync8(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
8268
9293
|
if (existing.length > 0) {
|
|
8269
9294
|
this.broadcastConversationLines(sessionId, existing);
|
|
8270
9295
|
}
|
|
8271
9296
|
} catch {
|
|
8272
9297
|
}
|
|
9298
|
+
this.fileWatcher.watch(resolvedFilePath);
|
|
8273
9299
|
if (this.scannerReady) {
|
|
8274
9300
|
this.scannerStale = true;
|
|
8275
9301
|
} else {
|
|
@@ -8303,7 +9329,7 @@ var StreamerServer = class {
|
|
|
8303
9329
|
watchForCodexRollout(sessionId, projectPath) {
|
|
8304
9330
|
const deadline = Date.now() + 12e4;
|
|
8305
9331
|
const now = /* @__PURE__ */ new Date();
|
|
8306
|
-
const dateDir =
|
|
9332
|
+
const dateDir = join17(
|
|
8307
9333
|
String(now.getFullYear()),
|
|
8308
9334
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
8309
9335
|
String(now.getDate()).padStart(2, "0")
|
|
@@ -8316,7 +9342,7 @@ var StreamerServer = class {
|
|
|
8316
9342
|
};
|
|
8317
9343
|
const matchesProjectPath = (candidatePath) => {
|
|
8318
9344
|
try {
|
|
8319
|
-
const firstLine =
|
|
9345
|
+
const firstLine = readFileSync8(candidatePath, "utf8").split("\n", 1)[0];
|
|
8320
9346
|
if (!firstLine) return null;
|
|
8321
9347
|
const parsed = JSON.parse(firstLine);
|
|
8322
9348
|
if (parsed?.type !== "session_meta") return null;
|
|
@@ -8344,18 +9370,18 @@ var StreamerServer = class {
|
|
|
8344
9370
|
this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
|
|
8345
9371
|
);
|
|
8346
9372
|
for (const root of this.codexRoots) {
|
|
8347
|
-
const sessionsDir =
|
|
8348
|
-
if (!
|
|
9373
|
+
const sessionsDir = join17(root, dateDir);
|
|
9374
|
+
if (!existsSync10(sessionsDir)) continue;
|
|
8349
9375
|
let candidateFiles;
|
|
8350
9376
|
try {
|
|
8351
|
-
candidateFiles =
|
|
9377
|
+
candidateFiles = readdirSync5(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
8352
9378
|
} catch {
|
|
8353
9379
|
continue;
|
|
8354
9380
|
}
|
|
8355
9381
|
const nowMs = Date.now();
|
|
8356
|
-
const recentCandidates = candidateFiles.map((f) => ({ f, mtime:
|
|
9382
|
+
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
9383
|
for (const { f } of recentCandidates) {
|
|
8358
|
-
const candidatePath =
|
|
9384
|
+
const candidatePath = join17(sessionsDir, f);
|
|
8359
9385
|
const match = matchesProjectPath(candidatePath);
|
|
8360
9386
|
if (!match) continue;
|
|
8361
9387
|
if (boundElsewhere.has(match.id)) continue;
|
|
@@ -8365,7 +9391,7 @@ var StreamerServer = class {
|
|
|
8365
9391
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
8366
9392
|
this.fileWatcher.watch(candidatePath);
|
|
8367
9393
|
try {
|
|
8368
|
-
const existing =
|
|
9394
|
+
const existing = readFileSync8(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
8369
9395
|
if (existing.length > 0) {
|
|
8370
9396
|
this.broadcastConversationLines(sessionId, existing);
|
|
8371
9397
|
}
|
|
@@ -8485,9 +9511,21 @@ var StreamerServer = class {
|
|
|
8485
9511
|
json(res, 200, this.cache.listSessionNames());
|
|
8486
9512
|
}
|
|
8487
9513
|
};
|
|
9514
|
+
async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
|
|
9515
|
+
const deadline = Date.now() + timeoutMs;
|
|
9516
|
+
for (; ; ) {
|
|
9517
|
+
try {
|
|
9518
|
+
process.kill(pid, 0);
|
|
9519
|
+
} catch {
|
|
9520
|
+
return true;
|
|
9521
|
+
}
|
|
9522
|
+
if (Date.now() >= deadline) return false;
|
|
9523
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollMs));
|
|
9524
|
+
}
|
|
9525
|
+
}
|
|
8488
9526
|
function classifyResumability(cwd) {
|
|
8489
9527
|
if (!cwd) return { resumable: true };
|
|
8490
|
-
if (
|
|
9528
|
+
if (existsSync10(cwd)) return { resumable: true };
|
|
8491
9529
|
const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
|
|
8492
9530
|
return {
|
|
8493
9531
|
resumable: false,
|
|
@@ -8502,6 +9540,9 @@ function conversationToResumableSession(c) {
|
|
|
8502
9540
|
id: c.id,
|
|
8503
9541
|
conversationId: c.id,
|
|
8504
9542
|
status: "on_hold",
|
|
9543
|
+
// A cached conversation with no process behind it. Distinguishes "nobody is
|
|
9544
|
+
// running this" from an external session that IS live (ownership "external").
|
|
9545
|
+
ownership: "historical",
|
|
8505
9546
|
ptyAttached: false,
|
|
8506
9547
|
projectId: c.projectId ?? void 0,
|
|
8507
9548
|
projectPath: c.projectPath ?? "",
|