@threadbase-sh/streamer 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -2260,6 +2260,14 @@ var PTYManager = class {
2260
2260
  if (session?.status !== "running") return;
2261
2261
  if (this.pendingReady.has(sessionId)) {
2262
2262
  this.markReady(sessionId, session, "quiet:timeout");
2263
+ } else {
2264
+ this.recheckReadyFromScreen(sessionId).catch((err) => {
2265
+ this.log.warn("[pty.ready] screen recheck failed", {
2266
+ event: "pty.ready_recheck_failed",
2267
+ sessionId,
2268
+ err
2269
+ });
2270
+ });
2263
2271
  }
2264
2272
  this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
2265
2273
  this.log.warn("[pty.prompt_detect] failed", {
@@ -2269,6 +2277,20 @@ var PTYManager = class {
2269
2277
  });
2270
2278
  });
2271
2279
  }
2280
+ // Re-check the rendered screen (not just the last chunk) for a prompt
2281
+ // marker. Only meaningful once pendingReady is already clear — the boot
2282
+ // fallback above covers the first prompt after spawn/resume. Scoped to a
2283
+ // full viewport (PTY_ROWS), not just the last few lines: "on screen" means
2284
+ // whatever a user attached to this PTY would currently see.
2285
+ async recheckReadyFromScreen(sessionId) {
2286
+ const session = this.sessions.get(sessionId);
2287
+ if (session?.status !== "running") return;
2288
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
2289
+ const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
2290
+ if (matchedMarker && session.status === "running") {
2291
+ this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
2292
+ }
2293
+ }
2272
2294
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2273
2295
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2274
2296
  markReady(sessionId, session, reason) {
@@ -2441,6 +2463,44 @@ async function discoverClaudeProcesses() {
2441
2463
  if ((0, import_os4.platform)() === "win32") return discoverWindows();
2442
2464
  return discoverUnix();
2443
2465
  }
2466
+ var CLAUDE_CLI_SCRIPT = /claude-code[\\/](?:cli|index)\.(?:js|mjs|cjs)$/i;
2467
+ var JS_RUNTIMES = /* @__PURE__ */ new Set(["node", "node.exe", "bun", "bun.exe", "deno", "deno.exe"]);
2468
+ function tokenizeCommandLine(commandLine) {
2469
+ const tokens = [];
2470
+ let current = "";
2471
+ let quoted = false;
2472
+ for (const ch of commandLine) {
2473
+ if (ch === '"') {
2474
+ quoted = !quoted;
2475
+ continue;
2476
+ }
2477
+ if (!quoted && (ch === " " || ch === " ")) {
2478
+ if (current) tokens.push(current);
2479
+ current = "";
2480
+ continue;
2481
+ }
2482
+ current += ch;
2483
+ }
2484
+ if (current) tokens.push(current);
2485
+ return tokens;
2486
+ }
2487
+ function exeBaseName(token) {
2488
+ const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
2489
+ return cut === -1 ? token : token.slice(cut + 1);
2490
+ }
2491
+ function looksLikeClaudeProcess(commandLine) {
2492
+ const tokens = tokenizeCommandLine(commandLine);
2493
+ if (tokens.length === 0) return false;
2494
+ const exe = exeBaseName(tokens[0]).toLowerCase();
2495
+ if (exe === "claude" || exe === "claude.exe") return true;
2496
+ if (JS_RUNTIMES.has(exe)) {
2497
+ for (const raw of tokens.slice(1)) {
2498
+ if (raw.startsWith("-")) continue;
2499
+ return CLAUDE_CLI_SCRIPT.test(raw);
2500
+ }
2501
+ }
2502
+ return false;
2503
+ }
2444
2504
  async function discoverUnix() {
2445
2505
  const pids = await getPidsUnix();
2446
2506
  const results = await Promise.all(
@@ -2468,6 +2528,8 @@ async function discoverUnix() {
2468
2528
  return results.filter((r) => r !== null);
2469
2529
  }
2470
2530
  async function discoverWindows() {
2531
+ const viaCim = await discoverWindowsViaCim();
2532
+ if (viaCim) return viaCim;
2471
2533
  const pids = await getPidsWindows();
2472
2534
  const results = await Promise.all(
2473
2535
  pids.map(async (pid) => {
@@ -2502,7 +2564,24 @@ function run(cmd, args, opts = {}) {
2502
2564
  );
2503
2565
  });
2504
2566
  }
2567
+ function parsePsOutput(stdout) {
2568
+ const pids = [];
2569
+ for (const line of stdout.split("\n")) {
2570
+ const trimmed = line.trim();
2571
+ if (!trimmed) continue;
2572
+ const match = trimmed.match(/^(\d+)\s+(.*)$/);
2573
+ if (!match) continue;
2574
+ const pid = Number.parseInt(match[1], 10);
2575
+ if (!(pid > 0)) continue;
2576
+ if (looksLikeClaudeProcess(match[2])) pids.push(pid);
2577
+ }
2578
+ return pids;
2579
+ }
2505
2580
  async function getPidsUnix() {
2581
+ try {
2582
+ return parsePsOutput(await run("ps", ["-eo", "pid=,args="]));
2583
+ } catch {
2584
+ }
2506
2585
  try {
2507
2586
  const output = await run("pgrep", ["-x", "claude"]);
2508
2587
  return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
@@ -2523,6 +2602,54 @@ async function getProcessStartTimeUnix(pid) {
2523
2602
  const d = new Date(raw);
2524
2603
  return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2525
2604
  }
2605
+ function parseCimProcesses(stdout) {
2606
+ const trimmed = stdout.trim();
2607
+ if (!trimmed) return [];
2608
+ const parsed = JSON.parse(trimmed);
2609
+ return Array.isArray(parsed) ? parsed : [parsed];
2610
+ }
2611
+ function parseCimDate(value) {
2612
+ if (value) {
2613
+ const epoch = value.match(/\/Date\((\d+)\)\//);
2614
+ if (epoch) return new Date(Number(epoch[1]));
2615
+ const d = new Date(value);
2616
+ if (!Number.isNaN(d.getTime())) return d;
2617
+ }
2618
+ return /* @__PURE__ */ new Date();
2619
+ }
2620
+ async function discoverWindowsViaCim() {
2621
+ let stdout;
2622
+ try {
2623
+ stdout = await run("powershell.exe", [
2624
+ "-NoProfile",
2625
+ "-NonInteractive",
2626
+ "-Command",
2627
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
2628
+ ]);
2629
+ } catch {
2630
+ return null;
2631
+ }
2632
+ let rows;
2633
+ try {
2634
+ rows = parseCimProcesses(stdout);
2635
+ } catch {
2636
+ return null;
2637
+ }
2638
+ const results = [];
2639
+ for (const row of rows) {
2640
+ const commandLine = row.CommandLine ?? "";
2641
+ if (!commandLine || !looksLikeClaudeProcess(commandLine)) continue;
2642
+ results.push({
2643
+ pid: row.ProcessId,
2644
+ projectPath: "",
2645
+ projectName: "",
2646
+ branch: "",
2647
+ conversationId: extractResumeId(commandLine),
2648
+ startedAt: parseCimDate(row.CreationDate)
2649
+ });
2650
+ }
2651
+ return results;
2652
+ }
2526
2653
  async function getPidsWindows() {
2527
2654
  try {
2528
2655
  const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
@@ -2565,10 +2692,15 @@ async function getProcessInfoWindows(pid) {
2565
2692
  }
2566
2693
  }
2567
2694
  function extractResumeId(args) {
2568
- const match = args.match(/--resume\s+(\S+)/);
2569
- return match?.[1] ?? null;
2695
+ const eq = args.match(/(?:--resume|-r)=(\S+)/);
2696
+ if (eq?.[1] && !eq[1].startsWith("-")) return eq[1];
2697
+ const spaced = args.match(/(?:--resume|-r)\s+(\S+)/);
2698
+ const candidate = spaced?.[1];
2699
+ if (!candidate || candidate.startsWith("-")) return null;
2700
+ return candidate;
2570
2701
  }
2571
2702
  async function readGitBranch(dir) {
2703
+ if (!dir) return "";
2572
2704
  try {
2573
2705
  return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
2574
2706
  } catch {
@@ -2581,11 +2713,11 @@ var import_node_ws = require("@hono/node-ws");
2581
2713
  var import_client = require("@temporalio/client");
2582
2714
  var import_scanner3 = require("@threadbase-sh/scanner");
2583
2715
  var import_events = require("events");
2584
- var import_fs13 = require("fs");
2716
+ var import_fs17 = require("fs");
2585
2717
  var import_promises7 = require("fs/promises");
2586
2718
  var import_http = require("http");
2587
- var import_os7 = require("os");
2588
- var import_path14 = require("path");
2719
+ var import_os8 = require("os");
2720
+ var import_path17 = require("path");
2589
2721
  var import_readline = require("readline");
2590
2722
 
2591
2723
  // node_modules/nanoid/index.js
@@ -2844,7 +2976,7 @@ async function handleStartAgentSession(body, deps) {
2844
2976
  }
2845
2977
 
2846
2978
  // src/api/app.ts
2847
- var import_hono12 = require("hono");
2979
+ var import_hono13 = require("hono");
2848
2980
 
2849
2981
  // src/api/middleware/auth.middleware.ts
2850
2982
  function isLocalRequest(remoteAddr) {
@@ -2966,12 +3098,72 @@ var createBrowseRoutes = (deps) => {
2966
3098
  return app;
2967
3099
  };
2968
3100
 
2969
- // src/api/routes/conversations.routes.ts
3101
+ // src/api/routes/cacheAlert.routes.ts
2970
3102
  var import_hono3 = require("hono");
3103
+
3104
+ // src/schemas/cacheAlert.schema.ts
3105
+ var import_zod = require("zod");
3106
+ var ResolveCacheAlertSchema = import_zod.z.object({
3107
+ fingerprint: import_zod.z.string(),
3108
+ action: import_zod.z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
3109
+ ids: import_zod.z.array(import_zod.z.string()).optional()
3110
+ }).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
3111
+ message: "prune_selected requires a non-empty ids array",
3112
+ path: ["ids"]
3113
+ });
3114
+
3115
+ // src/api/routes/cacheAlert.routes.ts
3116
+ function readRawBody2(req) {
3117
+ return new Promise((resolve2, reject) => {
3118
+ const chunks = [];
3119
+ req.on("data", (chunk) => chunks.push(chunk));
3120
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
3121
+ req.on("error", reject);
3122
+ });
3123
+ }
3124
+ var createCacheAlertRoutes = (deps) => {
3125
+ const app = new import_hono3.Hono();
3126
+ app.get("/", (c) => {
3127
+ const monitor = deps.cacheMonitor();
3128
+ return c.json({ pending: monitor?.pending ?? null });
3129
+ });
3130
+ app.post("/resolve", async (c) => {
3131
+ let body;
3132
+ try {
3133
+ const incoming = c.env?.incoming;
3134
+ const raw = incoming ? await readRawBody2(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
3135
+ body = raw ? JSON.parse(raw) : {};
3136
+ } catch {
3137
+ return c.json({ error: "invalid json" }, 400);
3138
+ }
3139
+ const parsed = ResolveCacheAlertSchema.safeParse(body);
3140
+ if (!parsed.success) {
3141
+ return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
3142
+ }
3143
+ const monitor = deps.cacheMonitor();
3144
+ if (!monitor) return c.json({ ok: true, alreadyResolved: true });
3145
+ const { fingerprint, action, ids } = parsed.data;
3146
+ const result = await monitor.resolve(fingerprint, action, ids);
3147
+ if ("conflict" in result) {
3148
+ return c.json(
3149
+ { error: "fingerprint_mismatch", currentFingerprint: result.currentFingerprint },
3150
+ 409
3151
+ );
3152
+ }
3153
+ if ("alreadyResolved" in result) {
3154
+ return c.json({ ok: true, alreadyResolved: true });
3155
+ }
3156
+ return c.json(result);
3157
+ });
3158
+ return app;
3159
+ };
3160
+
3161
+ // src/api/routes/conversations.routes.ts
3162
+ var import_hono4 = require("hono");
2971
3163
  var ALREADY_HANDLED2 = 597;
2972
3164
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
2973
3165
  var createConversationRoutes = (deps) => {
2974
- const app = new import_hono3.Hono();
3166
+ const app = new import_hono4.Hono();
2975
3167
  app.get("/count", async (c) => {
2976
3168
  const url = new URL(c.req.url);
2977
3169
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -2998,7 +3190,7 @@ var createConversationRoutes = (deps) => {
2998
3190
  };
2999
3191
 
3000
3192
  // src/api/routes/health.routes.ts
3001
- var import_hono4 = require("hono");
3193
+ var import_hono5 = require("hono");
3002
3194
 
3003
3195
  // src/version.ts
3004
3196
  var import_node_fs2 = require("fs");
@@ -3034,16 +3226,19 @@ function resolveVersion() {
3034
3226
  }
3035
3227
 
3036
3228
  // src/api/routes/health.routes.ts
3037
- var createHealthRoutes = () => {
3038
- const app = new import_hono4.Hono();
3039
- app.get("/", (c) => c.json({ ok: true, version: getVersion() }));
3229
+ var createHealthRoutes = (deps) => {
3230
+ const app = new import_hono5.Hono();
3231
+ app.get("/", (c) => {
3232
+ const cacheAlert = deps.cacheMonitor()?.healthzField();
3233
+ return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
3234
+ });
3040
3235
  return app;
3041
3236
  };
3042
3237
 
3043
3238
  // src/api/routes/logs.routes.ts
3044
3239
  var import_node_fs3 = require("fs");
3045
3240
  var import_node_path5 = require("path");
3046
- var import_hono5 = require("hono");
3241
+ var import_hono6 = require("hono");
3047
3242
 
3048
3243
  // src/lifecycle/constants.ts
3049
3244
  var import_node_os = require("os");
@@ -3101,7 +3296,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3101
3296
  }
3102
3297
  }
3103
3298
  function createLogsRoutes() {
3104
- const app = new import_hono5.Hono();
3299
+ const app = new import_hono6.Hono();
3105
3300
  app.get("/", (c) => {
3106
3301
  try {
3107
3302
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3172,7 +3367,7 @@ function createLogsRoutes() {
3172
3367
  // src/api/routes/misc.routes.ts
3173
3368
  var import_node_child_process = require("child_process");
3174
3369
  var import_node_crypto2 = require("crypto");
3175
- var import_hono6 = require("hono");
3370
+ var import_hono7 = require("hono");
3176
3371
  var import_os5 = require("os");
3177
3372
 
3178
3373
  // src/config/update-config.ts
@@ -3182,15 +3377,15 @@ var import_node_path6 = require("path");
3182
3377
  var import_yaml = require("yaml");
3183
3378
 
3184
3379
  // src/schemas/updateConfig.schema.ts
3185
- var import_zod = require("zod");
3186
- var UpdateConfigSchema = import_zod.z.object({
3187
- auto_update: import_zod.z.boolean().default(false),
3188
- channel: import_zod.z.enum(["stable", "next"]).default("stable"),
3189
- allow: import_zod.z.array(import_zod.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3190
- poll_interval_minutes: import_zod.z.number().int().min(0).default(60),
3191
- defer_if_active_sessions: import_zod.z.boolean().default(true),
3192
- github_repo: import_zod.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3193
- webhook_secret: import_zod.z.string().min(1).nullable().default(null)
3380
+ var import_zod2 = require("zod");
3381
+ var UpdateConfigSchema = import_zod2.z.object({
3382
+ auto_update: import_zod2.z.boolean().default(false),
3383
+ channel: import_zod2.z.enum(["stable", "next"]).default("stable"),
3384
+ allow: import_zod2.z.array(import_zod2.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3385
+ poll_interval_minutes: import_zod2.z.number().int().min(0).default(1440),
3386
+ defer_if_active_sessions: import_zod2.z.boolean().default(true),
3387
+ github_repo: import_zod2.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3388
+ webhook_secret: import_zod2.z.string().min(1).nullable().default(null)
3194
3389
  }).strict();
3195
3390
 
3196
3391
  // src/config/update-config.ts
@@ -3227,7 +3422,7 @@ function readJsonBody(req) {
3227
3422
  req.on("error", reject);
3228
3423
  });
3229
3424
  }
3230
- function readRawBody2(req) {
3425
+ function readRawBody3(req) {
3231
3426
  return new Promise((resolve2, reject) => {
3232
3427
  const chunks = [];
3233
3428
  req.on("data", (chunk) => chunks.push(chunk));
@@ -3246,7 +3441,7 @@ function verifyWebhookSignature(body, header, secret) {
3246
3441
  }
3247
3442
  var clientLog = getLogger("client");
3248
3443
  var createMiscRoutes = (deps) => {
3249
- const app = new import_hono6.Hono();
3444
+ const app = new import_hono7.Hono();
3250
3445
  app.get("/api/info", (c) => {
3251
3446
  const ptyIds = deps.ptyAttachedIds();
3252
3447
  return c.json({
@@ -3279,7 +3474,7 @@ var createMiscRoutes = (deps) => {
3279
3474
  }
3280
3475
  let body;
3281
3476
  try {
3282
- body = await readRawBody2(c.env.incoming);
3477
+ body = await readRawBody3(c.env.incoming);
3283
3478
  } catch {
3284
3479
  return c.json({ error: "could not read body" }, 400);
3285
3480
  }
@@ -3322,11 +3517,11 @@ var createMiscRoutes = (deps) => {
3322
3517
  };
3323
3518
 
3324
3519
  // src/api/routes/pair.routes.ts
3325
- var import_hono7 = require("hono");
3520
+ var import_hono8 = require("hono");
3326
3521
  var ALREADY_HANDLED3 = 597;
3327
3522
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
3328
3523
  var createPairRoutes = (deps) => {
3329
- const app = new import_hono7.Hono();
3524
+ const app = new import_hono8.Hono();
3330
3525
  app.post("/start", (c) => {
3331
3526
  deps.handlePairStart(c.env.outgoing);
3332
3527
  return alreadyHandled3();
@@ -3339,11 +3534,11 @@ var createPairRoutes = (deps) => {
3339
3534
  };
3340
3535
 
3341
3536
  // src/api/routes/projects.routes.ts
3342
- var import_hono8 = require("hono");
3537
+ var import_hono9 = require("hono");
3343
3538
  var ALREADY_HANDLED4 = 597;
3344
3539
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
3345
3540
  var createProjectRoutes = (deps) => {
3346
- const app = new import_hono8.Hono();
3541
+ const app = new import_hono9.Hono();
3347
3542
  app.get("/", (c) => {
3348
3543
  const url = new URL(c.req.url);
3349
3544
  deps.handleListProjects(url, c.env.outgoing);
@@ -3358,11 +3553,11 @@ var createProjectRoutes = (deps) => {
3358
3553
  };
3359
3554
 
3360
3555
  // src/api/routes/scanner.routes.ts
3361
- var import_hono9 = require("hono");
3556
+ var import_hono10 = require("hono");
3362
3557
  var ALREADY_HANDLED5 = 597;
3363
3558
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
3364
3559
  var createScannerRoutes = (deps) => {
3365
- const app = new import_hono9.Hono();
3560
+ const app = new import_hono10.Hono();
3366
3561
  app.get("/api/search", async (c) => {
3367
3562
  const url = new URL(c.req.url);
3368
3563
  await deps.handleSearch(url, c.env.outgoing);
@@ -3372,11 +3567,11 @@ var createScannerRoutes = (deps) => {
3372
3567
  };
3373
3568
 
3374
3569
  // src/api/routes/sessions.routes.ts
3375
- var import_hono10 = require("hono");
3570
+ var import_hono11 = require("hono");
3376
3571
  var ALREADY_HANDLED6 = 597;
3377
3572
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
3378
3573
  var createSessionRoutes = (deps) => {
3379
- const app = new import_hono10.Hono();
3574
+ const app = new import_hono11.Hono();
3380
3575
  app.get("/count", (c) => {
3381
3576
  deps.handleSessionsCount(c.env.outgoing);
3382
3577
  return alreadyHandled6();
@@ -3443,9 +3638,9 @@ var createSessionRoutes = (deps) => {
3443
3638
  };
3444
3639
 
3445
3640
  // src/api/routes/ws.routes.ts
3446
- var import_hono11 = require("hono");
3641
+ var import_hono12 = require("hono");
3447
3642
  var createWsRoutes = (deps, upgradeWebSocket) => {
3448
- const app = new import_hono11.Hono();
3643
+ const app = new import_hono12.Hono();
3449
3644
  app.get(
3450
3645
  "/ws",
3451
3646
  upgradeWebSocket(() => {
@@ -3471,7 +3666,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3471
3666
 
3472
3667
  // src/api/app.ts
3473
3668
  var createHonoApp = (deps, upgradeWebSocket) => {
3474
- const app = new import_hono12.Hono();
3669
+ const app = new import_hono13.Hono();
3475
3670
  const httpLog = getLogger("http");
3476
3671
  app.use("*", async (c, next) => {
3477
3672
  const start = Date.now();
@@ -3491,10 +3686,11 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3491
3686
  app.use("*", corsMiddleware(deps.browserCors));
3492
3687
  app.use("*", authMiddleware(deps));
3493
3688
  app.onError(errorMiddleware);
3494
- app.route("/healthz", createHealthRoutes());
3689
+ app.route("/healthz", createHealthRoutes(deps));
3495
3690
  app.route("/", createMiscRoutes(deps));
3496
3691
  app.route("/api/sessions", createSessionRoutes(deps));
3497
3692
  app.route("/api/conversations", createConversationRoutes(deps));
3693
+ app.route("/api/cache/alert", createCacheAlertRoutes(deps));
3498
3694
  app.route("/api/projects", createProjectRoutes(deps));
3499
3695
  app.route("/api/pair", createPairRoutes(deps));
3500
3696
  app.route("/api", createBrowseRoutes(deps));
@@ -3563,7 +3759,7 @@ var import_scanner2 = require("@threadbase-sh/scanner");
3563
3759
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
3564
3760
  var import_fs9 = require("fs");
3565
3761
  var import_promises3 = require("fs/promises");
3566
- var import_path11 = require("path");
3762
+ var import_path12 = require("path");
3567
3763
  var import_promises4 = require("timers/promises");
3568
3764
 
3569
3765
  // src/db/sqlite-migrate.ts
@@ -3677,6 +3873,31 @@ function parseAgentEntrypointsEnv(raw) {
3677
3873
  return new Set(parts);
3678
3874
  }
3679
3875
 
3876
+ // src/utils/canonicalizeFilePath.ts
3877
+ var import_path11 = require("path");
3878
+ function canonicalizeFilePath(filePath) {
3879
+ return filePath.trim().replace(/\\/g, "/");
3880
+ }
3881
+ function toNativeFilePath(filePath) {
3882
+ return (0, import_path11.normalize)(filePath.trim());
3883
+ }
3884
+ function canonicalLivePathSet(metas) {
3885
+ const live = /* @__PURE__ */ new Set();
3886
+ for (const meta of metas) {
3887
+ if (meta.filePath) live.add(canonicalizeFilePath(meta.filePath));
3888
+ }
3889
+ return live;
3890
+ }
3891
+ function joinStatCacheByNativePath(metas, canonicalStats) {
3892
+ const joined = /* @__PURE__ */ new Map();
3893
+ for (const meta of metas) {
3894
+ if (!meta.filePath) continue;
3895
+ const stat3 = canonicalStats.get(canonicalizeFilePath(meta.filePath));
3896
+ if (stat3) joined.set(meta.filePath, { stat: stat3, meta });
3897
+ }
3898
+ return joined;
3899
+ }
3900
+
3680
3901
  // src/utils/fileIdentity.ts
3681
3902
  var import_crypto5 = require("crypto");
3682
3903
  function fileIdentity(stat3, headBytes) {
@@ -3789,8 +4010,19 @@ var ConversationCache = class _ConversationCache {
3789
4010
  ),
3790
4011
  // Batch equivalent of updateMeta: bumps message_count by N in one write
3791
4012
  // (used by updateFromLines so a burst of appended lines is one UPDATE).
4013
+ // last_activity/last_message only move FORWARD: a batch whose newest line
4014
+ // predates the stored last_activity (interleaved writers appending an older
4015
+ // line) must not drag the metadata backward. message_count and updated_at
4016
+ // still advance — a real message was appended and the row did change.
3792
4017
  updateMetaBatch: db.prepare(
3793
- "UPDATE conversation_meta SET message_count = message_count + @inc, last_activity = @last_activity, last_message = @last_message, updated_at = @updated_at WHERE id = @id"
4018
+ `UPDATE conversation_meta SET
4019
+ message_count = message_count + @inc,
4020
+ last_activity = CASE WHEN @last_activity > IFNULL(last_activity, -1)
4021
+ THEN @last_activity ELSE last_activity END,
4022
+ last_message = CASE WHEN @last_activity > IFNULL(last_activity, -1)
4023
+ THEN @last_message ELSE last_message END,
4024
+ updated_at = @updated_at
4025
+ WHERE id = @id`
3794
4026
  ),
3795
4027
  insertSkeleton: db.prepare(
3796
4028
  "INSERT OR IGNORE INTO conversation_meta (id, file_path, message_count, updated_at) VALUES (?, ?, 1, ?)"
@@ -3872,6 +4104,7 @@ var ConversationCache = class _ConversationCache {
3872
4104
  "SELECT provider FROM conversation_meta WHERE file_path = ?"
3873
4105
  ),
3874
4106
  allFilePaths: db.prepare("SELECT id, file_path FROM conversation_meta"),
4107
+ allFilePathsWithTitle: db.prepare("SELECT id, file_path, title FROM conversation_meta"),
3875
4108
  allFileStats: db.prepare(
3876
4109
  "SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
3877
4110
  ),
@@ -4024,7 +4257,7 @@ var ConversationCache = class _ConversationCache {
4024
4257
  // excluded from the index entirely; the scanner serves them (it routes each
4025
4258
  // provider to its own parser).
4026
4259
  isIndexableFile(filePath) {
4027
- const row = this.stmts.getProviderByFilePath.get(filePath);
4260
+ const row = this.stmts.getProviderByFilePath.get(canonicalizeFilePath(filePath));
4028
4261
  if (!row) return false;
4029
4262
  return (row.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
4030
4263
  }
@@ -4271,7 +4504,7 @@ var ConversationCache = class _ConversationCache {
4271
4504
  return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
4272
4505
  }
4273
4506
  static open(dbPath, tailSize = 10, migrationsDir, options) {
4274
- (0, import_fs9.mkdirSync)((0, import_path11.dirname)(dbPath), { recursive: true });
4507
+ (0, import_fs9.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
4275
4508
  const db = new import_better_sqlite3.default(dbPath);
4276
4509
  db.pragma("journal_mode = WAL");
4277
4510
  db.pragma("foreign_keys = ON");
@@ -4292,7 +4525,7 @@ var ConversationCache = class _ConversationCache {
4292
4525
  if (this.fileIndexLoaded) return;
4293
4526
  const rows = this.stmts.allFilePaths.all();
4294
4527
  for (const row of rows) {
4295
- this.fileIndex.set(row.file_path, row.id);
4528
+ this.fileIndex.set(canonicalizeFilePath(row.file_path), row.id);
4296
4529
  }
4297
4530
  this.fileIndexLoaded = true;
4298
4531
  }
@@ -4311,12 +4544,13 @@ var ConversationCache = class _ConversationCache {
4311
4544
  const role = line.role ?? line.type;
4312
4545
  const isMessage = role === "user" || role === "assistant";
4313
4546
  this.ensureFileIndex();
4547
+ const key = canonicalizeFilePath(filePath);
4314
4548
  if (!isMessage && !line.cwd && !line.slug) return;
4315
- let convId = this.fileIndex.get(filePath);
4549
+ let convId = this.fileIndex.get(key);
4316
4550
  if (!convId) {
4317
- const pseudoId = filePath.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? filePath;
4318
- this.stmts.insertSkeleton.run(pseudoId, filePath, 0);
4319
- this.fileIndex.set(filePath, pseudoId);
4551
+ const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
4552
+ this.stmts.insertSkeleton.run(pseudoId, key, 0);
4553
+ this.fileIndex.set(key, pseudoId);
4320
4554
  convId = pseudoId;
4321
4555
  }
4322
4556
  if (line.cwd || line.slug) {
@@ -4331,18 +4565,18 @@ var ConversationCache = class _ConversationCache {
4331
4565
  });
4332
4566
  }
4333
4567
  if (!isMessage) return;
4334
- const timestamp = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4335
- const activityMs = new Date(timestamp).getTime();
4568
+ const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4569
+ const activityMs = new Date(timestamp2).getTime();
4336
4570
  if (Number.isNaN(activityMs)) return;
4337
4571
  const contentBlocks = normalizeContent(line.message?.content ?? line.content);
4338
4572
  const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
4339
- const lastMessage = JSON.stringify({ role, timestamp, text });
4573
+ const lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
4340
4574
  const seq = ++this.tailSeq;
4341
4575
  const result = this.stmts.updateMeta.run(activityMs, lastMessage, seq, convId);
4342
4576
  if (result.changes === 0) return;
4343
4577
  const tailRow = this.stmts.getTail.get(convId);
4344
4578
  const msgs = tailRow ? JSON.parse(tailRow.messages_json) : [];
4345
- msgs.push({ role, timestamp, text, content: contentBlocks });
4579
+ msgs.push({ role, timestamp: timestamp2, text, content: contentBlocks });
4346
4580
  if (msgs.length > this.tailSize) msgs.splice(0, msgs.length - this.tailSize);
4347
4581
  this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, seq);
4348
4582
  }
@@ -4354,7 +4588,8 @@ var ConversationCache = class _ConversationCache {
4354
4588
  * updateFromLine in order: the agent filter short-circuits the whole batch,
4355
4589
  * project context is backfilled last-wins, message_count increases by the
4356
4590
  * number of surviving message lines, and last_activity/last_message reflect
4357
- * the final message line.
4591
+ * the newest message line by timestamp (a monotonic guard keeps them from
4592
+ * moving backward when an interleaved writer appends an older line — P0.3).
4358
4593
  */
4359
4594
  updateFromLines(filePath, rawLines) {
4360
4595
  let sawProjectContext = false;
@@ -4390,23 +4625,26 @@ var ConversationCache = class _ConversationCache {
4390
4625
  backfillTitle ??= lineTitle;
4391
4626
  }
4392
4627
  if (!isMessage) continue;
4393
- const timestamp = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4394
- const activityMs = new Date(timestamp).getTime();
4628
+ const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4629
+ const activityMs = new Date(timestamp2).getTime();
4395
4630
  if (Number.isNaN(activityMs)) continue;
4396
4631
  const contentBlocks = normalizeContent(line.message?.content ?? line.content);
4397
4632
  const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
4398
4633
  msgCount += 1;
4399
- lastActivityMs = activityMs;
4400
- lastMessage = JSON.stringify({ role, timestamp, text });
4401
- newTail.push({ role, timestamp, text, content: contentBlocks });
4634
+ if (lastActivityMs === null || activityMs > lastActivityMs) {
4635
+ lastActivityMs = activityMs;
4636
+ lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
4637
+ }
4638
+ newTail.push({ role, timestamp: timestamp2, text, content: contentBlocks });
4402
4639
  }
4403
4640
  if (!sawProjectContext && msgCount === 0) return;
4404
4641
  this.ensureFileIndex();
4405
- let convId = this.fileIndex.get(filePath);
4642
+ const key = canonicalizeFilePath(filePath);
4643
+ let convId = this.fileIndex.get(key);
4406
4644
  if (!convId) {
4407
- const pseudoId = filePath.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? filePath;
4408
- this.stmts.insertSkeleton.run(pseudoId, filePath, 0);
4409
- this.fileIndex.set(filePath, pseudoId);
4645
+ const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
4646
+ this.stmts.insertSkeleton.run(pseudoId, key, 0);
4647
+ this.fileIndex.set(key, pseudoId);
4410
4648
  convId = pseudoId;
4411
4649
  }
4412
4650
  const id = convId;
@@ -4449,6 +4687,7 @@ var ConversationCache = class _ConversationCache {
4449
4687
  for (const m of items) {
4450
4688
  const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
4451
4689
  const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
4690
+ const canonicalPath = canonicalizeFilePath(m.filePath);
4452
4691
  let mtimeMs = null;
4453
4692
  let fileSize = null;
4454
4693
  try {
@@ -4464,10 +4703,10 @@ var ConversationCache = class _ConversationCache {
4464
4703
  const scannerMetaJson = JSON.stringify(m);
4465
4704
  this.stmts.upsertFull.run({
4466
4705
  id,
4467
- file_path: m.filePath,
4706
+ file_path: canonicalPath,
4468
4707
  project_path: m.projectPath ?? null,
4469
4708
  project_name: m.projectName ?? null,
4470
- title: m.title ?? m.projectName ?? null,
4709
+ title: m.title ?? m.sessionName ?? m.projectName ?? null,
4471
4710
  model: m.model ?? null,
4472
4711
  account: m.account ?? null,
4473
4712
  branch: m.gitBranch ?? null,
@@ -4483,7 +4722,7 @@ var ConversationCache = class _ConversationCache {
4483
4722
  scanner_meta_json: scannerMetaJson
4484
4723
  });
4485
4724
  this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
4486
- if (this.fileIndexLoaded) this.fileIndex.set(m.filePath, id);
4725
+ if (this.fileIndexLoaded) this.fileIndex.set(canonicalPath, id);
4487
4726
  upsertedIds.push(id);
4488
4727
  }
4489
4728
  });
@@ -4494,11 +4733,12 @@ var ConversationCache = class _ConversationCache {
4494
4733
  // by updateFromLine when a previously-cached file turns out to be an agent
4495
4734
  // JSONL.
4496
4735
  deleteByFilePath(filePath) {
4497
- const row = this.stmts.getIdByFilePath.get(filePath);
4736
+ const key = canonicalizeFilePath(filePath);
4737
+ const row = this.stmts.getIdByFilePath.get(key);
4498
4738
  if (!row) return false;
4499
4739
  this.stmts.deleteTailById.run(row.id);
4500
4740
  const result = this.stmts.deleteById.run(row.id);
4501
- this.fileIndex.delete(filePath);
4741
+ this.fileIndex.delete(key);
4502
4742
  return result.changes > 0;
4503
4743
  }
4504
4744
  // Reads the last `tailSize` qualifying lines from a JSONL file and writes them
@@ -4550,10 +4790,10 @@ var ConversationCache = class _ConversationCache {
4550
4790
  }
4551
4791
  const role = parsed.role ?? parsed.type;
4552
4792
  if (!role) continue;
4553
- const timestamp = parsed.timestamp ?? "";
4793
+ const timestamp2 = parsed.timestamp ?? "";
4554
4794
  const contentBlocks = normalizeContent(parsed.message?.content ?? parsed.content);
4555
4795
  const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
4556
- msgs.unshift({ role, timestamp, text, content: contentBlocks });
4796
+ msgs.unshift({ role, timestamp: timestamp2, text, content: contentBlocks });
4557
4797
  }
4558
4798
  if (msgs.length === 0) return false;
4559
4799
  this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, 0);
@@ -4621,6 +4861,16 @@ var ConversationCache = class _ConversationCache {
4621
4861
  }
4622
4862
  return map;
4623
4863
  }
4864
+ /**
4865
+ * Conversation id for a JSONL path, or null when no row exists yet. Resolves
4866
+ * by file_path (NOT conversationIdForFile) so codex rollout files — named
4867
+ * rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
4868
+ */
4869
+ getIdByFilePath(filePath) {
4870
+ const key = canonicalizeFilePath(filePath);
4871
+ const row = this.stmts.getIdByFilePath.get(key);
4872
+ return row?.id ?? null;
4873
+ }
4624
4874
  getMetaById(id) {
4625
4875
  const row = this.stmts.getFullById.get(id);
4626
4876
  if (!row) return null;
@@ -4713,22 +4963,23 @@ var ConversationCache = class _ConversationCache {
4713
4963
  /**
4714
4964
  * Drop the cached row for a file. Two callers with opposite intent:
4715
4965
  * - a directory-watch "change" event (the file was appended to) — pass
4716
- * `skipIfTailed: true`. A cached tail means the row is being actively
4717
- * maintained from the file's real content by the live-tail
4718
- * (updateFromLines) or warm-up path, fresher than any scanner-derived
4719
- * view. Both watchers fire on the same append with no ordering guarantee;
4720
- * without this guard the invalidate can land after the tail write and wipe
4721
- * the just-cached row, flickering the conversation out of
4722
- * /api/conversations on nearly every message (CRITICAL #2). The debounced
4723
- * rescan still re-derives metadata, so skipping the eager drop loses
4724
- * nothing.
4966
+ * `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
4967
+ * event fires on every external append; deleting here flickers the
4968
+ * conversation out of /api/conversations whether it's a live-tailed row
4969
+ * the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
4970
+ * fire on the same append with no ordering guarantee) OR a refresh-created
4971
+ * untailed row (a ?refresh=1 upsert never populates a tail, so the old
4972
+ * "delete when untailed" behavior made it vanish on its next append with no
4973
+ * client action). The live-tail path owns the row's content and the
4974
+ * debounced rescan re-derives metadata, so leaving the row loses nothing.
4725
4975
  * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
4726
4976
  * row is always removed, otherwise a deleted session ghosts in the cache.
4727
4977
  */
4728
4978
  invalidateByFilePath(filePath, opts) {
4729
- const row = this.stmts.getIdByFilePath.get(filePath);
4979
+ const key = canonicalizeFilePath(filePath);
4980
+ const row = this.stmts.getIdByFilePath.get(key);
4730
4981
  if (!row) return null;
4731
- if (opts?.skipIfTailed && this.stmts.hasTail.get(row.id)) return null;
4982
+ if (opts?.skipIfTailed) return null;
4732
4983
  this.invalidate(row.id);
4733
4984
  return row.id;
4734
4985
  }
@@ -4822,6 +5073,68 @@ var ConversationCache = class _ConversationCache {
4822
5073
  }
4823
5074
  return removed;
4824
5075
  }
5076
+ /**
5077
+ * Read-only: list cached rows whose `file_path` no longer exists on disk.
5078
+ * Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
5079
+ * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
5080
+ * rows that still have cached history (which pruneGhostFiles would keep).
5081
+ */
5082
+ listMissingFiles(exists = import_fs9.existsSync) {
5083
+ const rows = this.stmts.allFilePathsWithTitle.all();
5084
+ const missing = [];
5085
+ for (const row of rows) {
5086
+ if (exists(row.file_path)) continue;
5087
+ missing.push({
5088
+ id: row.id,
5089
+ filePath: row.file_path,
5090
+ title: row.title,
5091
+ tailed: !!this.stmts.hasTail.get(row.id)
5092
+ });
5093
+ }
5094
+ return missing;
5095
+ }
5096
+ /**
5097
+ * Drop the given conversation ids outright — main row, tail, and message
5098
+ * index — regardless of whether they have a tail. Used by the cache-integrity
5099
+ * resolution actions (prune_all / prune_selected). Returns the count dropped.
5100
+ */
5101
+ dropRowsById(ids) {
5102
+ if (ids.length === 0) return 0;
5103
+ const drop = this.db.transaction((toDrop) => {
5104
+ let n = 0;
5105
+ for (const id of toDrop) {
5106
+ this.stmts.deleteTailById.run(id);
5107
+ this.stmts.deleteMessageIndex.run(id);
5108
+ n += this.stmts.deleteById.run(id).changes;
5109
+ }
5110
+ return n;
5111
+ });
5112
+ const dropped = drop(ids);
5113
+ if (this.fileIndexLoaded) {
5114
+ for (const id of ids) {
5115
+ for (const [fp, cid] of this.fileIndex) {
5116
+ if (cid === id) {
5117
+ this.fileIndex.delete(fp);
5118
+ break;
5119
+ }
5120
+ }
5121
+ }
5122
+ }
5123
+ return dropped;
5124
+ }
5125
+ /**
5126
+ * Wipe all cached conversation state — meta, tails, and message index — and
5127
+ * reset the in-memory file index. Only called by the `reset_rescan`
5128
+ * resolution action, which repopulates from a fresh disk scan afterward.
5129
+ */
5130
+ clearAll() {
5131
+ this.db.transaction(() => {
5132
+ this.stmts.deleteTailAll.run();
5133
+ this.stmts.deleteAll.run();
5134
+ this.db.exec("DELETE FROM conversation_message_index");
5135
+ })();
5136
+ this.fileIndex = /* @__PURE__ */ new Map();
5137
+ }
4825
5138
  };
4826
5139
 
4827
5140
  // src/db/repositories/cacheMetadata.repository.ts
@@ -5024,18 +5337,18 @@ async function recordUpload(pool2, instanceId, row) {
5024
5337
  // src/handlers/handleListProjects.ts
5025
5338
  var import_fs10 = require("fs");
5026
5339
  var import_os6 = require("os");
5027
- var import_path12 = require("path");
5340
+ var import_path13 = require("path");
5028
5341
  function decodeProjectPath(dirName) {
5029
5342
  return dirName.replace(/-/g, "/");
5030
5343
  }
5031
5344
  function handleListProjects(url, res) {
5032
5345
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
5033
5346
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
5034
- const projectsDir = (0, import_path12.join)((0, import_os6.homedir)(), ".claude", "projects");
5347
+ const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
5035
5348
  let entries;
5036
5349
  try {
5037
5350
  entries = (0, import_fs10.readdirSync)(projectsDir).map((dirName) => {
5038
- const fullPath = (0, import_path12.join)(projectsDir, dirName);
5351
+ const fullPath = (0, import_path13.join)(projectsDir, dirName);
5039
5352
  let mtime = 0;
5040
5353
  try {
5041
5354
  mtime = (0, import_fs10.statSync)(fullPath).mtimeMs;
@@ -5131,9 +5444,316 @@ function seal(plaintext, recipientPublicKeyBase64) {
5131
5444
  };
5132
5445
  }
5133
5446
 
5447
+ // src/services/cache-integrity/cacheIntegrityMonitor.ts
5448
+ var import_crypto8 = require("crypto");
5449
+ var import_fs13 = require("fs");
5450
+
5451
+ // src/services/cache-integrity/alertStore.ts
5452
+ var import_fs11 = require("fs");
5453
+ var import_os7 = require("os");
5454
+ var import_path14 = require("path");
5455
+ function alertStatePath() {
5456
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase");
5457
+ return (0, import_path14.join)(dir, "cache-alert.json");
5458
+ }
5459
+ function loadAlertState() {
5460
+ try {
5461
+ const parsed = JSON.parse((0, import_fs11.readFileSync)(alertStatePath(), "utf-8"));
5462
+ return parsed && typeof parsed === "object" ? parsed : {};
5463
+ } catch {
5464
+ return {};
5465
+ }
5466
+ }
5467
+ function saveAlertState(state) {
5468
+ const path = alertStatePath();
5469
+ (0, import_fs11.mkdirSync)((0, import_path14.dirname)(path), { recursive: true });
5470
+ (0, import_fs11.writeFileSync)(path, `${JSON.stringify(state, null, 2)}
5471
+ `);
5472
+ }
5473
+
5474
+ // src/services/cache-integrity/backup.ts
5475
+ var import_fs12 = require("fs");
5476
+ var import_path15 = require("path");
5477
+ var DEFAULT_RETAIN = 3;
5478
+ function retainCount() {
5479
+ const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
5480
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RETAIN;
5481
+ }
5482
+ function timestamp(d) {
5483
+ const p = (n) => String(n).padStart(2, "0");
5484
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5485
+ }
5486
+ async function backupCacheDb(db, cacheDir) {
5487
+ const backupsDir = (0, import_path15.join)(cacheDir, "backups");
5488
+ (0, import_fs12.mkdirSync)(backupsDir, { recursive: true });
5489
+ const destPath = (0, import_path15.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
5490
+ await db.backup(destPath);
5491
+ const retain = retainCount();
5492
+ const backups = (0, import_fs12.readdirSync)(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
5493
+ const full = (0, import_path15.join)(backupsDir, f);
5494
+ return { full, mtime: (0, import_fs12.statSync)(full).mtimeMs };
5495
+ }).sort((a, b) => b.mtime - a.mtime);
5496
+ for (const stale of backups.slice(retain)) {
5497
+ if ((0, import_fs12.existsSync)(stale.full)) (0, import_fs12.unlinkSync)(stale.full);
5498
+ }
5499
+ return destPath;
5500
+ }
5501
+
5502
+ // src/services/cache-integrity/cacheIntegrityMonitor.ts
5503
+ var MAX_MISSING_PERSISTED = 1e3;
5504
+ var SAMPLE_SIZE = 20;
5505
+ var STORM_WINDOW_MS = 3e4;
5506
+ var STORM_THRESHOLD = 10;
5507
+ function envInt(name, fallback) {
5508
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
5509
+ return Number.isFinite(parsed) ? parsed : fallback;
5510
+ }
5511
+ function fingerprintOf(ids) {
5512
+ const sorted = [...ids].sort();
5513
+ return `sha256:${(0, import_crypto8.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
5514
+ }
5515
+ var CacheIntegrityMonitor = class {
5516
+ constructor(cache, wsHub, log3, cacheDir, rescan) {
5517
+ this.cache = cache;
5518
+ this.wsHub = wsHub;
5519
+ this.log = log3;
5520
+ this.cacheDir = cacheDir;
5521
+ this.rescan = rescan;
5522
+ const state = loadAlertState();
5523
+ this._pending = state.pending ?? null;
5524
+ this.ignoredIds = new Set(state.ignoredIds ?? []);
5525
+ }
5526
+ cache;
5527
+ wsHub;
5528
+ log;
5529
+ cacheDir;
5530
+ rescan;
5531
+ _pending;
5532
+ ignoredIds;
5533
+ deferredUnlinks = [];
5534
+ unlinkTimes = [];
5535
+ get pending() {
5536
+ return this._pending;
5537
+ }
5538
+ persist() {
5539
+ const state = {};
5540
+ if (this._pending) {
5541
+ state.pending = {
5542
+ ...this._pending,
5543
+ missing: this._pending.missing.slice(0, MAX_MISSING_PERSISTED)
5544
+ };
5545
+ }
5546
+ if (this.ignoredIds.size > 0) state.ignoredIds = [...this.ignoredIds];
5547
+ saveAlertState(state);
5548
+ }
5549
+ classifySeverity(missingCount, totalRows) {
5550
+ const minMissing = envInt("THREADBASE_CACHE_ALERT_MIN_MISSING", 20);
5551
+ const minRatio = Number.parseFloat(process.env.THREADBASE_CACHE_ALERT_MIN_RATIO ?? "0.20");
5552
+ const ratio = totalRows > 0 ? missingCount / totalRows : 0;
5553
+ const ratioThreshold = Number.isFinite(minRatio) ? minRatio : 0.2;
5554
+ return missingCount >= minMissing && ratio >= ratioThreshold ? "high" : "low";
5555
+ }
5556
+ sampleOf(missing) {
5557
+ return missing.slice(0, SAMPLE_SIZE).map((m) => ({
5558
+ id: m.id,
5559
+ ...m.title != null ? { title: m.title } : {}
5560
+ }));
5561
+ }
5562
+ buildWsMessage(pending) {
5563
+ return {
5564
+ type: "cache_alert",
5565
+ fingerprint: pending.fingerprint,
5566
+ severity: pending.severity,
5567
+ missingCount: pending.missingCount,
5568
+ totalRows: pending.totalRows,
5569
+ detectedAt: pending.detectedAt,
5570
+ sample: this.sampleOf(pending.missing)
5571
+ };
5572
+ }
5573
+ wsMessage() {
5574
+ return this._pending ? this.buildWsMessage(this._pending) : null;
5575
+ }
5576
+ healthzField() {
5577
+ if (!this._pending) return void 0;
5578
+ return {
5579
+ severity: this._pending.severity,
5580
+ missingCount: this._pending.missingCount,
5581
+ fingerprint: this._pending.fingerprint,
5582
+ detectedAt: this._pending.detectedAt
5583
+ };
5584
+ }
5585
+ /**
5586
+ * Scan the cache for rows whose file is gone, excluding ids the user chose to
5587
+ * ignore. If none remain, clear any stale pending alert and return (the caller
5588
+ * decides whether to run pruneGhostFiles). Otherwise classify severity, persist
5589
+ * the pending record, back up on high severity, and broadcast the alert.
5590
+ */
5591
+ async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
5592
+ const all = this.cache.listMissingFiles(import_fs13.existsSync);
5593
+ const missing = all.filter((m) => !this.ignoredIds.has(m.id));
5594
+ if (missing.length === 0) {
5595
+ if (this._pending) {
5596
+ this._pending = null;
5597
+ this.persist();
5598
+ }
5599
+ return;
5600
+ }
5601
+ const totalRows = this.cache.listConversations({ limit: 0, offset: 0 }).total;
5602
+ const fingerprint = fingerprintOf(missing.map((m) => m.id));
5603
+ const severity = this.classifySeverity(missing.length, totalRows);
5604
+ const pending = {
5605
+ fingerprint,
5606
+ severity,
5607
+ detectedAt,
5608
+ missingCount: missing.length,
5609
+ totalRows,
5610
+ missing
5611
+ };
5612
+ if (severity === "high") {
5613
+ try {
5614
+ pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
5615
+ } catch (err) {
5616
+ this.log.warn("cache-integrity backup failed", {
5617
+ event: "cache_integrity.backup_failed",
5618
+ error: err instanceof Error ? err.message : String(err)
5619
+ });
5620
+ }
5621
+ }
5622
+ this._pending = pending;
5623
+ this.persist();
5624
+ this.log.warn("cache integrity drift detected", {
5625
+ event: "cache_integrity.detected",
5626
+ severity,
5627
+ missingCount: missing.length,
5628
+ totalRows,
5629
+ fingerprint
5630
+ });
5631
+ this.wsHub.broadcast(this.buildWsMessage(pending));
5632
+ }
5633
+ /** Queue an unlink while an alert is pending — the row is not invalidated. */
5634
+ deferUnlink(filePath) {
5635
+ this.deferredUnlinks.push(filePath);
5636
+ }
5637
+ /**
5638
+ * Record a live unlink while NO alert is pending. Crossing the storm threshold
5639
+ * (>= 10 unlinks within 30s) re-triggers detection.
5640
+ */
5641
+ recordUnlink(filePath) {
5642
+ const now = Date.now();
5643
+ this.unlinkTimes.push(now);
5644
+ this.unlinkTimes = this.unlinkTimes.filter((t) => now - t < STORM_WINDOW_MS);
5645
+ if (this.unlinkTimes.length >= STORM_THRESHOLD) {
5646
+ this.unlinkTimes = [];
5647
+ void this.runDetection().catch((err) => {
5648
+ this.log.error("cache-integrity storm detection failed", {
5649
+ event: "cache_integrity.storm_detection_failed",
5650
+ error: err instanceof Error ? err.message : String(err),
5651
+ filePath
5652
+ });
5653
+ });
5654
+ }
5655
+ }
5656
+ async ensureBackup(pending) {
5657
+ if (pending.backupPath) return pending.backupPath;
5658
+ try {
5659
+ pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
5660
+ } catch (err) {
5661
+ this.log.warn("cache-integrity backup failed", {
5662
+ event: "cache_integrity.backup_failed",
5663
+ error: err instanceof Error ? err.message : String(err)
5664
+ });
5665
+ }
5666
+ return pending.backupPath;
5667
+ }
5668
+ clearPending() {
5669
+ this._pending = null;
5670
+ this.deferredUnlinks = [];
5671
+ this.persist();
5672
+ }
5673
+ applyDeferredUnlinks() {
5674
+ for (const fp of this.deferredUnlinks) this.cache.invalidateByFilePath(fp);
5675
+ this.deferredUnlinks = [];
5676
+ }
5677
+ broadcastResolved(fingerprint, action) {
5678
+ this.wsHub.broadcast({ type: "cache_alert_resolved", fingerprint, action });
5679
+ }
5680
+ /**
5681
+ * Apply the human's chosen resolution. Idempotent per fingerprint: no pending
5682
+ * alert → alreadyResolved; a different fingerprint → conflict. See the spec's
5683
+ * four-action semantics.
5684
+ */
5685
+ async resolve(fingerprint, action, ids) {
5686
+ const pending = this._pending;
5687
+ if (!pending) return { alreadyResolved: true };
5688
+ if (pending.fingerprint !== fingerprint) {
5689
+ return { conflict: true, currentFingerprint: pending.fingerprint };
5690
+ }
5691
+ this._pending = null;
5692
+ switch (action) {
5693
+ case "prune_all": {
5694
+ await this.ensureBackup(pending);
5695
+ const backupPath = pending.backupPath;
5696
+ const stillMissing = pending.missing.filter((m) => !(0, import_fs13.existsSync)(m.filePath)).map((m) => m.id);
5697
+ const pruned = this.cache.dropRowsById(stillMissing);
5698
+ this.applyDeferredUnlinks();
5699
+ this.clearPending();
5700
+ this.broadcastResolved(fingerprint, action);
5701
+ return { ok: true, action, pruned, backupPath };
5702
+ }
5703
+ case "prune_selected": {
5704
+ const requested = new Set(ids ?? []);
5705
+ const pendingIds = new Set(pending.missing.map((m) => m.id));
5706
+ const toDrop = [...requested].filter((id) => pendingIds.has(id));
5707
+ await this.ensureBackup(pending);
5708
+ const backupPath = pending.backupPath;
5709
+ const pruned = this.cache.dropRowsById(toDrop);
5710
+ const prunedPaths = new Set(
5711
+ pending.missing.filter((m) => toDrop.includes(m.id)).map((m) => m.filePath)
5712
+ );
5713
+ this.deferredUnlinks = this.deferredUnlinks.filter((fp) => {
5714
+ if (prunedPaths.has(fp)) {
5715
+ this.cache.invalidateByFilePath(fp);
5716
+ return false;
5717
+ }
5718
+ return true;
5719
+ });
5720
+ this.persist();
5721
+ await this.runDetection();
5722
+ this.broadcastResolved(fingerprint, action);
5723
+ return { ok: true, action, pruned, backupPath };
5724
+ }
5725
+ case "ignore": {
5726
+ for (const m of pending.missing) this.ignoredIds.add(m.id);
5727
+ this.deferredUnlinks = [];
5728
+ this.clearPending();
5729
+ this.broadcastResolved(fingerprint, action);
5730
+ return { ok: true, action };
5731
+ }
5732
+ case "reset_rescan": {
5733
+ const backupPath = await this.ensureBackup(pending);
5734
+ this.cache.clearAll();
5735
+ if (this.rescan) {
5736
+ try {
5737
+ const metas = await this.rescan();
5738
+ this.cache.upsertFromScannerMeta(metas);
5739
+ } catch (err) {
5740
+ this.log.error("cache-integrity reset rescan failed", {
5741
+ event: "cache_integrity.reset_rescan_failed",
5742
+ error: err instanceof Error ? err.message : String(err)
5743
+ });
5744
+ }
5745
+ }
5746
+ this.clearPending();
5747
+ this.broadcastResolved(fingerprint, action);
5748
+ return { ok: true, action, backupPath };
5749
+ }
5750
+ }
5751
+ }
5752
+ };
5753
+
5134
5754
  // src/services/conversations/conversationWatcher.ts
5135
5755
  var import_chokidar = __toESM(require("chokidar"), 1);
5136
- var import_fs11 = require("fs");
5756
+ var import_fs14 = require("fs");
5137
5757
  var import_promises5 = require("fs/promises");
5138
5758
  var ConversationWatcher = class {
5139
5759
  files = /* @__PURE__ */ new Map();
@@ -5143,6 +5763,7 @@ var ConversationWatcher = class {
5143
5763
  onNewLineSpans;
5144
5764
  onConversationChanged;
5145
5765
  onFileDeleted;
5766
+ onTruncated;
5146
5767
  onError;
5147
5768
  constructor(events = {}) {
5148
5769
  this.onNewLine = events.onNewLine;
@@ -5150,13 +5771,15 @@ var ConversationWatcher = class {
5150
5771
  this.onNewLineSpans = events.onNewLineSpans;
5151
5772
  this.onConversationChanged = events.onConversationChanged;
5152
5773
  this.onFileDeleted = events.onFileDeleted;
5774
+ this.onTruncated = events.onTruncated;
5153
5775
  this.onError = events.onError;
5154
5776
  }
5155
5777
  watch(filePath) {
5156
- if (this.files.has(filePath)) return;
5778
+ const key = canonicalizeFilePath(filePath);
5779
+ if (this.files.has(key)) return;
5157
5780
  let offset;
5158
5781
  try {
5159
- offset = (0, import_fs11.statSync)(filePath).size;
5782
+ offset = (0, import_fs14.statSync)(filePath).size;
5160
5783
  } catch {
5161
5784
  offset = 0;
5162
5785
  }
@@ -5165,23 +5788,24 @@ var ConversationWatcher = class {
5165
5788
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
5166
5789
  });
5167
5790
  watcher.on("change", () => {
5168
- void this.readNewLines(filePath);
5791
+ void this.readNewLines(key);
5169
5792
  });
5170
5793
  watcher.on("add", () => {
5171
- void this.readNewLines(filePath);
5794
+ void this.readNewLines(key);
5172
5795
  });
5173
5796
  watcher.on("unlink", () => this.onFileDeleted?.(filePath));
5174
5797
  watcher.on("error", (err) => {
5175
5798
  const error = err instanceof Error ? err : new Error(String(err));
5176
5799
  this.onError?.(filePath, error);
5177
5800
  });
5178
- this.files.set(filePath, { watcher, offset, reading: false, pending: false });
5801
+ this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
5179
5802
  }
5180
5803
  unwatch(filePath) {
5181
- const entry = this.files.get(filePath);
5804
+ const key = canonicalizeFilePath(filePath);
5805
+ const entry = this.files.get(key);
5182
5806
  if (!entry) return;
5183
5807
  void entry.watcher.close();
5184
- this.files.delete(filePath);
5808
+ this.files.delete(key);
5185
5809
  }
5186
5810
  /**
5187
5811
  * Re-drive the tail read for a file that's already being tailed. A per-file
@@ -5192,8 +5816,9 @@ var ConversationWatcher = class {
5192
5816
  * event is a cheap stat + no-op. Returns false for untailed paths.
5193
5817
  */
5194
5818
  poke(filePath) {
5195
- if (!this.files.has(filePath)) return false;
5196
- void this.readNewLines(filePath);
5819
+ const key = canonicalizeFilePath(filePath);
5820
+ if (!this.files.has(key)) return false;
5821
+ void this.readNewLines(key);
5197
5822
  return true;
5198
5823
  }
5199
5824
  /**
@@ -5230,9 +5855,10 @@ var ConversationWatcher = class {
5230
5855
  for (const [path] of this.files) this.unwatch(path);
5231
5856
  for (const [dir] of this.directories) this.unwatchDirectory(dir);
5232
5857
  }
5233
- async readNewLines(filePath) {
5234
- const entry = this.files.get(filePath);
5858
+ async readNewLines(key) {
5859
+ const entry = this.files.get(key);
5235
5860
  if (!entry) return;
5861
+ const filePath = entry.path;
5236
5862
  if (entry.reading) {
5237
5863
  entry.pending = true;
5238
5864
  return;
@@ -5241,6 +5867,10 @@ var ConversationWatcher = class {
5241
5867
  try {
5242
5868
  for (; ; ) {
5243
5869
  const st = await (0, import_promises5.stat)(filePath);
5870
+ if (st.size < entry.offset) {
5871
+ entry.offset = 0;
5872
+ this.onTruncated?.(filePath);
5873
+ }
5244
5874
  if (st.size <= entry.offset) break;
5245
5875
  const readFrom = entry.offset;
5246
5876
  const bytesToRead = st.size - readFrom;
@@ -5253,7 +5883,7 @@ var ConversationWatcher = class {
5253
5883
  }
5254
5884
  const { spans, consumed } = splitCompleteLines(buf, readFrom);
5255
5885
  entry.offset = readFrom + consumed;
5256
- if (!this.files.has(filePath)) return;
5886
+ if (!this.files.has(key)) return;
5257
5887
  const lines = spans.map((s) => s.text);
5258
5888
  if (spans.length > 0) {
5259
5889
  this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
@@ -5273,9 +5903,9 @@ var ConversationWatcher = class {
5273
5903
  this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
5274
5904
  } finally {
5275
5905
  entry.reading = false;
5276
- if (entry.pending && this.files.has(filePath)) {
5906
+ if (entry.pending && this.files.has(key)) {
5277
5907
  entry.pending = false;
5278
- void this.readNewLines(filePath);
5908
+ void this.readNewLines(key);
5279
5909
  }
5280
5910
  }
5281
5911
  }
@@ -5331,14 +5961,14 @@ function findSearchTarget(messages, query) {
5331
5961
  }
5332
5962
 
5333
5963
  // src/services/conversations/pruneAgentConversations.ts
5334
- var import_fs12 = require("fs");
5964
+ var import_fs15 = require("fs");
5335
5965
  function pruneAgentConversations(cache) {
5336
5966
  const db = cache.getDatabase();
5337
5967
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
5338
5968
  let pruned = 0;
5339
5969
  let missing = 0;
5340
5970
  for (const row of rows) {
5341
- if (!(0, import_fs12.existsSync)(row.file_path)) {
5971
+ if (!(0, import_fs15.existsSync)(row.file_path)) {
5342
5972
  missing += 1;
5343
5973
  continue;
5344
5974
  }
@@ -5484,6 +6114,52 @@ function resolveAnswer(pending, body) {
5484
6114
  }
5485
6115
  }
5486
6116
 
6117
+ // src/services/sessions/conversationBusy.ts
6118
+ var import_fs16 = require("fs");
6119
+ var RESUME_BUSY_WINDOW_MS = 12e4;
6120
+ function resolveResumeBusyWindowMs(env = process.env) {
6121
+ const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
6122
+ if (raw === void 0) return RESUME_BUSY_WINDOW_MS;
6123
+ const n = Number.parseInt(raw, 10);
6124
+ return Number.isFinite(n) && n >= 0 ? n : RESUME_BUSY_WINDOW_MS;
6125
+ }
6126
+ var SELF_ACTIVITY_SKEW_MS = 5e3;
6127
+ function conversationBusy(input) {
6128
+ const now = input.now ?? Date.now();
6129
+ const windowMs = input.windowMs ?? RESUME_BUSY_WINDOW_MS;
6130
+ const platform3 = input.platform ?? process.platform;
6131
+ const detectedBy = [];
6132
+ let lastActivityMs = null;
6133
+ if (input.jsonlPath) {
6134
+ try {
6135
+ const mtimeMs = (0, import_fs16.statSync)(input.jsonlPath).mtimeMs;
6136
+ const age = now - mtimeMs;
6137
+ lastActivityMs = Math.max(0, age);
6138
+ const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
6139
+ if (age <= windowMs && !isSelfEcho) detectedBy.push("jsonl_mtime");
6140
+ } catch {
6141
+ }
6142
+ }
6143
+ const argvMatch = input.discovered.some((p) => p.conversationId === input.conversationId);
6144
+ if (argvMatch) detectedBy.push("process_argv");
6145
+ let cwdMatch = false;
6146
+ if (platform3 !== "win32" && input.projectPath) {
6147
+ const target = canonicalizeProjectPath(input.projectPath);
6148
+ cwdMatch = input.discovered.some(
6149
+ (p) => !!p.projectPath && canonicalizeProjectPath(p.projectPath) === target
6150
+ );
6151
+ if (cwdMatch) detectedBy.push("process_cwd");
6152
+ }
6153
+ return {
6154
+ busy: detectedBy.length > 0,
6155
+ detectedBy,
6156
+ lastActivityMs,
6157
+ // A matched process is a concrete external owner; a lone mtime hit could be
6158
+ // an editor, a crashed process, or a process we could not enumerate.
6159
+ likelyOwner: argvMatch || cwdMatch ? "external" : "unknown"
6160
+ };
6161
+ }
6162
+
5487
6163
  // src/session-store.ts
5488
6164
  var SessionStore = class {
5489
6165
  managed = /* @__PURE__ */ new Map();
@@ -5623,6 +6299,9 @@ function managedToResponse(s, ptyAttached) {
5623
6299
  conversationId: s.id,
5624
6300
  provider: s.provider ?? CLAUDE_CODE_PROVIDER,
5625
6301
  status: s.status,
6302
+ // We spawned it, so `status` is the authoritative signal — no inferred
6303
+ // `activity` is attached for managed sessions.
6304
+ ownership: "managed",
5626
6305
  projectPath: s.projectPath,
5627
6306
  projectName: s.projectName,
5628
6307
  branch: s.branch,
@@ -5656,7 +6335,14 @@ function discoveredToResponse(d, conversationId) {
5656
6335
  id: conversationId,
5657
6336
  conversationId,
5658
6337
  provider: CLAUDE_CODE_PROVIDER,
6338
+ // Stays "idle" deliberately: we cannot see this process's prompt state, and
6339
+ // reporting `running` would route mobile to the destructive Overtake screen.
6340
+ // Liveness travels in the additive fields below instead.
5659
6341
  status: "idle",
6342
+ ownership: "external",
6343
+ // Discovery just enumerated this PID, so it was alive moments ago. We never
6344
+ // report "gone" here — a vanished process simply stops being listed.
6345
+ processLiveness: "alive",
5660
6346
  projectPath: d.projectPath,
5661
6347
  projectName: d.projectName,
5662
6348
  branch: d.branch,
@@ -5671,10 +6357,10 @@ function discoveredToResponse(d, conversationId) {
5671
6357
  }
5672
6358
 
5673
6359
  // src/uploads.ts
5674
- var import_crypto8 = require("crypto");
6360
+ var import_crypto9 = require("crypto");
5675
6361
  var import_promises6 = require("fs/promises");
5676
6362
  var import_heic_convert = __toESM(require("heic-convert"), 1);
5677
- var import_path13 = require("path");
6363
+ var import_path16 = require("path");
5678
6364
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
5679
6365
  var MAX_BYTES = 25 * 1024 * 1024;
5680
6366
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -5705,11 +6391,11 @@ async function saveUploadFile(input) {
5705
6391
  mimeType = "image/jpeg";
5706
6392
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
5707
6393
  }
5708
- const id = `up_${(0, import_crypto8.randomBytes)(8).toString("hex")}`;
6394
+ const id = `up_${(0, import_crypto9.randomBytes)(8).toString("hex")}`;
5709
6395
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
5710
- const dir = (0, import_path13.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
6396
+ const dir = (0, import_path16.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5711
6397
  await (0, import_promises6.mkdir)(dir, { recursive: true });
5712
- const filePath = (0, import_path13.join)(dir, `${Date.now()}-${id}-${safeName}`);
6398
+ const filePath = (0, import_path16.join)(dir, `${Date.now()}-${id}-${safeName}`);
5713
6399
  await (0, import_promises6.writeFile)(filePath, buffer);
5714
6400
  return {
5715
6401
  id,
@@ -5721,7 +6407,7 @@ async function saveUploadFile(input) {
5721
6407
  }
5722
6408
  function sanitizeFilename(name) {
5723
6409
  const base = name.split(/[\\/]/).pop() ?? "";
5724
- const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("");
6410
+ const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("").replace(/[\s@"'`$\\]/g, "_");
5725
6411
  return cleaned;
5726
6412
  }
5727
6413
 
@@ -5754,12 +6440,12 @@ function normalizeCodexLineToClaudeShape(line) {
5754
6440
  const text = extractCodexText(payload.content);
5755
6441
  if (!text) return null;
5756
6442
  if (role === "user" && isCodexInjectedContext(text)) return null;
5757
- const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
5758
- const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp}-${hashPrefix(text)}`;
6443
+ const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
6444
+ const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
5759
6445
  return JSON.stringify({
5760
6446
  type: role,
5761
6447
  uuid,
5762
- timestamp,
6448
+ timestamp: timestamp2,
5763
6449
  message: {
5764
6450
  role,
5765
6451
  content: [{ type: "text", text }]
@@ -5813,9 +6499,9 @@ var import_node_crypto3 = require("crypto");
5813
6499
  function computeConversationEtag({
5814
6500
  filePath,
5815
6501
  messageCount,
5816
- timestamp
6502
+ timestamp: timestamp2
5817
6503
  }) {
5818
- const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp}`).digest("hex").slice(0, 16);
6504
+ const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
5819
6505
  return `"${digest}"`;
5820
6506
  }
5821
6507
 
@@ -5965,8 +6651,17 @@ var WSHub = class {
5965
6651
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
5966
6652
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5967
6653
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
6654
+ var GRACE_MAX_DEFERS = 4;
6655
+ var RESUME_DISCOVERY_TIMEOUT_MS = 750;
6656
+ var DISCOVERY_TTL_MS = 15e3;
6657
+ var ADOPT_KILL_TIMEOUT_MS = 5e3;
6658
+ var ADOPT_KILL_POLL_MS = 100;
5968
6659
  var REFRESH_TTL_MS = 2e3;
5969
6660
  var START_READY_TIMEOUT_MS = 1e4;
6661
+ var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
6662
+ var EXTERNAL_TAIL_MAX = 32;
6663
+ var EXTERNAL_TAIL_IDLE_MS = 3e5;
6664
+ var EXTERNAL_ACTIVE_WRITING_MS = 3e4;
5970
6665
  function parseIncludeAgentsEnv(raw) {
5971
6666
  if (raw === void 0) return false;
5972
6667
  const v = raw.trim().toLowerCase();
@@ -5980,11 +6675,30 @@ var StreamerServer = class {
5980
6675
  fileWatcher;
5981
6676
  sessionFileMap = /* @__PURE__ */ new Map();
5982
6677
  // sessionId → JSONL filePath
6678
+ // canonical JSONL path → live tail on a file NO PTY session owns (an external
6679
+ // agent is writing it). Deliberately separate from sessionFileMap so managed
6680
+ // session semantics — terminal_output, session_update, question cards — are
6681
+ // untouched: an external tail only ever pushes transcript lines.
6682
+ externalTails = /* @__PURE__ */ new Map();
5983
6683
  // Per-file seq assignments from the most recent onNewLineSpans (offset index),
5984
6684
  // handed to the immediately-following onNewLines so it can stamp WS `seq` on
5985
6685
  // the matching conversation_events entries. Same read → same lines order.
5986
6686
  pendingLineSeqs = /* @__PURE__ */ new Map();
6687
+ // `origin` records whether the pending question came from the live PTY-screen
6688
+ // path (handleLiveQuestion) or a JSONL flush. A JSONL-derived question must
6689
+ // never clobber a PTY-originated one for a DIFFERENT question — an external
6690
+ // agent appending an AskUserQuestion into a shared conversation would
6691
+ // otherwise misroute the answer into this streamer's PTY.
5987
6692
  pendingQuestions = /* @__PURE__ */ new Map();
6693
+ // Sessions resumed past a detected collision (busy probe said busy, caller
6694
+ // forced). JSONL-derived actionable question cards are suppressed for these
6695
+ // because a line in the shared file may have been written by the other owner.
6696
+ contendedSessions = /* @__PURE__ */ new Set();
6697
+ // conversationId → ms epoch when THIS streamer's PTY for it last went idle.
6698
+ // Lets the resume collision probe tell our own trailing JSONL writes (a
6699
+ // hold → resume round trip) apart from another owner's. Pruned on write so it
6700
+ // cannot grow without bound across a long-lived process.
6701
+ selfPtyEndedAt = /* @__PURE__ */ new Map();
5988
6702
  // Content key of the AskUserQuestion currently broadcast for a session (from
5989
6703
  // either the rendered screen or JSONL), used to de-dupe the two paths: when
5990
6704
  // the screen detection fires first, the later JSONL flush of the same question
@@ -6051,6 +6765,9 @@ var StreamerServer = class {
6051
6765
  defaultEffort;
6052
6766
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
6053
6767
  ptyGraceTimers = /* @__PURE__ */ new Map();
6768
+ // Consecutive grace-timer defers for a still-`running` session (see
6769
+ // GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
6770
+ ptyGraceDeferCounts = /* @__PURE__ */ new Map();
6054
6771
  // Map of sessionId → set of subscribed WS clients
6055
6772
  sessionSubscribers = /* @__PURE__ */ new Map();
6056
6773
  // Map of clientId → WS socket (populated by the "register" WS handshake)
@@ -6058,6 +6775,7 @@ var StreamerServer = class {
6058
6775
  // Reverse map for cleanup on close
6059
6776
  wsToClientId = /* @__PURE__ */ new Map();
6060
6777
  cache = null;
6778
+ cacheMonitor = null;
6061
6779
  projectsRepo = null;
6062
6780
  conversationsRepo = null;
6063
6781
  sessionsRepo = null;
@@ -6094,13 +6812,13 @@ var StreamerServer = class {
6094
6812
  this.disableDb = config.disableDb ?? false;
6095
6813
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
6096
6814
  this.scanProfiles = config.scanProfiles;
6097
- this.codexRoots = config.codexRoots ?? [(0, import_path14.join)((0, import_os7.homedir)(), ".codex", "sessions")];
6815
+ this.codexRoots = config.codexRoots ?? [(0, import_path17.join)((0, import_os8.homedir)(), ".codex", "sessions")];
6098
6816
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
6099
6817
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
6100
6818
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
6101
6819
  this.defaultModel = config.defaultModel ?? "sonnet";
6102
6820
  this.defaultEffort = config.defaultEffort ?? "low";
6103
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase", "cache");
6821
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path17.join)((0, import_os8.homedir)(), ".threadbase", "cache");
6104
6822
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
6105
6823
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
6106
6824
  this.markScannerStaleDebounced = debounce(() => {
@@ -6141,7 +6859,7 @@ var StreamerServer = class {
6141
6859
  const seqs = cache.extendMessageIndex(
6142
6860
  filePath,
6143
6861
  spans,
6144
- (0, import_fs13.statSync)(filePath),
6862
+ (0, import_fs17.statSync)(filePath),
6145
6863
  readFrom,
6146
6864
  endOffset
6147
6865
  );
@@ -6171,39 +6889,25 @@ var StreamerServer = class {
6171
6889
  },
6172
6890
  onNewLines: (filePath, lines) => {
6173
6891
  this.cache?.updateFromLines(filePath, lines);
6892
+ let managed = false;
6174
6893
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
6175
6894
  if (watchedPath === filePath) {
6176
- const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
6177
- const { messages, pending } = questionsFromLines(sessionId, lines);
6178
- for (const p of pending) {
6179
- this.pendingQuestions.set(sessionId, p);
6180
- const t = setTimeout(() => {
6181
- if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
6182
- this.cancelPendingQuestion(sessionId);
6183
- }
6184
- }, 6e4);
6185
- t.unref();
6186
- }
6187
- for (const m of messages) {
6188
- const key = questionContentKey(m.questions);
6189
- const broadcast = shouldBroadcastQuestion({
6190
- newContentKey: key,
6191
- lastContentKey: this.pendingQuestionKey.get(sessionId),
6192
- newToolUseId: m.toolUseId,
6193
- priorToolUseId
6194
- });
6195
- this.pendingQuestionKey.set(sessionId, key);
6196
- if (broadcast) this.wsHub.broadcast(m);
6197
- }
6895
+ managed = true;
6896
+ this.processJsonlQuestions(sessionId, lines);
6198
6897
  const seqs = this.pendingLineSeqs.get(filePath);
6199
6898
  this.broadcastConversationLines(sessionId, lines, seqs);
6200
6899
  break;
6201
6900
  }
6202
6901
  }
6902
+ if (!managed) {
6903
+ this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
6904
+ }
6203
6905
  this.pendingLineSeqs.delete(filePath);
6204
6906
  },
6205
6907
  onConversationChanged: (filePath) => {
6206
- this.fileWatcher.poke(filePath);
6908
+ const tailed = this.fileWatcher.poke(filePath);
6909
+ if (!tailed) this.maybeAttachExternalTail(filePath);
6910
+ this.sweepIdleExternalTails();
6207
6911
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
6208
6912
  this.markScannerStaleDebounced();
6209
6913
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
@@ -6211,7 +6915,20 @@ var StreamerServer = class {
6211
6915
  event: "cache.directory_change"
6212
6916
  });
6213
6917
  },
6918
+ onTruncated: (filePath) => {
6919
+ this.cache?.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
6920
+ this.cache?.clearIndexParseState(filePath);
6921
+ this.log.warn(`JSONL truncated/replaced; offset index dropped: ${filePath}`, {
6922
+ filePath,
6923
+ event: "tail.truncated"
6924
+ });
6925
+ },
6214
6926
  onFileDeleted: (filePath) => {
6927
+ this.detachExternalTail(canonicalizeFilePath(filePath));
6928
+ if (this.cacheMonitor?.pending) {
6929
+ this.cacheMonitor.deferUnlink(filePath);
6930
+ return;
6931
+ }
6215
6932
  const id = this.cache?.invalidateByFilePath(filePath);
6216
6933
  if (id)
6217
6934
  this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
@@ -6219,6 +6936,7 @@ var StreamerServer = class {
6219
6936
  filePath,
6220
6937
  event: "cache.invalidate_on_unlink"
6221
6938
  });
6939
+ this.cacheMonitor?.recordUnlink(filePath);
6222
6940
  }
6223
6941
  });
6224
6942
  this.ptyManager = new LiveSessionManager({
@@ -6282,6 +7000,8 @@ var StreamerServer = class {
6282
7000
  this.cancelPendingQuestion(session.id);
6283
7001
  }
6284
7002
  this.pendingPermission.delete(session.id);
7003
+ this.contendedSessions.delete(session.id);
7004
+ this.rememberSelfPtyEnded(session.id);
6285
7005
  }
6286
7006
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
6287
7007
  if (resp) {
@@ -6305,7 +7025,7 @@ var StreamerServer = class {
6305
7025
  temporalClient,
6306
7026
  taskQueue: agentConfig.temporal.taskQueue
6307
7027
  });
6308
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path14.join)((0, import_path14.dirname)(this.cacheDir), "conversations");
7028
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path17.join)((0, import_path17.dirname)(this.cacheDir), "conversations");
6309
7029
  conversationWriter = createConversationWriter({
6310
7030
  baseDir: conversationsBaseDir
6311
7031
  });
@@ -6327,6 +7047,7 @@ var StreamerServer = class {
6327
7047
  sessionStore: this.sessionStore,
6328
7048
  wsHub: this.wsHub,
6329
7049
  cache: () => this.cache,
7050
+ cacheMonitor: () => this.cacheMonitor,
6330
7051
  projectsRepo: () => this.projectsRepo,
6331
7052
  conversationsRepo: () => this.conversationsRepo,
6332
7053
  sessionsRepo: () => this.sessionsRepo,
@@ -6365,6 +7086,8 @@ var StreamerServer = class {
6365
7086
  if (this.cacheReady) {
6366
7087
  ws.send(JSON.stringify({ type: "cache_ready" }));
6367
7088
  }
7089
+ const alertMsg = this.cacheMonitor?.wsMessage();
7090
+ if (alertMsg) this.wsHub.unicast(ws, alertMsg);
6368
7091
  },
6369
7092
  handleWsMessage: async (ws, raw) => {
6370
7093
  try {
@@ -6480,6 +7203,17 @@ var StreamerServer = class {
6480
7203
  ptyAttachedIds() {
6481
7204
  return new Set(this.ptyManager.listSessions().map((s) => s.id));
6482
7205
  }
7206
+ // Record that our own PTY for `conversationId` just ended. Entries older than
7207
+ // the busy window can never change a verdict, so drop them as we go rather
7208
+ // than accumulating one per conversation for the process's lifetime.
7209
+ rememberSelfPtyEnded(conversationId) {
7210
+ const now = Date.now();
7211
+ const cutoff = now - resolveResumeBusyWindowMs();
7212
+ for (const [id, at] of this.selfPtyEndedAt) {
7213
+ if (at < cutoff) this.selfPtyEndedAt.delete(id);
7214
+ }
7215
+ this.selfPtyEndedAt.set(conversationId, now);
7216
+ }
6483
7217
  /**
6484
7218
  * Send a session_list to only the client that triggered this HTTP request
6485
7219
  * (identified by X-Client-Id header → registered WS socket). Falls back to
@@ -6510,6 +7244,7 @@ var StreamerServer = class {
6510
7244
  clearTimeout(existing);
6511
7245
  this.ptyGraceTimers.delete(sessionId);
6512
7246
  }
7247
+ this.ptyGraceDeferCounts.delete(sessionId);
6513
7248
  }
6514
7249
  startGraceTimer(sessionId, delayMs) {
6515
7250
  const existing = this.ptyGraceTimers.get(sessionId);
@@ -6519,14 +7254,24 @@ var StreamerServer = class {
6519
7254
  if (this.ptyManager.hasSession(sessionId)) {
6520
7255
  const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6521
7256
  if (resp?.status === "running") {
6522
- this.log.info(
6523
- `[grace] session ${sessionId} still running, deferring hold`,
6524
- { sessionId, event: "pty.grace_defer" },
7257
+ const defers = (this.ptyGraceDeferCounts.get(sessionId) ?? 0) + 1;
7258
+ if (defers <= GRACE_MAX_DEFERS) {
7259
+ this.ptyGraceDeferCounts.set(sessionId, defers);
7260
+ this.log.info(
7261
+ `[grace] session ${sessionId} still running, deferring hold (${defers}/${GRACE_MAX_DEFERS})`,
7262
+ { sessionId, event: "pty.grace_defer", defers, maxDefers: GRACE_MAX_DEFERS },
7263
+ "pino"
7264
+ );
7265
+ this.startGraceTimer(sessionId, delayMs);
7266
+ return;
7267
+ }
7268
+ this.log.warn(
7269
+ `[grace] session ${sessionId} exceeded ${GRACE_MAX_DEFERS} defers, holding anyway`,
7270
+ { sessionId, event: "pty.grace_defer_cap", defers, maxDefers: GRACE_MAX_DEFERS },
6525
7271
  "pino"
6526
7272
  );
6527
- this.startGraceTimer(sessionId, delayMs);
6528
- return;
6529
7273
  }
7274
+ this.ptyGraceDeferCounts.delete(sessionId);
6530
7275
  this.sessionSubscribers.delete(sessionId);
6531
7276
  this.log.info(
6532
7277
  `[grace] killing idle PTY for ${sessionId}`,
@@ -6537,6 +7282,7 @@ var StreamerServer = class {
6537
7282
  const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6538
7283
  if (held) this.wsHub.broadcast({ type: "session_update", session: held });
6539
7284
  } else {
7285
+ this.ptyGraceDeferCounts.delete(sessionId);
6540
7286
  this.sessionSubscribers.delete(sessionId);
6541
7287
  }
6542
7288
  }, delayMs);
@@ -6569,13 +7315,16 @@ var StreamerServer = class {
6569
7315
  });
6570
7316
  try {
6571
7317
  this.cache = ConversationCache.open(
6572
- (0, import_path14.join)(this.cacheDir, "cache.db"),
7318
+ (0, import_path17.join)(this.cacheDir, "cache.db"),
6573
7319
  this.tailSize,
6574
7320
  void 0,
6575
7321
  {
6576
7322
  filterAgentConversations: !this.includeAgents,
6577
7323
  agentEntrypoints: this.agentEntrypoints,
6578
- onAgentFileDetected: (fp) => this.fileWatcher.unwatch(fp)
7324
+ onAgentFileDetected: (fp) => {
7325
+ this.fileWatcher.unwatch(fp);
7326
+ this.externalTails.delete(canonicalizeFilePath(fp));
7327
+ }
6579
7328
  }
6580
7329
  );
6581
7330
  if (!this.includeAgents) {
@@ -6592,9 +7341,23 @@ var StreamerServer = class {
6592
7341
  this.conversationsRepo = new ConversationsRepository(this.cache);
6593
7342
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
6594
7343
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
7344
+ this.cacheMonitor = new CacheIntegrityMonitor(
7345
+ this.cache,
7346
+ this.wsHub,
7347
+ this.log,
7348
+ this.cacheDir,
7349
+ async () => {
7350
+ const scanner = await this.rescanForRefresh();
7351
+ return [...scanner.getMetadataCache().values()];
7352
+ }
7353
+ );
6595
7354
  for (const dir of this.projectsDirs()) {
6596
7355
  this.fileWatcher.watchDirectory(dir);
6597
7356
  }
7357
+ for (const dir of this.codexRoots) {
7358
+ if (!(0, import_fs17.existsSync)(dir)) continue;
7359
+ this.fileWatcher.watchDirectory(dir);
7360
+ }
6598
7361
  } catch (err) {
6599
7362
  const message = err instanceof Error ? err.message : String(err);
6600
7363
  const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
@@ -6661,11 +7424,19 @@ var StreamerServer = class {
6661
7424
  }
6662
7425
  );
6663
7426
  }
6664
- const pruned = this.cache.pruneGhostFiles();
6665
- this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
6666
- count: pruned.length,
6667
- event: "cache.prune_ghosts"
6668
- });
7427
+ await this.cacheMonitor?.runDetection();
7428
+ if (this.cacheMonitor?.pending) {
7429
+ this.log.warn("Startup ghost prune skipped \u2014 cache integrity alert pending", {
7430
+ fingerprint: this.cacheMonitor.pending.fingerprint,
7431
+ event: "cache.prune_ghosts_frozen"
7432
+ });
7433
+ } else {
7434
+ const pruned = this.cache.pruneGhostFiles();
7435
+ this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
7436
+ count: pruned.length,
7437
+ event: "cache.prune_ghosts"
7438
+ });
7439
+ }
6669
7440
  }).catch((err) => {
6670
7441
  const message = err instanceof Error ? err.message : String(err);
6671
7442
  this.log.warn(`Startup cache warm-up failed: ${message}`, {
@@ -6777,6 +7548,7 @@ var StreamerServer = class {
6777
7548
  this.cache?.close();
6778
7549
  this.ptyManager.dispose();
6779
7550
  this.fileWatcher.dispose();
7551
+ this.externalTails.clear();
6780
7552
  this.wsHub.dispose();
6781
7553
  this.pairTokens.dispose();
6782
7554
  if (this.dbPool) {
@@ -6916,10 +7688,9 @@ var StreamerServer = class {
6916
7688
  const metas2 = [...scanner2.getMetadataCache().values()];
6917
7689
  try {
6918
7690
  this.cache.upsertFromScannerMeta(metas2);
6919
- const livePaths = new Set(
6920
- metas2.map((m) => m.filePath).filter((p) => Boolean(p))
6921
- );
6922
- this.cache.reconcileDeletions(livePaths);
7691
+ if (!this.cacheMonitor?.pending) {
7692
+ this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
7693
+ }
6923
7694
  } catch (err) {
6924
7695
  this.log.warn(
6925
7696
  `refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -7050,6 +7821,7 @@ var StreamerServer = class {
7050
7821
  type: "conversation",
7051
7822
  id: c.id,
7052
7823
  status: "idle",
7824
+ ownership: "historical",
7053
7825
  ptyAttached: false,
7054
7826
  projectId: c.projectId ?? void 0,
7055
7827
  projectPath: c.projectPath ?? "",
@@ -7076,21 +7848,19 @@ var StreamerServer = class {
7076
7848
  if (!this.cache) return void 0;
7077
7849
  if (!previousScanner) {
7078
7850
  const persisted = this.cache.getScannerStatCache();
7079
- return persisted.size > 0 ? persisted : void 0;
7851
+ if (persisted.size === 0) return void 0;
7852
+ const nativeKeyed = /* @__PURE__ */ new Map();
7853
+ for (const [canonicalPath, entry] of persisted) {
7854
+ nativeKeyed.set(toNativeFilePath(canonicalPath), entry);
7855
+ }
7856
+ return nativeKeyed;
7080
7857
  }
7081
7858
  const dbStats = this.cache.getFileStats();
7082
7859
  if (dbStats.size === 0) return void 0;
7083
- const metaByPath = /* @__PURE__ */ new Map();
7084
- if (previousScanner) {
7085
- for (const meta of previousScanner.getMetadataCache().values()) {
7086
- if (meta.filePath) metaByPath.set(meta.filePath, meta);
7087
- }
7088
- }
7089
- const statCache = /* @__PURE__ */ new Map();
7090
- for (const [filePath, stat3] of dbStats) {
7091
- const meta = metaByPath.get(filePath);
7092
- if (meta) statCache.set(filePath, { stat: stat3, meta });
7093
- }
7860
+ const statCache = joinStatCacheByNativePath(
7861
+ previousScanner.getMetadataCache().values(),
7862
+ dbStats
7863
+ );
7094
7864
  return statCache.size > 0 ? statCache : void 0;
7095
7865
  }
7096
7866
  // Returns the provider + codexRoots fragment to spread into every scan()/search() call.
@@ -7184,22 +7954,22 @@ var StreamerServer = class {
7184
7954
  */
7185
7955
  projectsDirs() {
7186
7956
  if (this.scanProfiles && this.scanProfiles.length > 0) {
7187
- return this.scanProfiles.filter((p) => p.enabled).map((p) => (0, import_path14.join)(p.configDir, "projects"));
7957
+ return this.scanProfiles.filter((p) => p.enabled).map((p) => (0, import_path17.join)(p.configDir, "projects"));
7188
7958
  }
7189
- return [(0, import_path14.join)((0, import_os7.homedir)(), ".claude", "projects")];
7959
+ return [(0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects")];
7190
7960
  }
7191
7961
  findJsonlPath(uuid) {
7192
7962
  const filename = `${uuid}.jsonl`;
7193
7963
  for (const projectsDir of this.projectsDirs()) {
7194
- if (!(0, import_fs13.existsSync)(projectsDir)) continue;
7195
- for (const dir of (0, import_fs13.readdirSync)(projectsDir)) {
7196
- const fp = (0, import_path14.join)(projectsDir, dir, filename);
7197
- if ((0, import_fs13.existsSync)(fp)) return fp;
7198
- const projectDir = (0, import_path14.join)(projectsDir, dir);
7964
+ if (!(0, import_fs17.existsSync)(projectsDir)) continue;
7965
+ for (const dir of (0, import_fs17.readdirSync)(projectsDir)) {
7966
+ const fp = (0, import_path17.join)(projectsDir, dir, filename);
7967
+ if ((0, import_fs17.existsSync)(fp)) return fp;
7968
+ const projectDir = (0, import_path17.join)(projectsDir, dir);
7199
7969
  try {
7200
- for (const sub of (0, import_fs13.readdirSync)(projectDir)) {
7201
- const subagentPath = (0, import_path14.join)(projectDir, sub, "subagents", filename);
7202
- if ((0, import_fs13.existsSync)(subagentPath)) return subagentPath;
7970
+ for (const sub of (0, import_fs17.readdirSync)(projectDir)) {
7971
+ const subagentPath = (0, import_path17.join)(projectDir, sub, "subagents", filename);
7972
+ if ((0, import_fs17.existsSync)(subagentPath)) return subagentPath;
7203
7973
  }
7204
7974
  } catch {
7205
7975
  }
@@ -7209,7 +7979,7 @@ var StreamerServer = class {
7209
7979
  }
7210
7980
  async readCwdFromJsonl(filePath) {
7211
7981
  return new Promise((resolve2) => {
7212
- const rl = (0, import_readline.createInterface)({ input: (0, import_fs13.createReadStream)(filePath), crlfDelay: Infinity });
7982
+ const rl = (0, import_readline.createInterface)({ input: (0, import_fs17.createReadStream)(filePath), crlfDelay: Infinity });
7213
7983
  let found = false;
7214
7984
  rl.on("line", (line) => {
7215
7985
  if (found) return;
@@ -7279,6 +8049,140 @@ var StreamerServer = class {
7279
8049
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
7280
8050
  }
7281
8051
  }
8052
+ // ─── External (non-PTY) live tails ───────────────────────────────
8053
+ /** True when a managed (PTY) session owns the tail for this canonical path. */
8054
+ isManagedTailPath(key) {
8055
+ for (const watchedPath of this.sessionFileMap.values()) {
8056
+ if (canonicalizeFilePath(watchedPath) === key) return true;
8057
+ }
8058
+ return false;
8059
+ }
8060
+ /**
8061
+ * Attach a live tail to a JSONL nobody is tailing yet, when it was touched
8062
+ * recently enough to look actively written by an external agent. Capped at
8063
+ * EXTERNAL_TAIL_MAX with LRU eviction.
8064
+ */
8065
+ maybeAttachExternalTail(filePath) {
8066
+ if (!filePath.endsWith(".jsonl")) return;
8067
+ const key = canonicalizeFilePath(filePath);
8068
+ if (this.externalTails.has(key)) return;
8069
+ if (this.isManagedTailPath(key)) return;
8070
+ let mtimeMs;
8071
+ try {
8072
+ mtimeMs = (0, import_fs17.statSync)(filePath).mtimeMs;
8073
+ } catch {
8074
+ return;
8075
+ }
8076
+ const now = Date.now();
8077
+ if (now - mtimeMs > EXTERNAL_TAIL_RECENCY_MS) return;
8078
+ this.evictExternalTailsIfNeeded();
8079
+ this.externalTails.set(key, {
8080
+ conversationId: ConversationCache.conversationIdForFile(key),
8081
+ lastActivityAt: now
8082
+ });
8083
+ this.fileWatcher.watch(filePath);
8084
+ this.log.debug?.(`External tail attached: ${filePath}`, {
8085
+ filePath,
8086
+ tails: this.externalTails.size,
8087
+ event: "external_tail.attach"
8088
+ });
8089
+ }
8090
+ /** Stop tailing an external file and drop its bookkeeping. */
8091
+ detachExternalTail(key) {
8092
+ if (!this.externalTails.delete(key)) return;
8093
+ this.fileWatcher.unwatch(key);
8094
+ this.log.debug?.(`External tail detached: ${key}`, {
8095
+ filePath: key,
8096
+ event: "external_tail.detach"
8097
+ });
8098
+ }
8099
+ /** Make room for one more tail by evicting the least recently active ones. */
8100
+ evictExternalTailsIfNeeded() {
8101
+ while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
8102
+ let lruKey = null;
8103
+ let lruAt = Number.POSITIVE_INFINITY;
8104
+ for (const [key, entry] of this.externalTails) {
8105
+ if (this.isManagedTailPath(key)) {
8106
+ this.externalTails.delete(key);
8107
+ return;
8108
+ }
8109
+ if (entry.lastActivityAt < lruAt) {
8110
+ lruAt = entry.lastActivityAt;
8111
+ lruKey = key;
8112
+ }
8113
+ }
8114
+ if (!lruKey) return;
8115
+ this.detachExternalTail(lruKey);
8116
+ }
8117
+ }
8118
+ /**
8119
+ * INFERRED activity for an externally-owned conversation, derived purely from
8120
+ * how recently its JSONL grew (the external tail's bookkeeping). Returns
8121
+ * undefined when we hold no tail for it, so a session we know nothing about
8122
+ * reports no activity rather than a fabricated "quiet".
8123
+ *
8124
+ * This can never distinguish a generating agent from one blocked on a
8125
+ * permission gate — gates render on the PTY screen and never reach the JSONL —
8126
+ * which is why it is a separate field and not folded into `status`.
8127
+ */
8128
+ externalActivityFor(conversationId, now = Date.now()) {
8129
+ for (const entry of this.externalTails.values()) {
8130
+ if (entry.conversationId !== conversationId) continue;
8131
+ return {
8132
+ state: now - entry.lastActivityAt <= EXTERNAL_ACTIVE_WRITING_MS ? "active_writing" : "quiet",
8133
+ lastEventAt: new Date(entry.lastActivityAt).toISOString(),
8134
+ source: "jsonl"
8135
+ };
8136
+ }
8137
+ return void 0;
8138
+ }
8139
+ /** Attach inferred `activity` to externally-owned sessions in a response set. */
8140
+ withExternalActivity(sessions) {
8141
+ if (this.externalTails.size === 0) return sessions;
8142
+ const now = Date.now();
8143
+ return sessions.map((s) => {
8144
+ if (s.ownership !== "external") return s;
8145
+ const activity = this.externalActivityFor(s.conversationId ?? s.id, now);
8146
+ return activity ? { ...s, activity } : s;
8147
+ });
8148
+ }
8149
+ /** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
8150
+ sweepIdleExternalTails(now = Date.now()) {
8151
+ for (const [key, entry] of [...this.externalTails]) {
8152
+ if (this.isManagedTailPath(key)) {
8153
+ this.externalTails.delete(key);
8154
+ continue;
8155
+ }
8156
+ if (now - entry.lastActivityAt > EXTERNAL_TAIL_IDLE_MS) this.detachExternalTail(key);
8157
+ }
8158
+ }
8159
+ /**
8160
+ * Push appended lines from an externally-owned conversation. Reuses the exact
8161
+ * conversation_events / conversation_event shapes mobile already consumes,
8162
+ * keyed by the conversation UUID — an external session has no PTY, so it must
8163
+ * never produce terminal_output / terminal_replay / session_ready, and never a
8164
+ * session_update whose session.id is a conversation UUID (that would mint a
8165
+ * phantom session row in the mobile cache). Question cards are likewise never
8166
+ * derived here: with no PTY there is nothing that could deliver an answer.
8167
+ */
8168
+ broadcastExternalTailLines(filePath, lines, seqs) {
8169
+ const key = canonicalizeFilePath(filePath);
8170
+ const entry = this.externalTails.get(key);
8171
+ if (!entry) return;
8172
+ entry.lastActivityAt = Date.now();
8173
+ const conversationId = this.cache?.getIdByFilePath(key);
8174
+ if (!conversationId) return;
8175
+ entry.conversationId = conversationId;
8176
+ this.broadcastConversationLines(conversationId, lines, seqs);
8177
+ const meta = this.cache?.getMetaById(conversationId);
8178
+ this.wsHub.broadcast({
8179
+ type: "conversation_updated",
8180
+ conversationId,
8181
+ messageCount: meta?.messageCount ?? 0,
8182
+ lastActivity: meta?.lastActivity ?? (/* @__PURE__ */ new Date()).toISOString(),
8183
+ ownership: "external"
8184
+ });
8185
+ }
7282
8186
  async findConversationByUuid(uuid) {
7283
8187
  const lookupId = this.resolveConversationLookupId(uuid);
7284
8188
  if (!this.scannerReady && !this.scanProfiles) {
@@ -7347,7 +8251,7 @@ var StreamerServer = class {
7347
8251
  if (!conv.filePath) return false;
7348
8252
  let mtimeMs = null;
7349
8253
  try {
7350
- mtimeMs = (0, import_fs13.statSync)(conv.filePath).mtimeMs;
8254
+ mtimeMs = (0, import_fs17.statSync)(conv.filePath).mtimeMs;
7351
8255
  } catch {
7352
8256
  return false;
7353
8257
  }
@@ -7681,7 +8585,6 @@ var StreamerServer = class {
7681
8585
  });
7682
8586
  }
7683
8587
  async handleListSessions(url, res) {
7684
- const DISCOVERY_TTL_MS = 15e3;
7685
8588
  const now = Date.now();
7686
8589
  if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
7687
8590
  try {
@@ -7693,7 +8596,7 @@ var StreamerServer = class {
7693
8596
  }
7694
8597
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
7695
8598
  if (!hasPaginationParams) {
7696
- json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
8599
+ json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
7697
8600
  return;
7698
8601
  }
7699
8602
  const parsed = parseSessionListQuery(url);
@@ -7703,6 +8606,7 @@ var StreamerServer = class {
7703
8606
  }
7704
8607
  try {
7705
8608
  const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
8609
+ page.sessions = this.withExternalActivity(page.sessions);
7706
8610
  json(res, 200, page);
7707
8611
  } catch (err) {
7708
8612
  if (err instanceof Error && err.message === "INVALID_CURSOR") {
@@ -7715,7 +8619,7 @@ var StreamerServer = class {
7715
8619
  handleGetSession(sessionId, res) {
7716
8620
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
7717
8621
  if (session) {
7718
- if (!(0, import_fs13.existsSync)(session.projectPath)) {
8622
+ if (!(0, import_fs17.existsSync)(session.projectPath)) {
7719
8623
  session.failureReason = `Project directory not found: ${session.projectPath}`;
7720
8624
  }
7721
8625
  json(res, 200, session);
@@ -7729,7 +8633,6 @@ var StreamerServer = class {
7729
8633
  json(res, 404, { error: "Session not found" });
7730
8634
  }
7731
8635
  async handleResume(req, res) {
7732
- this.discoveryCache = null;
7733
8636
  const body = await readBody(req);
7734
8637
  const sessionId = body.sessionId ?? body.conversationId;
7735
8638
  if (!sessionId) {
@@ -7758,8 +8661,48 @@ var StreamerServer = class {
7758
8661
  json(res, 400, { error: "Could not determine project path" });
7759
8662
  return;
7760
8663
  }
8664
+ let discovered = [];
8665
+ const cached2 = this.discoveryCache;
8666
+ if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
8667
+ discovered = cached2.entries;
8668
+ } else {
8669
+ try {
8670
+ discovered = await Promise.race([
8671
+ discoverClaudeProcesses(),
8672
+ new Promise(
8673
+ (resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
8674
+ )
8675
+ ]);
8676
+ if (discovered.length > 0) {
8677
+ this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
8678
+ }
8679
+ } catch {
8680
+ }
8681
+ }
8682
+ const busy = conversationBusy({
8683
+ conversationId: sessionId,
8684
+ projectPath,
8685
+ jsonlPath,
8686
+ discovered,
8687
+ windowMs: resolveResumeBusyWindowMs(),
8688
+ selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
8689
+ });
8690
+ if (busy.busy && body.force !== true) {
8691
+ json(res, 409, {
8692
+ error: "This conversation looks active in another session",
8693
+ code: "CONVERSATION_BUSY",
8694
+ detectedBy: busy.detectedBy,
8695
+ lastActivityMs: busy.lastActivityMs,
8696
+ likelyOwner: busy.likelyOwner
8697
+ });
8698
+ return;
8699
+ }
8700
+ if (busy.busy) {
8701
+ this.contendedSessions.add(sessionId);
8702
+ }
7761
8703
  const cachedConvMeta = this.cache?.getMetaById(sessionId);
7762
8704
  const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
8705
+ this.discoveryCache = null;
7763
8706
  const session = await this.ptyManager.start(sessionId, {
7764
8707
  provider,
7765
8708
  projectPath,
@@ -7893,6 +8836,45 @@ var StreamerServer = class {
7893
8836
  json(res, 400, { error: message });
7894
8837
  }
7895
8838
  }
8839
+ // Store + broadcast AskUserQuestion cards found in a JSONL batch for a watched
8840
+ // session. Two P0 safety guards on top of the screen/JSONL de-dupe:
8841
+ // (a) contended file → suppress JSONL-derived cards entirely (a line may be
8842
+ // the OTHER owner's question); the streamer's own PTY questions still
8843
+ // arrive via the live-screen path (handleLiveQuestion), not suppressed.
8844
+ // (b) a JSONL question must never clobber a PTY-screen question that is a
8845
+ // DIFFERENT question — answering it would type into this streamer's PTY.
8846
+ // Same-content re-syncs (screen synthetic id → real toolUseId) still pass.
8847
+ processJsonlQuestions(sessionId, lines) {
8848
+ const priorPending = this.pendingQuestions.get(sessionId);
8849
+ const priorToolUseId = priorPending?.toolUseId;
8850
+ const contended = this.contendedSessions.has(sessionId);
8851
+ const priorPtyKey = priorPending?.origin === "pty" ? questionContentKey(priorPending.questions) : null;
8852
+ const foreignVsPty = (questions) => priorPtyKey !== null && questionContentKey(questions) !== priorPtyKey;
8853
+ const { messages, pending } = questionsFromLines(sessionId, lines);
8854
+ for (const p of pending) {
8855
+ if (contended || foreignVsPty(p.questions)) continue;
8856
+ const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
8857
+ this.pendingQuestions.set(sessionId, { ...p, origin });
8858
+ const t = setTimeout(() => {
8859
+ if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
8860
+ this.cancelPendingQuestion(sessionId);
8861
+ }
8862
+ }, 6e4);
8863
+ t.unref();
8864
+ }
8865
+ for (const m of messages) {
8866
+ if (contended || foreignVsPty(m.questions)) continue;
8867
+ const key = questionContentKey(m.questions);
8868
+ const broadcast = shouldBroadcastQuestion({
8869
+ newContentKey: key,
8870
+ lastContentKey: this.pendingQuestionKey.get(sessionId),
8871
+ newToolUseId: m.toolUseId,
8872
+ priorToolUseId
8873
+ });
8874
+ this.pendingQuestionKey.set(sessionId, key);
8875
+ if (broadcast) this.wsHub.broadcast(m);
8876
+ }
8877
+ }
7896
8878
  cancelPendingQuestion(sessionId) {
7897
8879
  const pq = this.pendingQuestions.get(sessionId);
7898
8880
  if (!pq) return;
@@ -7909,7 +8891,7 @@ var StreamerServer = class {
7909
8891
  const key = questionContentKey(questions);
7910
8892
  if (this.pendingQuestionKey.get(sessionId) === key) return;
7911
8893
  const toolUseId = `screen:${sessionId}:${key.length}`;
7912
- this.pendingQuestions.set(sessionId, { toolUseId, questions });
8894
+ this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
7913
8895
  this.pendingQuestionKey.set(sessionId, key);
7914
8896
  this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
7915
8897
  }
@@ -8092,7 +9074,33 @@ var StreamerServer = class {
8092
9074
  json(res, 400, { error: "Session has no known PID" });
8093
9075
  return;
8094
9076
  }
9077
+ if (!projectPath) {
9078
+ this.log.warn("adopt: refusing, working directory unknown", {
9079
+ event: "adopt.no_project_path",
9080
+ sessionId,
9081
+ pid: discSession.pid
9082
+ });
9083
+ json(res, 400, {
9084
+ error: "Cannot take over this session: its working directory could not be determined on this platform",
9085
+ code: "ADOPT_NO_PROJECT_PATH"
9086
+ });
9087
+ return;
9088
+ }
8095
9089
  this.ptyManager.killPid(discSession.pid);
9090
+ const exited = await waitForProcessExit(discSession.pid, ADOPT_KILL_TIMEOUT_MS);
9091
+ if (!exited) {
9092
+ this.log.warn("adopt: external process did not exit; refusing to double-write", {
9093
+ event: "adopt.kill_timeout",
9094
+ sessionId,
9095
+ pid: discSession.pid
9096
+ });
9097
+ json(res, 409, {
9098
+ error: "The existing process did not exit; not starting a second agent on this conversation",
9099
+ code: "ADOPT_KILL_TIMEOUT",
9100
+ pid: discSession.pid
9101
+ });
9102
+ return;
9103
+ }
8096
9104
  const session = await this.ptyManager.start(convId, {
8097
9105
  projectPath,
8098
9106
  projectName,
@@ -8123,7 +9131,7 @@ var StreamerServer = class {
8123
9131
  sessionStore: this.sessionStore,
8124
9132
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
8125
9133
  agentClient: this.agentClient,
8126
- conversationsDir: this.cacheDir ? (0, import_path14.join)((0, import_path14.dirname)(this.cacheDir), "conversations") : "",
9134
+ conversationsDir: this.cacheDir ? (0, import_path17.join)((0, import_path17.dirname)(this.cacheDir), "conversations") : "",
8127
9135
  agentConfig: this.agentConfig
8128
9136
  });
8129
9137
  json(res, result.status, result.body);
@@ -8261,14 +9269,30 @@ var StreamerServer = class {
8261
9269
  } catch {
8262
9270
  }
8263
9271
  }
9272
+ // Read just the `sessionId` field from a JSONL's first line, used by the
9273
+ // watchForJsonl fallback to confirm a candidate file's identity before
9274
+ // binding it. Reads only up to the first newline so a large actively-written
9275
+ // file isn't slurped in full.
9276
+ readFirstLineSessionId(filePath) {
9277
+ try {
9278
+ const content = (0, import_fs17.readFileSync)(filePath, "utf8");
9279
+ const nl = content.indexOf("\n");
9280
+ const firstLine = nl === -1 ? content : content.slice(0, nl);
9281
+ if (!firstLine.trim()) return null;
9282
+ const obj = JSON.parse(firstLine);
9283
+ return typeof obj.sessionId === "string" ? obj.sessionId : null;
9284
+ } catch {
9285
+ return null;
9286
+ }
9287
+ }
8264
9288
  // Watch the project directory for the JSONL file Claude creates for sessionId.
8265
9289
  // Once found, wire up structured event streaming. No rekeying needed — the UUID
8266
9290
  // was passed to Claude via --session-id so the filename matches from the start.
8267
9291
  watchForJsonl(sessionId, projectPath) {
8268
9292
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
8269
- const projectsDir = (0, import_path14.join)((0, import_os7.homedir)(), ".claude", "projects", encoded);
9293
+ const projectsDir = (0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects", encoded);
8270
9294
  const expectedFile = `${sessionId}.jsonl`;
8271
- const filePath = (0, import_path14.join)(projectsDir, expectedFile);
9295
+ const filePath = (0, import_path17.join)(projectsDir, expectedFile);
8272
9296
  const deadline = Date.now() + 12e4;
8273
9297
  let watcher = null;
8274
9298
  const cleanup = () => {
@@ -8286,26 +9310,28 @@ var StreamerServer = class {
8286
9310
  cleanup();
8287
9311
  return;
8288
9312
  }
8289
- let resolvedFilePath = (0, import_fs13.existsSync)(filePath) ? filePath : null;
8290
- if (!resolvedFilePath && (0, import_fs13.existsSync)(projectsDir)) {
9313
+ let resolvedFilePath = (0, import_fs17.existsSync)(filePath) ? filePath : null;
9314
+ if (!resolvedFilePath && (0, import_fs17.existsSync)(projectsDir)) {
8291
9315
  try {
8292
9316
  const now = Date.now();
8293
- const recent = (0, import_fs13.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs13.statSync)((0, import_path14.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
8294
- if (recent) resolvedFilePath = (0, import_path14.join)(projectsDir, recent.f);
9317
+ const match = (0, import_fs17.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs17.statSync)((0, import_path17.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
9318
+ ({ f }) => (0, import_path17.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path17.join)(projectsDir, f)) === sessionId
9319
+ ).sort((a, b) => b.mtime - a.mtime)[0];
9320
+ if (match) resolvedFilePath = (0, import_path17.join)(projectsDir, match.f);
8295
9321
  } catch {
8296
9322
  }
8297
9323
  }
8298
9324
  if (!resolvedFilePath) return;
8299
9325
  cleanup();
8300
9326
  this.sessionFileMap.set(sessionId, resolvedFilePath);
8301
- this.fileWatcher.watch(resolvedFilePath);
8302
9327
  try {
8303
- const existing = (0, import_fs13.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
9328
+ const existing = (0, import_fs17.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
8304
9329
  if (existing.length > 0) {
8305
9330
  this.broadcastConversationLines(sessionId, existing);
8306
9331
  }
8307
9332
  } catch {
8308
9333
  }
9334
+ this.fileWatcher.watch(resolvedFilePath);
8309
9335
  if (this.scannerReady) {
8310
9336
  this.scannerStale = true;
8311
9337
  } else {
@@ -8323,7 +9349,7 @@ var StreamerServer = class {
8323
9349
  if (this.sessionFileMap.has(sessionId)) return;
8324
9350
  try {
8325
9351
  require("fs").mkdirSync(projectsDir, { recursive: true });
8326
- watcher = (0, import_fs13.watch)(projectsDir, tryWire);
9352
+ watcher = (0, import_fs17.watch)(projectsDir, tryWire);
8327
9353
  watcher.on("error", cleanup);
8328
9354
  } catch {
8329
9355
  }
@@ -8339,7 +9365,7 @@ var StreamerServer = class {
8339
9365
  watchForCodexRollout(sessionId, projectPath) {
8340
9366
  const deadline = Date.now() + 12e4;
8341
9367
  const now = /* @__PURE__ */ new Date();
8342
- const dateDir = (0, import_path14.join)(
9368
+ const dateDir = (0, import_path17.join)(
8343
9369
  String(now.getFullYear()),
8344
9370
  String(now.getMonth() + 1).padStart(2, "0"),
8345
9371
  String(now.getDate()).padStart(2, "0")
@@ -8352,7 +9378,7 @@ var StreamerServer = class {
8352
9378
  };
8353
9379
  const matchesProjectPath = (candidatePath) => {
8354
9380
  try {
8355
- const firstLine = (0, import_fs13.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
9381
+ const firstLine = (0, import_fs17.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
8356
9382
  if (!firstLine) return null;
8357
9383
  const parsed = JSON.parse(firstLine);
8358
9384
  if (parsed?.type !== "session_meta") return null;
@@ -8380,18 +9406,18 @@ var StreamerServer = class {
8380
9406
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
8381
9407
  );
8382
9408
  for (const root of this.codexRoots) {
8383
- const sessionsDir = (0, import_path14.join)(root, dateDir);
8384
- if (!(0, import_fs13.existsSync)(sessionsDir)) continue;
9409
+ const sessionsDir = (0, import_path17.join)(root, dateDir);
9410
+ if (!(0, import_fs17.existsSync)(sessionsDir)) continue;
8385
9411
  let candidateFiles;
8386
9412
  try {
8387
- candidateFiles = (0, import_fs13.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
9413
+ candidateFiles = (0, import_fs17.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
8388
9414
  } catch {
8389
9415
  continue;
8390
9416
  }
8391
9417
  const nowMs = Date.now();
8392
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs13.statSync)((0, import_path14.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
9418
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs17.statSync)((0, import_path17.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
8393
9419
  for (const { f } of recentCandidates) {
8394
- const candidatePath = (0, import_path14.join)(sessionsDir, f);
9420
+ const candidatePath = (0, import_path17.join)(sessionsDir, f);
8395
9421
  const match = matchesProjectPath(candidatePath);
8396
9422
  if (!match) continue;
8397
9423
  if (boundElsewhere.has(match.id)) continue;
@@ -8401,7 +9427,7 @@ var StreamerServer = class {
8401
9427
  this.sessionFileMap.set(sessionId, candidatePath);
8402
9428
  this.fileWatcher.watch(candidatePath);
8403
9429
  try {
8404
- const existing = (0, import_fs13.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
9430
+ const existing = (0, import_fs17.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
8405
9431
  if (existing.length > 0) {
8406
9432
  this.broadcastConversationLines(sessionId, existing);
8407
9433
  }
@@ -8521,9 +9547,21 @@ var StreamerServer = class {
8521
9547
  json(res, 200, this.cache.listSessionNames());
8522
9548
  }
8523
9549
  };
9550
+ async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
9551
+ const deadline = Date.now() + timeoutMs;
9552
+ for (; ; ) {
9553
+ try {
9554
+ process.kill(pid, 0);
9555
+ } catch {
9556
+ return true;
9557
+ }
9558
+ if (Date.now() >= deadline) return false;
9559
+ await new Promise((resolve2) => setTimeout(resolve2, pollMs));
9560
+ }
9561
+ }
8524
9562
  function classifyResumability(cwd) {
8525
9563
  if (!cwd) return { resumable: true };
8526
- if ((0, import_fs13.existsSync)(cwd)) return { resumable: true };
9564
+ if ((0, import_fs17.existsSync)(cwd)) return { resumable: true };
8527
9565
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
8528
9566
  return {
8529
9567
  resumable: false,
@@ -8538,6 +9576,9 @@ function conversationToResumableSession(c) {
8538
9576
  id: c.id,
8539
9577
  conversationId: c.id,
8540
9578
  status: "on_hold",
9579
+ // A cached conversation with no process behind it. Distinguishes "nobody is
9580
+ // running this" from an external session that IS live (ownership "external").
9581
+ ownership: "historical",
8541
9582
  ptyAttached: false,
8542
9583
  projectId: c.projectId ?? void 0,
8543
9584
  projectPath: c.projectPath ?? "",