@threadbase-sh/streamer 1.33.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +2087 -2612
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1325 -210
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +248 -12
- package/dist/index.d.ts +248 -12
- package/dist/index.js +1320 -205
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -2260,6 +2260,14 @@ var PTYManager = class {
|
|
|
2260
2260
|
if (session?.status !== "running") return;
|
|
2261
2261
|
if (this.pendingReady.has(sessionId)) {
|
|
2262
2262
|
this.markReady(sessionId, session, "quiet:timeout");
|
|
2263
|
+
} else {
|
|
2264
|
+
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2265
|
+
this.log.warn("[pty.ready] screen recheck failed", {
|
|
2266
|
+
event: "pty.ready_recheck_failed",
|
|
2267
|
+
sessionId,
|
|
2268
|
+
err
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2263
2271
|
}
|
|
2264
2272
|
this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
|
|
2265
2273
|
this.log.warn("[pty.prompt_detect] failed", {
|
|
@@ -2269,6 +2277,20 @@ var PTYManager = class {
|
|
|
2269
2277
|
});
|
|
2270
2278
|
});
|
|
2271
2279
|
}
|
|
2280
|
+
// Re-check the rendered screen (not just the last chunk) for a prompt
|
|
2281
|
+
// marker. Only meaningful once pendingReady is already clear — the boot
|
|
2282
|
+
// fallback above covers the first prompt after spawn/resume. Scoped to a
|
|
2283
|
+
// full viewport (PTY_ROWS), not just the last few lines: "on screen" means
|
|
2284
|
+
// whatever a user attached to this PTY would currently see.
|
|
2285
|
+
async recheckReadyFromScreen(sessionId) {
|
|
2286
|
+
const session = this.sessions.get(sessionId);
|
|
2287
|
+
if (session?.status !== "running") return;
|
|
2288
|
+
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2289
|
+
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2290
|
+
if (matchedMarker && session.status === "running") {
|
|
2291
|
+
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2272
2294
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2273
2295
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2274
2296
|
markReady(sessionId, session, reason) {
|
|
@@ -2441,6 +2463,44 @@ async function discoverClaudeProcesses() {
|
|
|
2441
2463
|
if ((0, import_os4.platform)() === "win32") return discoverWindows();
|
|
2442
2464
|
return discoverUnix();
|
|
2443
2465
|
}
|
|
2466
|
+
var CLAUDE_CLI_SCRIPT = /claude-code[\\/](?:cli|index)\.(?:js|mjs|cjs)$/i;
|
|
2467
|
+
var JS_RUNTIMES = /* @__PURE__ */ new Set(["node", "node.exe", "bun", "bun.exe", "deno", "deno.exe"]);
|
|
2468
|
+
function tokenizeCommandLine(commandLine) {
|
|
2469
|
+
const tokens = [];
|
|
2470
|
+
let current = "";
|
|
2471
|
+
let quoted = false;
|
|
2472
|
+
for (const ch of commandLine) {
|
|
2473
|
+
if (ch === '"') {
|
|
2474
|
+
quoted = !quoted;
|
|
2475
|
+
continue;
|
|
2476
|
+
}
|
|
2477
|
+
if (!quoted && (ch === " " || ch === " ")) {
|
|
2478
|
+
if (current) tokens.push(current);
|
|
2479
|
+
current = "";
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
current += ch;
|
|
2483
|
+
}
|
|
2484
|
+
if (current) tokens.push(current);
|
|
2485
|
+
return tokens;
|
|
2486
|
+
}
|
|
2487
|
+
function exeBaseName(token) {
|
|
2488
|
+
const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
|
|
2489
|
+
return cut === -1 ? token : token.slice(cut + 1);
|
|
2490
|
+
}
|
|
2491
|
+
function looksLikeClaudeProcess(commandLine) {
|
|
2492
|
+
const tokens = tokenizeCommandLine(commandLine);
|
|
2493
|
+
if (tokens.length === 0) return false;
|
|
2494
|
+
const exe = exeBaseName(tokens[0]).toLowerCase();
|
|
2495
|
+
if (exe === "claude" || exe === "claude.exe") return true;
|
|
2496
|
+
if (JS_RUNTIMES.has(exe)) {
|
|
2497
|
+
for (const raw of tokens.slice(1)) {
|
|
2498
|
+
if (raw.startsWith("-")) continue;
|
|
2499
|
+
return CLAUDE_CLI_SCRIPT.test(raw);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2444
2504
|
async function discoverUnix() {
|
|
2445
2505
|
const pids = await getPidsUnix();
|
|
2446
2506
|
const results = await Promise.all(
|
|
@@ -2468,6 +2528,8 @@ async function discoverUnix() {
|
|
|
2468
2528
|
return results.filter((r) => r !== null);
|
|
2469
2529
|
}
|
|
2470
2530
|
async function discoverWindows() {
|
|
2531
|
+
const viaCim = await discoverWindowsViaCim();
|
|
2532
|
+
if (viaCim) return viaCim;
|
|
2471
2533
|
const pids = await getPidsWindows();
|
|
2472
2534
|
const results = await Promise.all(
|
|
2473
2535
|
pids.map(async (pid) => {
|
|
@@ -2502,7 +2564,24 @@ function run(cmd, args, opts = {}) {
|
|
|
2502
2564
|
);
|
|
2503
2565
|
});
|
|
2504
2566
|
}
|
|
2567
|
+
function parsePsOutput(stdout) {
|
|
2568
|
+
const pids = [];
|
|
2569
|
+
for (const line of stdout.split("\n")) {
|
|
2570
|
+
const trimmed = line.trim();
|
|
2571
|
+
if (!trimmed) continue;
|
|
2572
|
+
const match = trimmed.match(/^(\d+)\s+(.*)$/);
|
|
2573
|
+
if (!match) continue;
|
|
2574
|
+
const pid = Number.parseInt(match[1], 10);
|
|
2575
|
+
if (!(pid > 0)) continue;
|
|
2576
|
+
if (looksLikeClaudeProcess(match[2])) pids.push(pid);
|
|
2577
|
+
}
|
|
2578
|
+
return pids;
|
|
2579
|
+
}
|
|
2505
2580
|
async function getPidsUnix() {
|
|
2581
|
+
try {
|
|
2582
|
+
return parsePsOutput(await run("ps", ["-eo", "pid=,args="]));
|
|
2583
|
+
} catch {
|
|
2584
|
+
}
|
|
2506
2585
|
try {
|
|
2507
2586
|
const output = await run("pgrep", ["-x", "claude"]);
|
|
2508
2587
|
return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
|
|
@@ -2523,6 +2602,54 @@ async function getProcessStartTimeUnix(pid) {
|
|
|
2523
2602
|
const d = new Date(raw);
|
|
2524
2603
|
return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
|
|
2525
2604
|
}
|
|
2605
|
+
function parseCimProcesses(stdout) {
|
|
2606
|
+
const trimmed = stdout.trim();
|
|
2607
|
+
if (!trimmed) return [];
|
|
2608
|
+
const parsed = JSON.parse(trimmed);
|
|
2609
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
2610
|
+
}
|
|
2611
|
+
function parseCimDate(value) {
|
|
2612
|
+
if (value) {
|
|
2613
|
+
const epoch = value.match(/\/Date\((\d+)\)\//);
|
|
2614
|
+
if (epoch) return new Date(Number(epoch[1]));
|
|
2615
|
+
const d = new Date(value);
|
|
2616
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
2617
|
+
}
|
|
2618
|
+
return /* @__PURE__ */ new Date();
|
|
2619
|
+
}
|
|
2620
|
+
async function discoverWindowsViaCim() {
|
|
2621
|
+
let stdout;
|
|
2622
|
+
try {
|
|
2623
|
+
stdout = await run("powershell.exe", [
|
|
2624
|
+
"-NoProfile",
|
|
2625
|
+
"-NonInteractive",
|
|
2626
|
+
"-Command",
|
|
2627
|
+
"Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
|
|
2628
|
+
]);
|
|
2629
|
+
} catch {
|
|
2630
|
+
return null;
|
|
2631
|
+
}
|
|
2632
|
+
let rows;
|
|
2633
|
+
try {
|
|
2634
|
+
rows = parseCimProcesses(stdout);
|
|
2635
|
+
} catch {
|
|
2636
|
+
return null;
|
|
2637
|
+
}
|
|
2638
|
+
const results = [];
|
|
2639
|
+
for (const row of rows) {
|
|
2640
|
+
const commandLine = row.CommandLine ?? "";
|
|
2641
|
+
if (!commandLine || !looksLikeClaudeProcess(commandLine)) continue;
|
|
2642
|
+
results.push({
|
|
2643
|
+
pid: row.ProcessId,
|
|
2644
|
+
projectPath: "",
|
|
2645
|
+
projectName: "",
|
|
2646
|
+
branch: "",
|
|
2647
|
+
conversationId: extractResumeId(commandLine),
|
|
2648
|
+
startedAt: parseCimDate(row.CreationDate)
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
return results;
|
|
2652
|
+
}
|
|
2526
2653
|
async function getPidsWindows() {
|
|
2527
2654
|
try {
|
|
2528
2655
|
const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
|
|
@@ -2565,10 +2692,15 @@ async function getProcessInfoWindows(pid) {
|
|
|
2565
2692
|
}
|
|
2566
2693
|
}
|
|
2567
2694
|
function extractResumeId(args) {
|
|
2568
|
-
const
|
|
2569
|
-
|
|
2695
|
+
const eq = args.match(/(?:--resume|-r)=(\S+)/);
|
|
2696
|
+
if (eq?.[1] && !eq[1].startsWith("-")) return eq[1];
|
|
2697
|
+
const spaced = args.match(/(?:--resume|-r)\s+(\S+)/);
|
|
2698
|
+
const candidate = spaced?.[1];
|
|
2699
|
+
if (!candidate || candidate.startsWith("-")) return null;
|
|
2700
|
+
return candidate;
|
|
2570
2701
|
}
|
|
2571
2702
|
async function readGitBranch(dir) {
|
|
2703
|
+
if (!dir) return "";
|
|
2572
2704
|
try {
|
|
2573
2705
|
return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
|
|
2574
2706
|
} catch {
|
|
@@ -2581,11 +2713,11 @@ var import_node_ws = require("@hono/node-ws");
|
|
|
2581
2713
|
var import_client = require("@temporalio/client");
|
|
2582
2714
|
var import_scanner3 = require("@threadbase-sh/scanner");
|
|
2583
2715
|
var import_events = require("events");
|
|
2584
|
-
var
|
|
2716
|
+
var import_fs17 = require("fs");
|
|
2585
2717
|
var import_promises7 = require("fs/promises");
|
|
2586
2718
|
var import_http = require("http");
|
|
2587
|
-
var
|
|
2588
|
-
var
|
|
2719
|
+
var import_os8 = require("os");
|
|
2720
|
+
var import_path17 = require("path");
|
|
2589
2721
|
var import_readline = require("readline");
|
|
2590
2722
|
|
|
2591
2723
|
// node_modules/nanoid/index.js
|
|
@@ -2844,7 +2976,7 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2844
2976
|
}
|
|
2845
2977
|
|
|
2846
2978
|
// src/api/app.ts
|
|
2847
|
-
var
|
|
2979
|
+
var import_hono13 = require("hono");
|
|
2848
2980
|
|
|
2849
2981
|
// src/api/middleware/auth.middleware.ts
|
|
2850
2982
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2966,12 +3098,72 @@ var createBrowseRoutes = (deps) => {
|
|
|
2966
3098
|
return app;
|
|
2967
3099
|
};
|
|
2968
3100
|
|
|
2969
|
-
// src/api/routes/
|
|
3101
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
2970
3102
|
var import_hono3 = require("hono");
|
|
3103
|
+
|
|
3104
|
+
// src/schemas/cacheAlert.schema.ts
|
|
3105
|
+
var import_zod = require("zod");
|
|
3106
|
+
var ResolveCacheAlertSchema = import_zod.z.object({
|
|
3107
|
+
fingerprint: import_zod.z.string(),
|
|
3108
|
+
action: import_zod.z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
|
|
3109
|
+
ids: import_zod.z.array(import_zod.z.string()).optional()
|
|
3110
|
+
}).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
|
|
3111
|
+
message: "prune_selected requires a non-empty ids array",
|
|
3112
|
+
path: ["ids"]
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
// src/api/routes/cacheAlert.routes.ts
|
|
3116
|
+
function readRawBody2(req) {
|
|
3117
|
+
return new Promise((resolve2, reject) => {
|
|
3118
|
+
const chunks = [];
|
|
3119
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3120
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3121
|
+
req.on("error", reject);
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
var createCacheAlertRoutes = (deps) => {
|
|
3125
|
+
const app = new import_hono3.Hono();
|
|
3126
|
+
app.get("/", (c) => {
|
|
3127
|
+
const monitor = deps.cacheMonitor();
|
|
3128
|
+
return c.json({ pending: monitor?.pending ?? null });
|
|
3129
|
+
});
|
|
3130
|
+
app.post("/resolve", async (c) => {
|
|
3131
|
+
let body;
|
|
3132
|
+
try {
|
|
3133
|
+
const incoming = c.env?.incoming;
|
|
3134
|
+
const raw = incoming ? await readRawBody2(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3135
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3136
|
+
} catch {
|
|
3137
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3138
|
+
}
|
|
3139
|
+
const parsed = ResolveCacheAlertSchema.safeParse(body);
|
|
3140
|
+
if (!parsed.success) {
|
|
3141
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3142
|
+
}
|
|
3143
|
+
const monitor = deps.cacheMonitor();
|
|
3144
|
+
if (!monitor) return c.json({ ok: true, alreadyResolved: true });
|
|
3145
|
+
const { fingerprint, action, ids } = parsed.data;
|
|
3146
|
+
const result = await monitor.resolve(fingerprint, action, ids);
|
|
3147
|
+
if ("conflict" in result) {
|
|
3148
|
+
return c.json(
|
|
3149
|
+
{ error: "fingerprint_mismatch", currentFingerprint: result.currentFingerprint },
|
|
3150
|
+
409
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
if ("alreadyResolved" in result) {
|
|
3154
|
+
return c.json({ ok: true, alreadyResolved: true });
|
|
3155
|
+
}
|
|
3156
|
+
return c.json(result);
|
|
3157
|
+
});
|
|
3158
|
+
return app;
|
|
3159
|
+
};
|
|
3160
|
+
|
|
3161
|
+
// src/api/routes/conversations.routes.ts
|
|
3162
|
+
var import_hono4 = require("hono");
|
|
2971
3163
|
var ALREADY_HANDLED2 = 597;
|
|
2972
3164
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
2973
3165
|
var createConversationRoutes = (deps) => {
|
|
2974
|
-
const app = new
|
|
3166
|
+
const app = new import_hono4.Hono();
|
|
2975
3167
|
app.get("/count", async (c) => {
|
|
2976
3168
|
const url = new URL(c.req.url);
|
|
2977
3169
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -2998,7 +3190,7 @@ var createConversationRoutes = (deps) => {
|
|
|
2998
3190
|
};
|
|
2999
3191
|
|
|
3000
3192
|
// src/api/routes/health.routes.ts
|
|
3001
|
-
var
|
|
3193
|
+
var import_hono5 = require("hono");
|
|
3002
3194
|
|
|
3003
3195
|
// src/version.ts
|
|
3004
3196
|
var import_node_fs2 = require("fs");
|
|
@@ -3034,16 +3226,19 @@ function resolveVersion() {
|
|
|
3034
3226
|
}
|
|
3035
3227
|
|
|
3036
3228
|
// src/api/routes/health.routes.ts
|
|
3037
|
-
var createHealthRoutes = () => {
|
|
3038
|
-
const app = new
|
|
3039
|
-
app.get("/", (c) =>
|
|
3229
|
+
var createHealthRoutes = (deps) => {
|
|
3230
|
+
const app = new import_hono5.Hono();
|
|
3231
|
+
app.get("/", (c) => {
|
|
3232
|
+
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3233
|
+
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
3234
|
+
});
|
|
3040
3235
|
return app;
|
|
3041
3236
|
};
|
|
3042
3237
|
|
|
3043
3238
|
// src/api/routes/logs.routes.ts
|
|
3044
3239
|
var import_node_fs3 = require("fs");
|
|
3045
3240
|
var import_node_path5 = require("path");
|
|
3046
|
-
var
|
|
3241
|
+
var import_hono6 = require("hono");
|
|
3047
3242
|
|
|
3048
3243
|
// src/lifecycle/constants.ts
|
|
3049
3244
|
var import_node_os = require("os");
|
|
@@ -3101,7 +3296,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3101
3296
|
}
|
|
3102
3297
|
}
|
|
3103
3298
|
function createLogsRoutes() {
|
|
3104
|
-
const app = new
|
|
3299
|
+
const app = new import_hono6.Hono();
|
|
3105
3300
|
app.get("/", (c) => {
|
|
3106
3301
|
try {
|
|
3107
3302
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3172,7 +3367,7 @@ function createLogsRoutes() {
|
|
|
3172
3367
|
// src/api/routes/misc.routes.ts
|
|
3173
3368
|
var import_node_child_process = require("child_process");
|
|
3174
3369
|
var import_node_crypto2 = require("crypto");
|
|
3175
|
-
var
|
|
3370
|
+
var import_hono7 = require("hono");
|
|
3176
3371
|
var import_os5 = require("os");
|
|
3177
3372
|
|
|
3178
3373
|
// src/config/update-config.ts
|
|
@@ -3182,15 +3377,15 @@ var import_node_path6 = require("path");
|
|
|
3182
3377
|
var import_yaml = require("yaml");
|
|
3183
3378
|
|
|
3184
3379
|
// src/schemas/updateConfig.schema.ts
|
|
3185
|
-
var
|
|
3186
|
-
var UpdateConfigSchema =
|
|
3187
|
-
auto_update:
|
|
3188
|
-
channel:
|
|
3189
|
-
allow:
|
|
3190
|
-
poll_interval_minutes:
|
|
3191
|
-
defer_if_active_sessions:
|
|
3192
|
-
github_repo:
|
|
3193
|
-
webhook_secret:
|
|
3380
|
+
var import_zod2 = require("zod");
|
|
3381
|
+
var UpdateConfigSchema = import_zod2.z.object({
|
|
3382
|
+
auto_update: import_zod2.z.boolean().default(false),
|
|
3383
|
+
channel: import_zod2.z.enum(["stable", "next"]).default("stable"),
|
|
3384
|
+
allow: import_zod2.z.array(import_zod2.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
3385
|
+
poll_interval_minutes: import_zod2.z.number().int().min(0).default(1440),
|
|
3386
|
+
defer_if_active_sessions: import_zod2.z.boolean().default(true),
|
|
3387
|
+
github_repo: import_zod2.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
3388
|
+
webhook_secret: import_zod2.z.string().min(1).nullable().default(null)
|
|
3194
3389
|
}).strict();
|
|
3195
3390
|
|
|
3196
3391
|
// src/config/update-config.ts
|
|
@@ -3227,7 +3422,7 @@ function readJsonBody(req) {
|
|
|
3227
3422
|
req.on("error", reject);
|
|
3228
3423
|
});
|
|
3229
3424
|
}
|
|
3230
|
-
function
|
|
3425
|
+
function readRawBody3(req) {
|
|
3231
3426
|
return new Promise((resolve2, reject) => {
|
|
3232
3427
|
const chunks = [];
|
|
3233
3428
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3246,7 +3441,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3246
3441
|
}
|
|
3247
3442
|
var clientLog = getLogger("client");
|
|
3248
3443
|
var createMiscRoutes = (deps) => {
|
|
3249
|
-
const app = new
|
|
3444
|
+
const app = new import_hono7.Hono();
|
|
3250
3445
|
app.get("/api/info", (c) => {
|
|
3251
3446
|
const ptyIds = deps.ptyAttachedIds();
|
|
3252
3447
|
return c.json({
|
|
@@ -3279,7 +3474,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3279
3474
|
}
|
|
3280
3475
|
let body;
|
|
3281
3476
|
try {
|
|
3282
|
-
body = await
|
|
3477
|
+
body = await readRawBody3(c.env.incoming);
|
|
3283
3478
|
} catch {
|
|
3284
3479
|
return c.json({ error: "could not read body" }, 400);
|
|
3285
3480
|
}
|
|
@@ -3322,11 +3517,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3322
3517
|
};
|
|
3323
3518
|
|
|
3324
3519
|
// src/api/routes/pair.routes.ts
|
|
3325
|
-
var
|
|
3520
|
+
var import_hono8 = require("hono");
|
|
3326
3521
|
var ALREADY_HANDLED3 = 597;
|
|
3327
3522
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3328
3523
|
var createPairRoutes = (deps) => {
|
|
3329
|
-
const app = new
|
|
3524
|
+
const app = new import_hono8.Hono();
|
|
3330
3525
|
app.post("/start", (c) => {
|
|
3331
3526
|
deps.handlePairStart(c.env.outgoing);
|
|
3332
3527
|
return alreadyHandled3();
|
|
@@ -3339,11 +3534,11 @@ var createPairRoutes = (deps) => {
|
|
|
3339
3534
|
};
|
|
3340
3535
|
|
|
3341
3536
|
// src/api/routes/projects.routes.ts
|
|
3342
|
-
var
|
|
3537
|
+
var import_hono9 = require("hono");
|
|
3343
3538
|
var ALREADY_HANDLED4 = 597;
|
|
3344
3539
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3345
3540
|
var createProjectRoutes = (deps) => {
|
|
3346
|
-
const app = new
|
|
3541
|
+
const app = new import_hono9.Hono();
|
|
3347
3542
|
app.get("/", (c) => {
|
|
3348
3543
|
const url = new URL(c.req.url);
|
|
3349
3544
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3358,11 +3553,11 @@ var createProjectRoutes = (deps) => {
|
|
|
3358
3553
|
};
|
|
3359
3554
|
|
|
3360
3555
|
// src/api/routes/scanner.routes.ts
|
|
3361
|
-
var
|
|
3556
|
+
var import_hono10 = require("hono");
|
|
3362
3557
|
var ALREADY_HANDLED5 = 597;
|
|
3363
3558
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3364
3559
|
var createScannerRoutes = (deps) => {
|
|
3365
|
-
const app = new
|
|
3560
|
+
const app = new import_hono10.Hono();
|
|
3366
3561
|
app.get("/api/search", async (c) => {
|
|
3367
3562
|
const url = new URL(c.req.url);
|
|
3368
3563
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3372,11 +3567,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3372
3567
|
};
|
|
3373
3568
|
|
|
3374
3569
|
// src/api/routes/sessions.routes.ts
|
|
3375
|
-
var
|
|
3570
|
+
var import_hono11 = require("hono");
|
|
3376
3571
|
var ALREADY_HANDLED6 = 597;
|
|
3377
3572
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3378
3573
|
var createSessionRoutes = (deps) => {
|
|
3379
|
-
const app = new
|
|
3574
|
+
const app = new import_hono11.Hono();
|
|
3380
3575
|
app.get("/count", (c) => {
|
|
3381
3576
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3382
3577
|
return alreadyHandled6();
|
|
@@ -3443,9 +3638,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3443
3638
|
};
|
|
3444
3639
|
|
|
3445
3640
|
// src/api/routes/ws.routes.ts
|
|
3446
|
-
var
|
|
3641
|
+
var import_hono12 = require("hono");
|
|
3447
3642
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3448
|
-
const app = new
|
|
3643
|
+
const app = new import_hono12.Hono();
|
|
3449
3644
|
app.get(
|
|
3450
3645
|
"/ws",
|
|
3451
3646
|
upgradeWebSocket(() => {
|
|
@@ -3471,7 +3666,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3471
3666
|
|
|
3472
3667
|
// src/api/app.ts
|
|
3473
3668
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3474
|
-
const app = new
|
|
3669
|
+
const app = new import_hono13.Hono();
|
|
3475
3670
|
const httpLog = getLogger("http");
|
|
3476
3671
|
app.use("*", async (c, next) => {
|
|
3477
3672
|
const start = Date.now();
|
|
@@ -3491,10 +3686,11 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3491
3686
|
app.use("*", corsMiddleware(deps.browserCors));
|
|
3492
3687
|
app.use("*", authMiddleware(deps));
|
|
3493
3688
|
app.onError(errorMiddleware);
|
|
3494
|
-
app.route("/healthz", createHealthRoutes());
|
|
3689
|
+
app.route("/healthz", createHealthRoutes(deps));
|
|
3495
3690
|
app.route("/", createMiscRoutes(deps));
|
|
3496
3691
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3497
3692
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3693
|
+
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
3498
3694
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
3499
3695
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3500
3696
|
app.route("/api", createBrowseRoutes(deps));
|
|
@@ -3563,7 +3759,7 @@ var import_scanner2 = require("@threadbase-sh/scanner");
|
|
|
3563
3759
|
var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
|
|
3564
3760
|
var import_fs9 = require("fs");
|
|
3565
3761
|
var import_promises3 = require("fs/promises");
|
|
3566
|
-
var
|
|
3762
|
+
var import_path12 = require("path");
|
|
3567
3763
|
var import_promises4 = require("timers/promises");
|
|
3568
3764
|
|
|
3569
3765
|
// src/db/sqlite-migrate.ts
|
|
@@ -3677,6 +3873,31 @@ function parseAgentEntrypointsEnv(raw) {
|
|
|
3677
3873
|
return new Set(parts);
|
|
3678
3874
|
}
|
|
3679
3875
|
|
|
3876
|
+
// src/utils/canonicalizeFilePath.ts
|
|
3877
|
+
var import_path11 = require("path");
|
|
3878
|
+
function canonicalizeFilePath(filePath) {
|
|
3879
|
+
return filePath.trim().replace(/\\/g, "/");
|
|
3880
|
+
}
|
|
3881
|
+
function toNativeFilePath(filePath) {
|
|
3882
|
+
return (0, import_path11.normalize)(filePath.trim());
|
|
3883
|
+
}
|
|
3884
|
+
function canonicalLivePathSet(metas) {
|
|
3885
|
+
const live = /* @__PURE__ */ new Set();
|
|
3886
|
+
for (const meta of metas) {
|
|
3887
|
+
if (meta.filePath) live.add(canonicalizeFilePath(meta.filePath));
|
|
3888
|
+
}
|
|
3889
|
+
return live;
|
|
3890
|
+
}
|
|
3891
|
+
function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
3892
|
+
const joined = /* @__PURE__ */ new Map();
|
|
3893
|
+
for (const meta of metas) {
|
|
3894
|
+
if (!meta.filePath) continue;
|
|
3895
|
+
const stat3 = canonicalStats.get(canonicalizeFilePath(meta.filePath));
|
|
3896
|
+
if (stat3) joined.set(meta.filePath, { stat: stat3, meta });
|
|
3897
|
+
}
|
|
3898
|
+
return joined;
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3680
3901
|
// src/utils/fileIdentity.ts
|
|
3681
3902
|
var import_crypto5 = require("crypto");
|
|
3682
3903
|
function fileIdentity(stat3, headBytes) {
|
|
@@ -3789,8 +4010,19 @@ var ConversationCache = class _ConversationCache {
|
|
|
3789
4010
|
),
|
|
3790
4011
|
// Batch equivalent of updateMeta: bumps message_count by N in one write
|
|
3791
4012
|
// (used by updateFromLines so a burst of appended lines is one UPDATE).
|
|
4013
|
+
// last_activity/last_message only move FORWARD: a batch whose newest line
|
|
4014
|
+
// predates the stored last_activity (interleaved writers appending an older
|
|
4015
|
+
// line) must not drag the metadata backward. message_count and updated_at
|
|
4016
|
+
// still advance — a real message was appended and the row did change.
|
|
3792
4017
|
updateMetaBatch: db.prepare(
|
|
3793
|
-
|
|
4018
|
+
`UPDATE conversation_meta SET
|
|
4019
|
+
message_count = message_count + @inc,
|
|
4020
|
+
last_activity = CASE WHEN @last_activity > IFNULL(last_activity, -1)
|
|
4021
|
+
THEN @last_activity ELSE last_activity END,
|
|
4022
|
+
last_message = CASE WHEN @last_activity > IFNULL(last_activity, -1)
|
|
4023
|
+
THEN @last_message ELSE last_message END,
|
|
4024
|
+
updated_at = @updated_at
|
|
4025
|
+
WHERE id = @id`
|
|
3794
4026
|
),
|
|
3795
4027
|
insertSkeleton: db.prepare(
|
|
3796
4028
|
"INSERT OR IGNORE INTO conversation_meta (id, file_path, message_count, updated_at) VALUES (?, ?, 1, ?)"
|
|
@@ -3872,6 +4104,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
3872
4104
|
"SELECT provider FROM conversation_meta WHERE file_path = ?"
|
|
3873
4105
|
),
|
|
3874
4106
|
allFilePaths: db.prepare("SELECT id, file_path FROM conversation_meta"),
|
|
4107
|
+
allFilePathsWithTitle: db.prepare("SELECT id, file_path, title FROM conversation_meta"),
|
|
3875
4108
|
allFileStats: db.prepare(
|
|
3876
4109
|
"SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
|
|
3877
4110
|
),
|
|
@@ -4024,7 +4257,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4024
4257
|
// excluded from the index entirely; the scanner serves them (it routes each
|
|
4025
4258
|
// provider to its own parser).
|
|
4026
4259
|
isIndexableFile(filePath) {
|
|
4027
|
-
const row = this.stmts.getProviderByFilePath.get(filePath);
|
|
4260
|
+
const row = this.stmts.getProviderByFilePath.get(canonicalizeFilePath(filePath));
|
|
4028
4261
|
if (!row) return false;
|
|
4029
4262
|
return (row.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
|
|
4030
4263
|
}
|
|
@@ -4271,7 +4504,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4271
4504
|
return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
|
|
4272
4505
|
}
|
|
4273
4506
|
static open(dbPath, tailSize = 10, migrationsDir, options) {
|
|
4274
|
-
(0, import_fs9.mkdirSync)((0,
|
|
4507
|
+
(0, import_fs9.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
|
|
4275
4508
|
const db = new import_better_sqlite3.default(dbPath);
|
|
4276
4509
|
db.pragma("journal_mode = WAL");
|
|
4277
4510
|
db.pragma("foreign_keys = ON");
|
|
@@ -4292,7 +4525,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4292
4525
|
if (this.fileIndexLoaded) return;
|
|
4293
4526
|
const rows = this.stmts.allFilePaths.all();
|
|
4294
4527
|
for (const row of rows) {
|
|
4295
|
-
this.fileIndex.set(row.file_path, row.id);
|
|
4528
|
+
this.fileIndex.set(canonicalizeFilePath(row.file_path), row.id);
|
|
4296
4529
|
}
|
|
4297
4530
|
this.fileIndexLoaded = true;
|
|
4298
4531
|
}
|
|
@@ -4311,12 +4544,13 @@ var ConversationCache = class _ConversationCache {
|
|
|
4311
4544
|
const role = line.role ?? line.type;
|
|
4312
4545
|
const isMessage = role === "user" || role === "assistant";
|
|
4313
4546
|
this.ensureFileIndex();
|
|
4547
|
+
const key = canonicalizeFilePath(filePath);
|
|
4314
4548
|
if (!isMessage && !line.cwd && !line.slug) return;
|
|
4315
|
-
let convId = this.fileIndex.get(
|
|
4549
|
+
let convId = this.fileIndex.get(key);
|
|
4316
4550
|
if (!convId) {
|
|
4317
|
-
const pseudoId =
|
|
4318
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4319
|
-
this.fileIndex.set(
|
|
4551
|
+
const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
|
|
4552
|
+
this.stmts.insertSkeleton.run(pseudoId, key, 0);
|
|
4553
|
+
this.fileIndex.set(key, pseudoId);
|
|
4320
4554
|
convId = pseudoId;
|
|
4321
4555
|
}
|
|
4322
4556
|
if (line.cwd || line.slug) {
|
|
@@ -4331,18 +4565,18 @@ var ConversationCache = class _ConversationCache {
|
|
|
4331
4565
|
});
|
|
4332
4566
|
}
|
|
4333
4567
|
if (!isMessage) return;
|
|
4334
|
-
const
|
|
4335
|
-
const activityMs = new Date(
|
|
4568
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4569
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4336
4570
|
if (Number.isNaN(activityMs)) return;
|
|
4337
4571
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4338
4572
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4339
|
-
const lastMessage = JSON.stringify({ role, timestamp, text });
|
|
4573
|
+
const lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4340
4574
|
const seq = ++this.tailSeq;
|
|
4341
4575
|
const result = this.stmts.updateMeta.run(activityMs, lastMessage, seq, convId);
|
|
4342
4576
|
if (result.changes === 0) return;
|
|
4343
4577
|
const tailRow = this.stmts.getTail.get(convId);
|
|
4344
4578
|
const msgs = tailRow ? JSON.parse(tailRow.messages_json) : [];
|
|
4345
|
-
msgs.push({ role, timestamp, text, content: contentBlocks });
|
|
4579
|
+
msgs.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4346
4580
|
if (msgs.length > this.tailSize) msgs.splice(0, msgs.length - this.tailSize);
|
|
4347
4581
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, seq);
|
|
4348
4582
|
}
|
|
@@ -4354,7 +4588,8 @@ var ConversationCache = class _ConversationCache {
|
|
|
4354
4588
|
* updateFromLine in order: the agent filter short-circuits the whole batch,
|
|
4355
4589
|
* project context is backfilled last-wins, message_count increases by the
|
|
4356
4590
|
* number of surviving message lines, and last_activity/last_message reflect
|
|
4357
|
-
* the
|
|
4591
|
+
* the newest message line by timestamp (a monotonic guard keeps them from
|
|
4592
|
+
* moving backward when an interleaved writer appends an older line — P0.3).
|
|
4358
4593
|
*/
|
|
4359
4594
|
updateFromLines(filePath, rawLines) {
|
|
4360
4595
|
let sawProjectContext = false;
|
|
@@ -4390,23 +4625,26 @@ var ConversationCache = class _ConversationCache {
|
|
|
4390
4625
|
backfillTitle ??= lineTitle;
|
|
4391
4626
|
}
|
|
4392
4627
|
if (!isMessage) continue;
|
|
4393
|
-
const
|
|
4394
|
-
const activityMs = new Date(
|
|
4628
|
+
const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4629
|
+
const activityMs = new Date(timestamp2).getTime();
|
|
4395
4630
|
if (Number.isNaN(activityMs)) continue;
|
|
4396
4631
|
const contentBlocks = normalizeContent(line.message?.content ?? line.content);
|
|
4397
4632
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4398
4633
|
msgCount += 1;
|
|
4399
|
-
lastActivityMs
|
|
4400
|
-
|
|
4401
|
-
|
|
4634
|
+
if (lastActivityMs === null || activityMs > lastActivityMs) {
|
|
4635
|
+
lastActivityMs = activityMs;
|
|
4636
|
+
lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
|
|
4637
|
+
}
|
|
4638
|
+
newTail.push({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4402
4639
|
}
|
|
4403
4640
|
if (!sawProjectContext && msgCount === 0) return;
|
|
4404
4641
|
this.ensureFileIndex();
|
|
4405
|
-
|
|
4642
|
+
const key = canonicalizeFilePath(filePath);
|
|
4643
|
+
let convId = this.fileIndex.get(key);
|
|
4406
4644
|
if (!convId) {
|
|
4407
|
-
const pseudoId =
|
|
4408
|
-
this.stmts.insertSkeleton.run(pseudoId,
|
|
4409
|
-
this.fileIndex.set(
|
|
4645
|
+
const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
|
|
4646
|
+
this.stmts.insertSkeleton.run(pseudoId, key, 0);
|
|
4647
|
+
this.fileIndex.set(key, pseudoId);
|
|
4410
4648
|
convId = pseudoId;
|
|
4411
4649
|
}
|
|
4412
4650
|
const id = convId;
|
|
@@ -4449,6 +4687,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4449
4687
|
for (const m of items) {
|
|
4450
4688
|
const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
|
|
4451
4689
|
const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
|
|
4690
|
+
const canonicalPath = canonicalizeFilePath(m.filePath);
|
|
4452
4691
|
let mtimeMs = null;
|
|
4453
4692
|
let fileSize = null;
|
|
4454
4693
|
try {
|
|
@@ -4464,10 +4703,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4464
4703
|
const scannerMetaJson = JSON.stringify(m);
|
|
4465
4704
|
this.stmts.upsertFull.run({
|
|
4466
4705
|
id,
|
|
4467
|
-
file_path:
|
|
4706
|
+
file_path: canonicalPath,
|
|
4468
4707
|
project_path: m.projectPath ?? null,
|
|
4469
4708
|
project_name: m.projectName ?? null,
|
|
4470
|
-
title: m.title ?? m.projectName ?? null,
|
|
4709
|
+
title: m.title ?? m.sessionName ?? m.projectName ?? null,
|
|
4471
4710
|
model: m.model ?? null,
|
|
4472
4711
|
account: m.account ?? null,
|
|
4473
4712
|
branch: m.gitBranch ?? null,
|
|
@@ -4483,7 +4722,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
4483
4722
|
scanner_meta_json: scannerMetaJson
|
|
4484
4723
|
});
|
|
4485
4724
|
this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
|
|
4486
|
-
if (this.fileIndexLoaded) this.fileIndex.set(
|
|
4725
|
+
if (this.fileIndexLoaded) this.fileIndex.set(canonicalPath, id);
|
|
4487
4726
|
upsertedIds.push(id);
|
|
4488
4727
|
}
|
|
4489
4728
|
});
|
|
@@ -4494,11 +4733,12 @@ var ConversationCache = class _ConversationCache {
|
|
|
4494
4733
|
// by updateFromLine when a previously-cached file turns out to be an agent
|
|
4495
4734
|
// JSONL.
|
|
4496
4735
|
deleteByFilePath(filePath) {
|
|
4497
|
-
const
|
|
4736
|
+
const key = canonicalizeFilePath(filePath);
|
|
4737
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4498
4738
|
if (!row) return false;
|
|
4499
4739
|
this.stmts.deleteTailById.run(row.id);
|
|
4500
4740
|
const result = this.stmts.deleteById.run(row.id);
|
|
4501
|
-
this.fileIndex.delete(
|
|
4741
|
+
this.fileIndex.delete(key);
|
|
4502
4742
|
return result.changes > 0;
|
|
4503
4743
|
}
|
|
4504
4744
|
// Reads the last `tailSize` qualifying lines from a JSONL file and writes them
|
|
@@ -4550,10 +4790,10 @@ var ConversationCache = class _ConversationCache {
|
|
|
4550
4790
|
}
|
|
4551
4791
|
const role = parsed.role ?? parsed.type;
|
|
4552
4792
|
if (!role) continue;
|
|
4553
|
-
const
|
|
4793
|
+
const timestamp2 = parsed.timestamp ?? "";
|
|
4554
4794
|
const contentBlocks = normalizeContent(parsed.message?.content ?? parsed.content);
|
|
4555
4795
|
const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
|
|
4556
|
-
msgs.unshift({ role, timestamp, text, content: contentBlocks });
|
|
4796
|
+
msgs.unshift({ role, timestamp: timestamp2, text, content: contentBlocks });
|
|
4557
4797
|
}
|
|
4558
4798
|
if (msgs.length === 0) return false;
|
|
4559
4799
|
this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, 0);
|
|
@@ -4621,6 +4861,16 @@ var ConversationCache = class _ConversationCache {
|
|
|
4621
4861
|
}
|
|
4622
4862
|
return map;
|
|
4623
4863
|
}
|
|
4864
|
+
/**
|
|
4865
|
+
* Conversation id for a JSONL path, or null when no row exists yet. Resolves
|
|
4866
|
+
* by file_path (NOT conversationIdForFile) so codex rollout files — named
|
|
4867
|
+
* rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
|
|
4868
|
+
*/
|
|
4869
|
+
getIdByFilePath(filePath) {
|
|
4870
|
+
const key = canonicalizeFilePath(filePath);
|
|
4871
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4872
|
+
return row?.id ?? null;
|
|
4873
|
+
}
|
|
4624
4874
|
getMetaById(id) {
|
|
4625
4875
|
const row = this.stmts.getFullById.get(id);
|
|
4626
4876
|
if (!row) return null;
|
|
@@ -4713,22 +4963,23 @@ var ConversationCache = class _ConversationCache {
|
|
|
4713
4963
|
/**
|
|
4714
4964
|
* Drop the cached row for a file. Two callers with opposite intent:
|
|
4715
4965
|
* - a directory-watch "change" event (the file was appended to) — pass
|
|
4716
|
-
* `skipIfTailed: true
|
|
4717
|
-
*
|
|
4718
|
-
*
|
|
4719
|
-
*
|
|
4720
|
-
*
|
|
4721
|
-
*
|
|
4722
|
-
*
|
|
4723
|
-
*
|
|
4724
|
-
* nothing.
|
|
4966
|
+
* `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
|
|
4967
|
+
* event fires on every external append; deleting here flickers the
|
|
4968
|
+
* conversation out of /api/conversations — whether it's a live-tailed row
|
|
4969
|
+
* the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
|
|
4970
|
+
* fire on the same append with no ordering guarantee) OR a refresh-created
|
|
4971
|
+
* untailed row (a ?refresh=1 upsert never populates a tail, so the old
|
|
4972
|
+
* "delete when untailed" behavior made it vanish on its next append with no
|
|
4973
|
+
* client action). The live-tail path owns the row's content and the
|
|
4974
|
+
* debounced rescan re-derives metadata, so leaving the row loses nothing.
|
|
4725
4975
|
* - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
|
|
4726
4976
|
* row is always removed, otherwise a deleted session ghosts in the cache.
|
|
4727
4977
|
*/
|
|
4728
4978
|
invalidateByFilePath(filePath, opts) {
|
|
4729
|
-
const
|
|
4979
|
+
const key = canonicalizeFilePath(filePath);
|
|
4980
|
+
const row = this.stmts.getIdByFilePath.get(key);
|
|
4730
4981
|
if (!row) return null;
|
|
4731
|
-
if (opts?.skipIfTailed
|
|
4982
|
+
if (opts?.skipIfTailed) return null;
|
|
4732
4983
|
this.invalidate(row.id);
|
|
4733
4984
|
return row.id;
|
|
4734
4985
|
}
|
|
@@ -4822,6 +5073,68 @@ var ConversationCache = class _ConversationCache {
|
|
|
4822
5073
|
}
|
|
4823
5074
|
return removed;
|
|
4824
5075
|
}
|
|
5076
|
+
/**
|
|
5077
|
+
* Read-only: list cached rows whose `file_path` no longer exists on disk.
|
|
5078
|
+
* Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
|
|
5079
|
+
* reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
|
|
5080
|
+
* rows that still have cached history (which pruneGhostFiles would keep).
|
|
5081
|
+
*/
|
|
5082
|
+
listMissingFiles(exists = import_fs9.existsSync) {
|
|
5083
|
+
const rows = this.stmts.allFilePathsWithTitle.all();
|
|
5084
|
+
const missing = [];
|
|
5085
|
+
for (const row of rows) {
|
|
5086
|
+
if (exists(row.file_path)) continue;
|
|
5087
|
+
missing.push({
|
|
5088
|
+
id: row.id,
|
|
5089
|
+
filePath: row.file_path,
|
|
5090
|
+
title: row.title,
|
|
5091
|
+
tailed: !!this.stmts.hasTail.get(row.id)
|
|
5092
|
+
});
|
|
5093
|
+
}
|
|
5094
|
+
return missing;
|
|
5095
|
+
}
|
|
5096
|
+
/**
|
|
5097
|
+
* Drop the given conversation ids outright — main row, tail, and message
|
|
5098
|
+
* index — regardless of whether they have a tail. Used by the cache-integrity
|
|
5099
|
+
* resolution actions (prune_all / prune_selected). Returns the count dropped.
|
|
5100
|
+
*/
|
|
5101
|
+
dropRowsById(ids) {
|
|
5102
|
+
if (ids.length === 0) return 0;
|
|
5103
|
+
const drop = this.db.transaction((toDrop) => {
|
|
5104
|
+
let n = 0;
|
|
5105
|
+
for (const id of toDrop) {
|
|
5106
|
+
this.stmts.deleteTailById.run(id);
|
|
5107
|
+
this.stmts.deleteMessageIndex.run(id);
|
|
5108
|
+
n += this.stmts.deleteById.run(id).changes;
|
|
5109
|
+
}
|
|
5110
|
+
return n;
|
|
5111
|
+
});
|
|
5112
|
+
const dropped = drop(ids);
|
|
5113
|
+
if (this.fileIndexLoaded) {
|
|
5114
|
+
for (const id of ids) {
|
|
5115
|
+
for (const [fp, cid] of this.fileIndex) {
|
|
5116
|
+
if (cid === id) {
|
|
5117
|
+
this.fileIndex.delete(fp);
|
|
5118
|
+
break;
|
|
5119
|
+
}
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
return dropped;
|
|
5124
|
+
}
|
|
5125
|
+
/**
|
|
5126
|
+
* Wipe all cached conversation state — meta, tails, and message index — and
|
|
5127
|
+
* reset the in-memory file index. Only called by the `reset_rescan`
|
|
5128
|
+
* resolution action, which repopulates from a fresh disk scan afterward.
|
|
5129
|
+
*/
|
|
5130
|
+
clearAll() {
|
|
5131
|
+
this.db.transaction(() => {
|
|
5132
|
+
this.stmts.deleteTailAll.run();
|
|
5133
|
+
this.stmts.deleteAll.run();
|
|
5134
|
+
this.db.exec("DELETE FROM conversation_message_index");
|
|
5135
|
+
})();
|
|
5136
|
+
this.fileIndex = /* @__PURE__ */ new Map();
|
|
5137
|
+
}
|
|
4825
5138
|
};
|
|
4826
5139
|
|
|
4827
5140
|
// src/db/repositories/cacheMetadata.repository.ts
|
|
@@ -5024,18 +5337,18 @@ async function recordUpload(pool2, instanceId, row) {
|
|
|
5024
5337
|
// src/handlers/handleListProjects.ts
|
|
5025
5338
|
var import_fs10 = require("fs");
|
|
5026
5339
|
var import_os6 = require("os");
|
|
5027
|
-
var
|
|
5340
|
+
var import_path13 = require("path");
|
|
5028
5341
|
function decodeProjectPath(dirName) {
|
|
5029
5342
|
return dirName.replace(/-/g, "/");
|
|
5030
5343
|
}
|
|
5031
5344
|
function handleListProjects(url, res) {
|
|
5032
5345
|
const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
|
|
5033
5346
|
const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
|
|
5034
|
-
const projectsDir = (0,
|
|
5347
|
+
const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
|
|
5035
5348
|
let entries;
|
|
5036
5349
|
try {
|
|
5037
5350
|
entries = (0, import_fs10.readdirSync)(projectsDir).map((dirName) => {
|
|
5038
|
-
const fullPath = (0,
|
|
5351
|
+
const fullPath = (0, import_path13.join)(projectsDir, dirName);
|
|
5039
5352
|
let mtime = 0;
|
|
5040
5353
|
try {
|
|
5041
5354
|
mtime = (0, import_fs10.statSync)(fullPath).mtimeMs;
|
|
@@ -5131,9 +5444,320 @@ function seal(plaintext, recipientPublicKeyBase64) {
|
|
|
5131
5444
|
};
|
|
5132
5445
|
}
|
|
5133
5446
|
|
|
5447
|
+
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5448
|
+
var import_crypto8 = require("crypto");
|
|
5449
|
+
var import_fs13 = require("fs");
|
|
5450
|
+
|
|
5451
|
+
// src/services/cache-integrity/alertStore.ts
|
|
5452
|
+
var import_fs11 = require("fs");
|
|
5453
|
+
var import_os7 = require("os");
|
|
5454
|
+
var import_path14 = require("path");
|
|
5455
|
+
function alertStatePath() {
|
|
5456
|
+
const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase");
|
|
5457
|
+
return (0, import_path14.join)(dir, "cache-alert.json");
|
|
5458
|
+
}
|
|
5459
|
+
function loadAlertState() {
|
|
5460
|
+
try {
|
|
5461
|
+
const parsed = JSON.parse((0, import_fs11.readFileSync)(alertStatePath(), "utf-8"));
|
|
5462
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
5463
|
+
} catch {
|
|
5464
|
+
return {};
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5467
|
+
function saveAlertState(state) {
|
|
5468
|
+
const path = alertStatePath();
|
|
5469
|
+
(0, import_fs11.mkdirSync)((0, import_path14.dirname)(path), { recursive: true });
|
|
5470
|
+
(0, import_fs11.writeFileSync)(path, `${JSON.stringify(state, null, 2)}
|
|
5471
|
+
`);
|
|
5472
|
+
}
|
|
5473
|
+
|
|
5474
|
+
// src/services/cache-integrity/backup.ts
|
|
5475
|
+
var import_fs12 = require("fs");
|
|
5476
|
+
var import_path15 = require("path");
|
|
5477
|
+
var DEFAULT_RETAIN = 3;
|
|
5478
|
+
function retainCount() {
|
|
5479
|
+
const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
|
|
5480
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RETAIN;
|
|
5481
|
+
}
|
|
5482
|
+
function timestamp(d) {
|
|
5483
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
5484
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
5485
|
+
}
|
|
5486
|
+
async function backupCacheDb(db, cacheDir) {
|
|
5487
|
+
const backupsDir = (0, import_path15.join)(cacheDir, "backups");
|
|
5488
|
+
(0, import_fs12.mkdirSync)(backupsDir, { recursive: true });
|
|
5489
|
+
const destPath = (0, import_path15.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
|
|
5490
|
+
await db.backup(destPath);
|
|
5491
|
+
const retain = retainCount();
|
|
5492
|
+
const backups = (0, import_fs12.readdirSync)(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
|
|
5493
|
+
const full = (0, import_path15.join)(backupsDir, f);
|
|
5494
|
+
return { full, mtime: (0, import_fs12.statSync)(full).mtimeMs };
|
|
5495
|
+
}).sort((a, b) => b.mtime - a.mtime);
|
|
5496
|
+
for (const stale of backups.slice(retain)) {
|
|
5497
|
+
if ((0, import_fs12.existsSync)(stale.full)) (0, import_fs12.unlinkSync)(stale.full);
|
|
5498
|
+
}
|
|
5499
|
+
return destPath;
|
|
5500
|
+
}
|
|
5501
|
+
|
|
5502
|
+
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5503
|
+
var MAX_MISSING_PERSISTED = 1e3;
|
|
5504
|
+
var SAMPLE_SIZE = 20;
|
|
5505
|
+
var STORM_WINDOW_MS = 3e4;
|
|
5506
|
+
var STORM_THRESHOLD = 10;
|
|
5507
|
+
function envInt(name, fallback) {
|
|
5508
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
5509
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
5510
|
+
}
|
|
5511
|
+
function fingerprintOf(ids) {
|
|
5512
|
+
const sorted = [...ids].sort();
|
|
5513
|
+
return `sha256:${(0, import_crypto8.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5514
|
+
}
|
|
5515
|
+
var CacheIntegrityMonitor = class {
|
|
5516
|
+
constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
|
|
5517
|
+
this.cache = cache;
|
|
5518
|
+
this.wsHub = wsHub;
|
|
5519
|
+
this.log = log3;
|
|
5520
|
+
this.cacheDir = cacheDir;
|
|
5521
|
+
this.rescan = rescan;
|
|
5522
|
+
this.runDuringReset = runDuringReset;
|
|
5523
|
+
const state = loadAlertState();
|
|
5524
|
+
this._pending = state.pending ?? null;
|
|
5525
|
+
this.ignoredIds = new Set(state.ignoredIds ?? []);
|
|
5526
|
+
}
|
|
5527
|
+
cache;
|
|
5528
|
+
wsHub;
|
|
5529
|
+
log;
|
|
5530
|
+
cacheDir;
|
|
5531
|
+
rescan;
|
|
5532
|
+
runDuringReset;
|
|
5533
|
+
_pending;
|
|
5534
|
+
ignoredIds;
|
|
5535
|
+
deferredUnlinks = [];
|
|
5536
|
+
unlinkTimes = [];
|
|
5537
|
+
get pending() {
|
|
5538
|
+
return this._pending;
|
|
5539
|
+
}
|
|
5540
|
+
persist() {
|
|
5541
|
+
const state = {};
|
|
5542
|
+
if (this._pending) {
|
|
5543
|
+
state.pending = {
|
|
5544
|
+
...this._pending,
|
|
5545
|
+
missing: this._pending.missing.slice(0, MAX_MISSING_PERSISTED)
|
|
5546
|
+
};
|
|
5547
|
+
}
|
|
5548
|
+
if (this.ignoredIds.size > 0) state.ignoredIds = [...this.ignoredIds];
|
|
5549
|
+
saveAlertState(state);
|
|
5550
|
+
}
|
|
5551
|
+
classifySeverity(missingCount, totalRows) {
|
|
5552
|
+
const minMissing = envInt("THREADBASE_CACHE_ALERT_MIN_MISSING", 20);
|
|
5553
|
+
const minRatio = Number.parseFloat(process.env.THREADBASE_CACHE_ALERT_MIN_RATIO ?? "0.20");
|
|
5554
|
+
const ratio = totalRows > 0 ? missingCount / totalRows : 0;
|
|
5555
|
+
const ratioThreshold = Number.isFinite(minRatio) ? minRatio : 0.2;
|
|
5556
|
+
return missingCount >= minMissing && ratio >= ratioThreshold ? "high" : "low";
|
|
5557
|
+
}
|
|
5558
|
+
sampleOf(missing) {
|
|
5559
|
+
return missing.slice(0, SAMPLE_SIZE).map((m) => ({
|
|
5560
|
+
id: m.id,
|
|
5561
|
+
...m.title != null ? { title: m.title } : {}
|
|
5562
|
+
}));
|
|
5563
|
+
}
|
|
5564
|
+
buildWsMessage(pending) {
|
|
5565
|
+
return {
|
|
5566
|
+
type: "cache_alert",
|
|
5567
|
+
fingerprint: pending.fingerprint,
|
|
5568
|
+
severity: pending.severity,
|
|
5569
|
+
missingCount: pending.missingCount,
|
|
5570
|
+
totalRows: pending.totalRows,
|
|
5571
|
+
detectedAt: pending.detectedAt,
|
|
5572
|
+
sample: this.sampleOf(pending.missing)
|
|
5573
|
+
};
|
|
5574
|
+
}
|
|
5575
|
+
wsMessage() {
|
|
5576
|
+
return this._pending ? this.buildWsMessage(this._pending) : null;
|
|
5577
|
+
}
|
|
5578
|
+
healthzField() {
|
|
5579
|
+
if (!this._pending) return void 0;
|
|
5580
|
+
return {
|
|
5581
|
+
severity: this._pending.severity,
|
|
5582
|
+
missingCount: this._pending.missingCount,
|
|
5583
|
+
fingerprint: this._pending.fingerprint,
|
|
5584
|
+
detectedAt: this._pending.detectedAt
|
|
5585
|
+
};
|
|
5586
|
+
}
|
|
5587
|
+
/**
|
|
5588
|
+
* Scan the cache for rows whose file is gone, excluding ids the user chose to
|
|
5589
|
+
* ignore. If none remain, clear any stale pending alert and return (the caller
|
|
5590
|
+
* decides whether to run pruneGhostFiles). Otherwise classify severity, persist
|
|
5591
|
+
* the pending record, back up on high severity, and broadcast the alert.
|
|
5592
|
+
*/
|
|
5593
|
+
async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
5594
|
+
const all = this.cache.listMissingFiles(import_fs13.existsSync);
|
|
5595
|
+
const missing = all.filter((m) => !this.ignoredIds.has(m.id));
|
|
5596
|
+
if (missing.length === 0) {
|
|
5597
|
+
if (this._pending) {
|
|
5598
|
+
this._pending = null;
|
|
5599
|
+
this.persist();
|
|
5600
|
+
}
|
|
5601
|
+
return;
|
|
5602
|
+
}
|
|
5603
|
+
const totalRows = this.cache.listConversations({ limit: 0, offset: 0 }).total;
|
|
5604
|
+
const fingerprint = fingerprintOf(missing.map((m) => m.id));
|
|
5605
|
+
const severity = this.classifySeverity(missing.length, totalRows);
|
|
5606
|
+
const pending = {
|
|
5607
|
+
fingerprint,
|
|
5608
|
+
severity,
|
|
5609
|
+
detectedAt,
|
|
5610
|
+
missingCount: missing.length,
|
|
5611
|
+
totalRows,
|
|
5612
|
+
missing
|
|
5613
|
+
};
|
|
5614
|
+
if (severity === "high") {
|
|
5615
|
+
try {
|
|
5616
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5617
|
+
} catch (err) {
|
|
5618
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5619
|
+
event: "cache_integrity.backup_failed",
|
|
5620
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5621
|
+
});
|
|
5622
|
+
}
|
|
5623
|
+
}
|
|
5624
|
+
this._pending = pending;
|
|
5625
|
+
this.persist();
|
|
5626
|
+
this.log.warn("cache integrity drift detected", {
|
|
5627
|
+
event: "cache_integrity.detected",
|
|
5628
|
+
severity,
|
|
5629
|
+
missingCount: missing.length,
|
|
5630
|
+
totalRows,
|
|
5631
|
+
fingerprint
|
|
5632
|
+
});
|
|
5633
|
+
this.wsHub.broadcast(this.buildWsMessage(pending));
|
|
5634
|
+
}
|
|
5635
|
+
/** Queue an unlink while an alert is pending — the row is not invalidated. */
|
|
5636
|
+
deferUnlink(filePath) {
|
|
5637
|
+
this.deferredUnlinks.push(filePath);
|
|
5638
|
+
}
|
|
5639
|
+
/**
|
|
5640
|
+
* Record a live unlink while NO alert is pending. Crossing the storm threshold
|
|
5641
|
+
* (>= 10 unlinks within 30s) re-triggers detection.
|
|
5642
|
+
*/
|
|
5643
|
+
recordUnlink(filePath) {
|
|
5644
|
+
const now = Date.now();
|
|
5645
|
+
this.unlinkTimes.push(now);
|
|
5646
|
+
this.unlinkTimes = this.unlinkTimes.filter((t) => now - t < STORM_WINDOW_MS);
|
|
5647
|
+
if (this.unlinkTimes.length >= STORM_THRESHOLD) {
|
|
5648
|
+
this.unlinkTimes = [];
|
|
5649
|
+
void this.runDetection().catch((err) => {
|
|
5650
|
+
this.log.error("cache-integrity storm detection failed", {
|
|
5651
|
+
event: "cache_integrity.storm_detection_failed",
|
|
5652
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5653
|
+
filePath
|
|
5654
|
+
});
|
|
5655
|
+
});
|
|
5656
|
+
}
|
|
5657
|
+
}
|
|
5658
|
+
async ensureBackup(pending) {
|
|
5659
|
+
if (pending.backupPath) return pending.backupPath;
|
|
5660
|
+
try {
|
|
5661
|
+
pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
|
|
5662
|
+
} catch (err) {
|
|
5663
|
+
this.log.warn("cache-integrity backup failed", {
|
|
5664
|
+
event: "cache_integrity.backup_failed",
|
|
5665
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5666
|
+
});
|
|
5667
|
+
}
|
|
5668
|
+
return pending.backupPath;
|
|
5669
|
+
}
|
|
5670
|
+
clearPending() {
|
|
5671
|
+
this._pending = null;
|
|
5672
|
+
this.deferredUnlinks = [];
|
|
5673
|
+
this.persist();
|
|
5674
|
+
}
|
|
5675
|
+
applyDeferredUnlinks() {
|
|
5676
|
+
for (const fp of this.deferredUnlinks) this.cache.invalidateByFilePath(fp);
|
|
5677
|
+
this.deferredUnlinks = [];
|
|
5678
|
+
}
|
|
5679
|
+
broadcastResolved(fingerprint, action) {
|
|
5680
|
+
this.wsHub.broadcast({ type: "cache_alert_resolved", fingerprint, action });
|
|
5681
|
+
}
|
|
5682
|
+
/**
|
|
5683
|
+
* Apply the human's chosen resolution. Idempotent per fingerprint: no pending
|
|
5684
|
+
* alert → alreadyResolved; a different fingerprint → conflict. See the spec's
|
|
5685
|
+
* four-action semantics.
|
|
5686
|
+
*/
|
|
5687
|
+
async resolve(fingerprint, action, ids) {
|
|
5688
|
+
const pending = this._pending;
|
|
5689
|
+
if (!pending) return { alreadyResolved: true };
|
|
5690
|
+
if (pending.fingerprint !== fingerprint) {
|
|
5691
|
+
return { conflict: true, currentFingerprint: pending.fingerprint };
|
|
5692
|
+
}
|
|
5693
|
+
this._pending = null;
|
|
5694
|
+
switch (action) {
|
|
5695
|
+
case "prune_all": {
|
|
5696
|
+
await this.ensureBackup(pending);
|
|
5697
|
+
const backupPath = pending.backupPath;
|
|
5698
|
+
const stillMissing = pending.missing.filter((m) => !(0, import_fs13.existsSync)(m.filePath)).map((m) => m.id);
|
|
5699
|
+
const pruned = this.cache.dropRowsById(stillMissing);
|
|
5700
|
+
this.applyDeferredUnlinks();
|
|
5701
|
+
this.clearPending();
|
|
5702
|
+
this.broadcastResolved(fingerprint, action);
|
|
5703
|
+
return { ok: true, action, pruned, backupPath };
|
|
5704
|
+
}
|
|
5705
|
+
case "prune_selected": {
|
|
5706
|
+
const requested = new Set(ids ?? []);
|
|
5707
|
+
const pendingIds = new Set(pending.missing.map((m) => m.id));
|
|
5708
|
+
const toDrop = [...requested].filter((id) => pendingIds.has(id));
|
|
5709
|
+
await this.ensureBackup(pending);
|
|
5710
|
+
const backupPath = pending.backupPath;
|
|
5711
|
+
const pruned = this.cache.dropRowsById(toDrop);
|
|
5712
|
+
const prunedPaths = new Set(
|
|
5713
|
+
pending.missing.filter((m) => toDrop.includes(m.id)).map((m) => m.filePath)
|
|
5714
|
+
);
|
|
5715
|
+
this.deferredUnlinks = this.deferredUnlinks.filter((fp) => {
|
|
5716
|
+
if (prunedPaths.has(fp)) {
|
|
5717
|
+
this.cache.invalidateByFilePath(fp);
|
|
5718
|
+
return false;
|
|
5719
|
+
}
|
|
5720
|
+
return true;
|
|
5721
|
+
});
|
|
5722
|
+
this.persist();
|
|
5723
|
+
await this.runDetection();
|
|
5724
|
+
this.broadcastResolved(fingerprint, action);
|
|
5725
|
+
return { ok: true, action, pruned, backupPath };
|
|
5726
|
+
}
|
|
5727
|
+
case "ignore": {
|
|
5728
|
+
for (const m of pending.missing) this.ignoredIds.add(m.id);
|
|
5729
|
+
this.deferredUnlinks = [];
|
|
5730
|
+
this.clearPending();
|
|
5731
|
+
this.broadcastResolved(fingerprint, action);
|
|
5732
|
+
return { ok: true, action };
|
|
5733
|
+
}
|
|
5734
|
+
case "reset_rescan": {
|
|
5735
|
+
const backupPath = await this.ensureBackup(pending);
|
|
5736
|
+
const reset = async () => {
|
|
5737
|
+
this.cache.clearAll();
|
|
5738
|
+
if (this.rescan) {
|
|
5739
|
+
const metas = await this.rescan();
|
|
5740
|
+
this.cache.upsertFromScannerMeta(metas);
|
|
5741
|
+
}
|
|
5742
|
+
};
|
|
5743
|
+
const resetPromise = this.runDuringReset ? this.runDuringReset(reset) : reset();
|
|
5744
|
+
void resetPromise.catch((err) => {
|
|
5745
|
+
this.log.error("cache-integrity reset rescan failed", {
|
|
5746
|
+
event: "cache_integrity.reset_rescan_failed",
|
|
5747
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5748
|
+
});
|
|
5749
|
+
});
|
|
5750
|
+
this.clearPending();
|
|
5751
|
+
this.broadcastResolved(fingerprint, action);
|
|
5752
|
+
return { ok: true, action, backupPath };
|
|
5753
|
+
}
|
|
5754
|
+
}
|
|
5755
|
+
}
|
|
5756
|
+
};
|
|
5757
|
+
|
|
5134
5758
|
// src/services/conversations/conversationWatcher.ts
|
|
5135
5759
|
var import_chokidar = __toESM(require("chokidar"), 1);
|
|
5136
|
-
var
|
|
5760
|
+
var import_fs14 = require("fs");
|
|
5137
5761
|
var import_promises5 = require("fs/promises");
|
|
5138
5762
|
var ConversationWatcher = class {
|
|
5139
5763
|
files = /* @__PURE__ */ new Map();
|
|
@@ -5143,6 +5767,7 @@ var ConversationWatcher = class {
|
|
|
5143
5767
|
onNewLineSpans;
|
|
5144
5768
|
onConversationChanged;
|
|
5145
5769
|
onFileDeleted;
|
|
5770
|
+
onTruncated;
|
|
5146
5771
|
onError;
|
|
5147
5772
|
constructor(events = {}) {
|
|
5148
5773
|
this.onNewLine = events.onNewLine;
|
|
@@ -5150,13 +5775,15 @@ var ConversationWatcher = class {
|
|
|
5150
5775
|
this.onNewLineSpans = events.onNewLineSpans;
|
|
5151
5776
|
this.onConversationChanged = events.onConversationChanged;
|
|
5152
5777
|
this.onFileDeleted = events.onFileDeleted;
|
|
5778
|
+
this.onTruncated = events.onTruncated;
|
|
5153
5779
|
this.onError = events.onError;
|
|
5154
5780
|
}
|
|
5155
5781
|
watch(filePath) {
|
|
5156
|
-
|
|
5782
|
+
const key = canonicalizeFilePath(filePath);
|
|
5783
|
+
if (this.files.has(key)) return;
|
|
5157
5784
|
let offset;
|
|
5158
5785
|
try {
|
|
5159
|
-
offset = (0,
|
|
5786
|
+
offset = (0, import_fs14.statSync)(filePath).size;
|
|
5160
5787
|
} catch {
|
|
5161
5788
|
offset = 0;
|
|
5162
5789
|
}
|
|
@@ -5165,23 +5792,24 @@ var ConversationWatcher = class {
|
|
|
5165
5792
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
|
|
5166
5793
|
});
|
|
5167
5794
|
watcher.on("change", () => {
|
|
5168
|
-
void this.readNewLines(
|
|
5795
|
+
void this.readNewLines(key);
|
|
5169
5796
|
});
|
|
5170
5797
|
watcher.on("add", () => {
|
|
5171
|
-
void this.readNewLines(
|
|
5798
|
+
void this.readNewLines(key);
|
|
5172
5799
|
});
|
|
5173
5800
|
watcher.on("unlink", () => this.onFileDeleted?.(filePath));
|
|
5174
5801
|
watcher.on("error", (err) => {
|
|
5175
5802
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
5176
5803
|
this.onError?.(filePath, error);
|
|
5177
5804
|
});
|
|
5178
|
-
this.files.set(
|
|
5805
|
+
this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
|
|
5179
5806
|
}
|
|
5180
5807
|
unwatch(filePath) {
|
|
5181
|
-
const
|
|
5808
|
+
const key = canonicalizeFilePath(filePath);
|
|
5809
|
+
const entry = this.files.get(key);
|
|
5182
5810
|
if (!entry) return;
|
|
5183
5811
|
void entry.watcher.close();
|
|
5184
|
-
this.files.delete(
|
|
5812
|
+
this.files.delete(key);
|
|
5185
5813
|
}
|
|
5186
5814
|
/**
|
|
5187
5815
|
* Re-drive the tail read for a file that's already being tailed. A per-file
|
|
@@ -5192,8 +5820,9 @@ var ConversationWatcher = class {
|
|
|
5192
5820
|
* event is a cheap stat + no-op. Returns false for untailed paths.
|
|
5193
5821
|
*/
|
|
5194
5822
|
poke(filePath) {
|
|
5195
|
-
|
|
5196
|
-
|
|
5823
|
+
const key = canonicalizeFilePath(filePath);
|
|
5824
|
+
if (!this.files.has(key)) return false;
|
|
5825
|
+
void this.readNewLines(key);
|
|
5197
5826
|
return true;
|
|
5198
5827
|
}
|
|
5199
5828
|
/**
|
|
@@ -5230,9 +5859,10 @@ var ConversationWatcher = class {
|
|
|
5230
5859
|
for (const [path] of this.files) this.unwatch(path);
|
|
5231
5860
|
for (const [dir] of this.directories) this.unwatchDirectory(dir);
|
|
5232
5861
|
}
|
|
5233
|
-
async readNewLines(
|
|
5234
|
-
const entry = this.files.get(
|
|
5862
|
+
async readNewLines(key) {
|
|
5863
|
+
const entry = this.files.get(key);
|
|
5235
5864
|
if (!entry) return;
|
|
5865
|
+
const filePath = entry.path;
|
|
5236
5866
|
if (entry.reading) {
|
|
5237
5867
|
entry.pending = true;
|
|
5238
5868
|
return;
|
|
@@ -5241,6 +5871,10 @@ var ConversationWatcher = class {
|
|
|
5241
5871
|
try {
|
|
5242
5872
|
for (; ; ) {
|
|
5243
5873
|
const st = await (0, import_promises5.stat)(filePath);
|
|
5874
|
+
if (st.size < entry.offset) {
|
|
5875
|
+
entry.offset = 0;
|
|
5876
|
+
this.onTruncated?.(filePath);
|
|
5877
|
+
}
|
|
5244
5878
|
if (st.size <= entry.offset) break;
|
|
5245
5879
|
const readFrom = entry.offset;
|
|
5246
5880
|
const bytesToRead = st.size - readFrom;
|
|
@@ -5253,7 +5887,7 @@ var ConversationWatcher = class {
|
|
|
5253
5887
|
}
|
|
5254
5888
|
const { spans, consumed } = splitCompleteLines(buf, readFrom);
|
|
5255
5889
|
entry.offset = readFrom + consumed;
|
|
5256
|
-
if (!this.files.has(
|
|
5890
|
+
if (!this.files.has(key)) return;
|
|
5257
5891
|
const lines = spans.map((s) => s.text);
|
|
5258
5892
|
if (spans.length > 0) {
|
|
5259
5893
|
this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
|
|
@@ -5273,9 +5907,9 @@ var ConversationWatcher = class {
|
|
|
5273
5907
|
this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
|
|
5274
5908
|
} finally {
|
|
5275
5909
|
entry.reading = false;
|
|
5276
|
-
if (entry.pending && this.files.has(
|
|
5910
|
+
if (entry.pending && this.files.has(key)) {
|
|
5277
5911
|
entry.pending = false;
|
|
5278
|
-
void this.readNewLines(
|
|
5912
|
+
void this.readNewLines(key);
|
|
5279
5913
|
}
|
|
5280
5914
|
}
|
|
5281
5915
|
}
|
|
@@ -5331,14 +5965,14 @@ function findSearchTarget(messages, query) {
|
|
|
5331
5965
|
}
|
|
5332
5966
|
|
|
5333
5967
|
// src/services/conversations/pruneAgentConversations.ts
|
|
5334
|
-
var
|
|
5968
|
+
var import_fs15 = require("fs");
|
|
5335
5969
|
function pruneAgentConversations(cache) {
|
|
5336
5970
|
const db = cache.getDatabase();
|
|
5337
5971
|
const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
|
|
5338
5972
|
let pruned = 0;
|
|
5339
5973
|
let missing = 0;
|
|
5340
5974
|
for (const row of rows) {
|
|
5341
|
-
if (!(0,
|
|
5975
|
+
if (!(0, import_fs15.existsSync)(row.file_path)) {
|
|
5342
5976
|
missing += 1;
|
|
5343
5977
|
continue;
|
|
5344
5978
|
}
|
|
@@ -5484,6 +6118,52 @@ function resolveAnswer(pending, body) {
|
|
|
5484
6118
|
}
|
|
5485
6119
|
}
|
|
5486
6120
|
|
|
6121
|
+
// src/services/sessions/conversationBusy.ts
|
|
6122
|
+
var import_fs16 = require("fs");
|
|
6123
|
+
var RESUME_BUSY_WINDOW_MS = 12e4;
|
|
6124
|
+
function resolveResumeBusyWindowMs(env = process.env) {
|
|
6125
|
+
const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
|
|
6126
|
+
if (raw === void 0) return RESUME_BUSY_WINDOW_MS;
|
|
6127
|
+
const n = Number.parseInt(raw, 10);
|
|
6128
|
+
return Number.isFinite(n) && n >= 0 ? n : RESUME_BUSY_WINDOW_MS;
|
|
6129
|
+
}
|
|
6130
|
+
var SELF_ACTIVITY_SKEW_MS = 5e3;
|
|
6131
|
+
function conversationBusy(input) {
|
|
6132
|
+
const now = input.now ?? Date.now();
|
|
6133
|
+
const windowMs = input.windowMs ?? RESUME_BUSY_WINDOW_MS;
|
|
6134
|
+
const platform3 = input.platform ?? process.platform;
|
|
6135
|
+
const detectedBy = [];
|
|
6136
|
+
let lastActivityMs = null;
|
|
6137
|
+
if (input.jsonlPath) {
|
|
6138
|
+
try {
|
|
6139
|
+
const mtimeMs = (0, import_fs16.statSync)(input.jsonlPath).mtimeMs;
|
|
6140
|
+
const age = now - mtimeMs;
|
|
6141
|
+
lastActivityMs = Math.max(0, age);
|
|
6142
|
+
const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
|
|
6143
|
+
if (age <= windowMs && !isSelfEcho) detectedBy.push("jsonl_mtime");
|
|
6144
|
+
} catch {
|
|
6145
|
+
}
|
|
6146
|
+
}
|
|
6147
|
+
const argvMatch = input.discovered.some((p) => p.conversationId === input.conversationId);
|
|
6148
|
+
if (argvMatch) detectedBy.push("process_argv");
|
|
6149
|
+
let cwdMatch = false;
|
|
6150
|
+
if (platform3 !== "win32" && input.projectPath) {
|
|
6151
|
+
const target = canonicalizeProjectPath(input.projectPath);
|
|
6152
|
+
cwdMatch = input.discovered.some(
|
|
6153
|
+
(p) => !!p.projectPath && canonicalizeProjectPath(p.projectPath) === target
|
|
6154
|
+
);
|
|
6155
|
+
if (cwdMatch) detectedBy.push("process_cwd");
|
|
6156
|
+
}
|
|
6157
|
+
return {
|
|
6158
|
+
busy: detectedBy.length > 0,
|
|
6159
|
+
detectedBy,
|
|
6160
|
+
lastActivityMs,
|
|
6161
|
+
// A matched process is a concrete external owner; a lone mtime hit could be
|
|
6162
|
+
// an editor, a crashed process, or a process we could not enumerate.
|
|
6163
|
+
likelyOwner: argvMatch || cwdMatch ? "external" : "unknown"
|
|
6164
|
+
};
|
|
6165
|
+
}
|
|
6166
|
+
|
|
5487
6167
|
// src/session-store.ts
|
|
5488
6168
|
var SessionStore = class {
|
|
5489
6169
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -5623,6 +6303,9 @@ function managedToResponse(s, ptyAttached) {
|
|
|
5623
6303
|
conversationId: s.id,
|
|
5624
6304
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
5625
6305
|
status: s.status,
|
|
6306
|
+
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6307
|
+
// `activity` is attached for managed sessions.
|
|
6308
|
+
ownership: "managed",
|
|
5626
6309
|
projectPath: s.projectPath,
|
|
5627
6310
|
projectName: s.projectName,
|
|
5628
6311
|
branch: s.branch,
|
|
@@ -5656,7 +6339,14 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5656
6339
|
id: conversationId,
|
|
5657
6340
|
conversationId,
|
|
5658
6341
|
provider: CLAUDE_CODE_PROVIDER,
|
|
6342
|
+
// Stays "idle" deliberately: we cannot see this process's prompt state, and
|
|
6343
|
+
// reporting `running` would route mobile to the destructive Overtake screen.
|
|
6344
|
+
// Liveness travels in the additive fields below instead.
|
|
5659
6345
|
status: "idle",
|
|
6346
|
+
ownership: "external",
|
|
6347
|
+
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6348
|
+
// report "gone" here — a vanished process simply stops being listed.
|
|
6349
|
+
processLiveness: "alive",
|
|
5660
6350
|
projectPath: d.projectPath,
|
|
5661
6351
|
projectName: d.projectName,
|
|
5662
6352
|
branch: d.branch,
|
|
@@ -5671,10 +6361,10 @@ function discoveredToResponse(d, conversationId) {
|
|
|
5671
6361
|
}
|
|
5672
6362
|
|
|
5673
6363
|
// src/uploads.ts
|
|
5674
|
-
var
|
|
6364
|
+
var import_crypto9 = require("crypto");
|
|
5675
6365
|
var import_promises6 = require("fs/promises");
|
|
5676
6366
|
var import_heic_convert = __toESM(require("heic-convert"), 1);
|
|
5677
|
-
var
|
|
6367
|
+
var import_path16 = require("path");
|
|
5678
6368
|
var UPLOAD_DIR_NAME = ".threadbase-uploads";
|
|
5679
6369
|
var MAX_BYTES = 25 * 1024 * 1024;
|
|
5680
6370
|
var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
@@ -5705,11 +6395,11 @@ async function saveUploadFile(input) {
|
|
|
5705
6395
|
mimeType = "image/jpeg";
|
|
5706
6396
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
5707
6397
|
}
|
|
5708
|
-
const id = `up_${(0,
|
|
6398
|
+
const id = `up_${(0, import_crypto9.randomBytes)(8).toString("hex")}`;
|
|
5709
6399
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
5710
|
-
const dir = (0,
|
|
6400
|
+
const dir = (0, import_path16.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
5711
6401
|
await (0, import_promises6.mkdir)(dir, { recursive: true });
|
|
5712
|
-
const filePath = (0,
|
|
6402
|
+
const filePath = (0, import_path16.join)(dir, `${Date.now()}-${id}-${safeName}`);
|
|
5713
6403
|
await (0, import_promises6.writeFile)(filePath, buffer);
|
|
5714
6404
|
return {
|
|
5715
6405
|
id,
|
|
@@ -5721,7 +6411,7 @@ async function saveUploadFile(input) {
|
|
|
5721
6411
|
}
|
|
5722
6412
|
function sanitizeFilename(name) {
|
|
5723
6413
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
5724
|
-
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("");
|
|
6414
|
+
const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("").replace(/[\s@"'`$\\]/g, "_");
|
|
5725
6415
|
return cleaned;
|
|
5726
6416
|
}
|
|
5727
6417
|
|
|
@@ -5754,12 +6444,12 @@ function normalizeCodexLineToClaudeShape(line) {
|
|
|
5754
6444
|
const text = extractCodexText(payload.content);
|
|
5755
6445
|
if (!text) return null;
|
|
5756
6446
|
if (role === "user" && isCodexInjectedContext(text)) return null;
|
|
5757
|
-
const
|
|
5758
|
-
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${
|
|
6447
|
+
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6448
|
+
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
5759
6449
|
return JSON.stringify({
|
|
5760
6450
|
type: role,
|
|
5761
6451
|
uuid,
|
|
5762
|
-
timestamp,
|
|
6452
|
+
timestamp: timestamp2,
|
|
5763
6453
|
message: {
|
|
5764
6454
|
role,
|
|
5765
6455
|
content: [{ type: "text", text }]
|
|
@@ -5813,9 +6503,9 @@ var import_node_crypto3 = require("crypto");
|
|
|
5813
6503
|
function computeConversationEtag({
|
|
5814
6504
|
filePath,
|
|
5815
6505
|
messageCount,
|
|
5816
|
-
timestamp
|
|
6506
|
+
timestamp: timestamp2
|
|
5817
6507
|
}) {
|
|
5818
|
-
const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${
|
|
6508
|
+
const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
5819
6509
|
return `"${digest}"`;
|
|
5820
6510
|
}
|
|
5821
6511
|
|
|
@@ -5965,8 +6655,17 @@ var WSHub = class {
|
|
|
5965
6655
|
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.`;
|
|
5966
6656
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
5967
6657
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6658
|
+
var GRACE_MAX_DEFERS = 4;
|
|
6659
|
+
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6660
|
+
var DISCOVERY_TTL_MS = 15e3;
|
|
6661
|
+
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
6662
|
+
var ADOPT_KILL_POLL_MS = 100;
|
|
5968
6663
|
var REFRESH_TTL_MS = 2e3;
|
|
5969
6664
|
var START_READY_TIMEOUT_MS = 1e4;
|
|
6665
|
+
var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
|
|
6666
|
+
var EXTERNAL_TAIL_MAX = 32;
|
|
6667
|
+
var EXTERNAL_TAIL_IDLE_MS = 3e5;
|
|
6668
|
+
var EXTERNAL_ACTIVE_WRITING_MS = 3e4;
|
|
5970
6669
|
function parseIncludeAgentsEnv(raw) {
|
|
5971
6670
|
if (raw === void 0) return false;
|
|
5972
6671
|
const v = raw.trim().toLowerCase();
|
|
@@ -5980,11 +6679,30 @@ var StreamerServer = class {
|
|
|
5980
6679
|
fileWatcher;
|
|
5981
6680
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
5982
6681
|
// sessionId → JSONL filePath
|
|
6682
|
+
// canonical JSONL path → live tail on a file NO PTY session owns (an external
|
|
6683
|
+
// agent is writing it). Deliberately separate from sessionFileMap so managed
|
|
6684
|
+
// session semantics — terminal_output, session_update, question cards — are
|
|
6685
|
+
// untouched: an external tail only ever pushes transcript lines.
|
|
6686
|
+
externalTails = /* @__PURE__ */ new Map();
|
|
5983
6687
|
// Per-file seq assignments from the most recent onNewLineSpans (offset index),
|
|
5984
6688
|
// handed to the immediately-following onNewLines so it can stamp WS `seq` on
|
|
5985
6689
|
// the matching conversation_events entries. Same read → same lines order.
|
|
5986
6690
|
pendingLineSeqs = /* @__PURE__ */ new Map();
|
|
6691
|
+
// `origin` records whether the pending question came from the live PTY-screen
|
|
6692
|
+
// path (handleLiveQuestion) or a JSONL flush. A JSONL-derived question must
|
|
6693
|
+
// never clobber a PTY-originated one for a DIFFERENT question — an external
|
|
6694
|
+
// agent appending an AskUserQuestion into a shared conversation would
|
|
6695
|
+
// otherwise misroute the answer into this streamer's PTY.
|
|
5987
6696
|
pendingQuestions = /* @__PURE__ */ new Map();
|
|
6697
|
+
// Sessions resumed past a detected collision (busy probe said busy, caller
|
|
6698
|
+
// forced). JSONL-derived actionable question cards are suppressed for these
|
|
6699
|
+
// because a line in the shared file may have been written by the other owner.
|
|
6700
|
+
contendedSessions = /* @__PURE__ */ new Set();
|
|
6701
|
+
// conversationId → ms epoch when THIS streamer's PTY for it last went idle.
|
|
6702
|
+
// Lets the resume collision probe tell our own trailing JSONL writes (a
|
|
6703
|
+
// hold → resume round trip) apart from another owner's. Pruned on write so it
|
|
6704
|
+
// cannot grow without bound across a long-lived process.
|
|
6705
|
+
selfPtyEndedAt = /* @__PURE__ */ new Map();
|
|
5988
6706
|
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
5989
6707
|
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
5990
6708
|
// the screen detection fires first, the later JSONL flush of the same question
|
|
@@ -6019,7 +6737,8 @@ var StreamerServer = class {
|
|
|
6019
6737
|
// listener-level 'error' handler demotes EADDRINUSE to debug during this
|
|
6020
6738
|
// window so the self-healing kickstart-relaunch race doesn't spam warn.
|
|
6021
6739
|
binding = false;
|
|
6022
|
-
|
|
6740
|
+
activeWarmups = /* @__PURE__ */ new Map([[0, "startup"]]);
|
|
6741
|
+
nextWarmupId = 1;
|
|
6023
6742
|
// Every fire-and-forget task that runs a scan and then writes to this.cache
|
|
6024
6743
|
// in an async continuation (startup warm-up, background count refresh, …).
|
|
6025
6744
|
// close() awaits all of them before closing this.cache, so a scan's post-scan
|
|
@@ -6051,6 +6770,9 @@ var StreamerServer = class {
|
|
|
6051
6770
|
defaultEffort;
|
|
6052
6771
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6053
6772
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6773
|
+
// Consecutive grace-timer defers for a still-`running` session (see
|
|
6774
|
+
// GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
|
|
6775
|
+
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6054
6776
|
// Map of sessionId → set of subscribed WS clients
|
|
6055
6777
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
6056
6778
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -6058,6 +6780,7 @@ var StreamerServer = class {
|
|
|
6058
6780
|
// Reverse map for cleanup on close
|
|
6059
6781
|
wsToClientId = /* @__PURE__ */ new Map();
|
|
6060
6782
|
cache = null;
|
|
6783
|
+
cacheMonitor = null;
|
|
6061
6784
|
projectsRepo = null;
|
|
6062
6785
|
conversationsRepo = null;
|
|
6063
6786
|
sessionsRepo = null;
|
|
@@ -6094,13 +6817,13 @@ var StreamerServer = class {
|
|
|
6094
6817
|
this.disableDb = config.disableDb ?? false;
|
|
6095
6818
|
this.scannerPersistenceDisabled = config.scannerPersistent === false;
|
|
6096
6819
|
this.scanProfiles = config.scanProfiles;
|
|
6097
|
-
this.codexRoots = config.codexRoots ?? [(0,
|
|
6820
|
+
this.codexRoots = config.codexRoots ?? [(0, import_path17.join)((0, import_os8.homedir)(), ".codex", "sessions")];
|
|
6098
6821
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
6099
6822
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
6100
6823
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6101
6824
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6102
6825
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
6103
|
-
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0,
|
|
6826
|
+
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path17.join)((0, import_os8.homedir)(), ".threadbase", "cache");
|
|
6104
6827
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6105
6828
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
6106
6829
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -6141,7 +6864,7 @@ var StreamerServer = class {
|
|
|
6141
6864
|
const seqs = cache.extendMessageIndex(
|
|
6142
6865
|
filePath,
|
|
6143
6866
|
spans,
|
|
6144
|
-
(0,
|
|
6867
|
+
(0, import_fs17.statSync)(filePath),
|
|
6145
6868
|
readFrom,
|
|
6146
6869
|
endOffset
|
|
6147
6870
|
);
|
|
@@ -6171,39 +6894,25 @@ var StreamerServer = class {
|
|
|
6171
6894
|
},
|
|
6172
6895
|
onNewLines: (filePath, lines) => {
|
|
6173
6896
|
this.cache?.updateFromLines(filePath, lines);
|
|
6897
|
+
let managed = false;
|
|
6174
6898
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
6175
6899
|
if (watchedPath === filePath) {
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
for (const p of pending) {
|
|
6179
|
-
this.pendingQuestions.set(sessionId, p);
|
|
6180
|
-
const t = setTimeout(() => {
|
|
6181
|
-
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
6182
|
-
this.cancelPendingQuestion(sessionId);
|
|
6183
|
-
}
|
|
6184
|
-
}, 6e4);
|
|
6185
|
-
t.unref();
|
|
6186
|
-
}
|
|
6187
|
-
for (const m of messages) {
|
|
6188
|
-
const key = questionContentKey(m.questions);
|
|
6189
|
-
const broadcast = shouldBroadcastQuestion({
|
|
6190
|
-
newContentKey: key,
|
|
6191
|
-
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
6192
|
-
newToolUseId: m.toolUseId,
|
|
6193
|
-
priorToolUseId
|
|
6194
|
-
});
|
|
6195
|
-
this.pendingQuestionKey.set(sessionId, key);
|
|
6196
|
-
if (broadcast) this.wsHub.broadcast(m);
|
|
6197
|
-
}
|
|
6900
|
+
managed = true;
|
|
6901
|
+
this.processJsonlQuestions(sessionId, lines);
|
|
6198
6902
|
const seqs = this.pendingLineSeqs.get(filePath);
|
|
6199
6903
|
this.broadcastConversationLines(sessionId, lines, seqs);
|
|
6200
6904
|
break;
|
|
6201
6905
|
}
|
|
6202
6906
|
}
|
|
6907
|
+
if (!managed) {
|
|
6908
|
+
this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
|
|
6909
|
+
}
|
|
6203
6910
|
this.pendingLineSeqs.delete(filePath);
|
|
6204
6911
|
},
|
|
6205
6912
|
onConversationChanged: (filePath) => {
|
|
6206
|
-
this.fileWatcher.poke(filePath);
|
|
6913
|
+
const tailed = this.fileWatcher.poke(filePath);
|
|
6914
|
+
if (!tailed) this.maybeAttachExternalTail(filePath);
|
|
6915
|
+
this.sweepIdleExternalTails();
|
|
6207
6916
|
this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
|
|
6208
6917
|
this.markScannerStaleDebounced();
|
|
6209
6918
|
this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
|
|
@@ -6211,7 +6920,20 @@ var StreamerServer = class {
|
|
|
6211
6920
|
event: "cache.directory_change"
|
|
6212
6921
|
});
|
|
6213
6922
|
},
|
|
6923
|
+
onTruncated: (filePath) => {
|
|
6924
|
+
this.cache?.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
|
|
6925
|
+
this.cache?.clearIndexParseState(filePath);
|
|
6926
|
+
this.log.warn(`JSONL truncated/replaced; offset index dropped: ${filePath}`, {
|
|
6927
|
+
filePath,
|
|
6928
|
+
event: "tail.truncated"
|
|
6929
|
+
});
|
|
6930
|
+
},
|
|
6214
6931
|
onFileDeleted: (filePath) => {
|
|
6932
|
+
this.detachExternalTail(canonicalizeFilePath(filePath));
|
|
6933
|
+
if (this.cacheMonitor?.pending) {
|
|
6934
|
+
this.cacheMonitor.deferUnlink(filePath);
|
|
6935
|
+
return;
|
|
6936
|
+
}
|
|
6215
6937
|
const id = this.cache?.invalidateByFilePath(filePath);
|
|
6216
6938
|
if (id)
|
|
6217
6939
|
this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
|
|
@@ -6219,6 +6941,7 @@ var StreamerServer = class {
|
|
|
6219
6941
|
filePath,
|
|
6220
6942
|
event: "cache.invalidate_on_unlink"
|
|
6221
6943
|
});
|
|
6944
|
+
this.cacheMonitor?.recordUnlink(filePath);
|
|
6222
6945
|
}
|
|
6223
6946
|
});
|
|
6224
6947
|
this.ptyManager = new LiveSessionManager({
|
|
@@ -6282,6 +7005,8 @@ var StreamerServer = class {
|
|
|
6282
7005
|
this.cancelPendingQuestion(session.id);
|
|
6283
7006
|
}
|
|
6284
7007
|
this.pendingPermission.delete(session.id);
|
|
7008
|
+
this.contendedSessions.delete(session.id);
|
|
7009
|
+
this.rememberSelfPtyEnded(session.id);
|
|
6285
7010
|
}
|
|
6286
7011
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
6287
7012
|
if (resp) {
|
|
@@ -6305,7 +7030,7 @@ var StreamerServer = class {
|
|
|
6305
7030
|
temporalClient,
|
|
6306
7031
|
taskQueue: agentConfig.temporal.taskQueue
|
|
6307
7032
|
});
|
|
6308
|
-
const conversationsBaseDir = agentConfig.conversationsDir || (0,
|
|
7033
|
+
const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path17.join)((0, import_path17.dirname)(this.cacheDir), "conversations");
|
|
6309
7034
|
conversationWriter = createConversationWriter({
|
|
6310
7035
|
baseDir: conversationsBaseDir
|
|
6311
7036
|
});
|
|
@@ -6327,6 +7052,7 @@ var StreamerServer = class {
|
|
|
6327
7052
|
sessionStore: this.sessionStore,
|
|
6328
7053
|
wsHub: this.wsHub,
|
|
6329
7054
|
cache: () => this.cache,
|
|
7055
|
+
cacheMonitor: () => this.cacheMonitor,
|
|
6330
7056
|
projectsRepo: () => this.projectsRepo,
|
|
6331
7057
|
conversationsRepo: () => this.conversationsRepo,
|
|
6332
7058
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -6362,9 +7088,11 @@ var StreamerServer = class {
|
|
|
6362
7088
|
this.wsHub.addClient(ws);
|
|
6363
7089
|
const sessions = this.sessionStore.list(this.ptyAttachedIds());
|
|
6364
7090
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
6365
|
-
if (this.
|
|
7091
|
+
if (!this.currentWarmupState()) {
|
|
6366
7092
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
6367
7093
|
}
|
|
7094
|
+
const alertMsg = this.cacheMonitor?.wsMessage();
|
|
7095
|
+
if (alertMsg) this.wsHub.unicast(ws, alertMsg);
|
|
6368
7096
|
},
|
|
6369
7097
|
handleWsMessage: async (ws, raw) => {
|
|
6370
7098
|
try {
|
|
@@ -6480,6 +7208,17 @@ var StreamerServer = class {
|
|
|
6480
7208
|
ptyAttachedIds() {
|
|
6481
7209
|
return new Set(this.ptyManager.listSessions().map((s) => s.id));
|
|
6482
7210
|
}
|
|
7211
|
+
// Record that our own PTY for `conversationId` just ended. Entries older than
|
|
7212
|
+
// the busy window can never change a verdict, so drop them as we go rather
|
|
7213
|
+
// than accumulating one per conversation for the process's lifetime.
|
|
7214
|
+
rememberSelfPtyEnded(conversationId) {
|
|
7215
|
+
const now = Date.now();
|
|
7216
|
+
const cutoff = now - resolveResumeBusyWindowMs();
|
|
7217
|
+
for (const [id, at] of this.selfPtyEndedAt) {
|
|
7218
|
+
if (at < cutoff) this.selfPtyEndedAt.delete(id);
|
|
7219
|
+
}
|
|
7220
|
+
this.selfPtyEndedAt.set(conversationId, now);
|
|
7221
|
+
}
|
|
6483
7222
|
/**
|
|
6484
7223
|
* Send a session_list to only the client that triggered this HTTP request
|
|
6485
7224
|
* (identified by X-Client-Id header → registered WS socket). Falls back to
|
|
@@ -6510,6 +7249,7 @@ var StreamerServer = class {
|
|
|
6510
7249
|
clearTimeout(existing);
|
|
6511
7250
|
this.ptyGraceTimers.delete(sessionId);
|
|
6512
7251
|
}
|
|
7252
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6513
7253
|
}
|
|
6514
7254
|
startGraceTimer(sessionId, delayMs) {
|
|
6515
7255
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
@@ -6519,14 +7259,24 @@ var StreamerServer = class {
|
|
|
6519
7259
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
6520
7260
|
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6521
7261
|
if (resp?.status === "running") {
|
|
6522
|
-
this.
|
|
6523
|
-
|
|
6524
|
-
|
|
7262
|
+
const defers = (this.ptyGraceDeferCounts.get(sessionId) ?? 0) + 1;
|
|
7263
|
+
if (defers <= GRACE_MAX_DEFERS) {
|
|
7264
|
+
this.ptyGraceDeferCounts.set(sessionId, defers);
|
|
7265
|
+
this.log.info(
|
|
7266
|
+
`[grace] session ${sessionId} still running, deferring hold (${defers}/${GRACE_MAX_DEFERS})`,
|
|
7267
|
+
{ sessionId, event: "pty.grace_defer", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
7268
|
+
"pino"
|
|
7269
|
+
);
|
|
7270
|
+
this.startGraceTimer(sessionId, delayMs);
|
|
7271
|
+
return;
|
|
7272
|
+
}
|
|
7273
|
+
this.log.warn(
|
|
7274
|
+
`[grace] session ${sessionId} exceeded ${GRACE_MAX_DEFERS} defers, holding anyway`,
|
|
7275
|
+
{ sessionId, event: "pty.grace_defer_cap", defers, maxDefers: GRACE_MAX_DEFERS },
|
|
6525
7276
|
"pino"
|
|
6526
7277
|
);
|
|
6527
|
-
this.startGraceTimer(sessionId, delayMs);
|
|
6528
|
-
return;
|
|
6529
7278
|
}
|
|
7279
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6530
7280
|
this.sessionSubscribers.delete(sessionId);
|
|
6531
7281
|
this.log.info(
|
|
6532
7282
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
@@ -6537,6 +7287,7 @@ var StreamerServer = class {
|
|
|
6537
7287
|
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
6538
7288
|
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
6539
7289
|
} else {
|
|
7290
|
+
this.ptyGraceDeferCounts.delete(sessionId);
|
|
6540
7291
|
this.sessionSubscribers.delete(sessionId);
|
|
6541
7292
|
}
|
|
6542
7293
|
}, delayMs);
|
|
@@ -6546,6 +7297,39 @@ var StreamerServer = class {
|
|
|
6546
7297
|
const addr = this.httpServer.address();
|
|
6547
7298
|
return typeof addr === "object" && addr ? addr.port : 0;
|
|
6548
7299
|
}
|
|
7300
|
+
currentWarmupState() {
|
|
7301
|
+
let current = null;
|
|
7302
|
+
for (const state of this.activeWarmups.values()) current = state;
|
|
7303
|
+
return current;
|
|
7304
|
+
}
|
|
7305
|
+
beginWarmup(state) {
|
|
7306
|
+
const id = this.nextWarmupId++;
|
|
7307
|
+
this.activeWarmups.set(id, state);
|
|
7308
|
+
return id;
|
|
7309
|
+
}
|
|
7310
|
+
finishWarmup(id) {
|
|
7311
|
+
if (!this.activeWarmups.delete(id) || this.activeWarmups.size > 0) return;
|
|
7312
|
+
this.wsHub.broadcast({ type: "cache_ready" });
|
|
7313
|
+
}
|
|
7314
|
+
async withWarmup(state, operation) {
|
|
7315
|
+
const id = this.beginWarmup(state);
|
|
7316
|
+
try {
|
|
7317
|
+
return await operation();
|
|
7318
|
+
} finally {
|
|
7319
|
+
this.finishWarmup(id);
|
|
7320
|
+
}
|
|
7321
|
+
}
|
|
7322
|
+
rejectIfWarmingUp(res) {
|
|
7323
|
+
const warmupState = this.currentWarmupState();
|
|
7324
|
+
if (!warmupState) return false;
|
|
7325
|
+
const body = {
|
|
7326
|
+
error: "Server is warming up",
|
|
7327
|
+
code: "SERVER_WARMING_UP",
|
|
7328
|
+
warmupState
|
|
7329
|
+
};
|
|
7330
|
+
json(res, 503, body);
|
|
7331
|
+
return true;
|
|
7332
|
+
}
|
|
6549
7333
|
async listen(port, opts) {
|
|
6550
7334
|
const dbConfig = this.disableDb ? null : getDbConfig();
|
|
6551
7335
|
if (dbConfig) {
|
|
@@ -6569,13 +7353,16 @@ var StreamerServer = class {
|
|
|
6569
7353
|
});
|
|
6570
7354
|
try {
|
|
6571
7355
|
this.cache = ConversationCache.open(
|
|
6572
|
-
(0,
|
|
7356
|
+
(0, import_path17.join)(this.cacheDir, "cache.db"),
|
|
6573
7357
|
this.tailSize,
|
|
6574
7358
|
void 0,
|
|
6575
7359
|
{
|
|
6576
7360
|
filterAgentConversations: !this.includeAgents,
|
|
6577
7361
|
agentEntrypoints: this.agentEntrypoints,
|
|
6578
|
-
onAgentFileDetected: (fp) =>
|
|
7362
|
+
onAgentFileDetected: (fp) => {
|
|
7363
|
+
this.fileWatcher.unwatch(fp);
|
|
7364
|
+
this.externalTails.delete(canonicalizeFilePath(fp));
|
|
7365
|
+
}
|
|
6579
7366
|
}
|
|
6580
7367
|
);
|
|
6581
7368
|
if (!this.includeAgents) {
|
|
@@ -6592,9 +7379,28 @@ var StreamerServer = class {
|
|
|
6592
7379
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
6593
7380
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
6594
7381
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
7382
|
+
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7383
|
+
this.cache,
|
|
7384
|
+
this.wsHub,
|
|
7385
|
+
this.log,
|
|
7386
|
+
this.cacheDir,
|
|
7387
|
+
async () => {
|
|
7388
|
+
const scanner = await this.rescanForRefresh();
|
|
7389
|
+
return [...scanner.getMetadataCache().values()];
|
|
7390
|
+
},
|
|
7391
|
+
(operation) => {
|
|
7392
|
+
const reset = this.withWarmup("cache_reset", operation);
|
|
7393
|
+
this.trackCacheWrite(reset);
|
|
7394
|
+
return reset;
|
|
7395
|
+
}
|
|
7396
|
+
);
|
|
6595
7397
|
for (const dir of this.projectsDirs()) {
|
|
6596
7398
|
this.fileWatcher.watchDirectory(dir);
|
|
6597
7399
|
}
|
|
7400
|
+
for (const dir of this.codexRoots) {
|
|
7401
|
+
if (!(0, import_fs17.existsSync)(dir)) continue;
|
|
7402
|
+
this.fileWatcher.watchDirectory(dir);
|
|
7403
|
+
}
|
|
6598
7404
|
} catch (err) {
|
|
6599
7405
|
const message = err instanceof Error ? err.message : String(err);
|
|
6600
7406
|
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
@@ -6661,11 +7467,19 @@ var StreamerServer = class {
|
|
|
6661
7467
|
}
|
|
6662
7468
|
);
|
|
6663
7469
|
}
|
|
6664
|
-
|
|
6665
|
-
this.
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
7470
|
+
await this.cacheMonitor?.runDetection();
|
|
7471
|
+
if (this.cacheMonitor?.pending) {
|
|
7472
|
+
this.log.warn("Startup ghost prune skipped \u2014 cache integrity alert pending", {
|
|
7473
|
+
fingerprint: this.cacheMonitor.pending.fingerprint,
|
|
7474
|
+
event: "cache.prune_ghosts_frozen"
|
|
7475
|
+
});
|
|
7476
|
+
} else {
|
|
7477
|
+
const pruned = this.cache.pruneGhostFiles();
|
|
7478
|
+
this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
|
|
7479
|
+
count: pruned.length,
|
|
7480
|
+
event: "cache.prune_ghosts"
|
|
7481
|
+
});
|
|
7482
|
+
}
|
|
6669
7483
|
}).catch((err) => {
|
|
6670
7484
|
const message = err instanceof Error ? err.message : String(err);
|
|
6671
7485
|
this.log.warn(`Startup cache warm-up failed: ${message}`, {
|
|
@@ -6673,8 +7487,7 @@ var StreamerServer = class {
|
|
|
6673
7487
|
event: "cache.warmup_failed"
|
|
6674
7488
|
});
|
|
6675
7489
|
}).finally(() => {
|
|
6676
|
-
this.
|
|
6677
|
-
this.wsHub.broadcast({ type: "cache_ready" });
|
|
7490
|
+
this.finishWarmup(0);
|
|
6678
7491
|
resolveWarm();
|
|
6679
7492
|
});
|
|
6680
7493
|
}
|
|
@@ -6777,6 +7590,7 @@ var StreamerServer = class {
|
|
|
6777
7590
|
this.cache?.close();
|
|
6778
7591
|
this.ptyManager.dispose();
|
|
6779
7592
|
this.fileWatcher.dispose();
|
|
7593
|
+
this.externalTails.clear();
|
|
6780
7594
|
this.wsHub.dispose();
|
|
6781
7595
|
this.pairTokens.dispose();
|
|
6782
7596
|
if (this.dbPool) {
|
|
@@ -6905,6 +7719,7 @@ var StreamerServer = class {
|
|
|
6905
7719
|
return this.checkRateLimit(this.sessionInputAttempts, sessionId, 500, 6e4);
|
|
6906
7720
|
}
|
|
6907
7721
|
async handleListConversations(url, res) {
|
|
7722
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
6908
7723
|
const limit = intParam(url, "limit", 50);
|
|
6909
7724
|
const offset = intParam(url, "offset", 0);
|
|
6910
7725
|
const sort = url.searchParams.get("sort") ?? "recent";
|
|
@@ -6912,14 +7727,13 @@ var StreamerServer = class {
|
|
|
6912
7727
|
const providerFilter = url.searchParams.get("provider") ?? void 0;
|
|
6913
7728
|
const bustCache = url.searchParams.get("refresh") === "1";
|
|
6914
7729
|
if (bustCache && this.cache) {
|
|
6915
|
-
const scanner2 = await this.rescanForRefresh();
|
|
7730
|
+
const scanner2 = await this.withWarmup("conversation_refresh", () => this.rescanForRefresh());
|
|
6916
7731
|
const metas2 = [...scanner2.getMetadataCache().values()];
|
|
6917
7732
|
try {
|
|
6918
7733
|
this.cache.upsertFromScannerMeta(metas2);
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
this.cache.reconcileDeletions(livePaths);
|
|
7734
|
+
if (!this.cacheMonitor?.pending) {
|
|
7735
|
+
this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
|
|
7736
|
+
}
|
|
6923
7737
|
} catch (err) {
|
|
6924
7738
|
this.log.warn(
|
|
6925
7739
|
`refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -6994,6 +7808,7 @@ var StreamerServer = class {
|
|
|
6994
7808
|
json(res, 200, { conversations: adapted, hasMore: offset + limit < total, offset, total });
|
|
6995
7809
|
}
|
|
6996
7810
|
async handleConversationsCount(url, res) {
|
|
7811
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
6997
7812
|
const project = url.searchParams.get("project") ?? void 0;
|
|
6998
7813
|
const providerFilter = url.searchParams.get("provider") ?? void 0;
|
|
6999
7814
|
const bustCache = url.searchParams.get("refresh") === "1";
|
|
@@ -7021,7 +7836,7 @@ var StreamerServer = class {
|
|
|
7021
7836
|
// path — refresh=1 returns the cached total synchronously and this catches up.
|
|
7022
7837
|
refreshCountInBackground() {
|
|
7023
7838
|
this.trackCacheWrite(
|
|
7024
|
-
(async () => {
|
|
7839
|
+
this.withWarmup("conversation_refresh", async () => {
|
|
7025
7840
|
try {
|
|
7026
7841
|
const scanner = await this.getFreshScanner();
|
|
7027
7842
|
if (this.cache) {
|
|
@@ -7033,13 +7848,15 @@ var StreamerServer = class {
|
|
|
7033
7848
|
{ event: "count.refresh_failed" }
|
|
7034
7849
|
);
|
|
7035
7850
|
}
|
|
7036
|
-
})
|
|
7851
|
+
})
|
|
7037
7852
|
);
|
|
7038
7853
|
}
|
|
7039
7854
|
handleSessionsCount(res) {
|
|
7855
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7040
7856
|
json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
|
|
7041
7857
|
}
|
|
7042
7858
|
handleGetRecentSessions(url, res) {
|
|
7859
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7043
7860
|
const limit = intParam(url, "limit", 20);
|
|
7044
7861
|
if (!this.cache) {
|
|
7045
7862
|
json(res, 200, { sessions: [], total: 0 });
|
|
@@ -7050,6 +7867,7 @@ var StreamerServer = class {
|
|
|
7050
7867
|
type: "conversation",
|
|
7051
7868
|
id: c.id,
|
|
7052
7869
|
status: "idle",
|
|
7870
|
+
ownership: "historical",
|
|
7053
7871
|
ptyAttached: false,
|
|
7054
7872
|
projectId: c.projectId ?? void 0,
|
|
7055
7873
|
projectPath: c.projectPath ?? "",
|
|
@@ -7076,21 +7894,19 @@ var StreamerServer = class {
|
|
|
7076
7894
|
if (!this.cache) return void 0;
|
|
7077
7895
|
if (!previousScanner) {
|
|
7078
7896
|
const persisted = this.cache.getScannerStatCache();
|
|
7079
|
-
|
|
7897
|
+
if (persisted.size === 0) return void 0;
|
|
7898
|
+
const nativeKeyed = /* @__PURE__ */ new Map();
|
|
7899
|
+
for (const [canonicalPath, entry] of persisted) {
|
|
7900
|
+
nativeKeyed.set(toNativeFilePath(canonicalPath), entry);
|
|
7901
|
+
}
|
|
7902
|
+
return nativeKeyed;
|
|
7080
7903
|
}
|
|
7081
7904
|
const dbStats = this.cache.getFileStats();
|
|
7082
7905
|
if (dbStats.size === 0) return void 0;
|
|
7083
|
-
const
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
}
|
|
7088
|
-
}
|
|
7089
|
-
const statCache = /* @__PURE__ */ new Map();
|
|
7090
|
-
for (const [filePath, stat3] of dbStats) {
|
|
7091
|
-
const meta = metaByPath.get(filePath);
|
|
7092
|
-
if (meta) statCache.set(filePath, { stat: stat3, meta });
|
|
7093
|
-
}
|
|
7906
|
+
const statCache = joinStatCacheByNativePath(
|
|
7907
|
+
previousScanner.getMetadataCache().values(),
|
|
7908
|
+
dbStats
|
|
7909
|
+
);
|
|
7094
7910
|
return statCache.size > 0 ? statCache : void 0;
|
|
7095
7911
|
}
|
|
7096
7912
|
// Returns the provider + codexRoots fragment to spread into every scan()/search() call.
|
|
@@ -7184,22 +8000,22 @@ var StreamerServer = class {
|
|
|
7184
8000
|
*/
|
|
7185
8001
|
projectsDirs() {
|
|
7186
8002
|
if (this.scanProfiles && this.scanProfiles.length > 0) {
|
|
7187
|
-
return this.scanProfiles.filter((p) => p.enabled).map((p) => (0,
|
|
8003
|
+
return this.scanProfiles.filter((p) => p.enabled).map((p) => (0, import_path17.join)(p.configDir, "projects"));
|
|
7188
8004
|
}
|
|
7189
|
-
return [(0,
|
|
8005
|
+
return [(0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects")];
|
|
7190
8006
|
}
|
|
7191
8007
|
findJsonlPath(uuid) {
|
|
7192
8008
|
const filename = `${uuid}.jsonl`;
|
|
7193
8009
|
for (const projectsDir of this.projectsDirs()) {
|
|
7194
|
-
if (!(0,
|
|
7195
|
-
for (const dir of (0,
|
|
7196
|
-
const fp = (0,
|
|
7197
|
-
if ((0,
|
|
7198
|
-
const projectDir = (0,
|
|
8010
|
+
if (!(0, import_fs17.existsSync)(projectsDir)) continue;
|
|
8011
|
+
for (const dir of (0, import_fs17.readdirSync)(projectsDir)) {
|
|
8012
|
+
const fp = (0, import_path17.join)(projectsDir, dir, filename);
|
|
8013
|
+
if ((0, import_fs17.existsSync)(fp)) return fp;
|
|
8014
|
+
const projectDir = (0, import_path17.join)(projectsDir, dir);
|
|
7199
8015
|
try {
|
|
7200
|
-
for (const sub of (0,
|
|
7201
|
-
const subagentPath = (0,
|
|
7202
|
-
if ((0,
|
|
8016
|
+
for (const sub of (0, import_fs17.readdirSync)(projectDir)) {
|
|
8017
|
+
const subagentPath = (0, import_path17.join)(projectDir, sub, "subagents", filename);
|
|
8018
|
+
if ((0, import_fs17.existsSync)(subagentPath)) return subagentPath;
|
|
7203
8019
|
}
|
|
7204
8020
|
} catch {
|
|
7205
8021
|
}
|
|
@@ -7209,7 +8025,7 @@ var StreamerServer = class {
|
|
|
7209
8025
|
}
|
|
7210
8026
|
async readCwdFromJsonl(filePath) {
|
|
7211
8027
|
return new Promise((resolve2) => {
|
|
7212
|
-
const rl = (0, import_readline.createInterface)({ input: (0,
|
|
8028
|
+
const rl = (0, import_readline.createInterface)({ input: (0, import_fs17.createReadStream)(filePath), crlfDelay: Infinity });
|
|
7213
8029
|
let found = false;
|
|
7214
8030
|
rl.on("line", (line) => {
|
|
7215
8031
|
if (found) return;
|
|
@@ -7279,6 +8095,140 @@ var StreamerServer = class {
|
|
|
7279
8095
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7280
8096
|
}
|
|
7281
8097
|
}
|
|
8098
|
+
// ─── External (non-PTY) live tails ───────────────────────────────
|
|
8099
|
+
/** True when a managed (PTY) session owns the tail for this canonical path. */
|
|
8100
|
+
isManagedTailPath(key) {
|
|
8101
|
+
for (const watchedPath of this.sessionFileMap.values()) {
|
|
8102
|
+
if (canonicalizeFilePath(watchedPath) === key) return true;
|
|
8103
|
+
}
|
|
8104
|
+
return false;
|
|
8105
|
+
}
|
|
8106
|
+
/**
|
|
8107
|
+
* Attach a live tail to a JSONL nobody is tailing yet, when it was touched
|
|
8108
|
+
* recently enough to look actively written by an external agent. Capped at
|
|
8109
|
+
* EXTERNAL_TAIL_MAX with LRU eviction.
|
|
8110
|
+
*/
|
|
8111
|
+
maybeAttachExternalTail(filePath) {
|
|
8112
|
+
if (!filePath.endsWith(".jsonl")) return;
|
|
8113
|
+
const key = canonicalizeFilePath(filePath);
|
|
8114
|
+
if (this.externalTails.has(key)) return;
|
|
8115
|
+
if (this.isManagedTailPath(key)) return;
|
|
8116
|
+
let mtimeMs;
|
|
8117
|
+
try {
|
|
8118
|
+
mtimeMs = (0, import_fs17.statSync)(filePath).mtimeMs;
|
|
8119
|
+
} catch {
|
|
8120
|
+
return;
|
|
8121
|
+
}
|
|
8122
|
+
const now = Date.now();
|
|
8123
|
+
if (now - mtimeMs > EXTERNAL_TAIL_RECENCY_MS) return;
|
|
8124
|
+
this.evictExternalTailsIfNeeded();
|
|
8125
|
+
this.externalTails.set(key, {
|
|
8126
|
+
conversationId: ConversationCache.conversationIdForFile(key),
|
|
8127
|
+
lastActivityAt: now
|
|
8128
|
+
});
|
|
8129
|
+
this.fileWatcher.watch(filePath);
|
|
8130
|
+
this.log.debug?.(`External tail attached: ${filePath}`, {
|
|
8131
|
+
filePath,
|
|
8132
|
+
tails: this.externalTails.size,
|
|
8133
|
+
event: "external_tail.attach"
|
|
8134
|
+
});
|
|
8135
|
+
}
|
|
8136
|
+
/** Stop tailing an external file and drop its bookkeeping. */
|
|
8137
|
+
detachExternalTail(key) {
|
|
8138
|
+
if (!this.externalTails.delete(key)) return;
|
|
8139
|
+
this.fileWatcher.unwatch(key);
|
|
8140
|
+
this.log.debug?.(`External tail detached: ${key}`, {
|
|
8141
|
+
filePath: key,
|
|
8142
|
+
event: "external_tail.detach"
|
|
8143
|
+
});
|
|
8144
|
+
}
|
|
8145
|
+
/** Make room for one more tail by evicting the least recently active ones. */
|
|
8146
|
+
evictExternalTailsIfNeeded() {
|
|
8147
|
+
while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
|
|
8148
|
+
let lruKey = null;
|
|
8149
|
+
let lruAt = Number.POSITIVE_INFINITY;
|
|
8150
|
+
for (const [key, entry] of this.externalTails) {
|
|
8151
|
+
if (this.isManagedTailPath(key)) {
|
|
8152
|
+
this.externalTails.delete(key);
|
|
8153
|
+
return;
|
|
8154
|
+
}
|
|
8155
|
+
if (entry.lastActivityAt < lruAt) {
|
|
8156
|
+
lruAt = entry.lastActivityAt;
|
|
8157
|
+
lruKey = key;
|
|
8158
|
+
}
|
|
8159
|
+
}
|
|
8160
|
+
if (!lruKey) return;
|
|
8161
|
+
this.detachExternalTail(lruKey);
|
|
8162
|
+
}
|
|
8163
|
+
}
|
|
8164
|
+
/**
|
|
8165
|
+
* INFERRED activity for an externally-owned conversation, derived purely from
|
|
8166
|
+
* how recently its JSONL grew (the external tail's bookkeeping). Returns
|
|
8167
|
+
* undefined when we hold no tail for it, so a session we know nothing about
|
|
8168
|
+
* reports no activity rather than a fabricated "quiet".
|
|
8169
|
+
*
|
|
8170
|
+
* This can never distinguish a generating agent from one blocked on a
|
|
8171
|
+
* permission gate — gates render on the PTY screen and never reach the JSONL —
|
|
8172
|
+
* which is why it is a separate field and not folded into `status`.
|
|
8173
|
+
*/
|
|
8174
|
+
externalActivityFor(conversationId, now = Date.now()) {
|
|
8175
|
+
for (const entry of this.externalTails.values()) {
|
|
8176
|
+
if (entry.conversationId !== conversationId) continue;
|
|
8177
|
+
return {
|
|
8178
|
+
state: now - entry.lastActivityAt <= EXTERNAL_ACTIVE_WRITING_MS ? "active_writing" : "quiet",
|
|
8179
|
+
lastEventAt: new Date(entry.lastActivityAt).toISOString(),
|
|
8180
|
+
source: "jsonl"
|
|
8181
|
+
};
|
|
8182
|
+
}
|
|
8183
|
+
return void 0;
|
|
8184
|
+
}
|
|
8185
|
+
/** Attach inferred `activity` to externally-owned sessions in a response set. */
|
|
8186
|
+
withExternalActivity(sessions) {
|
|
8187
|
+
if (this.externalTails.size === 0) return sessions;
|
|
8188
|
+
const now = Date.now();
|
|
8189
|
+
return sessions.map((s) => {
|
|
8190
|
+
if (s.ownership !== "external") return s;
|
|
8191
|
+
const activity = this.externalActivityFor(s.conversationId ?? s.id, now);
|
|
8192
|
+
return activity ? { ...s, activity } : s;
|
|
8193
|
+
});
|
|
8194
|
+
}
|
|
8195
|
+
/** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
|
|
8196
|
+
sweepIdleExternalTails(now = Date.now()) {
|
|
8197
|
+
for (const [key, entry] of [...this.externalTails]) {
|
|
8198
|
+
if (this.isManagedTailPath(key)) {
|
|
8199
|
+
this.externalTails.delete(key);
|
|
8200
|
+
continue;
|
|
8201
|
+
}
|
|
8202
|
+
if (now - entry.lastActivityAt > EXTERNAL_TAIL_IDLE_MS) this.detachExternalTail(key);
|
|
8203
|
+
}
|
|
8204
|
+
}
|
|
8205
|
+
/**
|
|
8206
|
+
* Push appended lines from an externally-owned conversation. Reuses the exact
|
|
8207
|
+
* conversation_events / conversation_event shapes mobile already consumes,
|
|
8208
|
+
* keyed by the conversation UUID — an external session has no PTY, so it must
|
|
8209
|
+
* never produce terminal_output / terminal_replay / session_ready, and never a
|
|
8210
|
+
* session_update whose session.id is a conversation UUID (that would mint a
|
|
8211
|
+
* phantom session row in the mobile cache). Question cards are likewise never
|
|
8212
|
+
* derived here: with no PTY there is nothing that could deliver an answer.
|
|
8213
|
+
*/
|
|
8214
|
+
broadcastExternalTailLines(filePath, lines, seqs) {
|
|
8215
|
+
const key = canonicalizeFilePath(filePath);
|
|
8216
|
+
const entry = this.externalTails.get(key);
|
|
8217
|
+
if (!entry) return;
|
|
8218
|
+
entry.lastActivityAt = Date.now();
|
|
8219
|
+
const conversationId = this.cache?.getIdByFilePath(key);
|
|
8220
|
+
if (!conversationId) return;
|
|
8221
|
+
entry.conversationId = conversationId;
|
|
8222
|
+
this.broadcastConversationLines(conversationId, lines, seqs);
|
|
8223
|
+
const meta = this.cache?.getMetaById(conversationId);
|
|
8224
|
+
this.wsHub.broadcast({
|
|
8225
|
+
type: "conversation_updated",
|
|
8226
|
+
conversationId,
|
|
8227
|
+
messageCount: meta?.messageCount ?? 0,
|
|
8228
|
+
lastActivity: meta?.lastActivity ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
8229
|
+
ownership: "external"
|
|
8230
|
+
});
|
|
8231
|
+
}
|
|
7282
8232
|
async findConversationByUuid(uuid) {
|
|
7283
8233
|
const lookupId = this.resolveConversationLookupId(uuid);
|
|
7284
8234
|
if (!this.scannerReady && !this.scanProfiles) {
|
|
@@ -7347,13 +8297,14 @@ var StreamerServer = class {
|
|
|
7347
8297
|
if (!conv.filePath) return false;
|
|
7348
8298
|
let mtimeMs = null;
|
|
7349
8299
|
try {
|
|
7350
|
-
mtimeMs = (0,
|
|
8300
|
+
mtimeMs = (0, import_fs17.statSync)(conv.filePath).mtimeMs;
|
|
7351
8301
|
} catch {
|
|
7352
8302
|
return false;
|
|
7353
8303
|
}
|
|
7354
8304
|
return isScannedSnapshotStale(conv.timestamp, mtimeMs);
|
|
7355
8305
|
}
|
|
7356
8306
|
async handleGetConversation(id, url, res, ifNoneMatch) {
|
|
8307
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7357
8308
|
const conversation = await this.findConversationByUuid(id);
|
|
7358
8309
|
if (!conversation && this.cache) {
|
|
7359
8310
|
const isFirstLoad = !url.searchParams.has("before_index");
|
|
@@ -7681,7 +8632,7 @@ var StreamerServer = class {
|
|
|
7681
8632
|
});
|
|
7682
8633
|
}
|
|
7683
8634
|
async handleListSessions(url, res) {
|
|
7684
|
-
|
|
8635
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7685
8636
|
const now = Date.now();
|
|
7686
8637
|
if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
|
|
7687
8638
|
try {
|
|
@@ -7693,7 +8644,7 @@ var StreamerServer = class {
|
|
|
7693
8644
|
}
|
|
7694
8645
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
7695
8646
|
if (!hasPaginationParams) {
|
|
7696
|
-
json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
|
|
8647
|
+
json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
|
|
7697
8648
|
return;
|
|
7698
8649
|
}
|
|
7699
8650
|
const parsed = parseSessionListQuery(url);
|
|
@@ -7703,6 +8654,7 @@ var StreamerServer = class {
|
|
|
7703
8654
|
}
|
|
7704
8655
|
try {
|
|
7705
8656
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8657
|
+
page.sessions = this.withExternalActivity(page.sessions);
|
|
7706
8658
|
json(res, 200, page);
|
|
7707
8659
|
} catch (err) {
|
|
7708
8660
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -7713,9 +8665,10 @@ var StreamerServer = class {
|
|
|
7713
8665
|
}
|
|
7714
8666
|
}
|
|
7715
8667
|
handleGetSession(sessionId, res) {
|
|
8668
|
+
if (this.rejectIfWarmingUp(res)) return;
|
|
7716
8669
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
7717
8670
|
if (session) {
|
|
7718
|
-
if (!(0,
|
|
8671
|
+
if (!(0, import_fs17.existsSync)(session.projectPath)) {
|
|
7719
8672
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
7720
8673
|
}
|
|
7721
8674
|
json(res, 200, session);
|
|
@@ -7729,7 +8682,6 @@ var StreamerServer = class {
|
|
|
7729
8682
|
json(res, 404, { error: "Session not found" });
|
|
7730
8683
|
}
|
|
7731
8684
|
async handleResume(req, res) {
|
|
7732
|
-
this.discoveryCache = null;
|
|
7733
8685
|
const body = await readBody(req);
|
|
7734
8686
|
const sessionId = body.sessionId ?? body.conversationId;
|
|
7735
8687
|
if (!sessionId) {
|
|
@@ -7758,8 +8710,48 @@ var StreamerServer = class {
|
|
|
7758
8710
|
json(res, 400, { error: "Could not determine project path" });
|
|
7759
8711
|
return;
|
|
7760
8712
|
}
|
|
8713
|
+
let discovered = [];
|
|
8714
|
+
const cached2 = this.discoveryCache;
|
|
8715
|
+
if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
|
|
8716
|
+
discovered = cached2.entries;
|
|
8717
|
+
} else {
|
|
8718
|
+
try {
|
|
8719
|
+
discovered = await Promise.race([
|
|
8720
|
+
discoverClaudeProcesses(),
|
|
8721
|
+
new Promise(
|
|
8722
|
+
(resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
|
|
8723
|
+
)
|
|
8724
|
+
]);
|
|
8725
|
+
if (discovered.length > 0) {
|
|
8726
|
+
this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
|
|
8727
|
+
}
|
|
8728
|
+
} catch {
|
|
8729
|
+
}
|
|
8730
|
+
}
|
|
8731
|
+
const busy = conversationBusy({
|
|
8732
|
+
conversationId: sessionId,
|
|
8733
|
+
projectPath,
|
|
8734
|
+
jsonlPath,
|
|
8735
|
+
discovered,
|
|
8736
|
+
windowMs: resolveResumeBusyWindowMs(),
|
|
8737
|
+
selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
|
|
8738
|
+
});
|
|
8739
|
+
if (busy.busy && body.force !== true) {
|
|
8740
|
+
json(res, 409, {
|
|
8741
|
+
error: "This conversation looks active in another session",
|
|
8742
|
+
code: "CONVERSATION_BUSY",
|
|
8743
|
+
detectedBy: busy.detectedBy,
|
|
8744
|
+
lastActivityMs: busy.lastActivityMs,
|
|
8745
|
+
likelyOwner: busy.likelyOwner
|
|
8746
|
+
});
|
|
8747
|
+
return;
|
|
8748
|
+
}
|
|
8749
|
+
if (busy.busy) {
|
|
8750
|
+
this.contendedSessions.add(sessionId);
|
|
8751
|
+
}
|
|
7761
8752
|
const cachedConvMeta = this.cache?.getMetaById(sessionId);
|
|
7762
8753
|
const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
|
|
8754
|
+
this.discoveryCache = null;
|
|
7763
8755
|
const session = await this.ptyManager.start(sessionId, {
|
|
7764
8756
|
provider,
|
|
7765
8757
|
projectPath,
|
|
@@ -7893,6 +8885,45 @@ var StreamerServer = class {
|
|
|
7893
8885
|
json(res, 400, { error: message });
|
|
7894
8886
|
}
|
|
7895
8887
|
}
|
|
8888
|
+
// Store + broadcast AskUserQuestion cards found in a JSONL batch for a watched
|
|
8889
|
+
// session. Two P0 safety guards on top of the screen/JSONL de-dupe:
|
|
8890
|
+
// (a) contended file → suppress JSONL-derived cards entirely (a line may be
|
|
8891
|
+
// the OTHER owner's question); the streamer's own PTY questions still
|
|
8892
|
+
// arrive via the live-screen path (handleLiveQuestion), not suppressed.
|
|
8893
|
+
// (b) a JSONL question must never clobber a PTY-screen question that is a
|
|
8894
|
+
// DIFFERENT question — answering it would type into this streamer's PTY.
|
|
8895
|
+
// Same-content re-syncs (screen synthetic id → real toolUseId) still pass.
|
|
8896
|
+
processJsonlQuestions(sessionId, lines) {
|
|
8897
|
+
const priorPending = this.pendingQuestions.get(sessionId);
|
|
8898
|
+
const priorToolUseId = priorPending?.toolUseId;
|
|
8899
|
+
const contended = this.contendedSessions.has(sessionId);
|
|
8900
|
+
const priorPtyKey = priorPending?.origin === "pty" ? questionContentKey(priorPending.questions) : null;
|
|
8901
|
+
const foreignVsPty = (questions) => priorPtyKey !== null && questionContentKey(questions) !== priorPtyKey;
|
|
8902
|
+
const { messages, pending } = questionsFromLines(sessionId, lines);
|
|
8903
|
+
for (const p of pending) {
|
|
8904
|
+
if (contended || foreignVsPty(p.questions)) continue;
|
|
8905
|
+
const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
|
|
8906
|
+
this.pendingQuestions.set(sessionId, { ...p, origin });
|
|
8907
|
+
const t = setTimeout(() => {
|
|
8908
|
+
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
8909
|
+
this.cancelPendingQuestion(sessionId);
|
|
8910
|
+
}
|
|
8911
|
+
}, 6e4);
|
|
8912
|
+
t.unref();
|
|
8913
|
+
}
|
|
8914
|
+
for (const m of messages) {
|
|
8915
|
+
if (contended || foreignVsPty(m.questions)) continue;
|
|
8916
|
+
const key = questionContentKey(m.questions);
|
|
8917
|
+
const broadcast = shouldBroadcastQuestion({
|
|
8918
|
+
newContentKey: key,
|
|
8919
|
+
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
8920
|
+
newToolUseId: m.toolUseId,
|
|
8921
|
+
priorToolUseId
|
|
8922
|
+
});
|
|
8923
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
8924
|
+
if (broadcast) this.wsHub.broadcast(m);
|
|
8925
|
+
}
|
|
8926
|
+
}
|
|
7896
8927
|
cancelPendingQuestion(sessionId) {
|
|
7897
8928
|
const pq = this.pendingQuestions.get(sessionId);
|
|
7898
8929
|
if (!pq) return;
|
|
@@ -7909,7 +8940,7 @@ var StreamerServer = class {
|
|
|
7909
8940
|
const key = questionContentKey(questions);
|
|
7910
8941
|
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
7911
8942
|
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
7912
|
-
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
8943
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
|
|
7913
8944
|
this.pendingQuestionKey.set(sessionId, key);
|
|
7914
8945
|
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
7915
8946
|
}
|
|
@@ -8086,13 +9117,64 @@ var StreamerServer = class {
|
|
|
8086
9117
|
json(res, 404, { error: "Discovered session not found" });
|
|
8087
9118
|
return;
|
|
8088
9119
|
}
|
|
8089
|
-
const {
|
|
9120
|
+
const { branch } = discSession;
|
|
9121
|
+
let { projectPath, projectName } = discSession;
|
|
8090
9122
|
const convId = discSession.id;
|
|
8091
9123
|
if (discSession.pid == null) {
|
|
8092
9124
|
json(res, 400, { error: "Session has no known PID" });
|
|
8093
9125
|
return;
|
|
8094
9126
|
}
|
|
9127
|
+
if (!projectPath) {
|
|
9128
|
+
const jsonlPath = this.findJsonlPath(convId);
|
|
9129
|
+
const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
|
|
9130
|
+
if (jsonlCwd) {
|
|
9131
|
+
projectPath = jsonlCwd;
|
|
9132
|
+
projectName = projectName || (0, import_path17.basename)(jsonlCwd);
|
|
9133
|
+
}
|
|
9134
|
+
}
|
|
9135
|
+
if (!projectPath) {
|
|
9136
|
+
this.log.warn("adopt: refusing, working directory unknown", {
|
|
9137
|
+
event: "adopt.no_project_path",
|
|
9138
|
+
sessionId,
|
|
9139
|
+
pid: discSession.pid
|
|
9140
|
+
});
|
|
9141
|
+
json(res, 400, {
|
|
9142
|
+
error: "Cannot take over this session: its working directory could not be determined on this platform",
|
|
9143
|
+
code: "ADOPT_NO_PROJECT_PATH"
|
|
9144
|
+
});
|
|
9145
|
+
return;
|
|
9146
|
+
}
|
|
9147
|
+
const availability = classifyResumability(projectPath);
|
|
9148
|
+
if (!availability.resumable) {
|
|
9149
|
+
this.log.warn("adopt: refusing, project directory no longer exists", {
|
|
9150
|
+
event: "adopt.project_path_missing",
|
|
9151
|
+
sessionId,
|
|
9152
|
+
pid: discSession.pid,
|
|
9153
|
+
projectPath,
|
|
9154
|
+
reason: availability.unavailable_reason
|
|
9155
|
+
});
|
|
9156
|
+
json(res, 400, {
|
|
9157
|
+
error: "Cannot take over this session: its project directory no longer exists",
|
|
9158
|
+
code: "ADOPT_PROJECT_PATH_MISSING",
|
|
9159
|
+
reason: availability.unavailable_reason
|
|
9160
|
+
});
|
|
9161
|
+
return;
|
|
9162
|
+
}
|
|
8095
9163
|
this.ptyManager.killPid(discSession.pid);
|
|
9164
|
+
const exited = await waitForProcessExit(discSession.pid, ADOPT_KILL_TIMEOUT_MS);
|
|
9165
|
+
if (!exited) {
|
|
9166
|
+
this.log.warn("adopt: external process did not exit; refusing to double-write", {
|
|
9167
|
+
event: "adopt.kill_timeout",
|
|
9168
|
+
sessionId,
|
|
9169
|
+
pid: discSession.pid
|
|
9170
|
+
});
|
|
9171
|
+
json(res, 409, {
|
|
9172
|
+
error: "The existing process did not exit; not starting a second agent on this conversation",
|
|
9173
|
+
code: "ADOPT_KILL_TIMEOUT",
|
|
9174
|
+
pid: discSession.pid
|
|
9175
|
+
});
|
|
9176
|
+
return;
|
|
9177
|
+
}
|
|
8096
9178
|
const session = await this.ptyManager.start(convId, {
|
|
8097
9179
|
projectPath,
|
|
8098
9180
|
projectName,
|
|
@@ -8123,7 +9205,7 @@ var StreamerServer = class {
|
|
|
8123
9205
|
sessionStore: this.sessionStore,
|
|
8124
9206
|
// biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
|
|
8125
9207
|
agentClient: this.agentClient,
|
|
8126
|
-
conversationsDir: this.cacheDir ? (0,
|
|
9208
|
+
conversationsDir: this.cacheDir ? (0, import_path17.join)((0, import_path17.dirname)(this.cacheDir), "conversations") : "",
|
|
8127
9209
|
agentConfig: this.agentConfig
|
|
8128
9210
|
});
|
|
8129
9211
|
json(res, result.status, result.body);
|
|
@@ -8261,14 +9343,30 @@ var StreamerServer = class {
|
|
|
8261
9343
|
} catch {
|
|
8262
9344
|
}
|
|
8263
9345
|
}
|
|
9346
|
+
// Read just the `sessionId` field from a JSONL's first line, used by the
|
|
9347
|
+
// watchForJsonl fallback to confirm a candidate file's identity before
|
|
9348
|
+
// binding it. Reads only up to the first newline so a large actively-written
|
|
9349
|
+
// file isn't slurped in full.
|
|
9350
|
+
readFirstLineSessionId(filePath) {
|
|
9351
|
+
try {
|
|
9352
|
+
const content = (0, import_fs17.readFileSync)(filePath, "utf8");
|
|
9353
|
+
const nl = content.indexOf("\n");
|
|
9354
|
+
const firstLine = nl === -1 ? content : content.slice(0, nl);
|
|
9355
|
+
if (!firstLine.trim()) return null;
|
|
9356
|
+
const obj = JSON.parse(firstLine);
|
|
9357
|
+
return typeof obj.sessionId === "string" ? obj.sessionId : null;
|
|
9358
|
+
} catch {
|
|
9359
|
+
return null;
|
|
9360
|
+
}
|
|
9361
|
+
}
|
|
8264
9362
|
// Watch the project directory for the JSONL file Claude creates for sessionId.
|
|
8265
9363
|
// Once found, wire up structured event streaming. No rekeying needed — the UUID
|
|
8266
9364
|
// was passed to Claude via --session-id so the filename matches from the start.
|
|
8267
9365
|
watchForJsonl(sessionId, projectPath) {
|
|
8268
9366
|
const encoded = projectPath.replace(/[/\\:.]/g, "-");
|
|
8269
|
-
const projectsDir = (0,
|
|
9367
|
+
const projectsDir = (0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects", encoded);
|
|
8270
9368
|
const expectedFile = `${sessionId}.jsonl`;
|
|
8271
|
-
const filePath = (0,
|
|
9369
|
+
const filePath = (0, import_path17.join)(projectsDir, expectedFile);
|
|
8272
9370
|
const deadline = Date.now() + 12e4;
|
|
8273
9371
|
let watcher = null;
|
|
8274
9372
|
const cleanup = () => {
|
|
@@ -8286,26 +9384,28 @@ var StreamerServer = class {
|
|
|
8286
9384
|
cleanup();
|
|
8287
9385
|
return;
|
|
8288
9386
|
}
|
|
8289
|
-
let resolvedFilePath = (0,
|
|
8290
|
-
if (!resolvedFilePath && (0,
|
|
9387
|
+
let resolvedFilePath = (0, import_fs17.existsSync)(filePath) ? filePath : null;
|
|
9388
|
+
if (!resolvedFilePath && (0, import_fs17.existsSync)(projectsDir)) {
|
|
8291
9389
|
try {
|
|
8292
9390
|
const now = Date.now();
|
|
8293
|
-
const
|
|
8294
|
-
|
|
9391
|
+
const match = (0, import_fs17.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs17.statSync)((0, import_path17.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
|
|
9392
|
+
({ f }) => (0, import_path17.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path17.join)(projectsDir, f)) === sessionId
|
|
9393
|
+
).sort((a, b) => b.mtime - a.mtime)[0];
|
|
9394
|
+
if (match) resolvedFilePath = (0, import_path17.join)(projectsDir, match.f);
|
|
8295
9395
|
} catch {
|
|
8296
9396
|
}
|
|
8297
9397
|
}
|
|
8298
9398
|
if (!resolvedFilePath) return;
|
|
8299
9399
|
cleanup();
|
|
8300
9400
|
this.sessionFileMap.set(sessionId, resolvedFilePath);
|
|
8301
|
-
this.fileWatcher.watch(resolvedFilePath);
|
|
8302
9401
|
try {
|
|
8303
|
-
const existing = (0,
|
|
9402
|
+
const existing = (0, import_fs17.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
8304
9403
|
if (existing.length > 0) {
|
|
8305
9404
|
this.broadcastConversationLines(sessionId, existing);
|
|
8306
9405
|
}
|
|
8307
9406
|
} catch {
|
|
8308
9407
|
}
|
|
9408
|
+
this.fileWatcher.watch(resolvedFilePath);
|
|
8309
9409
|
if (this.scannerReady) {
|
|
8310
9410
|
this.scannerStale = true;
|
|
8311
9411
|
} else {
|
|
@@ -8323,7 +9423,7 @@ var StreamerServer = class {
|
|
|
8323
9423
|
if (this.sessionFileMap.has(sessionId)) return;
|
|
8324
9424
|
try {
|
|
8325
9425
|
require("fs").mkdirSync(projectsDir, { recursive: true });
|
|
8326
|
-
watcher = (0,
|
|
9426
|
+
watcher = (0, import_fs17.watch)(projectsDir, tryWire);
|
|
8327
9427
|
watcher.on("error", cleanup);
|
|
8328
9428
|
} catch {
|
|
8329
9429
|
}
|
|
@@ -8339,7 +9439,7 @@ var StreamerServer = class {
|
|
|
8339
9439
|
watchForCodexRollout(sessionId, projectPath) {
|
|
8340
9440
|
const deadline = Date.now() + 12e4;
|
|
8341
9441
|
const now = /* @__PURE__ */ new Date();
|
|
8342
|
-
const dateDir = (0,
|
|
9442
|
+
const dateDir = (0, import_path17.join)(
|
|
8343
9443
|
String(now.getFullYear()),
|
|
8344
9444
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
8345
9445
|
String(now.getDate()).padStart(2, "0")
|
|
@@ -8352,7 +9452,7 @@ var StreamerServer = class {
|
|
|
8352
9452
|
};
|
|
8353
9453
|
const matchesProjectPath = (candidatePath) => {
|
|
8354
9454
|
try {
|
|
8355
|
-
const firstLine = (0,
|
|
9455
|
+
const firstLine = (0, import_fs17.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
|
|
8356
9456
|
if (!firstLine) return null;
|
|
8357
9457
|
const parsed = JSON.parse(firstLine);
|
|
8358
9458
|
if (parsed?.type !== "session_meta") return null;
|
|
@@ -8380,18 +9480,18 @@ var StreamerServer = class {
|
|
|
8380
9480
|
this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
|
|
8381
9481
|
);
|
|
8382
9482
|
for (const root of this.codexRoots) {
|
|
8383
|
-
const sessionsDir = (0,
|
|
8384
|
-
if (!(0,
|
|
9483
|
+
const sessionsDir = (0, import_path17.join)(root, dateDir);
|
|
9484
|
+
if (!(0, import_fs17.existsSync)(sessionsDir)) continue;
|
|
8385
9485
|
let candidateFiles;
|
|
8386
9486
|
try {
|
|
8387
|
-
candidateFiles = (0,
|
|
9487
|
+
candidateFiles = (0, import_fs17.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
8388
9488
|
} catch {
|
|
8389
9489
|
continue;
|
|
8390
9490
|
}
|
|
8391
9491
|
const nowMs = Date.now();
|
|
8392
|
-
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0,
|
|
9492
|
+
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs17.statSync)((0, import_path17.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
|
|
8393
9493
|
for (const { f } of recentCandidates) {
|
|
8394
|
-
const candidatePath = (0,
|
|
9494
|
+
const candidatePath = (0, import_path17.join)(sessionsDir, f);
|
|
8395
9495
|
const match = matchesProjectPath(candidatePath);
|
|
8396
9496
|
if (!match) continue;
|
|
8397
9497
|
if (boundElsewhere.has(match.id)) continue;
|
|
@@ -8401,7 +9501,7 @@ var StreamerServer = class {
|
|
|
8401
9501
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
8402
9502
|
this.fileWatcher.watch(candidatePath);
|
|
8403
9503
|
try {
|
|
8404
|
-
const existing = (0,
|
|
9504
|
+
const existing = (0, import_fs17.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
8405
9505
|
if (existing.length > 0) {
|
|
8406
9506
|
this.broadcastConversationLines(sessionId, existing);
|
|
8407
9507
|
}
|
|
@@ -8521,9 +9621,21 @@ var StreamerServer = class {
|
|
|
8521
9621
|
json(res, 200, this.cache.listSessionNames());
|
|
8522
9622
|
}
|
|
8523
9623
|
};
|
|
9624
|
+
async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
|
|
9625
|
+
const deadline = Date.now() + timeoutMs;
|
|
9626
|
+
for (; ; ) {
|
|
9627
|
+
try {
|
|
9628
|
+
process.kill(pid, 0);
|
|
9629
|
+
} catch {
|
|
9630
|
+
return true;
|
|
9631
|
+
}
|
|
9632
|
+
if (Date.now() >= deadline) return false;
|
|
9633
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollMs));
|
|
9634
|
+
}
|
|
9635
|
+
}
|
|
8524
9636
|
function classifyResumability(cwd) {
|
|
8525
9637
|
if (!cwd) return { resumable: true };
|
|
8526
|
-
if ((0,
|
|
9638
|
+
if ((0, import_fs17.existsSync)(cwd)) return { resumable: true };
|
|
8527
9639
|
const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
|
|
8528
9640
|
return {
|
|
8529
9641
|
resumable: false,
|
|
@@ -8538,6 +9650,9 @@ function conversationToResumableSession(c) {
|
|
|
8538
9650
|
id: c.id,
|
|
8539
9651
|
conversationId: c.id,
|
|
8540
9652
|
status: "on_hold",
|
|
9653
|
+
// A cached conversation with no process behind it. Distinguishes "nobody is
|
|
9654
|
+
// running this" from an external session that IS live (ownership "external").
|
|
9655
|
+
ownership: "historical",
|
|
8541
9656
|
ptyAttached: false,
|
|
8542
9657
|
projectId: c.projectId ?? void 0,
|
|
8543
9658
|
projectPath: c.projectPath ?? "",
|