@threadbase-sh/streamer 1.32.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +2042 -2606
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1264 -205
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +242 -11
- package/dist/index.d.ts +242 -11
- package/dist/index.js +1259 -200
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1731,6 +1731,10 @@ var PTYManager = class {
|
|
|
1731
1731
|
options.permissionMode ?? "acceptEdits",
|
|
1732
1732
|
"--settings",
|
|
1733
1733
|
'{"spinnerTipsEnabled":false}',
|
|
1734
|
+
"--model",
|
|
1735
|
+
options.model ?? "sonnet",
|
|
1736
|
+
"--effort",
|
|
1737
|
+
options.effort ?? "low",
|
|
1734
1738
|
"--resume",
|
|
1735
1739
|
sessionId
|
|
1736
1740
|
],
|
|
@@ -1781,6 +1785,10 @@ var PTYManager = class {
|
|
|
1781
1785
|
options.permissionMode ?? "acceptEdits",
|
|
1782
1786
|
"--settings",
|
|
1783
1787
|
'{"spinnerTipsEnabled":false}',
|
|
1788
|
+
"--model",
|
|
1789
|
+
options.model ?? "sonnet",
|
|
1790
|
+
"--effort",
|
|
1791
|
+
options.effort ?? "low",
|
|
1784
1792
|
"--session-id",
|
|
1785
1793
|
sessionId
|
|
1786
1794
|
];
|
|
@@ -2200,6 +2208,14 @@ var PTYManager = class {
|
|
|
2200
2208
|
if (session?.status !== "running") return;
|
|
2201
2209
|
if (this.pendingReady.has(sessionId)) {
|
|
2202
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
|
+
});
|
|
2203
2219
|
}
|
|
2204
2220
|
this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
|
|
2205
2221
|
this.log.warn("[pty.prompt_detect] failed", {
|
|
@@ -2209,6 +2225,20 @@ var PTYManager = class {
|
|
|
2209
2225
|
});
|
|
2210
2226
|
});
|
|
2211
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
|
+
}
|
|
2212
2242
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2213
2243
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2214
2244
|
markReady(sessionId, session, reason) {
|
|
@@ -2381,6 +2411,44 @@ async function discoverClaudeProcesses() {
|
|
|
2381
2411
|
if (platform2() === "win32") return discoverWindows();
|
|
2382
2412
|
return discoverUnix();
|
|
2383
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
|
+
}
|
|
2384
2452
|
async function discoverUnix() {
|
|
2385
2453
|
const pids = await getPidsUnix();
|
|
2386
2454
|
const results = await Promise.all(
|
|
@@ -2408,6 +2476,8 @@ async function discoverUnix() {
|
|
|
2408
2476
|
return results.filter((r) => r !== null);
|
|
2409
2477
|
}
|
|
2410
2478
|
async function discoverWindows() {
|
|
2479
|
+
const viaCim = await discoverWindowsViaCim();
|
|
2480
|
+
if (viaCim) return viaCim;
|
|
2411
2481
|
const pids = await getPidsWindows();
|
|
2412
2482
|
const results = await Promise.all(
|
|
2413
2483
|
pids.map(async (pid) => {
|
|
@@ -2442,7 +2512,24 @@ function run(cmd, args, opts = {}) {
|
|
|
2442
2512
|
);
|
|
2443
2513
|
});
|
|
2444
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
|
+
}
|
|
2445
2528
|
async function getPidsUnix() {
|
|
2529
|
+
try {
|
|
2530
|
+
return parsePsOutput(await run("ps", ["-eo", "pid=,args="]));
|
|
2531
|
+
} catch {
|
|
2532
|
+
}
|
|
2446
2533
|
try {
|
|
2447
2534
|
const output = await run("pgrep", ["-x", "claude"]);
|
|
2448
2535
|
return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
|
|
@@ -2463,6 +2550,54 @@ async function getProcessStartTimeUnix(pid) {
|
|
|
2463
2550
|
const d = new Date(raw);
|
|
2464
2551
|
return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
|
|
2465
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
|
+
}
|
|
2466
2601
|
async function getPidsWindows() {
|
|
2467
2602
|
try {
|
|
2468
2603
|
const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
|
|
@@ -2505,10 +2640,15 @@ async function getProcessInfoWindows(pid) {
|
|
|
2505
2640
|
}
|
|
2506
2641
|
}
|
|
2507
2642
|
function extractResumeId(args) {
|
|
2508
|
-
const
|
|
2509
|
-
|
|
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;
|
|
2510
2649
|
}
|
|
2511
2650
|
async function readGitBranch(dir) {
|
|
2651
|
+
if (!dir) return "";
|
|
2512
2652
|
try {
|
|
2513
2653
|
return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
|
|
2514
2654
|
} catch {
|
|
@@ -2530,16 +2670,16 @@ import {
|
|
|
2530
2670
|
import { EventEmitter } from "events";
|
|
2531
2671
|
import {
|
|
2532
2672
|
createReadStream,
|
|
2533
|
-
existsSync as
|
|
2673
|
+
existsSync as existsSync10,
|
|
2534
2674
|
watch as fsWatch,
|
|
2535
|
-
readdirSync as
|
|
2536
|
-
readFileSync as
|
|
2537
|
-
statSync as
|
|
2675
|
+
readdirSync as readdirSync5,
|
|
2676
|
+
readFileSync as readFileSync8,
|
|
2677
|
+
statSync as statSync8
|
|
2538
2678
|
} from "fs";
|
|
2539
2679
|
import { realpath as realpath2 } from "fs/promises";
|
|
2540
2680
|
import { createServer } from "http";
|
|
2541
|
-
import { homedir as
|
|
2542
|
-
import { dirname as
|
|
2681
|
+
import { homedir as homedir8 } from "os";
|
|
2682
|
+
import { basename as basename5, dirname as dirname9, join as join17 } from "path";
|
|
2543
2683
|
import { createInterface } from "readline";
|
|
2544
2684
|
|
|
2545
2685
|
// node_modules/nanoid/index.js
|
|
@@ -2798,7 +2938,7 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2798
2938
|
}
|
|
2799
2939
|
|
|
2800
2940
|
// src/api/app.ts
|
|
2801
|
-
import { Hono as
|
|
2941
|
+
import { Hono as Hono13 } from "hono";
|
|
2802
2942
|
|
|
2803
2943
|
// src/api/middleware/auth.middleware.ts
|
|
2804
2944
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2920,12 +3060,72 @@ var createBrowseRoutes = (deps) => {
|
|
|
2920
3060
|
return app;
|
|
2921
3061
|
};
|
|
2922
3062
|
|
|
2923
|
-
// src/api/routes/
|
|
3063
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
2924
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";
|
|
2925
3125
|
var ALREADY_HANDLED2 = 597;
|
|
2926
3126
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
2927
3127
|
var createConversationRoutes = (deps) => {
|
|
2928
|
-
const app = new
|
|
3128
|
+
const app = new Hono4();
|
|
2929
3129
|
app.get("/count", async (c) => {
|
|
2930
3130
|
const url = new URL(c.req.url);
|
|
2931
3131
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -2952,7 +3152,7 @@ var createConversationRoutes = (deps) => {
|
|
|
2952
3152
|
};
|
|
2953
3153
|
|
|
2954
3154
|
// src/api/routes/health.routes.ts
|
|
2955
|
-
import { Hono as
|
|
3155
|
+
import { Hono as Hono5 } from "hono";
|
|
2956
3156
|
|
|
2957
3157
|
// src/version.ts
|
|
2958
3158
|
import { readFileSync as readFileSync4, realpathSync } from "fs";
|
|
@@ -2988,16 +3188,19 @@ function resolveVersion() {
|
|
|
2988
3188
|
}
|
|
2989
3189
|
|
|
2990
3190
|
// src/api/routes/health.routes.ts
|
|
2991
|
-
var createHealthRoutes = () => {
|
|
2992
|
-
const app = new
|
|
2993
|
-
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
|
+
});
|
|
2994
3197
|
return app;
|
|
2995
3198
|
};
|
|
2996
3199
|
|
|
2997
3200
|
// src/api/routes/logs.routes.ts
|
|
2998
3201
|
import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
|
|
2999
3202
|
import { join as join9 } from "path";
|
|
3000
|
-
import { Hono as
|
|
3203
|
+
import { Hono as Hono6 } from "hono";
|
|
3001
3204
|
|
|
3002
3205
|
// src/lifecycle/constants.ts
|
|
3003
3206
|
import { homedir as homedir4 } from "os";
|
|
@@ -3055,7 +3258,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3055
3258
|
}
|
|
3056
3259
|
}
|
|
3057
3260
|
function createLogsRoutes() {
|
|
3058
|
-
const app = new
|
|
3261
|
+
const app = new Hono6();
|
|
3059
3262
|
app.get("/", (c) => {
|
|
3060
3263
|
try {
|
|
3061
3264
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3126,7 +3329,7 @@ function createLogsRoutes() {
|
|
|
3126
3329
|
// src/api/routes/misc.routes.ts
|
|
3127
3330
|
import { spawn } from "child_process";
|
|
3128
3331
|
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3129
|
-
import { Hono as
|
|
3332
|
+
import { Hono as Hono7 } from "hono";
|
|
3130
3333
|
import { hostname } from "os";
|
|
3131
3334
|
|
|
3132
3335
|
// src/config/update-config.ts
|
|
@@ -3136,15 +3339,15 @@ import { join as join10 } from "path";
|
|
|
3136
3339
|
import { parse as parseYaml } from "yaml";
|
|
3137
3340
|
|
|
3138
3341
|
// src/schemas/updateConfig.schema.ts
|
|
3139
|
-
import { z } from "zod";
|
|
3140
|
-
var UpdateConfigSchema =
|
|
3141
|
-
auto_update:
|
|
3142
|
-
channel:
|
|
3143
|
-
allow:
|
|
3144
|
-
poll_interval_minutes:
|
|
3145
|
-
defer_if_active_sessions:
|
|
3146
|
-
github_repo:
|
|
3147
|
-
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)
|
|
3148
3351
|
}).strict();
|
|
3149
3352
|
|
|
3150
3353
|
// src/config/update-config.ts
|
|
@@ -3181,7 +3384,7 @@ function readJsonBody(req) {
|
|
|
3181
3384
|
req.on("error", reject);
|
|
3182
3385
|
});
|
|
3183
3386
|
}
|
|
3184
|
-
function
|
|
3387
|
+
function readRawBody3(req) {
|
|
3185
3388
|
return new Promise((resolve2, reject) => {
|
|
3186
3389
|
const chunks = [];
|
|
3187
3390
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3200,7 +3403,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3200
3403
|
}
|
|
3201
3404
|
var clientLog = getLogger("client");
|
|
3202
3405
|
var createMiscRoutes = (deps) => {
|
|
3203
|
-
const app = new
|
|
3406
|
+
const app = new Hono7();
|
|
3204
3407
|
app.get("/api/info", (c) => {
|
|
3205
3408
|
const ptyIds = deps.ptyAttachedIds();
|
|
3206
3409
|
return c.json({
|
|
@@ -3233,7 +3436,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3233
3436
|
}
|
|
3234
3437
|
let body;
|
|
3235
3438
|
try {
|
|
3236
|
-
body = await
|
|
3439
|
+
body = await readRawBody3(c.env.incoming);
|
|
3237
3440
|
} catch {
|
|
3238
3441
|
return c.json({ error: "could not read body" }, 400);
|
|
3239
3442
|
}
|
|
@@ -3276,11 +3479,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3276
3479
|
};
|
|
3277
3480
|
|
|
3278
3481
|
// src/api/routes/pair.routes.ts
|
|
3279
|
-
import { Hono as
|
|
3482
|
+
import { Hono as Hono8 } from "hono";
|
|
3280
3483
|
var ALREADY_HANDLED3 = 597;
|
|
3281
3484
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3282
3485
|
var createPairRoutes = (deps) => {
|
|
3283
|
-
const app = new
|
|
3486
|
+
const app = new Hono8();
|
|
3284
3487
|
app.post("/start", (c) => {
|
|
3285
3488
|
deps.handlePairStart(c.env.outgoing);
|
|
3286
3489
|
return alreadyHandled3();
|
|
@@ -3293,11 +3496,11 @@ var createPairRoutes = (deps) => {
|
|
|
3293
3496
|
};
|
|
3294
3497
|
|
|
3295
3498
|
// src/api/routes/projects.routes.ts
|
|
3296
|
-
import { Hono as
|
|
3499
|
+
import { Hono as Hono9 } from "hono";
|
|
3297
3500
|
var ALREADY_HANDLED4 = 597;
|
|
3298
3501
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3299
3502
|
var createProjectRoutes = (deps) => {
|
|
3300
|
-
const app = new
|
|
3503
|
+
const app = new Hono9();
|
|
3301
3504
|
app.get("/", (c) => {
|
|
3302
3505
|
const url = new URL(c.req.url);
|
|
3303
3506
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3312,11 +3515,11 @@ var createProjectRoutes = (deps) => {
|
|
|
3312
3515
|
};
|
|
3313
3516
|
|
|
3314
3517
|
// src/api/routes/scanner.routes.ts
|
|
3315
|
-
import { Hono as
|
|
3518
|
+
import { Hono as Hono10 } from "hono";
|
|
3316
3519
|
var ALREADY_HANDLED5 = 597;
|
|
3317
3520
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3318
3521
|
var createScannerRoutes = (deps) => {
|
|
3319
|
-
const app = new
|
|
3522
|
+
const app = new Hono10();
|
|
3320
3523
|
app.get("/api/search", async (c) => {
|
|
3321
3524
|
const url = new URL(c.req.url);
|
|
3322
3525
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3326,11 +3529,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3326
3529
|
};
|
|
3327
3530
|
|
|
3328
3531
|
// src/api/routes/sessions.routes.ts
|
|
3329
|
-
import { Hono as
|
|
3532
|
+
import { Hono as Hono11 } from "hono";
|
|
3330
3533
|
var ALREADY_HANDLED6 = 597;
|
|
3331
3534
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3332
3535
|
var createSessionRoutes = (deps) => {
|
|
3333
|
-
const app = new
|
|
3536
|
+
const app = new Hono11();
|
|
3334
3537
|
app.get("/count", (c) => {
|
|
3335
3538
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3336
3539
|
return alreadyHandled6();
|
|
@@ -3397,9 +3600,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3397
3600
|
};
|
|
3398
3601
|
|
|
3399
3602
|
// src/api/routes/ws.routes.ts
|
|
3400
|
-
import { Hono as
|
|
3603
|
+
import { Hono as Hono12 } from "hono";
|
|
3401
3604
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3402
|
-
const app = new
|
|
3605
|
+
const app = new Hono12();
|
|
3403
3606
|
app.get(
|
|
3404
3607
|
"/ws",
|
|
3405
3608
|
upgradeWebSocket(() => {
|
|
@@ -3425,7 +3628,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3425
3628
|
|
|
3426
3629
|
// src/api/app.ts
|
|
3427
3630
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3428
|
-
const app = new
|
|
3631
|
+
const app = new Hono13();
|
|
3429
3632
|
const httpLog = getLogger("http");
|
|
3430
3633
|
app.use("*", async (c, next) => {
|
|
3431
3634
|
const start = Date.now();
|
|
@@ -3445,10 +3648,11 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3445
3648
|
app.use("*", corsMiddleware(deps.browserCors));
|
|
3446
3649
|
app.use("*", authMiddleware(deps));
|
|
3447
3650
|
app.onError(errorMiddleware);
|
|
3448
|
-
app.route("/healthz", createHealthRoutes());
|
|
3651
|
+
app.route("/healthz", createHealthRoutes(deps));
|
|
3449
3652
|
app.route("/", createMiscRoutes(deps));
|
|
3450
3653
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3451
3654
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3655
|
+
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
3452
3656
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
3453
3657
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3454
3658
|
app.route("/api", createBrowseRoutes(deps));
|
|
@@ -3633,6 +3837,31 @@ function parseAgentEntrypointsEnv(raw) {
|
|
|
3633
3837
|
return new Set(parts);
|
|
3634
3838
|
}
|
|
3635
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
|
+
|
|
3636
3865
|
// src/utils/fileIdentity.ts
|
|
3637
3866
|
import { createHash } from "crypto";
|
|
3638
3867
|
function fileIdentity(stat3, headBytes) {
|
|
@@ -3745,8 +3974,19 @@ var ConversationCache = class _ConversationCache {
|
|
|
3745
3974
|
),
|
|
3746
3975
|
// Batch equivalent of updateMeta: bumps message_count by N in one write
|
|
3747
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.
|
|
3748
3981
|
updateMetaBatch: db.prepare(
|
|
3749
|
-
|
|
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`
|
|
3750
3990
|
),
|
|
3751
3991
|
insertSkeleton: db.prepare(
|
|
3752
3992
|
"INSERT OR IGNORE INTO conversation_meta (id, file_path, message_count, updated_at) VALUES (?, ?, 1, ?)"
|
|
@@ -3828,6 +4068,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
3828
4068
|
"SELECT provider FROM conversation_meta WHERE file_path = ?"
|
|
3829
4069
|
),
|
|
3830
4070
|
allFilePaths: db.prepare("SELECT id, file_path FROM conversation_meta"),
|
|
4071
|
+
allFilePathsWithTitle: db.prepare("SELECT id, file_path, title FROM conversation_meta"),
|
|
3831
4072
|
allFileStats: db.prepare(
|
|
3832
4073
|
"SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
|
|
3833
4074
|
),
|
|
@@ -3980,7 +4221,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
3980
4221
|
// excluded from the index entirely; the scanner serves them (it routes each
|
|
3981
4222
|
// provider to its own parser).
|
|
3982
4223
|
isIndexableFile(filePath) {
|
|
3983
|
-
const row = this.stmts.getProviderByFilePath.get(filePath);
|
|
4224
|
+
const row = this.stmts.getProviderByFilePath.get(canonicalizeFilePath(filePath));
|
|
3984
4225
|
if (!row) return false;
|
|
3985
4226
|
return (row.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
|
|
3986
4227
|
}
|
|
@@ -4248,7 +4489,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4248
4489
|
if (this.fileIndexLoaded) return;
|
|
4249
4490
|
const rows = this.stmts.allFilePaths.all();
|
|
4250
4491
|
for (const row of rows) {
|
|
4251
|
-
this.fileIndex.set(row.file_path, row.id);
|
|
4492
|
+
this.fileIndex.set(canonicalizeFilePath(row.file_path), row.id);
|
|
4252
4493
|
}
|
|
4253
4494
|
this.fileIndexLoaded = true;
|
|
4254
4495
|
}
|
|
@@ -4267,12 +4508,13 @@ var ConversationCache = class _ConversationCache {
|
|
|
4267
4508
|
const role = line.role ?? line.type;
|
|
4268
4509
|
const isMessage = role === "user" || role === "assistant";
|
|
4269
4510
|
this.ensureFileIndex();
|
|
4511
|
+
const key = canonicalizeFilePath(filePath);
|
|
4270
4512
|
if (!isMessage && !line.cwd && !line.slug) return;
|
|
4271
|
-
let convId = this.fileIndex.get(
|
|
4513
|
+
let convId = this.fileIndex.get(key);
|
|
4272
4514
|
if (!convId) {
|
|
4273
|
-
const pseudoId =
|
|
4274
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4275
|
-
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);
|
|
4276
4518
|
convId = pseudoId;
|
|
4277
4519
|
}
|
|
4278
4520
|
if (line.cwd || line.slug) {
|
|
@@ -4287,18 +4529,18 @@ var ConversationCache = class _ConversationCache {
|
|
|
4287
4529
|
});
|
|
4288
4530
|
}
|
|
4289
4531
|
if (!isMessage) return;
|
|
4290
|
-
const
|
|
4291
|
-
const activityMs = new Date(
|
|
4532
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4533
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4292
4534
|
if (Number.isNaN(activityMs)) return;
|
|
4293
4535
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4294
4536
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4295
|
-
const lastMessage = JSON.stringify({ role, timestamp, text });
|
|
4537
|
+
const lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4296
4538
|
const seq = ++this.tailSeq;
|
|
4297
4539
|
const result = this.stmts.updateMeta.run(activityMs, lastMessage, seq, convId);
|
|
4298
4540
|
if (result.changes === 0) return;
|
|
4299
4541
|
const tailRow = this.stmts.getTail.get(convId);
|
|
4300
4542
|
const msgs = tailRow ? JSON.parse(tailRow.messages_json) : [];
|
|
4301
|
-
msgs.push({ role, timestamp, text, content: contentBlocks });
|
|
4543
|
+
msgs.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4302
4544
|
if (msgs.length > this.tailSize) msgs.splice(0, msgs.length - this.tailSize);
|
|
4303
4545
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, seq);
|
|
4304
4546
|
}
|
|
@@ -4310,7 +4552,8 @@ var ConversationCache = class _ConversationCache {
|
|
|
4310
4552
|
* updateFromLine in order: the agent filter short-circuits the whole batch,
|
|
4311
4553
|
* project context is backfilled last-wins, message_count increases by the
|
|
4312
4554
|
* number of surviving message lines, and last_activity/last_message reflect
|
|
4313
|
-
* 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).
|
|
4314
4557
|
*/
|
|
4315
4558
|
updateFromLines(filePath, rawLines) {
|
|
4316
4559
|
let sawProjectContext = false;
|
|
@@ -4346,23 +4589,26 @@ var ConversationCache = class _ConversationCache {
|
|
|
4346
4589
|
backfillTitle ??= lineTitle;
|
|
4347
4590
|
}
|
|
4348
4591
|
if (!isMessage) continue;
|
|
4349
|
-
const
|
|
4350
|
-
const activityMs = new Date(
|
|
4592
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4593
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4351
4594
|
if (Number.isNaN(activityMs)) continue;
|
|
4352
4595
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4353
4596
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4354
4597
|
msgCount += 1;
|
|
4355
|
-
lastActivityMs
|
|
4356
|
-
|
|
4357
|
-
|
|
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 });
|
|
4358
4603
|
}
|
|
4359
4604
|
if (!sawProjectContext && msgCount === 0) return;
|
|
4360
4605
|
this.ensureFileIndex();
|
|
4361
|
-
|
|
4606
|
+
const key = canonicalizeFilePath(filePath);
|
|
4607
|
+
let convId = this.fileIndex.get(key);
|
|
4362
4608
|
if (!convId) {
|
|
4363
|
-
const pseudoId =
|
|
4364
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4365
|
-
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);
|
|
4366
4612
|
convId = pseudoId;
|
|
4367
4613
|
}
|
|
4368
4614
|
const id = convId;
|
|
@@ -4405,6 +4651,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4405
4651
|
for (const m of items) {
|
|
4406
4652
|
const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
|
|
4407
4653
|
const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
|
|
4654
|
+
const canonicalPath = canonicalizeFilePath(m.filePath);
|
|
4408
4655
|
let mtimeMs = null;
|
|
4409
4656
|
let fileSize = null;
|
|
4410
4657
|
try {
|
|
@@ -4420,10 +4667,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4420
4667
|
const scannerMetaJson = JSON.stringify(m);
|
|
4421
4668
|
this.stmts.upsertFull.run({
|
|
4422
4669
|
id,
|
|
4423
|
-
file_path:
|
|
4670
|
+
file_path: canonicalPath,
|
|
4424
4671
|
project_path: m.projectPath ?? null,
|
|
4425
4672
|
project_name: m.projectName ?? null,
|
|
4426
|
-
title: m.title ?? m.projectName ?? null,
|
|
4673
|
+
title: m.title ?? m.sessionName ?? m.projectName ?? null,
|
|
4427
4674
|
model: m.model ?? null,
|
|
4428
4675
|
account: m.account ?? null,
|
|
4429
4676
|
branch: m.gitBranch ?? null,
|
|
@@ -4439,7 +4686,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4439
4686
|
scanner_meta_json: scannerMetaJson
|
|
4440
4687
|
});
|
|
4441
4688
|
this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
|
|
4442
|
-
if (this.fileIndexLoaded) this.fileIndex.set(
|
|
4689
|
+
if (this.fileIndexLoaded) this.fileIndex.set(canonicalPath, id);
|
|
4443
4690
|
upsertedIds.push(id);
|
|
4444
4691
|
}
|
|
4445
4692
|
});
|
|
@@ -4450,11 +4697,12 @@ var ConversationCache = class _ConversationCache {
|
|
|
4450
4697
|
// by updateFromLine when a previously-cached file turns out to be an agent
|
|
4451
4698
|
// JSONL.
|
|
4452
4699
|
deleteByFilePath(filePath) {
|
|
4453
|
-
const
|
|
4700
|
+
const key = canonicalizeFilePath(filePath);
|
|
4701
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4454
4702
|
if (!row) return false;
|
|
4455
4703
|
this.stmts.deleteTailById.run(row.id);
|
|
4456
4704
|
const result = this.stmts.deleteById.run(row.id);
|
|
4457
|
-
this.fileIndex.delete(
|
|
4705
|
+
this.fileIndex.delete(key);
|
|
4458
4706
|
return result.changes > 0;
|
|
4459
4707
|
}
|
|
4460
4708
|
// Reads the last `tailSize` qualifying lines from a JSONL file and writes them
|
|
@@ -4506,10 +4754,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4506
4754
|
}
|
|
4507
4755
|
const role = parsed.role ?? parsed.type;
|
|
4508
4756
|
if (!role) continue;
|
|
4509
|
-
const
|
|
4757
|
+
const timestamp2 = parsed.timestamp ?? "";
|
|
4510
4758
|
const contentBlocks = normalizeContent(parsed.message?.content ?? parsed.content);
|
|
4511
4759
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4512
|
-
msgs.unshift({ role, timestamp, text, content: contentBlocks });
|
|
4760
|
+
msgs.unshift({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4513
4761
|
}
|
|
4514
4762
|
if (msgs.length === 0) return false;
|
|
4515
4763
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, 0);
|
|
@@ -4577,6 +4825,16 @@ var ConversationCache = class _ConversationCache {
|
|
|
4577
4825
|
}
|
|
4578
4826
|
return map;
|
|
4579
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
|
+
}
|
|
4580
4838
|
getMetaById(id) {
|
|
4581
4839
|
const row = this.stmts.getFullById.get(id);
|
|
4582
4840
|
if (!row) return null;
|
|
@@ -4669,22 +4927,23 @@ var ConversationCache = class _ConversationCache {
|
|
|
4669
4927
|
/**
|
|
4670
4928
|
* Drop the cached row for a file. Two callers with opposite intent:
|
|
4671
4929
|
* - a directory-watch "change" event (the file was appended to) — pass
|
|
4672
|
-
* `skipIfTailed: true
|
|
4673
|
-
*
|
|
4674
|
-
*
|
|
4675
|
-
*
|
|
4676
|
-
*
|
|
4677
|
-
*
|
|
4678
|
-
*
|
|
4679
|
-
*
|
|
4680
|
-
* 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.
|
|
4681
4939
|
* - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
|
|
4682
4940
|
* row is always removed, otherwise a deleted session ghosts in the cache.
|
|
4683
4941
|
*/
|
|
4684
4942
|
invalidateByFilePath(filePath, opts) {
|
|
4685
|
-
const
|
|
4943
|
+
const key = canonicalizeFilePath(filePath);
|
|
4944
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4686
4945
|
if (!row) return null;
|
|
4687
|
-
if (opts?.skipIfTailed
|
|
4946
|
+
if (opts?.skipIfTailed) return null;
|
|
4688
4947
|
this.invalidate(row.id);
|
|
4689
4948
|
return row.id;
|
|
4690
4949
|
}
|
|
@@ -4778,6 +5037,68 @@ var ConversationCache = class _ConversationCache {
|
|
|
4778
5037
|
}
|
|
4779
5038
|
return removed;
|
|
4780
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
|
+
}
|
|
4781
5102
|
};
|
|
4782
5103
|
|
|
4783
5104
|
// src/db/repositories/cacheMetadata.repository.ts
|
|
@@ -5087,9 +5408,316 @@ function seal(plaintext, recipientPublicKeyBase64) {
|
|
|
5087
5408
|
};
|
|
5088
5409
|
}
|
|
5089
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
|
+
|
|
5090
5718
|
// src/services/conversations/conversationWatcher.ts
|
|
5091
5719
|
import chokidar from "chokidar";
|
|
5092
|
-
import { statSync as
|
|
5720
|
+
import { statSync as statSync6 } from "fs";
|
|
5093
5721
|
import { open, stat as stat2 } from "fs/promises";
|
|
5094
5722
|
var ConversationWatcher = class {
|
|
5095
5723
|
files = /* @__PURE__ */ new Map();
|
|
@@ -5099,6 +5727,7 @@ var ConversationWatcher = class {
|
|
|
5099
5727
|
onNewLineSpans;
|
|
5100
5728
|
onConversationChanged;
|
|
5101
5729
|
onFileDeleted;
|
|
5730
|
+
onTruncated;
|
|
5102
5731
|
onError;
|
|
5103
5732
|
constructor(events = {}) {
|
|
5104
5733
|
this.onNewLine = events.onNewLine;
|
|
@@ -5106,13 +5735,15 @@ var ConversationWatcher = class {
|
|
|
5106
5735
|
this.onNewLineSpans = events.onNewLineSpans;
|
|
5107
5736
|
this.onConversationChanged = events.onConversationChanged;
|
|
5108
5737
|
this.onFileDeleted = events.onFileDeleted;
|
|
5738
|
+
this.onTruncated = events.onTruncated;
|
|
5109
5739
|
this.onError = events.onError;
|
|
5110
5740
|
}
|
|
5111
5741
|
watch(filePath) {
|
|
5112
|
-
|
|
5742
|
+
const key = canonicalizeFilePath(filePath);
|
|
5743
|
+
if (this.files.has(key)) return;
|
|
5113
5744
|
let offset;
|
|
5114
5745
|
try {
|
|
5115
|
-
offset =
|
|
5746
|
+
offset = statSync6(filePath).size;
|
|
5116
5747
|
} catch {
|
|
5117
5748
|
offset = 0;
|
|
5118
5749
|
}
|
|
@@ -5121,23 +5752,24 @@ var ConversationWatcher = class {
|
|
|
5121
5752
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
|
|
5122
5753
|
});
|
|
5123
5754
|
watcher.on("change", () => {
|
|
5124
|
-
void this.readNewLines(
|
|
5755
|
+
void this.readNewLines(key);
|
|
5125
5756
|
});
|
|
5126
5757
|
watcher.on("add", () => {
|
|
5127
|
-
void this.readNewLines(
|
|
5758
|
+
void this.readNewLines(key);
|
|
5128
5759
|
});
|
|
5129
5760
|
watcher.on("unlink", () => this.onFileDeleted?.(filePath));
|
|
5130
5761
|
watcher.on("error", (err) => {
|
|
5131
5762
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
5132
5763
|
this.onError?.(filePath, error);
|
|
5133
5764
|
});
|
|
5134
|
-
this.files.set(
|
|
5765
|
+
this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
|
|
5135
5766
|
}
|
|
5136
5767
|
unwatch(filePath) {
|
|
5137
|
-
const
|
|
5768
|
+
const key = canonicalizeFilePath(filePath);
|
|
5769
|
+
const entry = this.files.get(key);
|
|
5138
5770
|
if (!entry) return;
|
|
5139
5771
|
void entry.watcher.close();
|
|
5140
|
-
this.files.delete(
|
|
5772
|
+
this.files.delete(key);
|
|
5141
5773
|
}
|
|
5142
5774
|
/**
|
|
5143
5775
|
* Re-drive the tail read for a file that's already being tailed. A per-file
|
|
@@ -5148,8 +5780,9 @@ var ConversationWatcher = class {
|
|
|
5148
5780
|
* event is a cheap stat + no-op. Returns false for untailed paths.
|
|
5149
5781
|
*/
|
|
5150
5782
|
poke(filePath) {
|
|
5151
|
-
|
|
5152
|
-
|
|
5783
|
+
const key = canonicalizeFilePath(filePath);
|
|
5784
|
+
if (!this.files.has(key)) return false;
|
|
5785
|
+
void this.readNewLines(key);
|
|
5153
5786
|
return true;
|
|
5154
5787
|
}
|
|
5155
5788
|
/**
|
|
@@ -5186,9 +5819,10 @@ var ConversationWatcher = class {
|
|
|
5186
5819
|
for (const [path] of this.files) this.unwatch(path);
|
|
5187
5820
|
for (const [dir] of this.directories) this.unwatchDirectory(dir);
|
|
5188
5821
|
}
|
|
5189
|
-
async readNewLines(
|
|
5190
|
-
const entry = this.files.get(
|
|
5822
|
+
async readNewLines(key) {
|
|
5823
|
+
const entry = this.files.get(key);
|
|
5191
5824
|
if (!entry) return;
|
|
5825
|
+
const filePath = entry.path;
|
|
5192
5826
|
if (entry.reading) {
|
|
5193
5827
|
entry.pending = true;
|
|
5194
5828
|
return;
|
|
@@ -5197,6 +5831,10 @@ var ConversationWatcher = class {
|
|
|
5197
5831
|
try {
|
|
5198
5832
|
for (; ; ) {
|
|
5199
5833
|
const st = await stat2(filePath);
|
|
5834
|
+
if (st.size < entry.offset) {
|
|
5835
|
+
entry.offset = 0;
|
|
5836
|
+
this.onTruncated?.(filePath);
|
|
5837
|
+
}
|
|
5200
5838
|
if (st.size <= entry.offset) break;
|
|
5201
5839
|
const readFrom = entry.offset;
|
|
5202
5840
|
const bytesToRead = st.size - readFrom;
|
|
@@ -5209,7 +5847,7 @@ var ConversationWatcher = class {
|
|
|
5209
5847
|
}
|
|
5210
5848
|
const { spans, consumed } = splitCompleteLines(buf, readFrom);
|
|
5211
5849
|
entry.offset = readFrom + consumed;
|
|
5212
|
-
if (!this.files.has(
|
|
5850
|
+
if (!this.files.has(key)) return;
|
|
5213
5851
|
const lines = spans.map((s) => s.text);
|
|
5214
5852
|
if (spans.length > 0) {
|
|
5215
5853
|
this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
|
|
@@ -5229,9 +5867,9 @@ var ConversationWatcher = class {
|
|
|
5229
5867
|
this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
|
|
5230
5868
|
} finally {
|
|
5231
5869
|
entry.reading = false;
|
|
5232
|
-
if (entry.pending && this.files.has(
|
|
5870
|
+
if (entry.pending && this.files.has(key)) {
|
|
5233
5871
|
entry.pending = false;
|
|
5234
|
-
void this.readNewLines(
|
|
5872
|
+
void this.readNewLines(key);
|
|
5235
5873
|
}
|
|
5236
5874
|
}
|
|
5237
5875
|
}
|
|
@@ -5287,14 +5925,14 @@ function findSearchTarget(messages, query) {
|
|
|
5287
5925
|
}
|
|
5288
5926
|
|
|
5289
5927
|
// src/services/conversations/pruneAgentConversations.ts
|
|
5290
|
-
import { existsSync as
|
|
5928
|
+
import { existsSync as existsSync9 } from "fs";
|
|
5291
5929
|
function pruneAgentConversations(cache) {
|
|
5292
5930
|
const db = cache.getDatabase();
|
|
5293
5931
|
const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
|
|
5294
5932
|
let pruned = 0;
|
|
5295
5933
|
let missing = 0;
|
|
5296
5934
|
for (const row of rows) {
|
|
5297
|
-
if (!
|
|
5935
|
+
if (!existsSync9(row.file_path)) {
|
|
5298
5936
|
missing += 1;
|
|
5299
5937
|
continue;
|
|
5300
5938
|
}
|
|
@@ -5440,6 +6078,52 @@ function resolveAnswer(pending, body) {
|
|
|
5440
6078
|
}
|
|
5441
6079
|
}
|
|
5442
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
|
+
|
|
5443
6127
|
// src/session-store.ts
|
|
5444
6128
|
var SessionStore = class {
|
|
5445
6129
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -5579,6 +6263,9 @@ function managedToResponse(s, ptyAttached) {
|
|
|
5579
6263
|
conversationId: s.id,
|
|
5580
6264
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
5581
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",
|
|
5582
6269
|
projectPath: s.projectPath,
|
|
5583
6270
|
projectName: s.projectName,
|
|
5584
6271
|
branch: s.branch,
|
|
@@ -5612,7 +6299,14 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5612
6299
|
id: conversationId,
|
|
5613
6300
|
conversationId,
|
|
5614
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.
|
|
5615
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",
|
|
5616
6310
|
projectPath: d.projectPath,
|
|
5617
6311
|
projectName: d.projectName,
|
|
5618
6312
|
branch: d.branch,
|
|
@@ -5630,7 +6324,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5630
6324
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
5631
6325
|
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
5632
6326
|
import heicConvert from "heic-convert";
|
|
5633
|
-
import { join as
|
|
6327
|
+
import { join as join16 } from "path";
|
|
5634
6328
|
var UPLOAD_DIR_NAME = ".threadbase-uploads";
|
|
5635
6329
|
var MAX_BYTES = 25 * 1024 * 1024;
|
|
5636
6330
|
var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
@@ -5663,9 +6357,9 @@ async function saveUploadFile(input) {
|
|
|
5663
6357
|
}
|
|
5664
6358
|
const id = `up_${randomBytes3(8).toString("hex")}`;
|
|
5665
6359
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
5666
|
-
const dir =
|
|
6360
|
+
const dir = join16(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
5667
6361
|
await mkdir3(dir, { recursive: true });
|
|
5668
|
-
const filePath =
|
|
6362
|
+
const filePath = join16(dir, `${Date.now()}-${id}-${safeName}`);
|
|
5669
6363
|
await writeFile(filePath, buffer);
|
|
5670
6364
|
return {
|
|
5671
6365
|
id,
|
|
@@ -5677,7 +6371,7 @@ async function saveUploadFile(input) {
|
|
|
5677
6371
|
}
|
|
5678
6372
|
function sanitizeFilename(name) {
|
|
5679
6373
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
5680
|
-
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, "_");
|
|
5681
6375
|
return cleaned;
|
|
5682
6376
|
}
|
|
5683
6377
|
|
|
@@ -5710,12 +6404,12 @@ function normalizeCodexLineToClaudeShape(line) {
|
|
|
5710
6404
|
const text = extractCodexText(payload.content);
|
|
5711
6405
|
if (!text) return null;
|
|
5712
6406
|
if (role === "user" && isCodexInjectedContext(text)) return null;
|
|
5713
|
-
const
|
|
5714
|
-
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)}`;
|
|
5715
6409
|
return JSON.stringify({
|
|
5716
6410
|
type: role,
|
|
5717
6411
|
uuid,
|
|
5718
|
-
timestamp,
|
|
6412
|
+
timestamp: timestamp2,
|
|
5719
6413
|
message: {
|
|
5720
6414
|
role,
|
|
5721
6415
|
content: [{ type: "text", text }]
|
|
@@ -5765,13 +6459,13 @@ function hashPrefix(text) {
|
|
|
5765
6459
|
}
|
|
5766
6460
|
|
|
5767
6461
|
// src/utils/conversationEtag.ts
|
|
5768
|
-
import { createHash as
|
|
6462
|
+
import { createHash as createHash3 } from "crypto";
|
|
5769
6463
|
function computeConversationEtag({
|
|
5770
6464
|
filePath,
|
|
5771
6465
|
messageCount,
|
|
5772
|
-
timestamp
|
|
6466
|
+
timestamp: timestamp2
|
|
5773
6467
|
}) {
|
|
5774
|
-
const digest =
|
|
6468
|
+
const digest = createHash3("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
5775
6469
|
return `"${digest}"`;
|
|
5776
6470
|
}
|
|
5777
6471
|
|
|
@@ -5921,8 +6615,17 @@ var WSHub = class {
|
|
|
5921
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.`;
|
|
5922
6616
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
5923
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;
|
|
5924
6623
|
var REFRESH_TTL_MS = 2e3;
|
|
5925
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;
|
|
5926
6629
|
function parseIncludeAgentsEnv(raw) {
|
|
5927
6630
|
if (raw === void 0) return false;
|
|
5928
6631
|
const v = raw.trim().toLowerCase();
|
|
@@ -5936,11 +6639,30 @@ var StreamerServer = class {
|
|
|
5936
6639
|
fileWatcher;
|
|
5937
6640
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
5938
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();
|
|
5939
6647
|
// Per-file seq assignments from the most recent onNewLineSpans (offset index),
|
|
5940
6648
|
// handed to the immediately-following onNewLines so it can stamp WS `seq` on
|
|
5941
6649
|
// the matching conversation_events entries. Same read → same lines order.
|
|
5942
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.
|
|
5943
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();
|
|
5944
6666
|
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
5945
6667
|
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
5946
6668
|
// the screen detection fires first, the later JSONL flush of the same question
|
|
@@ -6003,8 +6725,13 @@ var StreamerServer = class {
|
|
|
6003
6725
|
ptyGracePeriodMs;
|
|
6004
6726
|
defaultSystemPrompt;
|
|
6005
6727
|
defaultPermissionMode;
|
|
6728
|
+
defaultModel;
|
|
6729
|
+
defaultEffort;
|
|
6006
6730
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6007
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();
|
|
6008
6735
|
// Map of sessionId → set of subscribed WS clients
|
|
6009
6736
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
6010
6737
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -6012,6 +6739,7 @@ var StreamerServer = class {
|
|
|
6012
6739
|
// Reverse map for cleanup on close
|
|
6013
6740
|
wsToClientId = /* @__PURE__ */ new Map();
|
|
6014
6741
|
cache = null;
|
|
6742
|
+
cacheMonitor = null;
|
|
6015
6743
|
projectsRepo = null;
|
|
6016
6744
|
conversationsRepo = null;
|
|
6017
6745
|
sessionsRepo = null;
|
|
@@ -6048,11 +6776,13 @@ var StreamerServer = class {
|
|
|
6048
6776
|
this.disableDb = config.disableDb ?? false;
|
|
6049
6777
|
this.scannerPersistenceDisabled = config.scannerPersistent === false;
|
|
6050
6778
|
this.scanProfiles = config.scanProfiles;
|
|
6051
|
-
this.codexRoots = config.codexRoots ?? [
|
|
6779
|
+
this.codexRoots = config.codexRoots ?? [join17(homedir8(), ".codex", "sessions")];
|
|
6052
6780
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
6053
6781
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
6054
6782
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6055
|
-
this.
|
|
6783
|
+
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6784
|
+
this.defaultEffort = config.defaultEffort ?? "low";
|
|
6785
|
+
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join17(homedir8(), ".threadbase", "cache");
|
|
6056
6786
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6057
6787
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
6058
6788
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -6093,7 +6823,7 @@ var StreamerServer = class {
|
|
|
6093
6823
|
const seqs = cache.extendMessageIndex(
|
|
6094
6824
|
filePath,
|
|
6095
6825
|
spans,
|
|
6096
|
-
|
|
6826
|
+
statSync8(filePath),
|
|
6097
6827
|
readFrom,
|
|
6098
6828
|
endOffset
|
|
6099
6829
|
);
|
|
@@ -6123,39 +6853,25 @@ var StreamerServer = class {
|
|
|
6123
6853
|
},
|
|
6124
6854
|
onNewLines: (filePath, lines) => {
|
|
6125
6855
|
this.cache?.updateFromLines(filePath, lines);
|
|
6856
|
+
let managed = false;
|
|
6126
6857
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
6127
6858
|
if (watchedPath === filePath) {
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
for (const p of pending) {
|
|
6131
|
-
this.pendingQuestions.set(sessionId, p);
|
|
6132
|
-
const t = setTimeout(() => {
|
|
6133
|
-
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
6134
|
-
this.cancelPendingQuestion(sessionId);
|
|
6135
|
-
}
|
|
6136
|
-
}, 6e4);
|
|
6137
|
-
t.unref();
|
|
6138
|
-
}
|
|
6139
|
-
for (const m of messages) {
|
|
6140
|
-
const key = questionContentKey(m.questions);
|
|
6141
|
-
const broadcast = shouldBroadcastQuestion({
|
|
6142
|
-
newContentKey: key,
|
|
6143
|
-
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
6144
|
-
newToolUseId: m.toolUseId,
|
|
6145
|
-
priorToolUseId
|
|
6146
|
-
});
|
|
6147
|
-
this.pendingQuestionKey.set(sessionId, key);
|
|
6148
|
-
if (broadcast) this.wsHub.broadcast(m);
|
|
6149
|
-
}
|
|
6859
|
+
managed = true;
|
|
6860
|
+
this.processJsonlQuestions(sessionId, lines);
|
|
6150
6861
|
const seqs = this.pendingLineSeqs.get(filePath);
|
|
6151
6862
|
this.broadcastConversationLines(sessionId, lines, seqs);
|
|
6152
6863
|
break;
|
|
6153
6864
|
}
|
|
6154
6865
|
}
|
|
6866
|
+
if (!managed) {
|
|
6867
|
+
this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
|
|
6868
|
+
}
|
|
6155
6869
|
this.pendingLineSeqs.delete(filePath);
|
|
6156
6870
|
},
|
|
6157
6871
|
onConversationChanged: (filePath) => {
|
|
6158
|
-
this.fileWatcher.poke(filePath);
|
|
6872
|
+
const tailed = this.fileWatcher.poke(filePath);
|
|
6873
|
+
if (!tailed) this.maybeAttachExternalTail(filePath);
|
|
6874
|
+
this.sweepIdleExternalTails();
|
|
6159
6875
|
this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
|
|
6160
6876
|
this.markScannerStaleDebounced();
|
|
6161
6877
|
this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
|
|
@@ -6163,7 +6879,20 @@ var StreamerServer = class {
|
|
|
6163
6879
|
event: "cache.directory_change"
|
|
6164
6880
|
});
|
|
6165
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
|
+
},
|
|
6166
6890
|
onFileDeleted: (filePath) => {
|
|
6891
|
+
this.detachExternalTail(canonicalizeFilePath(filePath));
|
|
6892
|
+
if (this.cacheMonitor?.pending) {
|
|
6893
|
+
this.cacheMonitor.deferUnlink(filePath);
|
|
6894
|
+
return;
|
|
6895
|
+
}
|
|
6167
6896
|
const id = this.cache?.invalidateByFilePath(filePath);
|
|
6168
6897
|
if (id)
|
|
6169
6898
|
this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
|
|
@@ -6171,6 +6900,7 @@ var StreamerServer = class {
|
|
|
6171
6900
|
filePath,
|
|
6172
6901
|
event: "cache.invalidate_on_unlink"
|
|
6173
6902
|
});
|
|
6903
|
+
this.cacheMonitor?.recordUnlink(filePath);
|
|
6174
6904
|
}
|
|
6175
6905
|
});
|
|
6176
6906
|
this.ptyManager = new LiveSessionManager({
|
|
@@ -6234,6 +6964,8 @@ var StreamerServer = class {
|
|
|
6234
6964
|
this.cancelPendingQuestion(session.id);
|
|
6235
6965
|
}
|
|
6236
6966
|
this.pendingPermission.delete(session.id);
|
|
6967
|
+
this.contendedSessions.delete(session.id);
|
|
6968
|
+
this.rememberSelfPtyEnded(session.id);
|
|
6237
6969
|
}
|
|
6238
6970
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
6239
6971
|
if (resp) {
|
|
@@ -6257,7 +6989,7 @@ var StreamerServer = class {
|
|
|
6257
6989
|
temporalClient,
|
|
6258
6990
|
taskQueue: agentConfig.temporal.taskQueue
|
|
6259
6991
|
});
|
|
6260
|
-
const conversationsBaseDir = agentConfig.conversationsDir ||
|
|
6992
|
+
const conversationsBaseDir = agentConfig.conversationsDir || join17(dirname9(this.cacheDir), "conversations");
|
|
6261
6993
|
conversationWriter = createConversationWriter({
|
|
6262
6994
|
baseDir: conversationsBaseDir
|
|
6263
6995
|
});
|
|
@@ -6279,6 +7011,7 @@ var StreamerServer = class {
|
|
|
6279
7011
|
sessionStore: this.sessionStore,
|
|
6280
7012
|
wsHub: this.wsHub,
|
|
6281
7013
|
cache: () => this.cache,
|
|
7014
|
+
cacheMonitor: () => this.cacheMonitor,
|
|
6282
7015
|
projectsRepo: () => this.projectsRepo,
|
|
6283
7016
|
conversationsRepo: () => this.conversationsRepo,
|
|
6284
7017
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -6317,6 +7050,8 @@ var StreamerServer = class {
|
|
|
6317
7050
|
if (this.cacheReady) {
|
|
6318
7051
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
6319
7052
|
}
|
|
7053
|
+
const alertMsg = this.cacheMonitor?.wsMessage();
|
|
7054
|
+
if (alertMsg) this.wsHub.unicast(ws, alertMsg);
|
|
6320
7055
|
},
|
|
6321
7056
|
handleWsMessage: async (ws, raw) => {
|
|
6322
7057
|
try {
|
|
@@ -6432,6 +7167,17 @@ var StreamerServer = class {
|
|
|
6432
7167
|
ptyAttachedIds() {
|
|
6433
7168
|
return new Set(this.ptyManager.listSessions().map((s) => s.id));
|
|
6434
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
|
+
}
|
|
6435
7181
|
/**
|
|
6436
7182
|
* Send a session_list to only the client that triggered this HTTP request
|
|
6437
7183
|
* (identified by X-Client-Id header → registered WS socket). Falls back to
|
|
@@ -6462,6 +7208,7 @@ var StreamerServer = class {
|
|
|
6462
7208
|
clearTimeout(existing);
|
|
6463
7209
|
this.ptyGraceTimers.delete(sessionId);
|
|
6464
7210
|
}
|
|
7211
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6465
7212
|
}
|
|
6466
7213
|
startGraceTimer(sessionId, delayMs) {
|
|
6467
7214
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
@@ -6471,14 +7218,24 @@ var StreamerServer = class {
|
|
|
6471
7218
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
6472
7219
|
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6473
7220
|
if (resp?.status === "running") {
|
|
6474
|
-
this.
|
|
6475
|
-
|
|
6476
|
-
|
|
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 },
|
|
6477
7235
|
"pino"
|
|
6478
7236
|
);
|
|
6479
|
-
this.startGraceTimer(sessionId, delayMs);
|
|
6480
|
-
return;
|
|
6481
7237
|
}
|
|
7238
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6482
7239
|
this.sessionSubscribers.delete(sessionId);
|
|
6483
7240
|
this.log.info(
|
|
6484
7241
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
@@ -6489,6 +7246,7 @@ var StreamerServer = class {
|
|
|
6489
7246
|
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6490
7247
|
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
6491
7248
|
} else {
|
|
7249
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6492
7250
|
this.sessionSubscribers.delete(sessionId);
|
|
6493
7251
|
}
|
|
6494
7252
|
}, delayMs);
|
|
@@ -6521,13 +7279,16 @@ var StreamerServer = class {
|
|
|
6521
7279
|
});
|
|
6522
7280
|
try {
|
|
6523
7281
|
this.cache = ConversationCache.open(
|
|
6524
|
-
|
|
7282
|
+
join17(this.cacheDir, "cache.db"),
|
|
6525
7283
|
this.tailSize,
|
|
6526
7284
|
void 0,
|
|
6527
7285
|
{
|
|
6528
7286
|
filterAgentConversations: !this.includeAgents,
|
|
6529
7287
|
agentEntrypoints: this.agentEntrypoints,
|
|
6530
|
-
onAgentFileDetected: (fp) =>
|
|
7288
|
+
onAgentFileDetected: (fp) => {
|
|
7289
|
+
this.fileWatcher.unwatch(fp);
|
|
7290
|
+
this.externalTails.delete(canonicalizeFilePath(fp));
|
|
7291
|
+
}
|
|
6531
7292
|
}
|
|
6532
7293
|
);
|
|
6533
7294
|
if (!this.includeAgents) {
|
|
@@ -6544,9 +7305,23 @@ var StreamerServer = class {
|
|
|
6544
7305
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
6545
7306
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
6546
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
|
+
);
|
|
6547
7318
|
for (const dir of this.projectsDirs()) {
|
|
6548
7319
|
this.fileWatcher.watchDirectory(dir);
|
|
6549
7320
|
}
|
|
7321
|
+
for (const dir of this.codexRoots) {
|
|
7322
|
+
if (!existsSync10(dir)) continue;
|
|
7323
|
+
this.fileWatcher.watchDirectory(dir);
|
|
7324
|
+
}
|
|
6550
7325
|
} catch (err) {
|
|
6551
7326
|
const message = err instanceof Error ? err.message : String(err);
|
|
6552
7327
|
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
@@ -6613,11 +7388,19 @@ var StreamerServer = class {
|
|
|
6613
7388
|
}
|
|
6614
7389
|
);
|
|
6615
7390
|
}
|
|
6616
|
-
|
|
6617
|
-
this.
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
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
|
+
}
|
|
6621
7404
|
}).catch((err) => {
|
|
6622
7405
|
const message = err instanceof Error ? err.message : String(err);
|
|
6623
7406
|
this.log.warn(`Startup cache warm-up failed: ${message}`, {
|
|
@@ -6729,6 +7512,7 @@ var StreamerServer = class {
|
|
|
6729
7512
|
this.cache?.close();
|
|
6730
7513
|
this.ptyManager.dispose();
|
|
6731
7514
|
this.fileWatcher.dispose();
|
|
7515
|
+
this.externalTails.clear();
|
|
6732
7516
|
this.wsHub.dispose();
|
|
6733
7517
|
this.pairTokens.dispose();
|
|
6734
7518
|
if (this.dbPool) {
|
|
@@ -6868,10 +7652,9 @@ var StreamerServer = class {
|
|
|
6868
7652
|
const metas2 = [...scanner2.getMetadataCache().values()];
|
|
6869
7653
|
try {
|
|
6870
7654
|
this.cache.upsertFromScannerMeta(metas2);
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
this.cache.reconcileDeletions(livePaths);
|
|
7655
|
+
if (!this.cacheMonitor?.pending) {
|
|
7656
|
+
this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
|
|
7657
|
+
}
|
|
6875
7658
|
} catch (err) {
|
|
6876
7659
|
this.log.warn(
|
|
6877
7660
|
`refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -7002,6 +7785,7 @@ var StreamerServer = class {
|
|
|
7002
7785
|
type: "conversation",
|
|
7003
7786
|
id: c.id,
|
|
7004
7787
|
status: "idle",
|
|
7788
|
+
ownership: "historical",
|
|
7005
7789
|
ptyAttached: false,
|
|
7006
7790
|
projectId: c.projectId ?? void 0,
|
|
7007
7791
|
projectPath: c.projectPath ?? "",
|
|
@@ -7028,21 +7812,19 @@ var StreamerServer = class {
|
|
|
7028
7812
|
if (!this.cache) return void 0;
|
|
7029
7813
|
if (!previousScanner) {
|
|
7030
7814
|
const persisted = this.cache.getScannerStatCache();
|
|
7031
|
-
|
|
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;
|
|
7032
7821
|
}
|
|
7033
7822
|
const dbStats = this.cache.getFileStats();
|
|
7034
7823
|
if (dbStats.size === 0) return void 0;
|
|
7035
|
-
const
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
}
|
|
7040
|
-
}
|
|
7041
|
-
const statCache = /* @__PURE__ */ new Map();
|
|
7042
|
-
for (const [filePath, stat3] of dbStats) {
|
|
7043
|
-
const meta = metaByPath.get(filePath);
|
|
7044
|
-
if (meta) statCache.set(filePath, { stat: stat3, meta });
|
|
7045
|
-
}
|
|
7824
|
+
const statCache = joinStatCacheByNativePath(
|
|
7825
|
+
previousScanner.getMetadataCache().values(),
|
|
7826
|
+
dbStats
|
|
7827
|
+
);
|
|
7046
7828
|
return statCache.size > 0 ? statCache : void 0;
|
|
7047
7829
|
}
|
|
7048
7830
|
// Returns the provider + codexRoots fragment to spread into every scan()/search() call.
|
|
@@ -7136,22 +7918,22 @@ var StreamerServer = class {
|
|
|
7136
7918
|
*/
|
|
7137
7919
|
projectsDirs() {
|
|
7138
7920
|
if (this.scanProfiles && this.scanProfiles.length > 0) {
|
|
7139
|
-
return this.scanProfiles.filter((p) => p.enabled).map((p) =>
|
|
7921
|
+
return this.scanProfiles.filter((p) => p.enabled).map((p) => join17(p.configDir, "projects"));
|
|
7140
7922
|
}
|
|
7141
|
-
return [
|
|
7923
|
+
return [join17(homedir8(), ".claude", "projects")];
|
|
7142
7924
|
}
|
|
7143
7925
|
findJsonlPath(uuid) {
|
|
7144
7926
|
const filename = `${uuid}.jsonl`;
|
|
7145
7927
|
for (const projectsDir of this.projectsDirs()) {
|
|
7146
|
-
if (!
|
|
7147
|
-
for (const dir of
|
|
7148
|
-
const fp =
|
|
7149
|
-
if (
|
|
7150
|
-
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);
|
|
7151
7933
|
try {
|
|
7152
|
-
for (const sub of
|
|
7153
|
-
const subagentPath =
|
|
7154
|
-
if (
|
|
7934
|
+
for (const sub of readdirSync5(projectDir)) {
|
|
7935
|
+
const subagentPath = join17(projectDir, sub, "subagents", filename);
|
|
7936
|
+
if (existsSync10(subagentPath)) return subagentPath;
|
|
7155
7937
|
}
|
|
7156
7938
|
} catch {
|
|
7157
7939
|
}
|
|
@@ -7231,6 +8013,140 @@ var StreamerServer = class {
|
|
|
7231
8013
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7232
8014
|
}
|
|
7233
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
|
+
}
|
|
7234
8150
|
async findConversationByUuid(uuid) {
|
|
7235
8151
|
const lookupId = this.resolveConversationLookupId(uuid);
|
|
7236
8152
|
if (!this.scannerReady && !this.scanProfiles) {
|
|
@@ -7299,7 +8215,7 @@ var StreamerServer = class {
|
|
|
7299
8215
|
if (!conv.filePath) return false;
|
|
7300
8216
|
let mtimeMs = null;
|
|
7301
8217
|
try {
|
|
7302
|
-
mtimeMs =
|
|
8218
|
+
mtimeMs = statSync8(conv.filePath).mtimeMs;
|
|
7303
8219
|
} catch {
|
|
7304
8220
|
return false;
|
|
7305
8221
|
}
|
|
@@ -7633,7 +8549,6 @@ var StreamerServer = class {
|
|
|
7633
8549
|
});
|
|
7634
8550
|
}
|
|
7635
8551
|
async handleListSessions(url, res) {
|
|
7636
|
-
const DISCOVERY_TTL_MS = 15e3;
|
|
7637
8552
|
const now = Date.now();
|
|
7638
8553
|
if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
|
|
7639
8554
|
try {
|
|
@@ -7645,7 +8560,7 @@ var StreamerServer = class {
|
|
|
7645
8560
|
}
|
|
7646
8561
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
7647
8562
|
if (!hasPaginationParams) {
|
|
7648
|
-
json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
|
|
8563
|
+
json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
|
|
7649
8564
|
return;
|
|
7650
8565
|
}
|
|
7651
8566
|
const parsed = parseSessionListQuery(url);
|
|
@@ -7655,6 +8570,7 @@ var StreamerServer = class {
|
|
|
7655
8570
|
}
|
|
7656
8571
|
try {
|
|
7657
8572
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8573
|
+
page.sessions = this.withExternalActivity(page.sessions);
|
|
7658
8574
|
json(res, 200, page);
|
|
7659
8575
|
} catch (err) {
|
|
7660
8576
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -7667,7 +8583,7 @@ var StreamerServer = class {
|
|
|
7667
8583
|
handleGetSession(sessionId, res) {
|
|
7668
8584
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
7669
8585
|
if (session) {
|
|
7670
|
-
if (!
|
|
8586
|
+
if (!existsSync10(session.projectPath)) {
|
|
7671
8587
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
7672
8588
|
}
|
|
7673
8589
|
json(res, 200, session);
|
|
@@ -7681,7 +8597,6 @@ var StreamerServer = class {
|
|
|
7681
8597
|
json(res, 404, { error: "Session not found" });
|
|
7682
8598
|
}
|
|
7683
8599
|
async handleResume(req, res) {
|
|
7684
|
-
this.discoveryCache = null;
|
|
7685
8600
|
const body = await readBody(req);
|
|
7686
8601
|
const sessionId = body.sessionId ?? body.conversationId;
|
|
7687
8602
|
if (!sessionId) {
|
|
@@ -7710,14 +8625,56 @@ var StreamerServer = class {
|
|
|
7710
8625
|
json(res, 400, { error: "Could not determine project path" });
|
|
7711
8626
|
return;
|
|
7712
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
|
+
}
|
|
7713
8667
|
const cachedConvMeta = this.cache?.getMetaById(sessionId);
|
|
7714
8668
|
const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
|
|
8669
|
+
this.discoveryCache = null;
|
|
7715
8670
|
const session = await this.ptyManager.start(sessionId, {
|
|
7716
8671
|
provider,
|
|
7717
8672
|
projectPath,
|
|
7718
8673
|
projectName: body.projectName,
|
|
7719
8674
|
branch: body.branch,
|
|
7720
|
-
permissionMode: this.defaultPermissionMode
|
|
8675
|
+
permissionMode: this.defaultPermissionMode,
|
|
8676
|
+
model: this.defaultModel,
|
|
8677
|
+
effort: this.defaultEffort
|
|
7721
8678
|
});
|
|
7722
8679
|
this.sessionStore.addManaged(session);
|
|
7723
8680
|
void this.watchConversationFile(sessionId);
|
|
@@ -7843,6 +8800,45 @@ var StreamerServer = class {
|
|
|
7843
8800
|
json(res, 400, { error: message });
|
|
7844
8801
|
}
|
|
7845
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
|
+
}
|
|
7846
8842
|
cancelPendingQuestion(sessionId) {
|
|
7847
8843
|
const pq = this.pendingQuestions.get(sessionId);
|
|
7848
8844
|
if (!pq) return;
|
|
@@ -7859,7 +8855,7 @@ var StreamerServer = class {
|
|
|
7859
8855
|
const key = questionContentKey(questions);
|
|
7860
8856
|
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
7861
8857
|
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
7862
|
-
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
8858
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
|
|
7863
8859
|
this.pendingQuestionKey.set(sessionId, key);
|
|
7864
8860
|
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
7865
8861
|
}
|
|
@@ -8042,12 +9038,40 @@ var StreamerServer = class {
|
|
|
8042
9038
|
json(res, 400, { error: "Session has no known PID" });
|
|
8043
9039
|
return;
|
|
8044
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
|
+
}
|
|
8045
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
|
+
}
|
|
8046
9068
|
const session = await this.ptyManager.start(convId, {
|
|
8047
9069
|
projectPath,
|
|
8048
9070
|
projectName,
|
|
8049
9071
|
branch,
|
|
8050
|
-
permissionMode: this.defaultPermissionMode
|
|
9072
|
+
permissionMode: this.defaultPermissionMode,
|
|
9073
|
+
model: this.defaultModel,
|
|
9074
|
+
effort: this.defaultEffort
|
|
8051
9075
|
});
|
|
8052
9076
|
this.sessionStore.addManaged(session);
|
|
8053
9077
|
void this.watchConversationFile(session.id);
|
|
@@ -8071,7 +9095,7 @@ var StreamerServer = class {
|
|
|
8071
9095
|
sessionStore: this.sessionStore,
|
|
8072
9096
|
// biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
|
|
8073
9097
|
agentClient: this.agentClient,
|
|
8074
|
-
conversationsDir: this.cacheDir ?
|
|
9098
|
+
conversationsDir: this.cacheDir ? join17(dirname9(this.cacheDir), "conversations") : "",
|
|
8075
9099
|
agentConfig: this.agentConfig
|
|
8076
9100
|
});
|
|
8077
9101
|
json(res, result.status, result.body);
|
|
@@ -8118,7 +9142,9 @@ var StreamerServer = class {
|
|
|
8118
9142
|
projectPath: resolvedPath,
|
|
8119
9143
|
projectName: body.projectName,
|
|
8120
9144
|
systemPrompt: systemPromptParts.join("\n"),
|
|
8121
|
-
permissionMode: this.defaultPermissionMode
|
|
9145
|
+
permissionMode: this.defaultPermissionMode,
|
|
9146
|
+
model: this.defaultModel,
|
|
9147
|
+
effort: this.defaultEffort
|
|
8122
9148
|
});
|
|
8123
9149
|
this.sessionStore.addManaged(session);
|
|
8124
9150
|
const readyOrFailed = new Promise((resolve2) => {
|
|
@@ -8207,14 +9233,30 @@ var StreamerServer = class {
|
|
|
8207
9233
|
} catch {
|
|
8208
9234
|
}
|
|
8209
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
|
+
}
|
|
8210
9252
|
// Watch the project directory for the JSONL file Claude creates for sessionId.
|
|
8211
9253
|
// Once found, wire up structured event streaming. No rekeying needed — the UUID
|
|
8212
9254
|
// was passed to Claude via --session-id so the filename matches from the start.
|
|
8213
9255
|
watchForJsonl(sessionId, projectPath) {
|
|
8214
9256
|
const encoded = projectPath.replace(/[/\\:.]/g, "-");
|
|
8215
|
-
const projectsDir =
|
|
9257
|
+
const projectsDir = join17(homedir8(), ".claude", "projects", encoded);
|
|
8216
9258
|
const expectedFile = `${sessionId}.jsonl`;
|
|
8217
|
-
const filePath =
|
|
9259
|
+
const filePath = join17(projectsDir, expectedFile);
|
|
8218
9260
|
const deadline = Date.now() + 12e4;
|
|
8219
9261
|
let watcher = null;
|
|
8220
9262
|
const cleanup = () => {
|
|
@@ -8232,26 +9274,28 @@ var StreamerServer = class {
|
|
|
8232
9274
|
cleanup();
|
|
8233
9275
|
return;
|
|
8234
9276
|
}
|
|
8235
|
-
let resolvedFilePath =
|
|
8236
|
-
if (!resolvedFilePath &&
|
|
9277
|
+
let resolvedFilePath = existsSync10(filePath) ? filePath : null;
|
|
9278
|
+
if (!resolvedFilePath && existsSync10(projectsDir)) {
|
|
8237
9279
|
try {
|
|
8238
9280
|
const now = Date.now();
|
|
8239
|
-
const
|
|
8240
|
-
|
|
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);
|
|
8241
9285
|
} catch {
|
|
8242
9286
|
}
|
|
8243
9287
|
}
|
|
8244
9288
|
if (!resolvedFilePath) return;
|
|
8245
9289
|
cleanup();
|
|
8246
9290
|
this.sessionFileMap.set(sessionId, resolvedFilePath);
|
|
8247
|
-
this.fileWatcher.watch(resolvedFilePath);
|
|
8248
9291
|
try {
|
|
8249
|
-
const existing =
|
|
9292
|
+
const existing = readFileSync8(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
8250
9293
|
if (existing.length > 0) {
|
|
8251
9294
|
this.broadcastConversationLines(sessionId, existing);
|
|
8252
9295
|
}
|
|
8253
9296
|
} catch {
|
|
8254
9297
|
}
|
|
9298
|
+
this.fileWatcher.watch(resolvedFilePath);
|
|
8255
9299
|
if (this.scannerReady) {
|
|
8256
9300
|
this.scannerStale = true;
|
|
8257
9301
|
} else {
|
|
@@ -8285,7 +9329,7 @@ var StreamerServer = class {
|
|
|
8285
9329
|
watchForCodexRollout(sessionId, projectPath) {
|
|
8286
9330
|
const deadline = Date.now() + 12e4;
|
|
8287
9331
|
const now = /* @__PURE__ */ new Date();
|
|
8288
|
-
const dateDir =
|
|
9332
|
+
const dateDir = join17(
|
|
8289
9333
|
String(now.getFullYear()),
|
|
8290
9334
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
8291
9335
|
String(now.getDate()).padStart(2, "0")
|
|
@@ -8298,7 +9342,7 @@ var StreamerServer = class {
|
|
|
8298
9342
|
};
|
|
8299
9343
|
const matchesProjectPath = (candidatePath) => {
|
|
8300
9344
|
try {
|
|
8301
|
-
const firstLine =
|
|
9345
|
+
const firstLine = readFileSync8(candidatePath, "utf8").split("\n", 1)[0];
|
|
8302
9346
|
if (!firstLine) return null;
|
|
8303
9347
|
const parsed = JSON.parse(firstLine);
|
|
8304
9348
|
if (parsed?.type !== "session_meta") return null;
|
|
@@ -8326,18 +9370,18 @@ var StreamerServer = class {
|
|
|
8326
9370
|
this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
|
|
8327
9371
|
);
|
|
8328
9372
|
for (const root of this.codexRoots) {
|
|
8329
|
-
const sessionsDir =
|
|
8330
|
-
if (!
|
|
9373
|
+
const sessionsDir = join17(root, dateDir);
|
|
9374
|
+
if (!existsSync10(sessionsDir)) continue;
|
|
8331
9375
|
let candidateFiles;
|
|
8332
9376
|
try {
|
|
8333
|
-
candidateFiles =
|
|
9377
|
+
candidateFiles = readdirSync5(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
8334
9378
|
} catch {
|
|
8335
9379
|
continue;
|
|
8336
9380
|
}
|
|
8337
9381
|
const nowMs = Date.now();
|
|
8338
|
-
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);
|
|
8339
9383
|
for (const { f } of recentCandidates) {
|
|
8340
|
-
const candidatePath =
|
|
9384
|
+
const candidatePath = join17(sessionsDir, f);
|
|
8341
9385
|
const match = matchesProjectPath(candidatePath);
|
|
8342
9386
|
if (!match) continue;
|
|
8343
9387
|
if (boundElsewhere.has(match.id)) continue;
|
|
@@ -8347,7 +9391,7 @@ var StreamerServer = class {
|
|
|
8347
9391
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
8348
9392
|
this.fileWatcher.watch(candidatePath);
|
|
8349
9393
|
try {
|
|
8350
|
-
const existing =
|
|
9394
|
+
const existing = readFileSync8(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
8351
9395
|
if (existing.length > 0) {
|
|
8352
9396
|
this.broadcastConversationLines(sessionId, existing);
|
|
8353
9397
|
}
|
|
@@ -8467,9 +9511,21 @@ var StreamerServer = class {
|
|
|
8467
9511
|
json(res, 200, this.cache.listSessionNames());
|
|
8468
9512
|
}
|
|
8469
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
|
+
}
|
|
8470
9526
|
function classifyResumability(cwd) {
|
|
8471
9527
|
if (!cwd) return { resumable: true };
|
|
8472
|
-
if (
|
|
9528
|
+
if (existsSync10(cwd)) return { resumable: true };
|
|
8473
9529
|
const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
|
|
8474
9530
|
return {
|
|
8475
9531
|
resumable: false,
|
|
@@ -8484,6 +9540,9 @@ function conversationToResumableSession(c) {
|
|
|
8484
9540
|
id: c.id,
|
|
8485
9541
|
conversationId: c.id,
|
|
8486
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",
|
|
8487
9546
|
ptyAttached: false,
|
|
8488
9547
|
projectId: c.projectId ?? void 0,
|
|
8489
9548
|
projectPath: c.projectPath ?? "",
|