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