@threadbase-sh/streamer 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1731,6 +1731,10 @@ var PTYManager = class {
1731
1731
  options.permissionMode ?? "acceptEdits",
1732
1732
  "--settings",
1733
1733
  '{"spinnerTipsEnabled":false}',
1734
+ "--model",
1735
+ options.model ?? "sonnet",
1736
+ "--effort",
1737
+ options.effort ?? "low",
1734
1738
  "--resume",
1735
1739
  sessionId
1736
1740
  ],
@@ -1781,6 +1785,10 @@ var PTYManager = class {
1781
1785
  options.permissionMode ?? "acceptEdits",
1782
1786
  "--settings",
1783
1787
  '{"spinnerTipsEnabled":false}',
1788
+ "--model",
1789
+ options.model ?? "sonnet",
1790
+ "--effort",
1791
+ options.effort ?? "low",
1784
1792
  "--session-id",
1785
1793
  sessionId
1786
1794
  ];
@@ -2200,6 +2208,14 @@ var PTYManager = class {
2200
2208
  if (session?.status !== "running") return;
2201
2209
  if (this.pendingReady.has(sessionId)) {
2202
2210
  this.markReady(sessionId, session, "quiet:timeout");
2211
+ } else {
2212
+ this.recheckReadyFromScreen(sessionId).catch((err) => {
2213
+ this.log.warn("[pty.ready] screen recheck failed", {
2214
+ event: "pty.ready_recheck_failed",
2215
+ sessionId,
2216
+ err
2217
+ });
2218
+ });
2203
2219
  }
2204
2220
  this.detectLivePrompts(sessionId, "", session.lastOutput).catch((err) => {
2205
2221
  this.log.warn("[pty.prompt_detect] failed", {
@@ -2209,6 +2225,20 @@ var PTYManager = class {
2209
2225
  });
2210
2226
  });
2211
2227
  }
2228
+ // Re-check the rendered screen (not just the last chunk) for a prompt
2229
+ // marker. Only meaningful once pendingReady is already clear — the boot
2230
+ // fallback above covers the first prompt after spawn/resume. Scoped to a
2231
+ // full viewport (PTY_ROWS), not just the last few lines: "on screen" means
2232
+ // whatever a user attached to this PTY would currently see.
2233
+ async recheckReadyFromScreen(sessionId) {
2234
+ const session = this.sessions.get(sessionId);
2235
+ if (session?.status !== "running") return;
2236
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
2237
+ const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
2238
+ if (matchedMarker && session.status === "running") {
2239
+ this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
2240
+ }
2241
+ }
2212
2242
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2213
2243
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2214
2244
  markReady(sessionId, session, reason) {
@@ -2381,6 +2411,44 @@ async function discoverClaudeProcesses() {
2381
2411
  if (platform2() === "win32") return discoverWindows();
2382
2412
  return discoverUnix();
2383
2413
  }
2414
+ var CLAUDE_CLI_SCRIPT = /claude-code[\\/](?:cli|index)\.(?:js|mjs|cjs)$/i;
2415
+ var JS_RUNTIMES = /* @__PURE__ */ new Set(["node", "node.exe", "bun", "bun.exe", "deno", "deno.exe"]);
2416
+ function tokenizeCommandLine(commandLine) {
2417
+ const tokens = [];
2418
+ let current = "";
2419
+ let quoted = false;
2420
+ for (const ch of commandLine) {
2421
+ if (ch === '"') {
2422
+ quoted = !quoted;
2423
+ continue;
2424
+ }
2425
+ if (!quoted && (ch === " " || ch === " ")) {
2426
+ if (current) tokens.push(current);
2427
+ current = "";
2428
+ continue;
2429
+ }
2430
+ current += ch;
2431
+ }
2432
+ if (current) tokens.push(current);
2433
+ return tokens;
2434
+ }
2435
+ function exeBaseName(token) {
2436
+ const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
2437
+ return cut === -1 ? token : token.slice(cut + 1);
2438
+ }
2439
+ function looksLikeClaudeProcess(commandLine) {
2440
+ const tokens = tokenizeCommandLine(commandLine);
2441
+ if (tokens.length === 0) return false;
2442
+ const exe = exeBaseName(tokens[0]).toLowerCase();
2443
+ if (exe === "claude" || exe === "claude.exe") return true;
2444
+ if (JS_RUNTIMES.has(exe)) {
2445
+ for (const raw of tokens.slice(1)) {
2446
+ if (raw.startsWith("-")) continue;
2447
+ return CLAUDE_CLI_SCRIPT.test(raw);
2448
+ }
2449
+ }
2450
+ return false;
2451
+ }
2384
2452
  async function discoverUnix() {
2385
2453
  const pids = await getPidsUnix();
2386
2454
  const results = await Promise.all(
@@ -2408,6 +2476,8 @@ async function discoverUnix() {
2408
2476
  return results.filter((r) => r !== null);
2409
2477
  }
2410
2478
  async function discoverWindows() {
2479
+ const viaCim = await discoverWindowsViaCim();
2480
+ if (viaCim) return viaCim;
2411
2481
  const pids = await getPidsWindows();
2412
2482
  const results = await Promise.all(
2413
2483
  pids.map(async (pid) => {
@@ -2442,7 +2512,24 @@ function run(cmd, args, opts = {}) {
2442
2512
  );
2443
2513
  });
2444
2514
  }
2515
+ function parsePsOutput(stdout) {
2516
+ const pids = [];
2517
+ for (const line of stdout.split("\n")) {
2518
+ const trimmed = line.trim();
2519
+ if (!trimmed) continue;
2520
+ const match = trimmed.match(/^(\d+)\s+(.*)$/);
2521
+ if (!match) continue;
2522
+ const pid = Number.parseInt(match[1], 10);
2523
+ if (!(pid > 0)) continue;
2524
+ if (looksLikeClaudeProcess(match[2])) pids.push(pid);
2525
+ }
2526
+ return pids;
2527
+ }
2445
2528
  async function getPidsUnix() {
2529
+ try {
2530
+ return parsePsOutput(await run("ps", ["-eo", "pid=,args="]));
2531
+ } catch {
2532
+ }
2446
2533
  try {
2447
2534
  const output = await run("pgrep", ["-x", "claude"]);
2448
2535
  return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
@@ -2463,6 +2550,54 @@ async function getProcessStartTimeUnix(pid) {
2463
2550
  const d = new Date(raw);
2464
2551
  return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2465
2552
  }
2553
+ function parseCimProcesses(stdout) {
2554
+ const trimmed = stdout.trim();
2555
+ if (!trimmed) return [];
2556
+ const parsed = JSON.parse(trimmed);
2557
+ return Array.isArray(parsed) ? parsed : [parsed];
2558
+ }
2559
+ function parseCimDate(value) {
2560
+ if (value) {
2561
+ const epoch = value.match(/\/Date\((\d+)\)\//);
2562
+ if (epoch) return new Date(Number(epoch[1]));
2563
+ const d = new Date(value);
2564
+ if (!Number.isNaN(d.getTime())) return d;
2565
+ }
2566
+ return /* @__PURE__ */ new Date();
2567
+ }
2568
+ async function discoverWindowsViaCim() {
2569
+ let stdout;
2570
+ try {
2571
+ stdout = await run("powershell.exe", [
2572
+ "-NoProfile",
2573
+ "-NonInteractive",
2574
+ "-Command",
2575
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"
2576
+ ]);
2577
+ } catch {
2578
+ return null;
2579
+ }
2580
+ let rows;
2581
+ try {
2582
+ rows = parseCimProcesses(stdout);
2583
+ } catch {
2584
+ return null;
2585
+ }
2586
+ const results = [];
2587
+ for (const row of rows) {
2588
+ const commandLine = row.CommandLine ?? "";
2589
+ if (!commandLine || !looksLikeClaudeProcess(commandLine)) continue;
2590
+ results.push({
2591
+ pid: row.ProcessId,
2592
+ projectPath: "",
2593
+ projectName: "",
2594
+ branch: "",
2595
+ conversationId: extractResumeId(commandLine),
2596
+ startedAt: parseCimDate(row.CreationDate)
2597
+ });
2598
+ }
2599
+ return results;
2600
+ }
2466
2601
  async function getPidsWindows() {
2467
2602
  try {
2468
2603
  const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
@@ -2505,10 +2640,15 @@ async function getProcessInfoWindows(pid) {
2505
2640
  }
2506
2641
  }
2507
2642
  function extractResumeId(args) {
2508
- const match = args.match(/--resume\s+(\S+)/);
2509
- return match?.[1] ?? null;
2643
+ const eq = args.match(/(?:--resume|-r)=(\S+)/);
2644
+ if (eq?.[1] && !eq[1].startsWith("-")) return eq[1];
2645
+ const spaced = args.match(/(?:--resume|-r)\s+(\S+)/);
2646
+ const candidate = spaced?.[1];
2647
+ if (!candidate || candidate.startsWith("-")) return null;
2648
+ return candidate;
2510
2649
  }
2511
2650
  async function readGitBranch(dir) {
2651
+ if (!dir) return "";
2512
2652
  try {
2513
2653
  return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
2514
2654
  } catch {
@@ -2530,16 +2670,16 @@ import {
2530
2670
  import { EventEmitter } from "events";
2531
2671
  import {
2532
2672
  createReadStream,
2533
- existsSync as existsSync8,
2673
+ existsSync as existsSync10,
2534
2674
  watch as fsWatch,
2535
- readdirSync as readdirSync4,
2536
- readFileSync as readFileSync7,
2537
- statSync as statSync6
2675
+ readdirSync as readdirSync5,
2676
+ readFileSync as readFileSync8,
2677
+ statSync as statSync8
2538
2678
  } from "fs";
2539
2679
  import { realpath as realpath2 } from "fs/promises";
2540
2680
  import { createServer } from "http";
2541
- import { homedir as homedir7 } from "os";
2542
- import { dirname as dirname8, join as join15 } from "path";
2681
+ import { homedir as homedir8 } from "os";
2682
+ import { basename as basename5, dirname as dirname9, join as join17 } from "path";
2543
2683
  import { createInterface } from "readline";
2544
2684
 
2545
2685
  // node_modules/nanoid/index.js
@@ -2798,7 +2938,7 @@ async function handleStartAgentSession(body, deps) {
2798
2938
  }
2799
2939
 
2800
2940
  // src/api/app.ts
2801
- import { Hono as Hono12 } from "hono";
2941
+ import { Hono as Hono13 } from "hono";
2802
2942
 
2803
2943
  // src/api/middleware/auth.middleware.ts
2804
2944
  function isLocalRequest(remoteAddr) {
@@ -2920,12 +3060,72 @@ var createBrowseRoutes = (deps) => {
2920
3060
  return app;
2921
3061
  };
2922
3062
 
2923
- // src/api/routes/conversations.routes.ts
3063
+ // src/api/routes/cacheAlert.routes.ts
2924
3064
  import { Hono as Hono3 } from "hono";
3065
+
3066
+ // src/schemas/cacheAlert.schema.ts
3067
+ import { z } from "zod";
3068
+ var ResolveCacheAlertSchema = z.object({
3069
+ fingerprint: z.string(),
3070
+ action: z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
3071
+ ids: z.array(z.string()).optional()
3072
+ }).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
3073
+ message: "prune_selected requires a non-empty ids array",
3074
+ path: ["ids"]
3075
+ });
3076
+
3077
+ // src/api/routes/cacheAlert.routes.ts
3078
+ function readRawBody2(req) {
3079
+ return new Promise((resolve2, reject) => {
3080
+ const chunks = [];
3081
+ req.on("data", (chunk) => chunks.push(chunk));
3082
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
3083
+ req.on("error", reject);
3084
+ });
3085
+ }
3086
+ var createCacheAlertRoutes = (deps) => {
3087
+ const app = new Hono3();
3088
+ app.get("/", (c) => {
3089
+ const monitor = deps.cacheMonitor();
3090
+ return c.json({ pending: monitor?.pending ?? null });
3091
+ });
3092
+ app.post("/resolve", async (c) => {
3093
+ let body;
3094
+ try {
3095
+ const incoming = c.env?.incoming;
3096
+ const raw = incoming ? await readRawBody2(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
3097
+ body = raw ? JSON.parse(raw) : {};
3098
+ } catch {
3099
+ return c.json({ error: "invalid json" }, 400);
3100
+ }
3101
+ const parsed = ResolveCacheAlertSchema.safeParse(body);
3102
+ if (!parsed.success) {
3103
+ return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
3104
+ }
3105
+ const monitor = deps.cacheMonitor();
3106
+ if (!monitor) return c.json({ ok: true, alreadyResolved: true });
3107
+ const { fingerprint, action, ids } = parsed.data;
3108
+ const result = await monitor.resolve(fingerprint, action, ids);
3109
+ if ("conflict" in result) {
3110
+ return c.json(
3111
+ { error: "fingerprint_mismatch", currentFingerprint: result.currentFingerprint },
3112
+ 409
3113
+ );
3114
+ }
3115
+ if ("alreadyResolved" in result) {
3116
+ return c.json({ ok: true, alreadyResolved: true });
3117
+ }
3118
+ return c.json(result);
3119
+ });
3120
+ return app;
3121
+ };
3122
+
3123
+ // src/api/routes/conversations.routes.ts
3124
+ import { Hono as Hono4 } from "hono";
2925
3125
  var ALREADY_HANDLED2 = 597;
2926
3126
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
2927
3127
  var createConversationRoutes = (deps) => {
2928
- const app = new Hono3();
3128
+ const app = new Hono4();
2929
3129
  app.get("/count", async (c) => {
2930
3130
  const url = new URL(c.req.url);
2931
3131
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -2952,7 +3152,7 @@ var createConversationRoutes = (deps) => {
2952
3152
  };
2953
3153
 
2954
3154
  // src/api/routes/health.routes.ts
2955
- import { Hono as Hono4 } from "hono";
3155
+ import { Hono as Hono5 } from "hono";
2956
3156
 
2957
3157
  // src/version.ts
2958
3158
  import { readFileSync as readFileSync4, realpathSync } from "fs";
@@ -2988,16 +3188,19 @@ function resolveVersion() {
2988
3188
  }
2989
3189
 
2990
3190
  // src/api/routes/health.routes.ts
2991
- var createHealthRoutes = () => {
2992
- const app = new Hono4();
2993
- app.get("/", (c) => c.json({ ok: true, version: getVersion() }));
3191
+ var createHealthRoutes = (deps) => {
3192
+ const app = new Hono5();
3193
+ app.get("/", (c) => {
3194
+ const cacheAlert = deps.cacheMonitor()?.healthzField();
3195
+ return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
3196
+ });
2994
3197
  return app;
2995
3198
  };
2996
3199
 
2997
3200
  // src/api/routes/logs.routes.ts
2998
3201
  import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
2999
3202
  import { join as join9 } from "path";
3000
- import { Hono as Hono5 } from "hono";
3203
+ import { Hono as Hono6 } from "hono";
3001
3204
 
3002
3205
  // src/lifecycle/constants.ts
3003
3206
  import { homedir as homedir4 } from "os";
@@ -3055,7 +3258,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3055
3258
  }
3056
3259
  }
3057
3260
  function createLogsRoutes() {
3058
- const app = new Hono5();
3261
+ const app = new Hono6();
3059
3262
  app.get("/", (c) => {
3060
3263
  try {
3061
3264
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3126,7 +3329,7 @@ function createLogsRoutes() {
3126
3329
  // src/api/routes/misc.routes.ts
3127
3330
  import { spawn } from "child_process";
3128
3331
  import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
3129
- import { Hono as Hono6 } from "hono";
3332
+ import { Hono as Hono7 } from "hono";
3130
3333
  import { hostname } from "os";
3131
3334
 
3132
3335
  // src/config/update-config.ts
@@ -3136,15 +3339,15 @@ import { join as join10 } from "path";
3136
3339
  import { parse as parseYaml } from "yaml";
3137
3340
 
3138
3341
  // src/schemas/updateConfig.schema.ts
3139
- import { z } from "zod";
3140
- var UpdateConfigSchema = z.object({
3141
- auto_update: z.boolean().default(false),
3142
- channel: z.enum(["stable", "next"]).default("stable"),
3143
- allow: z.array(z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3144
- poll_interval_minutes: z.number().int().min(0).default(60),
3145
- defer_if_active_sessions: z.boolean().default(true),
3146
- github_repo: z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3147
- webhook_secret: z.string().min(1).nullable().default(null)
3342
+ import { z as z2 } from "zod";
3343
+ var UpdateConfigSchema = z2.object({
3344
+ auto_update: z2.boolean().default(false),
3345
+ channel: z2.enum(["stable", "next"]).default("stable"),
3346
+ allow: z2.array(z2.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
3347
+ poll_interval_minutes: z2.number().int().min(0).default(1440),
3348
+ defer_if_active_sessions: z2.boolean().default(true),
3349
+ github_repo: z2.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
3350
+ webhook_secret: z2.string().min(1).nullable().default(null)
3148
3351
  }).strict();
3149
3352
 
3150
3353
  // src/config/update-config.ts
@@ -3181,7 +3384,7 @@ function readJsonBody(req) {
3181
3384
  req.on("error", reject);
3182
3385
  });
3183
3386
  }
3184
- function readRawBody2(req) {
3387
+ function readRawBody3(req) {
3185
3388
  return new Promise((resolve2, reject) => {
3186
3389
  const chunks = [];
3187
3390
  req.on("data", (chunk) => chunks.push(chunk));
@@ -3200,7 +3403,7 @@ function verifyWebhookSignature(body, header, secret) {
3200
3403
  }
3201
3404
  var clientLog = getLogger("client");
3202
3405
  var createMiscRoutes = (deps) => {
3203
- const app = new Hono6();
3406
+ const app = new Hono7();
3204
3407
  app.get("/api/info", (c) => {
3205
3408
  const ptyIds = deps.ptyAttachedIds();
3206
3409
  return c.json({
@@ -3233,7 +3436,7 @@ var createMiscRoutes = (deps) => {
3233
3436
  }
3234
3437
  let body;
3235
3438
  try {
3236
- body = await readRawBody2(c.env.incoming);
3439
+ body = await readRawBody3(c.env.incoming);
3237
3440
  } catch {
3238
3441
  return c.json({ error: "could not read body" }, 400);
3239
3442
  }
@@ -3276,11 +3479,11 @@ var createMiscRoutes = (deps) => {
3276
3479
  };
3277
3480
 
3278
3481
  // src/api/routes/pair.routes.ts
3279
- import { Hono as Hono7 } from "hono";
3482
+ import { Hono as Hono8 } from "hono";
3280
3483
  var ALREADY_HANDLED3 = 597;
3281
3484
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
3282
3485
  var createPairRoutes = (deps) => {
3283
- const app = new Hono7();
3486
+ const app = new Hono8();
3284
3487
  app.post("/start", (c) => {
3285
3488
  deps.handlePairStart(c.env.outgoing);
3286
3489
  return alreadyHandled3();
@@ -3293,11 +3496,11 @@ var createPairRoutes = (deps) => {
3293
3496
  };
3294
3497
 
3295
3498
  // src/api/routes/projects.routes.ts
3296
- import { Hono as Hono8 } from "hono";
3499
+ import { Hono as Hono9 } from "hono";
3297
3500
  var ALREADY_HANDLED4 = 597;
3298
3501
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
3299
3502
  var createProjectRoutes = (deps) => {
3300
- const app = new Hono8();
3503
+ const app = new Hono9();
3301
3504
  app.get("/", (c) => {
3302
3505
  const url = new URL(c.req.url);
3303
3506
  deps.handleListProjects(url, c.env.outgoing);
@@ -3312,11 +3515,11 @@ var createProjectRoutes = (deps) => {
3312
3515
  };
3313
3516
 
3314
3517
  // src/api/routes/scanner.routes.ts
3315
- import { Hono as Hono9 } from "hono";
3518
+ import { Hono as Hono10 } from "hono";
3316
3519
  var ALREADY_HANDLED5 = 597;
3317
3520
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
3318
3521
  var createScannerRoutes = (deps) => {
3319
- const app = new Hono9();
3522
+ const app = new Hono10();
3320
3523
  app.get("/api/search", async (c) => {
3321
3524
  const url = new URL(c.req.url);
3322
3525
  await deps.handleSearch(url, c.env.outgoing);
@@ -3326,11 +3529,11 @@ var createScannerRoutes = (deps) => {
3326
3529
  };
3327
3530
 
3328
3531
  // src/api/routes/sessions.routes.ts
3329
- import { Hono as Hono10 } from "hono";
3532
+ import { Hono as Hono11 } from "hono";
3330
3533
  var ALREADY_HANDLED6 = 597;
3331
3534
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
3332
3535
  var createSessionRoutes = (deps) => {
3333
- const app = new Hono10();
3536
+ const app = new Hono11();
3334
3537
  app.get("/count", (c) => {
3335
3538
  deps.handleSessionsCount(c.env.outgoing);
3336
3539
  return alreadyHandled6();
@@ -3397,9 +3600,9 @@ var createSessionRoutes = (deps) => {
3397
3600
  };
3398
3601
 
3399
3602
  // src/api/routes/ws.routes.ts
3400
- import { Hono as Hono11 } from "hono";
3603
+ import { Hono as Hono12 } from "hono";
3401
3604
  var createWsRoutes = (deps, upgradeWebSocket) => {
3402
- const app = new Hono11();
3605
+ const app = new Hono12();
3403
3606
  app.get(
3404
3607
  "/ws",
3405
3608
  upgradeWebSocket(() => {
@@ -3425,7 +3628,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3425
3628
 
3426
3629
  // src/api/app.ts
3427
3630
  var createHonoApp = (deps, upgradeWebSocket) => {
3428
- const app = new Hono12();
3631
+ const app = new Hono13();
3429
3632
  const httpLog = getLogger("http");
3430
3633
  app.use("*", async (c, next) => {
3431
3634
  const start = Date.now();
@@ -3445,10 +3648,11 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3445
3648
  app.use("*", corsMiddleware(deps.browserCors));
3446
3649
  app.use("*", authMiddleware(deps));
3447
3650
  app.onError(errorMiddleware);
3448
- app.route("/healthz", createHealthRoutes());
3651
+ app.route("/healthz", createHealthRoutes(deps));
3449
3652
  app.route("/", createMiscRoutes(deps));
3450
3653
  app.route("/api/sessions", createSessionRoutes(deps));
3451
3654
  app.route("/api/conversations", createConversationRoutes(deps));
3655
+ app.route("/api/cache/alert", createCacheAlertRoutes(deps));
3452
3656
  app.route("/api/projects", createProjectRoutes(deps));
3453
3657
  app.route("/api/pair", createPairRoutes(deps));
3454
3658
  app.route("/api", createBrowseRoutes(deps));
@@ -3633,6 +3837,31 @@ function parseAgentEntrypointsEnv(raw) {
3633
3837
  return new Set(parts);
3634
3838
  }
3635
3839
 
3840
+ // src/utils/canonicalizeFilePath.ts
3841
+ import { normalize } from "path";
3842
+ function canonicalizeFilePath(filePath) {
3843
+ return filePath.trim().replace(/\\/g, "/");
3844
+ }
3845
+ function toNativeFilePath(filePath) {
3846
+ return normalize(filePath.trim());
3847
+ }
3848
+ function canonicalLivePathSet(metas) {
3849
+ const live = /* @__PURE__ */ new Set();
3850
+ for (const meta of metas) {
3851
+ if (meta.filePath) live.add(canonicalizeFilePath(meta.filePath));
3852
+ }
3853
+ return live;
3854
+ }
3855
+ function joinStatCacheByNativePath(metas, canonicalStats) {
3856
+ const joined = /* @__PURE__ */ new Map();
3857
+ for (const meta of metas) {
3858
+ if (!meta.filePath) continue;
3859
+ const stat3 = canonicalStats.get(canonicalizeFilePath(meta.filePath));
3860
+ if (stat3) joined.set(meta.filePath, { stat: stat3, meta });
3861
+ }
3862
+ return joined;
3863
+ }
3864
+
3636
3865
  // src/utils/fileIdentity.ts
3637
3866
  import { createHash } from "crypto";
3638
3867
  function fileIdentity(stat3, headBytes) {
@@ -3745,8 +3974,19 @@ var ConversationCache = class _ConversationCache {
3745
3974
  ),
3746
3975
  // Batch equivalent of updateMeta: bumps message_count by N in one write
3747
3976
  // (used by updateFromLines so a burst of appended lines is one UPDATE).
3977
+ // last_activity/last_message only move FORWARD: a batch whose newest line
3978
+ // predates the stored last_activity (interleaved writers appending an older
3979
+ // line) must not drag the metadata backward. message_count and updated_at
3980
+ // still advance — a real message was appended and the row did change.
3748
3981
  updateMetaBatch: db.prepare(
3749
- "UPDATE conversation_meta SET message_count = message_count + @inc, last_activity = @last_activity, last_message = @last_message, updated_at = @updated_at WHERE id = @id"
3982
+ `UPDATE conversation_meta SET
3983
+ message_count = message_count + @inc,
3984
+ last_activity = CASE WHEN @last_activity > IFNULL(last_activity, -1)
3985
+ THEN @last_activity ELSE last_activity END,
3986
+ last_message = CASE WHEN @last_activity > IFNULL(last_activity, -1)
3987
+ THEN @last_message ELSE last_message END,
3988
+ updated_at = @updated_at
3989
+ WHERE id = @id`
3750
3990
  ),
3751
3991
  insertSkeleton: db.prepare(
3752
3992
  "INSERT OR IGNORE INTO conversation_meta (id, file_path, message_count, updated_at) VALUES (?, ?, 1, ?)"
@@ -3828,6 +4068,7 @@ var ConversationCache = class _ConversationCache {
3828
4068
  "SELECT provider FROM conversation_meta WHERE file_path = ?"
3829
4069
  ),
3830
4070
  allFilePaths: db.prepare("SELECT id, file_path FROM conversation_meta"),
4071
+ allFilePathsWithTitle: db.prepare("SELECT id, file_path, title FROM conversation_meta"),
3831
4072
  allFileStats: db.prepare(
3832
4073
  "SELECT file_path, mtime_ms, file_size FROM conversation_meta WHERE mtime_ms IS NOT NULL AND file_size IS NOT NULL"
3833
4074
  ),
@@ -3980,7 +4221,7 @@ var ConversationCache = class _ConversationCache {
3980
4221
  // excluded from the index entirely; the scanner serves them (it routes each
3981
4222
  // provider to its own parser).
3982
4223
  isIndexableFile(filePath) {
3983
- const row = this.stmts.getProviderByFilePath.get(filePath);
4224
+ const row = this.stmts.getProviderByFilePath.get(canonicalizeFilePath(filePath));
3984
4225
  if (!row) return false;
3985
4226
  return (row.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
3986
4227
  }
@@ -4248,7 +4489,7 @@ var ConversationCache = class _ConversationCache {
4248
4489
  if (this.fileIndexLoaded) return;
4249
4490
  const rows = this.stmts.allFilePaths.all();
4250
4491
  for (const row of rows) {
4251
- this.fileIndex.set(row.file_path, row.id);
4492
+ this.fileIndex.set(canonicalizeFilePath(row.file_path), row.id);
4252
4493
  }
4253
4494
  this.fileIndexLoaded = true;
4254
4495
  }
@@ -4267,12 +4508,13 @@ var ConversationCache = class _ConversationCache {
4267
4508
  const role = line.role ?? line.type;
4268
4509
  const isMessage = role === "user" || role === "assistant";
4269
4510
  this.ensureFileIndex();
4511
+ const key = canonicalizeFilePath(filePath);
4270
4512
  if (!isMessage && !line.cwd && !line.slug) return;
4271
- let convId = this.fileIndex.get(filePath);
4513
+ let convId = this.fileIndex.get(key);
4272
4514
  if (!convId) {
4273
- const pseudoId = filePath.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? filePath;
4274
- this.stmts.insertSkeleton.run(pseudoId, filePath, 0);
4275
- this.fileIndex.set(filePath, pseudoId);
4515
+ const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
4516
+ this.stmts.insertSkeleton.run(pseudoId, key, 0);
4517
+ this.fileIndex.set(key, pseudoId);
4276
4518
  convId = pseudoId;
4277
4519
  }
4278
4520
  if (line.cwd || line.slug) {
@@ -4287,18 +4529,18 @@ var ConversationCache = class _ConversationCache {
4287
4529
  });
4288
4530
  }
4289
4531
  if (!isMessage) return;
4290
- const timestamp = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4291
- const activityMs = new Date(timestamp).getTime();
4532
+ const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4533
+ const activityMs = new Date(timestamp2).getTime();
4292
4534
  if (Number.isNaN(activityMs)) return;
4293
4535
  const contentBlocks = normalizeContent(line.message?.content ?? line.content);
4294
4536
  const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
4295
- const lastMessage = JSON.stringify({ role, timestamp, text });
4537
+ const lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
4296
4538
  const seq = ++this.tailSeq;
4297
4539
  const result = this.stmts.updateMeta.run(activityMs, lastMessage, seq, convId);
4298
4540
  if (result.changes === 0) return;
4299
4541
  const tailRow = this.stmts.getTail.get(convId);
4300
4542
  const msgs = tailRow ? JSON.parse(tailRow.messages_json) : [];
4301
- msgs.push({ role, timestamp, text, content: contentBlocks });
4543
+ msgs.push({ role, timestamp: timestamp2, text, content: contentBlocks });
4302
4544
  if (msgs.length > this.tailSize) msgs.splice(0, msgs.length - this.tailSize);
4303
4545
  this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, seq);
4304
4546
  }
@@ -4310,7 +4552,8 @@ var ConversationCache = class _ConversationCache {
4310
4552
  * updateFromLine in order: the agent filter short-circuits the whole batch,
4311
4553
  * project context is backfilled last-wins, message_count increases by the
4312
4554
  * number of surviving message lines, and last_activity/last_message reflect
4313
- * the final message line.
4555
+ * the newest message line by timestamp (a monotonic guard keeps them from
4556
+ * moving backward when an interleaved writer appends an older line — P0.3).
4314
4557
  */
4315
4558
  updateFromLines(filePath, rawLines) {
4316
4559
  let sawProjectContext = false;
@@ -4346,23 +4589,26 @@ var ConversationCache = class _ConversationCache {
4346
4589
  backfillTitle ??= lineTitle;
4347
4590
  }
4348
4591
  if (!isMessage) continue;
4349
- const timestamp = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4350
- const activityMs = new Date(timestamp).getTime();
4592
+ const timestamp2 = line.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4593
+ const activityMs = new Date(timestamp2).getTime();
4351
4594
  if (Number.isNaN(activityMs)) continue;
4352
4595
  const contentBlocks = normalizeContent(line.message?.content ?? line.content);
4353
4596
  const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
4354
4597
  msgCount += 1;
4355
- lastActivityMs = activityMs;
4356
- lastMessage = JSON.stringify({ role, timestamp, text });
4357
- newTail.push({ role, timestamp, text, content: contentBlocks });
4598
+ if (lastActivityMs === null || activityMs > lastActivityMs) {
4599
+ lastActivityMs = activityMs;
4600
+ lastMessage = JSON.stringify({ role, timestamp: timestamp2, text });
4601
+ }
4602
+ newTail.push({ role, timestamp: timestamp2, text, content: contentBlocks });
4358
4603
  }
4359
4604
  if (!sawProjectContext && msgCount === 0) return;
4360
4605
  this.ensureFileIndex();
4361
- let convId = this.fileIndex.get(filePath);
4606
+ const key = canonicalizeFilePath(filePath);
4607
+ let convId = this.fileIndex.get(key);
4362
4608
  if (!convId) {
4363
- const pseudoId = filePath.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? filePath;
4364
- this.stmts.insertSkeleton.run(pseudoId, filePath, 0);
4365
- this.fileIndex.set(filePath, pseudoId);
4609
+ const pseudoId = key.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? key;
4610
+ this.stmts.insertSkeleton.run(pseudoId, key, 0);
4611
+ this.fileIndex.set(key, pseudoId);
4366
4612
  convId = pseudoId;
4367
4613
  }
4368
4614
  const id = convId;
@@ -4405,6 +4651,7 @@ var ConversationCache = class _ConversationCache {
4405
4651
  for (const m of items) {
4406
4652
  const id = m.sessionId || m.id.split("/").pop()?.replace(/\.jsonl$/, "") || m.id;
4407
4653
  const lastActivityMs = m.timestamp ? new Date(m.timestamp).getTime() : null;
4654
+ const canonicalPath = canonicalizeFilePath(m.filePath);
4408
4655
  let mtimeMs = null;
4409
4656
  let fileSize = null;
4410
4657
  try {
@@ -4420,10 +4667,10 @@ var ConversationCache = class _ConversationCache {
4420
4667
  const scannerMetaJson = JSON.stringify(m);
4421
4668
  this.stmts.upsertFull.run({
4422
4669
  id,
4423
- file_path: m.filePath,
4670
+ file_path: canonicalPath,
4424
4671
  project_path: m.projectPath ?? null,
4425
4672
  project_name: m.projectName ?? null,
4426
- title: m.title ?? m.projectName ?? null,
4673
+ title: m.title ?? m.sessionName ?? m.projectName ?? null,
4427
4674
  model: m.model ?? null,
4428
4675
  account: m.account ?? null,
4429
4676
  branch: m.gitBranch ?? null,
@@ -4439,7 +4686,7 @@ var ConversationCache = class _ConversationCache {
4439
4686
  scanner_meta_json: scannerMetaJson
4440
4687
  });
4441
4688
  this.stmts.updateScannerCache.run(mtimeMs, fileSize, scannerMetaJson, id);
4442
- if (this.fileIndexLoaded) this.fileIndex.set(m.filePath, id);
4689
+ if (this.fileIndexLoaded) this.fileIndex.set(canonicalPath, id);
4443
4690
  upsertedIds.push(id);
4444
4691
  }
4445
4692
  });
@@ -4450,11 +4697,12 @@ var ConversationCache = class _ConversationCache {
4450
4697
  // by updateFromLine when a previously-cached file turns out to be an agent
4451
4698
  // JSONL.
4452
4699
  deleteByFilePath(filePath) {
4453
- const row = this.stmts.getIdByFilePath.get(filePath);
4700
+ const key = canonicalizeFilePath(filePath);
4701
+ const row = this.stmts.getIdByFilePath.get(key);
4454
4702
  if (!row) return false;
4455
4703
  this.stmts.deleteTailById.run(row.id);
4456
4704
  const result = this.stmts.deleteById.run(row.id);
4457
- this.fileIndex.delete(filePath);
4705
+ this.fileIndex.delete(key);
4458
4706
  return result.changes > 0;
4459
4707
  }
4460
4708
  // Reads the last `tailSize` qualifying lines from a JSONL file and writes them
@@ -4506,10 +4754,10 @@ var ConversationCache = class _ConversationCache {
4506
4754
  }
4507
4755
  const role = parsed.role ?? parsed.type;
4508
4756
  if (!role) continue;
4509
- const timestamp = parsed.timestamp ?? "";
4757
+ const timestamp2 = parsed.timestamp ?? "";
4510
4758
  const contentBlocks = normalizeContent(parsed.message?.content ?? parsed.content);
4511
4759
  const text = contentBlocks.find((b) => b.type === "text")?.text?.slice(0, 200) ?? "";
4512
- msgs.unshift({ role, timestamp, text, content: contentBlocks });
4760
+ msgs.unshift({ role, timestamp: timestamp2, text, content: contentBlocks });
4513
4761
  }
4514
4762
  if (msgs.length === 0) return false;
4515
4763
  this.stmts.upsertTail.run(convId, JSON.stringify(msgs), msgs.length, 0);
@@ -4577,6 +4825,16 @@ var ConversationCache = class _ConversationCache {
4577
4825
  }
4578
4826
  return map;
4579
4827
  }
4828
+ /**
4829
+ * Conversation id for a JSONL path, or null when no row exists yet. Resolves
4830
+ * by file_path (NOT conversationIdForFile) so codex rollout files — named
4831
+ * rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
4832
+ */
4833
+ getIdByFilePath(filePath) {
4834
+ const key = canonicalizeFilePath(filePath);
4835
+ const row = this.stmts.getIdByFilePath.get(key);
4836
+ return row?.id ?? null;
4837
+ }
4580
4838
  getMetaById(id) {
4581
4839
  const row = this.stmts.getFullById.get(id);
4582
4840
  if (!row) return null;
@@ -4669,22 +4927,23 @@ var ConversationCache = class _ConversationCache {
4669
4927
  /**
4670
4928
  * Drop the cached row for a file. Two callers with opposite intent:
4671
4929
  * - a directory-watch "change" event (the file was appended to) — pass
4672
- * `skipIfTailed: true`. A cached tail means the row is being actively
4673
- * maintained from the file's real content by the live-tail
4674
- * (updateFromLines) or warm-up path, fresher than any scanner-derived
4675
- * view. Both watchers fire on the same append with no ordering guarantee;
4676
- * without this guard the invalidate can land after the tail write and wipe
4677
- * the just-cached row, flickering the conversation out of
4678
- * /api/conversations on nearly every message (CRITICAL #2). The debounced
4679
- * rescan still re-derives metadata, so skipping the eager drop loses
4680
- * nothing.
4930
+ * `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
4931
+ * event fires on every external append; deleting here flickers the
4932
+ * conversation out of /api/conversations whether it's a live-tailed row
4933
+ * the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
4934
+ * fire on the same append with no ordering guarantee) OR a refresh-created
4935
+ * untailed row (a ?refresh=1 upsert never populates a tail, so the old
4936
+ * "delete when untailed" behavior made it vanish on its next append with no
4937
+ * client action). The live-tail path owns the row's content and the
4938
+ * debounced rescan re-derives metadata, so leaving the row loses nothing.
4681
4939
  * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
4682
4940
  * row is always removed, otherwise a deleted session ghosts in the cache.
4683
4941
  */
4684
4942
  invalidateByFilePath(filePath, opts) {
4685
- const row = this.stmts.getIdByFilePath.get(filePath);
4943
+ const key = canonicalizeFilePath(filePath);
4944
+ const row = this.stmts.getIdByFilePath.get(key);
4686
4945
  if (!row) return null;
4687
- if (opts?.skipIfTailed && this.stmts.hasTail.get(row.id)) return null;
4946
+ if (opts?.skipIfTailed) return null;
4688
4947
  this.invalidate(row.id);
4689
4948
  return row.id;
4690
4949
  }
@@ -4778,6 +5037,68 @@ var ConversationCache = class _ConversationCache {
4778
5037
  }
4779
5038
  return removed;
4780
5039
  }
5040
+ /**
5041
+ * Read-only: list cached rows whose `file_path` no longer exists on disk.
5042
+ * Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
5043
+ * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
5044
+ * rows that still have cached history (which pruneGhostFiles would keep).
5045
+ */
5046
+ listMissingFiles(exists = existsSync6) {
5047
+ const rows = this.stmts.allFilePathsWithTitle.all();
5048
+ const missing = [];
5049
+ for (const row of rows) {
5050
+ if (exists(row.file_path)) continue;
5051
+ missing.push({
5052
+ id: row.id,
5053
+ filePath: row.file_path,
5054
+ title: row.title,
5055
+ tailed: !!this.stmts.hasTail.get(row.id)
5056
+ });
5057
+ }
5058
+ return missing;
5059
+ }
5060
+ /**
5061
+ * Drop the given conversation ids outright — main row, tail, and message
5062
+ * index — regardless of whether they have a tail. Used by the cache-integrity
5063
+ * resolution actions (prune_all / prune_selected). Returns the count dropped.
5064
+ */
5065
+ dropRowsById(ids) {
5066
+ if (ids.length === 0) return 0;
5067
+ const drop = this.db.transaction((toDrop) => {
5068
+ let n = 0;
5069
+ for (const id of toDrop) {
5070
+ this.stmts.deleteTailById.run(id);
5071
+ this.stmts.deleteMessageIndex.run(id);
5072
+ n += this.stmts.deleteById.run(id).changes;
5073
+ }
5074
+ return n;
5075
+ });
5076
+ const dropped = drop(ids);
5077
+ if (this.fileIndexLoaded) {
5078
+ for (const id of ids) {
5079
+ for (const [fp, cid] of this.fileIndex) {
5080
+ if (cid === id) {
5081
+ this.fileIndex.delete(fp);
5082
+ break;
5083
+ }
5084
+ }
5085
+ }
5086
+ }
5087
+ return dropped;
5088
+ }
5089
+ /**
5090
+ * Wipe all cached conversation state — meta, tails, and message index — and
5091
+ * reset the in-memory file index. Only called by the `reset_rescan`
5092
+ * resolution action, which repopulates from a fresh disk scan afterward.
5093
+ */
5094
+ clearAll() {
5095
+ this.db.transaction(() => {
5096
+ this.stmts.deleteTailAll.run();
5097
+ this.stmts.deleteAll.run();
5098
+ this.db.exec("DELETE FROM conversation_message_index");
5099
+ })();
5100
+ this.fileIndex = /* @__PURE__ */ new Map();
5101
+ }
4781
5102
  };
4782
5103
 
4783
5104
  // src/db/repositories/cacheMetadata.repository.ts
@@ -5087,9 +5408,316 @@ function seal(plaintext, recipientPublicKeyBase64) {
5087
5408
  };
5088
5409
  }
5089
5410
 
5411
+ // src/services/cache-integrity/cacheIntegrityMonitor.ts
5412
+ import { createHash as createHash2 } from "crypto";
5413
+ import { existsSync as existsSync8 } from "fs";
5414
+
5415
+ // src/services/cache-integrity/alertStore.ts
5416
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
5417
+ import { homedir as homedir7 } from "os";
5418
+ import { dirname as dirname8, join as join14 } from "path";
5419
+ function alertStatePath() {
5420
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? join14(homedir7(), ".threadbase");
5421
+ return join14(dir, "cache-alert.json");
5422
+ }
5423
+ function loadAlertState() {
5424
+ try {
5425
+ const parsed = JSON.parse(readFileSync7(alertStatePath(), "utf-8"));
5426
+ return parsed && typeof parsed === "object" ? parsed : {};
5427
+ } catch {
5428
+ return {};
5429
+ }
5430
+ }
5431
+ function saveAlertState(state) {
5432
+ const path = alertStatePath();
5433
+ mkdirSync4(dirname8(path), { recursive: true });
5434
+ writeFileSync3(path, `${JSON.stringify(state, null, 2)}
5435
+ `);
5436
+ }
5437
+
5438
+ // src/services/cache-integrity/backup.ts
5439
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
5440
+ import { join as join15 } from "path";
5441
+ var DEFAULT_RETAIN = 3;
5442
+ function retainCount() {
5443
+ const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
5444
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RETAIN;
5445
+ }
5446
+ function timestamp(d) {
5447
+ const p = (n) => String(n).padStart(2, "0");
5448
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5449
+ }
5450
+ async function backupCacheDb(db, cacheDir) {
5451
+ const backupsDir = join15(cacheDir, "backups");
5452
+ mkdirSync5(backupsDir, { recursive: true });
5453
+ const destPath = join15(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
5454
+ await db.backup(destPath);
5455
+ const retain = retainCount();
5456
+ const backups = readdirSync4(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
5457
+ const full = join15(backupsDir, f);
5458
+ return { full, mtime: statSync5(full).mtimeMs };
5459
+ }).sort((a, b) => b.mtime - a.mtime);
5460
+ for (const stale of backups.slice(retain)) {
5461
+ if (existsSync7(stale.full)) unlinkSync(stale.full);
5462
+ }
5463
+ return destPath;
5464
+ }
5465
+
5466
+ // src/services/cache-integrity/cacheIntegrityMonitor.ts
5467
+ var MAX_MISSING_PERSISTED = 1e3;
5468
+ var SAMPLE_SIZE = 20;
5469
+ var STORM_WINDOW_MS = 3e4;
5470
+ var STORM_THRESHOLD = 10;
5471
+ function envInt(name, fallback) {
5472
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
5473
+ return Number.isFinite(parsed) ? parsed : fallback;
5474
+ }
5475
+ function fingerprintOf(ids) {
5476
+ const sorted = [...ids].sort();
5477
+ return `sha256:${createHash2("sha256").update(sorted.join("\n")).digest("hex")}`;
5478
+ }
5479
+ var CacheIntegrityMonitor = class {
5480
+ constructor(cache, wsHub, log3, cacheDir, rescan) {
5481
+ this.cache = cache;
5482
+ this.wsHub = wsHub;
5483
+ this.log = log3;
5484
+ this.cacheDir = cacheDir;
5485
+ this.rescan = rescan;
5486
+ const state = loadAlertState();
5487
+ this._pending = state.pending ?? null;
5488
+ this.ignoredIds = new Set(state.ignoredIds ?? []);
5489
+ }
5490
+ cache;
5491
+ wsHub;
5492
+ log;
5493
+ cacheDir;
5494
+ rescan;
5495
+ _pending;
5496
+ ignoredIds;
5497
+ deferredUnlinks = [];
5498
+ unlinkTimes = [];
5499
+ get pending() {
5500
+ return this._pending;
5501
+ }
5502
+ persist() {
5503
+ const state = {};
5504
+ if (this._pending) {
5505
+ state.pending = {
5506
+ ...this._pending,
5507
+ missing: this._pending.missing.slice(0, MAX_MISSING_PERSISTED)
5508
+ };
5509
+ }
5510
+ if (this.ignoredIds.size > 0) state.ignoredIds = [...this.ignoredIds];
5511
+ saveAlertState(state);
5512
+ }
5513
+ classifySeverity(missingCount, totalRows) {
5514
+ const minMissing = envInt("THREADBASE_CACHE_ALERT_MIN_MISSING", 20);
5515
+ const minRatio = Number.parseFloat(process.env.THREADBASE_CACHE_ALERT_MIN_RATIO ?? "0.20");
5516
+ const ratio = totalRows > 0 ? missingCount / totalRows : 0;
5517
+ const ratioThreshold = Number.isFinite(minRatio) ? minRatio : 0.2;
5518
+ return missingCount >= minMissing && ratio >= ratioThreshold ? "high" : "low";
5519
+ }
5520
+ sampleOf(missing) {
5521
+ return missing.slice(0, SAMPLE_SIZE).map((m) => ({
5522
+ id: m.id,
5523
+ ...m.title != null ? { title: m.title } : {}
5524
+ }));
5525
+ }
5526
+ buildWsMessage(pending) {
5527
+ return {
5528
+ type: "cache_alert",
5529
+ fingerprint: pending.fingerprint,
5530
+ severity: pending.severity,
5531
+ missingCount: pending.missingCount,
5532
+ totalRows: pending.totalRows,
5533
+ detectedAt: pending.detectedAt,
5534
+ sample: this.sampleOf(pending.missing)
5535
+ };
5536
+ }
5537
+ wsMessage() {
5538
+ return this._pending ? this.buildWsMessage(this._pending) : null;
5539
+ }
5540
+ healthzField() {
5541
+ if (!this._pending) return void 0;
5542
+ return {
5543
+ severity: this._pending.severity,
5544
+ missingCount: this._pending.missingCount,
5545
+ fingerprint: this._pending.fingerprint,
5546
+ detectedAt: this._pending.detectedAt
5547
+ };
5548
+ }
5549
+ /**
5550
+ * Scan the cache for rows whose file is gone, excluding ids the user chose to
5551
+ * ignore. If none remain, clear any stale pending alert and return (the caller
5552
+ * decides whether to run pruneGhostFiles). Otherwise classify severity, persist
5553
+ * the pending record, back up on high severity, and broadcast the alert.
5554
+ */
5555
+ async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
5556
+ const all = this.cache.listMissingFiles(existsSync8);
5557
+ const missing = all.filter((m) => !this.ignoredIds.has(m.id));
5558
+ if (missing.length === 0) {
5559
+ if (this._pending) {
5560
+ this._pending = null;
5561
+ this.persist();
5562
+ }
5563
+ return;
5564
+ }
5565
+ const totalRows = this.cache.listConversations({ limit: 0, offset: 0 }).total;
5566
+ const fingerprint = fingerprintOf(missing.map((m) => m.id));
5567
+ const severity = this.classifySeverity(missing.length, totalRows);
5568
+ const pending = {
5569
+ fingerprint,
5570
+ severity,
5571
+ detectedAt,
5572
+ missingCount: missing.length,
5573
+ totalRows,
5574
+ missing
5575
+ };
5576
+ if (severity === "high") {
5577
+ try {
5578
+ pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
5579
+ } catch (err) {
5580
+ this.log.warn("cache-integrity backup failed", {
5581
+ event: "cache_integrity.backup_failed",
5582
+ error: err instanceof Error ? err.message : String(err)
5583
+ });
5584
+ }
5585
+ }
5586
+ this._pending = pending;
5587
+ this.persist();
5588
+ this.log.warn("cache integrity drift detected", {
5589
+ event: "cache_integrity.detected",
5590
+ severity,
5591
+ missingCount: missing.length,
5592
+ totalRows,
5593
+ fingerprint
5594
+ });
5595
+ this.wsHub.broadcast(this.buildWsMessage(pending));
5596
+ }
5597
+ /** Queue an unlink while an alert is pending — the row is not invalidated. */
5598
+ deferUnlink(filePath) {
5599
+ this.deferredUnlinks.push(filePath);
5600
+ }
5601
+ /**
5602
+ * Record a live unlink while NO alert is pending. Crossing the storm threshold
5603
+ * (>= 10 unlinks within 30s) re-triggers detection.
5604
+ */
5605
+ recordUnlink(filePath) {
5606
+ const now = Date.now();
5607
+ this.unlinkTimes.push(now);
5608
+ this.unlinkTimes = this.unlinkTimes.filter((t) => now - t < STORM_WINDOW_MS);
5609
+ if (this.unlinkTimes.length >= STORM_THRESHOLD) {
5610
+ this.unlinkTimes = [];
5611
+ void this.runDetection().catch((err) => {
5612
+ this.log.error("cache-integrity storm detection failed", {
5613
+ event: "cache_integrity.storm_detection_failed",
5614
+ error: err instanceof Error ? err.message : String(err),
5615
+ filePath
5616
+ });
5617
+ });
5618
+ }
5619
+ }
5620
+ async ensureBackup(pending) {
5621
+ if (pending.backupPath) return pending.backupPath;
5622
+ try {
5623
+ pending.backupPath = await backupCacheDb(this.cache.getDatabase(), this.cacheDir);
5624
+ } catch (err) {
5625
+ this.log.warn("cache-integrity backup failed", {
5626
+ event: "cache_integrity.backup_failed",
5627
+ error: err instanceof Error ? err.message : String(err)
5628
+ });
5629
+ }
5630
+ return pending.backupPath;
5631
+ }
5632
+ clearPending() {
5633
+ this._pending = null;
5634
+ this.deferredUnlinks = [];
5635
+ this.persist();
5636
+ }
5637
+ applyDeferredUnlinks() {
5638
+ for (const fp of this.deferredUnlinks) this.cache.invalidateByFilePath(fp);
5639
+ this.deferredUnlinks = [];
5640
+ }
5641
+ broadcastResolved(fingerprint, action) {
5642
+ this.wsHub.broadcast({ type: "cache_alert_resolved", fingerprint, action });
5643
+ }
5644
+ /**
5645
+ * Apply the human's chosen resolution. Idempotent per fingerprint: no pending
5646
+ * alert → alreadyResolved; a different fingerprint → conflict. See the spec's
5647
+ * four-action semantics.
5648
+ */
5649
+ async resolve(fingerprint, action, ids) {
5650
+ const pending = this._pending;
5651
+ if (!pending) return { alreadyResolved: true };
5652
+ if (pending.fingerprint !== fingerprint) {
5653
+ return { conflict: true, currentFingerprint: pending.fingerprint };
5654
+ }
5655
+ this._pending = null;
5656
+ switch (action) {
5657
+ case "prune_all": {
5658
+ await this.ensureBackup(pending);
5659
+ const backupPath = pending.backupPath;
5660
+ const stillMissing = pending.missing.filter((m) => !existsSync8(m.filePath)).map((m) => m.id);
5661
+ const pruned = this.cache.dropRowsById(stillMissing);
5662
+ this.applyDeferredUnlinks();
5663
+ this.clearPending();
5664
+ this.broadcastResolved(fingerprint, action);
5665
+ return { ok: true, action, pruned, backupPath };
5666
+ }
5667
+ case "prune_selected": {
5668
+ const requested = new Set(ids ?? []);
5669
+ const pendingIds = new Set(pending.missing.map((m) => m.id));
5670
+ const toDrop = [...requested].filter((id) => pendingIds.has(id));
5671
+ await this.ensureBackup(pending);
5672
+ const backupPath = pending.backupPath;
5673
+ const pruned = this.cache.dropRowsById(toDrop);
5674
+ const prunedPaths = new Set(
5675
+ pending.missing.filter((m) => toDrop.includes(m.id)).map((m) => m.filePath)
5676
+ );
5677
+ this.deferredUnlinks = this.deferredUnlinks.filter((fp) => {
5678
+ if (prunedPaths.has(fp)) {
5679
+ this.cache.invalidateByFilePath(fp);
5680
+ return false;
5681
+ }
5682
+ return true;
5683
+ });
5684
+ this.persist();
5685
+ await this.runDetection();
5686
+ this.broadcastResolved(fingerprint, action);
5687
+ return { ok: true, action, pruned, backupPath };
5688
+ }
5689
+ case "ignore": {
5690
+ for (const m of pending.missing) this.ignoredIds.add(m.id);
5691
+ this.deferredUnlinks = [];
5692
+ this.clearPending();
5693
+ this.broadcastResolved(fingerprint, action);
5694
+ return { ok: true, action };
5695
+ }
5696
+ case "reset_rescan": {
5697
+ const backupPath = await this.ensureBackup(pending);
5698
+ this.cache.clearAll();
5699
+ if (this.rescan) {
5700
+ try {
5701
+ const metas = await this.rescan();
5702
+ this.cache.upsertFromScannerMeta(metas);
5703
+ } catch (err) {
5704
+ this.log.error("cache-integrity reset rescan failed", {
5705
+ event: "cache_integrity.reset_rescan_failed",
5706
+ error: err instanceof Error ? err.message : String(err)
5707
+ });
5708
+ }
5709
+ }
5710
+ this.clearPending();
5711
+ this.broadcastResolved(fingerprint, action);
5712
+ return { ok: true, action, backupPath };
5713
+ }
5714
+ }
5715
+ }
5716
+ };
5717
+
5090
5718
  // src/services/conversations/conversationWatcher.ts
5091
5719
  import chokidar from "chokidar";
5092
- import { statSync as statSync5 } from "fs";
5720
+ import { statSync as statSync6 } from "fs";
5093
5721
  import { open, stat as stat2 } from "fs/promises";
5094
5722
  var ConversationWatcher = class {
5095
5723
  files = /* @__PURE__ */ new Map();
@@ -5099,6 +5727,7 @@ var ConversationWatcher = class {
5099
5727
  onNewLineSpans;
5100
5728
  onConversationChanged;
5101
5729
  onFileDeleted;
5730
+ onTruncated;
5102
5731
  onError;
5103
5732
  constructor(events = {}) {
5104
5733
  this.onNewLine = events.onNewLine;
@@ -5106,13 +5735,15 @@ var ConversationWatcher = class {
5106
5735
  this.onNewLineSpans = events.onNewLineSpans;
5107
5736
  this.onConversationChanged = events.onConversationChanged;
5108
5737
  this.onFileDeleted = events.onFileDeleted;
5738
+ this.onTruncated = events.onTruncated;
5109
5739
  this.onError = events.onError;
5110
5740
  }
5111
5741
  watch(filePath) {
5112
- if (this.files.has(filePath)) return;
5742
+ const key = canonicalizeFilePath(filePath);
5743
+ if (this.files.has(key)) return;
5113
5744
  let offset;
5114
5745
  try {
5115
- offset = statSync5(filePath).size;
5746
+ offset = statSync6(filePath).size;
5116
5747
  } catch {
5117
5748
  offset = 0;
5118
5749
  }
@@ -5121,23 +5752,24 @@ var ConversationWatcher = class {
5121
5752
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
5122
5753
  });
5123
5754
  watcher.on("change", () => {
5124
- void this.readNewLines(filePath);
5755
+ void this.readNewLines(key);
5125
5756
  });
5126
5757
  watcher.on("add", () => {
5127
- void this.readNewLines(filePath);
5758
+ void this.readNewLines(key);
5128
5759
  });
5129
5760
  watcher.on("unlink", () => this.onFileDeleted?.(filePath));
5130
5761
  watcher.on("error", (err) => {
5131
5762
  const error = err instanceof Error ? err : new Error(String(err));
5132
5763
  this.onError?.(filePath, error);
5133
5764
  });
5134
- this.files.set(filePath, { watcher, offset, reading: false, pending: false });
5765
+ this.files.set(key, { watcher, offset, reading: false, pending: false, path: filePath });
5135
5766
  }
5136
5767
  unwatch(filePath) {
5137
- const entry = this.files.get(filePath);
5768
+ const key = canonicalizeFilePath(filePath);
5769
+ const entry = this.files.get(key);
5138
5770
  if (!entry) return;
5139
5771
  void entry.watcher.close();
5140
- this.files.delete(filePath);
5772
+ this.files.delete(key);
5141
5773
  }
5142
5774
  /**
5143
5775
  * Re-drive the tail read for a file that's already being tailed. A per-file
@@ -5148,8 +5780,9 @@ var ConversationWatcher = class {
5148
5780
  * event is a cheap stat + no-op. Returns false for untailed paths.
5149
5781
  */
5150
5782
  poke(filePath) {
5151
- if (!this.files.has(filePath)) return false;
5152
- void this.readNewLines(filePath);
5783
+ const key = canonicalizeFilePath(filePath);
5784
+ if (!this.files.has(key)) return false;
5785
+ void this.readNewLines(key);
5153
5786
  return true;
5154
5787
  }
5155
5788
  /**
@@ -5186,9 +5819,10 @@ var ConversationWatcher = class {
5186
5819
  for (const [path] of this.files) this.unwatch(path);
5187
5820
  for (const [dir] of this.directories) this.unwatchDirectory(dir);
5188
5821
  }
5189
- async readNewLines(filePath) {
5190
- const entry = this.files.get(filePath);
5822
+ async readNewLines(key) {
5823
+ const entry = this.files.get(key);
5191
5824
  if (!entry) return;
5825
+ const filePath = entry.path;
5192
5826
  if (entry.reading) {
5193
5827
  entry.pending = true;
5194
5828
  return;
@@ -5197,6 +5831,10 @@ var ConversationWatcher = class {
5197
5831
  try {
5198
5832
  for (; ; ) {
5199
5833
  const st = await stat2(filePath);
5834
+ if (st.size < entry.offset) {
5835
+ entry.offset = 0;
5836
+ this.onTruncated?.(filePath);
5837
+ }
5200
5838
  if (st.size <= entry.offset) break;
5201
5839
  const readFrom = entry.offset;
5202
5840
  const bytesToRead = st.size - readFrom;
@@ -5209,7 +5847,7 @@ var ConversationWatcher = class {
5209
5847
  }
5210
5848
  const { spans, consumed } = splitCompleteLines(buf, readFrom);
5211
5849
  entry.offset = readFrom + consumed;
5212
- if (!this.files.has(filePath)) return;
5850
+ if (!this.files.has(key)) return;
5213
5851
  const lines = spans.map((s) => s.text);
5214
5852
  if (spans.length > 0) {
5215
5853
  this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
@@ -5229,9 +5867,9 @@ var ConversationWatcher = class {
5229
5867
  this.onError?.(filePath, err instanceof Error ? err : new Error(String(err)));
5230
5868
  } finally {
5231
5869
  entry.reading = false;
5232
- if (entry.pending && this.files.has(filePath)) {
5870
+ if (entry.pending && this.files.has(key)) {
5233
5871
  entry.pending = false;
5234
- void this.readNewLines(filePath);
5872
+ void this.readNewLines(key);
5235
5873
  }
5236
5874
  }
5237
5875
  }
@@ -5287,14 +5925,14 @@ function findSearchTarget(messages, query) {
5287
5925
  }
5288
5926
 
5289
5927
  // src/services/conversations/pruneAgentConversations.ts
5290
- import { existsSync as existsSync7 } from "fs";
5928
+ import { existsSync as existsSync9 } from "fs";
5291
5929
  function pruneAgentConversations(cache) {
5292
5930
  const db = cache.getDatabase();
5293
5931
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
5294
5932
  let pruned = 0;
5295
5933
  let missing = 0;
5296
5934
  for (const row of rows) {
5297
- if (!existsSync7(row.file_path)) {
5935
+ if (!existsSync9(row.file_path)) {
5298
5936
  missing += 1;
5299
5937
  continue;
5300
5938
  }
@@ -5440,6 +6078,52 @@ function resolveAnswer(pending, body) {
5440
6078
  }
5441
6079
  }
5442
6080
 
6081
+ // src/services/sessions/conversationBusy.ts
6082
+ import { statSync as statSync7 } from "fs";
6083
+ var RESUME_BUSY_WINDOW_MS = 12e4;
6084
+ function resolveResumeBusyWindowMs(env = process.env) {
6085
+ const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
6086
+ if (raw === void 0) return RESUME_BUSY_WINDOW_MS;
6087
+ const n = Number.parseInt(raw, 10);
6088
+ return Number.isFinite(n) && n >= 0 ? n : RESUME_BUSY_WINDOW_MS;
6089
+ }
6090
+ var SELF_ACTIVITY_SKEW_MS = 5e3;
6091
+ function conversationBusy(input) {
6092
+ const now = input.now ?? Date.now();
6093
+ const windowMs = input.windowMs ?? RESUME_BUSY_WINDOW_MS;
6094
+ const platform3 = input.platform ?? process.platform;
6095
+ const detectedBy = [];
6096
+ let lastActivityMs = null;
6097
+ if (input.jsonlPath) {
6098
+ try {
6099
+ const mtimeMs = statSync7(input.jsonlPath).mtimeMs;
6100
+ const age = now - mtimeMs;
6101
+ lastActivityMs = Math.max(0, age);
6102
+ const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
6103
+ if (age <= windowMs && !isSelfEcho) detectedBy.push("jsonl_mtime");
6104
+ } catch {
6105
+ }
6106
+ }
6107
+ const argvMatch = input.discovered.some((p) => p.conversationId === input.conversationId);
6108
+ if (argvMatch) detectedBy.push("process_argv");
6109
+ let cwdMatch = false;
6110
+ if (platform3 !== "win32" && input.projectPath) {
6111
+ const target = canonicalizeProjectPath(input.projectPath);
6112
+ cwdMatch = input.discovered.some(
6113
+ (p) => !!p.projectPath && canonicalizeProjectPath(p.projectPath) === target
6114
+ );
6115
+ if (cwdMatch) detectedBy.push("process_cwd");
6116
+ }
6117
+ return {
6118
+ busy: detectedBy.length > 0,
6119
+ detectedBy,
6120
+ lastActivityMs,
6121
+ // A matched process is a concrete external owner; a lone mtime hit could be
6122
+ // an editor, a crashed process, or a process we could not enumerate.
6123
+ likelyOwner: argvMatch || cwdMatch ? "external" : "unknown"
6124
+ };
6125
+ }
6126
+
5443
6127
  // src/session-store.ts
5444
6128
  var SessionStore = class {
5445
6129
  managed = /* @__PURE__ */ new Map();
@@ -5579,6 +6263,9 @@ function managedToResponse(s, ptyAttached) {
5579
6263
  conversationId: s.id,
5580
6264
  provider: s.provider ?? CLAUDE_CODE_PROVIDER,
5581
6265
  status: s.status,
6266
+ // We spawned it, so `status` is the authoritative signal — no inferred
6267
+ // `activity` is attached for managed sessions.
6268
+ ownership: "managed",
5582
6269
  projectPath: s.projectPath,
5583
6270
  projectName: s.projectName,
5584
6271
  branch: s.branch,
@@ -5612,7 +6299,14 @@ function discoveredToResponse(d, conversationId) {
5612
6299
  id: conversationId,
5613
6300
  conversationId,
5614
6301
  provider: CLAUDE_CODE_PROVIDER,
6302
+ // Stays "idle" deliberately: we cannot see this process's prompt state, and
6303
+ // reporting `running` would route mobile to the destructive Overtake screen.
6304
+ // Liveness travels in the additive fields below instead.
5615
6305
  status: "idle",
6306
+ ownership: "external",
6307
+ // Discovery just enumerated this PID, so it was alive moments ago. We never
6308
+ // report "gone" here — a vanished process simply stops being listed.
6309
+ processLiveness: "alive",
5616
6310
  projectPath: d.projectPath,
5617
6311
  projectName: d.projectName,
5618
6312
  branch: d.branch,
@@ -5630,7 +6324,7 @@ function discoveredToResponse(d, conversationId) {
5630
6324
  import { randomBytes as randomBytes3 } from "crypto";
5631
6325
  import { mkdir as mkdir3, writeFile } from "fs/promises";
5632
6326
  import heicConvert from "heic-convert";
5633
- import { join as join14 } from "path";
6327
+ import { join as join16 } from "path";
5634
6328
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
5635
6329
  var MAX_BYTES = 25 * 1024 * 1024;
5636
6330
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -5663,9 +6357,9 @@ async function saveUploadFile(input) {
5663
6357
  }
5664
6358
  const id = `up_${randomBytes3(8).toString("hex")}`;
5665
6359
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
5666
- const dir = join14(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
6360
+ const dir = join16(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
5667
6361
  await mkdir3(dir, { recursive: true });
5668
- const filePath = join14(dir, `${Date.now()}-${id}-${safeName}`);
6362
+ const filePath = join16(dir, `${Date.now()}-${id}-${safeName}`);
5669
6363
  await writeFile(filePath, buffer);
5670
6364
  return {
5671
6365
  id,
@@ -5677,7 +6371,7 @@ async function saveUploadFile(input) {
5677
6371
  }
5678
6372
  function sanitizeFilename(name) {
5679
6373
  const base = name.split(/[\\/]/).pop() ?? "";
5680
- const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("");
6374
+ const cleaned = base.replace(/^\.+/, "").split("").filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join("").replace(/[\s@"'`$\\]/g, "_");
5681
6375
  return cleaned;
5682
6376
  }
5683
6377
 
@@ -5710,12 +6404,12 @@ function normalizeCodexLineToClaudeShape(line) {
5710
6404
  const text = extractCodexText(payload.content);
5711
6405
  if (!text) return null;
5712
6406
  if (role === "user" && isCodexInjectedContext(text)) return null;
5713
- const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
5714
- const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp}-${hashPrefix(text)}`;
6407
+ const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
6408
+ const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
5715
6409
  return JSON.stringify({
5716
6410
  type: role,
5717
6411
  uuid,
5718
- timestamp,
6412
+ timestamp: timestamp2,
5719
6413
  message: {
5720
6414
  role,
5721
6415
  content: [{ type: "text", text }]
@@ -5765,13 +6459,13 @@ function hashPrefix(text) {
5765
6459
  }
5766
6460
 
5767
6461
  // src/utils/conversationEtag.ts
5768
- import { createHash as createHash2 } from "crypto";
6462
+ import { createHash as createHash3 } from "crypto";
5769
6463
  function computeConversationEtag({
5770
6464
  filePath,
5771
6465
  messageCount,
5772
- timestamp
6466
+ timestamp: timestamp2
5773
6467
  }) {
5774
- const digest = createHash2("sha1").update(`${filePath}:${messageCount}:${timestamp}`).digest("hex").slice(0, 16);
6468
+ const digest = createHash3("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
5775
6469
  return `"${digest}"`;
5776
6470
  }
5777
6471
 
@@ -5921,8 +6615,17 @@ var WSHub = class {
5921
6615
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
5922
6616
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5923
6617
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
6618
+ var GRACE_MAX_DEFERS = 4;
6619
+ var RESUME_DISCOVERY_TIMEOUT_MS = 750;
6620
+ var DISCOVERY_TTL_MS = 15e3;
6621
+ var ADOPT_KILL_TIMEOUT_MS = 5e3;
6622
+ var ADOPT_KILL_POLL_MS = 100;
5924
6623
  var REFRESH_TTL_MS = 2e3;
5925
6624
  var START_READY_TIMEOUT_MS = 1e4;
6625
+ var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
6626
+ var EXTERNAL_TAIL_MAX = 32;
6627
+ var EXTERNAL_TAIL_IDLE_MS = 3e5;
6628
+ var EXTERNAL_ACTIVE_WRITING_MS = 3e4;
5926
6629
  function parseIncludeAgentsEnv(raw) {
5927
6630
  if (raw === void 0) return false;
5928
6631
  const v = raw.trim().toLowerCase();
@@ -5936,11 +6639,30 @@ var StreamerServer = class {
5936
6639
  fileWatcher;
5937
6640
  sessionFileMap = /* @__PURE__ */ new Map();
5938
6641
  // sessionId → JSONL filePath
6642
+ // canonical JSONL path → live tail on a file NO PTY session owns (an external
6643
+ // agent is writing it). Deliberately separate from sessionFileMap so managed
6644
+ // session semantics — terminal_output, session_update, question cards — are
6645
+ // untouched: an external tail only ever pushes transcript lines.
6646
+ externalTails = /* @__PURE__ */ new Map();
5939
6647
  // Per-file seq assignments from the most recent onNewLineSpans (offset index),
5940
6648
  // handed to the immediately-following onNewLines so it can stamp WS `seq` on
5941
6649
  // the matching conversation_events entries. Same read → same lines order.
5942
6650
  pendingLineSeqs = /* @__PURE__ */ new Map();
6651
+ // `origin` records whether the pending question came from the live PTY-screen
6652
+ // path (handleLiveQuestion) or a JSONL flush. A JSONL-derived question must
6653
+ // never clobber a PTY-originated one for a DIFFERENT question — an external
6654
+ // agent appending an AskUserQuestion into a shared conversation would
6655
+ // otherwise misroute the answer into this streamer's PTY.
5943
6656
  pendingQuestions = /* @__PURE__ */ new Map();
6657
+ // Sessions resumed past a detected collision (busy probe said busy, caller
6658
+ // forced). JSONL-derived actionable question cards are suppressed for these
6659
+ // because a line in the shared file may have been written by the other owner.
6660
+ contendedSessions = /* @__PURE__ */ new Set();
6661
+ // conversationId → ms epoch when THIS streamer's PTY for it last went idle.
6662
+ // Lets the resume collision probe tell our own trailing JSONL writes (a
6663
+ // hold → resume round trip) apart from another owner's. Pruned on write so it
6664
+ // cannot grow without bound across a long-lived process.
6665
+ selfPtyEndedAt = /* @__PURE__ */ new Map();
5944
6666
  // Content key of the AskUserQuestion currently broadcast for a session (from
5945
6667
  // either the rendered screen or JSONL), used to de-dupe the two paths: when
5946
6668
  // the screen detection fires first, the later JSONL flush of the same question
@@ -6003,8 +6725,13 @@ var StreamerServer = class {
6003
6725
  ptyGracePeriodMs;
6004
6726
  defaultSystemPrompt;
6005
6727
  defaultPermissionMode;
6728
+ defaultModel;
6729
+ defaultEffort;
6006
6730
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
6007
6731
  ptyGraceTimers = /* @__PURE__ */ new Map();
6732
+ // Consecutive grace-timer defers for a still-`running` session (see
6733
+ // GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
6734
+ ptyGraceDeferCounts = /* @__PURE__ */ new Map();
6008
6735
  // Map of sessionId → set of subscribed WS clients
6009
6736
  sessionSubscribers = /* @__PURE__ */ new Map();
6010
6737
  // Map of clientId → WS socket (populated by the "register" WS handshake)
@@ -6012,6 +6739,7 @@ var StreamerServer = class {
6012
6739
  // Reverse map for cleanup on close
6013
6740
  wsToClientId = /* @__PURE__ */ new Map();
6014
6741
  cache = null;
6742
+ cacheMonitor = null;
6015
6743
  projectsRepo = null;
6016
6744
  conversationsRepo = null;
6017
6745
  sessionsRepo = null;
@@ -6048,11 +6776,13 @@ var StreamerServer = class {
6048
6776
  this.disableDb = config.disableDb ?? false;
6049
6777
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
6050
6778
  this.scanProfiles = config.scanProfiles;
6051
- this.codexRoots = config.codexRoots ?? [join15(homedir7(), ".codex", "sessions")];
6779
+ this.codexRoots = config.codexRoots ?? [join17(homedir8(), ".codex", "sessions")];
6052
6780
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
6053
6781
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
6054
6782
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
6055
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join15(homedir7(), ".threadbase", "cache");
6783
+ this.defaultModel = config.defaultModel ?? "sonnet";
6784
+ this.defaultEffort = config.defaultEffort ?? "low";
6785
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join17(homedir8(), ".threadbase", "cache");
6056
6786
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
6057
6787
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
6058
6788
  this.markScannerStaleDebounced = debounce(() => {
@@ -6093,7 +6823,7 @@ var StreamerServer = class {
6093
6823
  const seqs = cache.extendMessageIndex(
6094
6824
  filePath,
6095
6825
  spans,
6096
- statSync6(filePath),
6826
+ statSync8(filePath),
6097
6827
  readFrom,
6098
6828
  endOffset
6099
6829
  );
@@ -6123,39 +6853,25 @@ var StreamerServer = class {
6123
6853
  },
6124
6854
  onNewLines: (filePath, lines) => {
6125
6855
  this.cache?.updateFromLines(filePath, lines);
6856
+ let managed = false;
6126
6857
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
6127
6858
  if (watchedPath === filePath) {
6128
- const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
6129
- const { messages, pending } = questionsFromLines(sessionId, lines);
6130
- for (const p of pending) {
6131
- this.pendingQuestions.set(sessionId, p);
6132
- const t = setTimeout(() => {
6133
- if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
6134
- this.cancelPendingQuestion(sessionId);
6135
- }
6136
- }, 6e4);
6137
- t.unref();
6138
- }
6139
- for (const m of messages) {
6140
- const key = questionContentKey(m.questions);
6141
- const broadcast = shouldBroadcastQuestion({
6142
- newContentKey: key,
6143
- lastContentKey: this.pendingQuestionKey.get(sessionId),
6144
- newToolUseId: m.toolUseId,
6145
- priorToolUseId
6146
- });
6147
- this.pendingQuestionKey.set(sessionId, key);
6148
- if (broadcast) this.wsHub.broadcast(m);
6149
- }
6859
+ managed = true;
6860
+ this.processJsonlQuestions(sessionId, lines);
6150
6861
  const seqs = this.pendingLineSeqs.get(filePath);
6151
6862
  this.broadcastConversationLines(sessionId, lines, seqs);
6152
6863
  break;
6153
6864
  }
6154
6865
  }
6866
+ if (!managed) {
6867
+ this.broadcastExternalTailLines(filePath, lines, this.pendingLineSeqs.get(filePath));
6868
+ }
6155
6869
  this.pendingLineSeqs.delete(filePath);
6156
6870
  },
6157
6871
  onConversationChanged: (filePath) => {
6158
- this.fileWatcher.poke(filePath);
6872
+ const tailed = this.fileWatcher.poke(filePath);
6873
+ if (!tailed) this.maybeAttachExternalTail(filePath);
6874
+ this.sweepIdleExternalTails();
6159
6875
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
6160
6876
  this.markScannerStaleDebounced();
6161
6877
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
@@ -6163,7 +6879,20 @@ var StreamerServer = class {
6163
6879
  event: "cache.directory_change"
6164
6880
  });
6165
6881
  },
6882
+ onTruncated: (filePath) => {
6883
+ this.cache?.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
6884
+ this.cache?.clearIndexParseState(filePath);
6885
+ this.log.warn(`JSONL truncated/replaced; offset index dropped: ${filePath}`, {
6886
+ filePath,
6887
+ event: "tail.truncated"
6888
+ });
6889
+ },
6166
6890
  onFileDeleted: (filePath) => {
6891
+ this.detachExternalTail(canonicalizeFilePath(filePath));
6892
+ if (this.cacheMonitor?.pending) {
6893
+ this.cacheMonitor.deferUnlink(filePath);
6894
+ return;
6895
+ }
6167
6896
  const id = this.cache?.invalidateByFilePath(filePath);
6168
6897
  if (id)
6169
6898
  this.log.info(`Cache row invalidated after JSONL delete: ${id}`, {
@@ -6171,6 +6900,7 @@ var StreamerServer = class {
6171
6900
  filePath,
6172
6901
  event: "cache.invalidate_on_unlink"
6173
6902
  });
6903
+ this.cacheMonitor?.recordUnlink(filePath);
6174
6904
  }
6175
6905
  });
6176
6906
  this.ptyManager = new LiveSessionManager({
@@ -6234,6 +6964,8 @@ var StreamerServer = class {
6234
6964
  this.cancelPendingQuestion(session.id);
6235
6965
  }
6236
6966
  this.pendingPermission.delete(session.id);
6967
+ this.contendedSessions.delete(session.id);
6968
+ this.rememberSelfPtyEnded(session.id);
6237
6969
  }
6238
6970
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
6239
6971
  if (resp) {
@@ -6257,7 +6989,7 @@ var StreamerServer = class {
6257
6989
  temporalClient,
6258
6990
  taskQueue: agentConfig.temporal.taskQueue
6259
6991
  });
6260
- const conversationsBaseDir = agentConfig.conversationsDir || join15(dirname8(this.cacheDir), "conversations");
6992
+ const conversationsBaseDir = agentConfig.conversationsDir || join17(dirname9(this.cacheDir), "conversations");
6261
6993
  conversationWriter = createConversationWriter({
6262
6994
  baseDir: conversationsBaseDir
6263
6995
  });
@@ -6279,6 +7011,7 @@ var StreamerServer = class {
6279
7011
  sessionStore: this.sessionStore,
6280
7012
  wsHub: this.wsHub,
6281
7013
  cache: () => this.cache,
7014
+ cacheMonitor: () => this.cacheMonitor,
6282
7015
  projectsRepo: () => this.projectsRepo,
6283
7016
  conversationsRepo: () => this.conversationsRepo,
6284
7017
  sessionsRepo: () => this.sessionsRepo,
@@ -6317,6 +7050,8 @@ var StreamerServer = class {
6317
7050
  if (this.cacheReady) {
6318
7051
  ws.send(JSON.stringify({ type: "cache_ready" }));
6319
7052
  }
7053
+ const alertMsg = this.cacheMonitor?.wsMessage();
7054
+ if (alertMsg) this.wsHub.unicast(ws, alertMsg);
6320
7055
  },
6321
7056
  handleWsMessage: async (ws, raw) => {
6322
7057
  try {
@@ -6432,6 +7167,17 @@ var StreamerServer = class {
6432
7167
  ptyAttachedIds() {
6433
7168
  return new Set(this.ptyManager.listSessions().map((s) => s.id));
6434
7169
  }
7170
+ // Record that our own PTY for `conversationId` just ended. Entries older than
7171
+ // the busy window can never change a verdict, so drop them as we go rather
7172
+ // than accumulating one per conversation for the process's lifetime.
7173
+ rememberSelfPtyEnded(conversationId) {
7174
+ const now = Date.now();
7175
+ const cutoff = now - resolveResumeBusyWindowMs();
7176
+ for (const [id, at] of this.selfPtyEndedAt) {
7177
+ if (at < cutoff) this.selfPtyEndedAt.delete(id);
7178
+ }
7179
+ this.selfPtyEndedAt.set(conversationId, now);
7180
+ }
6435
7181
  /**
6436
7182
  * Send a session_list to only the client that triggered this HTTP request
6437
7183
  * (identified by X-Client-Id header → registered WS socket). Falls back to
@@ -6462,6 +7208,7 @@ var StreamerServer = class {
6462
7208
  clearTimeout(existing);
6463
7209
  this.ptyGraceTimers.delete(sessionId);
6464
7210
  }
7211
+ this.ptyGraceDeferCounts.delete(sessionId);
6465
7212
  }
6466
7213
  startGraceTimer(sessionId, delayMs) {
6467
7214
  const existing = this.ptyGraceTimers.get(sessionId);
@@ -6471,14 +7218,24 @@ var StreamerServer = class {
6471
7218
  if (this.ptyManager.hasSession(sessionId)) {
6472
7219
  const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6473
7220
  if (resp?.status === "running") {
6474
- this.log.info(
6475
- `[grace] session ${sessionId} still running, deferring hold`,
6476
- { sessionId, event: "pty.grace_defer" },
7221
+ const defers = (this.ptyGraceDeferCounts.get(sessionId) ?? 0) + 1;
7222
+ if (defers <= GRACE_MAX_DEFERS) {
7223
+ this.ptyGraceDeferCounts.set(sessionId, defers);
7224
+ this.log.info(
7225
+ `[grace] session ${sessionId} still running, deferring hold (${defers}/${GRACE_MAX_DEFERS})`,
7226
+ { sessionId, event: "pty.grace_defer", defers, maxDefers: GRACE_MAX_DEFERS },
7227
+ "pino"
7228
+ );
7229
+ this.startGraceTimer(sessionId, delayMs);
7230
+ return;
7231
+ }
7232
+ this.log.warn(
7233
+ `[grace] session ${sessionId} exceeded ${GRACE_MAX_DEFERS} defers, holding anyway`,
7234
+ { sessionId, event: "pty.grace_defer_cap", defers, maxDefers: GRACE_MAX_DEFERS },
6477
7235
  "pino"
6478
7236
  );
6479
- this.startGraceTimer(sessionId, delayMs);
6480
- return;
6481
7237
  }
7238
+ this.ptyGraceDeferCounts.delete(sessionId);
6482
7239
  this.sessionSubscribers.delete(sessionId);
6483
7240
  this.log.info(
6484
7241
  `[grace] killing idle PTY for ${sessionId}`,
@@ -6489,6 +7246,7 @@ var StreamerServer = class {
6489
7246
  const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6490
7247
  if (held) this.wsHub.broadcast({ type: "session_update", session: held });
6491
7248
  } else {
7249
+ this.ptyGraceDeferCounts.delete(sessionId);
6492
7250
  this.sessionSubscribers.delete(sessionId);
6493
7251
  }
6494
7252
  }, delayMs);
@@ -6521,13 +7279,16 @@ var StreamerServer = class {
6521
7279
  });
6522
7280
  try {
6523
7281
  this.cache = ConversationCache.open(
6524
- join15(this.cacheDir, "cache.db"),
7282
+ join17(this.cacheDir, "cache.db"),
6525
7283
  this.tailSize,
6526
7284
  void 0,
6527
7285
  {
6528
7286
  filterAgentConversations: !this.includeAgents,
6529
7287
  agentEntrypoints: this.agentEntrypoints,
6530
- onAgentFileDetected: (fp) => this.fileWatcher.unwatch(fp)
7288
+ onAgentFileDetected: (fp) => {
7289
+ this.fileWatcher.unwatch(fp);
7290
+ this.externalTails.delete(canonicalizeFilePath(fp));
7291
+ }
6531
7292
  }
6532
7293
  );
6533
7294
  if (!this.includeAgents) {
@@ -6544,9 +7305,23 @@ var StreamerServer = class {
6544
7305
  this.conversationsRepo = new ConversationsRepository(this.cache);
6545
7306
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
6546
7307
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
7308
+ this.cacheMonitor = new CacheIntegrityMonitor(
7309
+ this.cache,
7310
+ this.wsHub,
7311
+ this.log,
7312
+ this.cacheDir,
7313
+ async () => {
7314
+ const scanner = await this.rescanForRefresh();
7315
+ return [...scanner.getMetadataCache().values()];
7316
+ }
7317
+ );
6547
7318
  for (const dir of this.projectsDirs()) {
6548
7319
  this.fileWatcher.watchDirectory(dir);
6549
7320
  }
7321
+ for (const dir of this.codexRoots) {
7322
+ if (!existsSync10(dir)) continue;
7323
+ this.fileWatcher.watchDirectory(dir);
7324
+ }
6550
7325
  } catch (err) {
6551
7326
  const message = err instanceof Error ? err.message : String(err);
6552
7327
  const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
@@ -6613,11 +7388,19 @@ var StreamerServer = class {
6613
7388
  }
6614
7389
  );
6615
7390
  }
6616
- const pruned = this.cache.pruneGhostFiles();
6617
- this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
6618
- count: pruned.length,
6619
- event: "cache.prune_ghosts"
6620
- });
7391
+ await this.cacheMonitor?.runDetection();
7392
+ if (this.cacheMonitor?.pending) {
7393
+ this.log.warn("Startup ghost prune skipped \u2014 cache integrity alert pending", {
7394
+ fingerprint: this.cacheMonitor.pending.fingerprint,
7395
+ event: "cache.prune_ghosts_frozen"
7396
+ });
7397
+ } else {
7398
+ const pruned = this.cache.pruneGhostFiles();
7399
+ this.log.info(`Startup ghost prune: removed ${pruned.length} stale cache rows`, {
7400
+ count: pruned.length,
7401
+ event: "cache.prune_ghosts"
7402
+ });
7403
+ }
6621
7404
  }).catch((err) => {
6622
7405
  const message = err instanceof Error ? err.message : String(err);
6623
7406
  this.log.warn(`Startup cache warm-up failed: ${message}`, {
@@ -6729,6 +7512,7 @@ var StreamerServer = class {
6729
7512
  this.cache?.close();
6730
7513
  this.ptyManager.dispose();
6731
7514
  this.fileWatcher.dispose();
7515
+ this.externalTails.clear();
6732
7516
  this.wsHub.dispose();
6733
7517
  this.pairTokens.dispose();
6734
7518
  if (this.dbPool) {
@@ -6868,10 +7652,9 @@ var StreamerServer = class {
6868
7652
  const metas2 = [...scanner2.getMetadataCache().values()];
6869
7653
  try {
6870
7654
  this.cache.upsertFromScannerMeta(metas2);
6871
- const livePaths = new Set(
6872
- metas2.map((m) => m.filePath).filter((p) => Boolean(p))
6873
- );
6874
- this.cache.reconcileDeletions(livePaths);
7655
+ if (!this.cacheMonitor?.pending) {
7656
+ this.cache.reconcileDeletions(canonicalLivePathSet(metas2));
7657
+ }
6875
7658
  } catch (err) {
6876
7659
  this.log.warn(
6877
7660
  `refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -7002,6 +7785,7 @@ var StreamerServer = class {
7002
7785
  type: "conversation",
7003
7786
  id: c.id,
7004
7787
  status: "idle",
7788
+ ownership: "historical",
7005
7789
  ptyAttached: false,
7006
7790
  projectId: c.projectId ?? void 0,
7007
7791
  projectPath: c.projectPath ?? "",
@@ -7028,21 +7812,19 @@ var StreamerServer = class {
7028
7812
  if (!this.cache) return void 0;
7029
7813
  if (!previousScanner) {
7030
7814
  const persisted = this.cache.getScannerStatCache();
7031
- return persisted.size > 0 ? persisted : void 0;
7815
+ if (persisted.size === 0) return void 0;
7816
+ const nativeKeyed = /* @__PURE__ */ new Map();
7817
+ for (const [canonicalPath, entry] of persisted) {
7818
+ nativeKeyed.set(toNativeFilePath(canonicalPath), entry);
7819
+ }
7820
+ return nativeKeyed;
7032
7821
  }
7033
7822
  const dbStats = this.cache.getFileStats();
7034
7823
  if (dbStats.size === 0) return void 0;
7035
- const metaByPath = /* @__PURE__ */ new Map();
7036
- if (previousScanner) {
7037
- for (const meta of previousScanner.getMetadataCache().values()) {
7038
- if (meta.filePath) metaByPath.set(meta.filePath, meta);
7039
- }
7040
- }
7041
- const statCache = /* @__PURE__ */ new Map();
7042
- for (const [filePath, stat3] of dbStats) {
7043
- const meta = metaByPath.get(filePath);
7044
- if (meta) statCache.set(filePath, { stat: stat3, meta });
7045
- }
7824
+ const statCache = joinStatCacheByNativePath(
7825
+ previousScanner.getMetadataCache().values(),
7826
+ dbStats
7827
+ );
7046
7828
  return statCache.size > 0 ? statCache : void 0;
7047
7829
  }
7048
7830
  // Returns the provider + codexRoots fragment to spread into every scan()/search() call.
@@ -7136,22 +7918,22 @@ var StreamerServer = class {
7136
7918
  */
7137
7919
  projectsDirs() {
7138
7920
  if (this.scanProfiles && this.scanProfiles.length > 0) {
7139
- return this.scanProfiles.filter((p) => p.enabled).map((p) => join15(p.configDir, "projects"));
7921
+ return this.scanProfiles.filter((p) => p.enabled).map((p) => join17(p.configDir, "projects"));
7140
7922
  }
7141
- return [join15(homedir7(), ".claude", "projects")];
7923
+ return [join17(homedir8(), ".claude", "projects")];
7142
7924
  }
7143
7925
  findJsonlPath(uuid) {
7144
7926
  const filename = `${uuid}.jsonl`;
7145
7927
  for (const projectsDir of this.projectsDirs()) {
7146
- if (!existsSync8(projectsDir)) continue;
7147
- for (const dir of readdirSync4(projectsDir)) {
7148
- const fp = join15(projectsDir, dir, filename);
7149
- if (existsSync8(fp)) return fp;
7150
- const projectDir = join15(projectsDir, dir);
7928
+ if (!existsSync10(projectsDir)) continue;
7929
+ for (const dir of readdirSync5(projectsDir)) {
7930
+ const fp = join17(projectsDir, dir, filename);
7931
+ if (existsSync10(fp)) return fp;
7932
+ const projectDir = join17(projectsDir, dir);
7151
7933
  try {
7152
- for (const sub of readdirSync4(projectDir)) {
7153
- const subagentPath = join15(projectDir, sub, "subagents", filename);
7154
- if (existsSync8(subagentPath)) return subagentPath;
7934
+ for (const sub of readdirSync5(projectDir)) {
7935
+ const subagentPath = join17(projectDir, sub, "subagents", filename);
7936
+ if (existsSync10(subagentPath)) return subagentPath;
7155
7937
  }
7156
7938
  } catch {
7157
7939
  }
@@ -7231,6 +8013,140 @@ var StreamerServer = class {
7231
8013
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
7232
8014
  }
7233
8015
  }
8016
+ // ─── External (non-PTY) live tails ───────────────────────────────
8017
+ /** True when a managed (PTY) session owns the tail for this canonical path. */
8018
+ isManagedTailPath(key) {
8019
+ for (const watchedPath of this.sessionFileMap.values()) {
8020
+ if (canonicalizeFilePath(watchedPath) === key) return true;
8021
+ }
8022
+ return false;
8023
+ }
8024
+ /**
8025
+ * Attach a live tail to a JSONL nobody is tailing yet, when it was touched
8026
+ * recently enough to look actively written by an external agent. Capped at
8027
+ * EXTERNAL_TAIL_MAX with LRU eviction.
8028
+ */
8029
+ maybeAttachExternalTail(filePath) {
8030
+ if (!filePath.endsWith(".jsonl")) return;
8031
+ const key = canonicalizeFilePath(filePath);
8032
+ if (this.externalTails.has(key)) return;
8033
+ if (this.isManagedTailPath(key)) return;
8034
+ let mtimeMs;
8035
+ try {
8036
+ mtimeMs = statSync8(filePath).mtimeMs;
8037
+ } catch {
8038
+ return;
8039
+ }
8040
+ const now = Date.now();
8041
+ if (now - mtimeMs > EXTERNAL_TAIL_RECENCY_MS) return;
8042
+ this.evictExternalTailsIfNeeded();
8043
+ this.externalTails.set(key, {
8044
+ conversationId: ConversationCache.conversationIdForFile(key),
8045
+ lastActivityAt: now
8046
+ });
8047
+ this.fileWatcher.watch(filePath);
8048
+ this.log.debug?.(`External tail attached: ${filePath}`, {
8049
+ filePath,
8050
+ tails: this.externalTails.size,
8051
+ event: "external_tail.attach"
8052
+ });
8053
+ }
8054
+ /** Stop tailing an external file and drop its bookkeeping. */
8055
+ detachExternalTail(key) {
8056
+ if (!this.externalTails.delete(key)) return;
8057
+ this.fileWatcher.unwatch(key);
8058
+ this.log.debug?.(`External tail detached: ${key}`, {
8059
+ filePath: key,
8060
+ event: "external_tail.detach"
8061
+ });
8062
+ }
8063
+ /** Make room for one more tail by evicting the least recently active ones. */
8064
+ evictExternalTailsIfNeeded() {
8065
+ while (this.externalTails.size >= EXTERNAL_TAIL_MAX) {
8066
+ let lruKey = null;
8067
+ let lruAt = Number.POSITIVE_INFINITY;
8068
+ for (const [key, entry] of this.externalTails) {
8069
+ if (this.isManagedTailPath(key)) {
8070
+ this.externalTails.delete(key);
8071
+ return;
8072
+ }
8073
+ if (entry.lastActivityAt < lruAt) {
8074
+ lruAt = entry.lastActivityAt;
8075
+ lruKey = key;
8076
+ }
8077
+ }
8078
+ if (!lruKey) return;
8079
+ this.detachExternalTail(lruKey);
8080
+ }
8081
+ }
8082
+ /**
8083
+ * INFERRED activity for an externally-owned conversation, derived purely from
8084
+ * how recently its JSONL grew (the external tail's bookkeeping). Returns
8085
+ * undefined when we hold no tail for it, so a session we know nothing about
8086
+ * reports no activity rather than a fabricated "quiet".
8087
+ *
8088
+ * This can never distinguish a generating agent from one blocked on a
8089
+ * permission gate — gates render on the PTY screen and never reach the JSONL —
8090
+ * which is why it is a separate field and not folded into `status`.
8091
+ */
8092
+ externalActivityFor(conversationId, now = Date.now()) {
8093
+ for (const entry of this.externalTails.values()) {
8094
+ if (entry.conversationId !== conversationId) continue;
8095
+ return {
8096
+ state: now - entry.lastActivityAt <= EXTERNAL_ACTIVE_WRITING_MS ? "active_writing" : "quiet",
8097
+ lastEventAt: new Date(entry.lastActivityAt).toISOString(),
8098
+ source: "jsonl"
8099
+ };
8100
+ }
8101
+ return void 0;
8102
+ }
8103
+ /** Attach inferred `activity` to externally-owned sessions in a response set. */
8104
+ withExternalActivity(sessions) {
8105
+ if (this.externalTails.size === 0) return sessions;
8106
+ const now = Date.now();
8107
+ return sessions.map((s) => {
8108
+ if (s.ownership !== "external") return s;
8109
+ const activity = this.externalActivityFor(s.conversationId ?? s.id, now);
8110
+ return activity ? { ...s, activity } : s;
8111
+ });
8112
+ }
8113
+ /** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
8114
+ sweepIdleExternalTails(now = Date.now()) {
8115
+ for (const [key, entry] of [...this.externalTails]) {
8116
+ if (this.isManagedTailPath(key)) {
8117
+ this.externalTails.delete(key);
8118
+ continue;
8119
+ }
8120
+ if (now - entry.lastActivityAt > EXTERNAL_TAIL_IDLE_MS) this.detachExternalTail(key);
8121
+ }
8122
+ }
8123
+ /**
8124
+ * Push appended lines from an externally-owned conversation. Reuses the exact
8125
+ * conversation_events / conversation_event shapes mobile already consumes,
8126
+ * keyed by the conversation UUID — an external session has no PTY, so it must
8127
+ * never produce terminal_output / terminal_replay / session_ready, and never a
8128
+ * session_update whose session.id is a conversation UUID (that would mint a
8129
+ * phantom session row in the mobile cache). Question cards are likewise never
8130
+ * derived here: with no PTY there is nothing that could deliver an answer.
8131
+ */
8132
+ broadcastExternalTailLines(filePath, lines, seqs) {
8133
+ const key = canonicalizeFilePath(filePath);
8134
+ const entry = this.externalTails.get(key);
8135
+ if (!entry) return;
8136
+ entry.lastActivityAt = Date.now();
8137
+ const conversationId = this.cache?.getIdByFilePath(key);
8138
+ if (!conversationId) return;
8139
+ entry.conversationId = conversationId;
8140
+ this.broadcastConversationLines(conversationId, lines, seqs);
8141
+ const meta = this.cache?.getMetaById(conversationId);
8142
+ this.wsHub.broadcast({
8143
+ type: "conversation_updated",
8144
+ conversationId,
8145
+ messageCount: meta?.messageCount ?? 0,
8146
+ lastActivity: meta?.lastActivity ?? (/* @__PURE__ */ new Date()).toISOString(),
8147
+ ownership: "external"
8148
+ });
8149
+ }
7234
8150
  async findConversationByUuid(uuid) {
7235
8151
  const lookupId = this.resolveConversationLookupId(uuid);
7236
8152
  if (!this.scannerReady && !this.scanProfiles) {
@@ -7299,7 +8215,7 @@ var StreamerServer = class {
7299
8215
  if (!conv.filePath) return false;
7300
8216
  let mtimeMs = null;
7301
8217
  try {
7302
- mtimeMs = statSync6(conv.filePath).mtimeMs;
8218
+ mtimeMs = statSync8(conv.filePath).mtimeMs;
7303
8219
  } catch {
7304
8220
  return false;
7305
8221
  }
@@ -7633,7 +8549,6 @@ var StreamerServer = class {
7633
8549
  });
7634
8550
  }
7635
8551
  async handleListSessions(url, res) {
7636
- const DISCOVERY_TTL_MS = 15e3;
7637
8552
  const now = Date.now();
7638
8553
  if (!this.discoveryCache || now - this.discoveryCache.fetchedAt >= DISCOVERY_TTL_MS) {
7639
8554
  try {
@@ -7645,7 +8560,7 @@ var StreamerServer = class {
7645
8560
  }
7646
8561
  const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
7647
8562
  if (!hasPaginationParams) {
7648
- json(res, 200, this.sessionStore.list(this.ptyAttachedIds()));
8563
+ json(res, 200, this.withExternalActivity(this.sessionStore.list(this.ptyAttachedIds())));
7649
8564
  return;
7650
8565
  }
7651
8566
  const parsed = parseSessionListQuery(url);
@@ -7655,6 +8570,7 @@ var StreamerServer = class {
7655
8570
  }
7656
8571
  try {
7657
8572
  const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
8573
+ page.sessions = this.withExternalActivity(page.sessions);
7658
8574
  json(res, 200, page);
7659
8575
  } catch (err) {
7660
8576
  if (err instanceof Error && err.message === "INVALID_CURSOR") {
@@ -7667,7 +8583,7 @@ var StreamerServer = class {
7667
8583
  handleGetSession(sessionId, res) {
7668
8584
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
7669
8585
  if (session) {
7670
- if (!existsSync8(session.projectPath)) {
8586
+ if (!existsSync10(session.projectPath)) {
7671
8587
  session.failureReason = `Project directory not found: ${session.projectPath}`;
7672
8588
  }
7673
8589
  json(res, 200, session);
@@ -7681,7 +8597,6 @@ var StreamerServer = class {
7681
8597
  json(res, 404, { error: "Session not found" });
7682
8598
  }
7683
8599
  async handleResume(req, res) {
7684
- this.discoveryCache = null;
7685
8600
  const body = await readBody(req);
7686
8601
  const sessionId = body.sessionId ?? body.conversationId;
7687
8602
  if (!sessionId) {
@@ -7710,14 +8625,56 @@ var StreamerServer = class {
7710
8625
  json(res, 400, { error: "Could not determine project path" });
7711
8626
  return;
7712
8627
  }
8628
+ let discovered = [];
8629
+ const cached2 = this.discoveryCache;
8630
+ if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
8631
+ discovered = cached2.entries;
8632
+ } else {
8633
+ try {
8634
+ discovered = await Promise.race([
8635
+ discoverClaudeProcesses(),
8636
+ new Promise(
8637
+ (resolve2) => setTimeout(() => resolve2([]), RESUME_DISCOVERY_TIMEOUT_MS).unref?.()
8638
+ )
8639
+ ]);
8640
+ if (discovered.length > 0) {
8641
+ this.discoveryCache = { entries: discovered, fetchedAt: Date.now() };
8642
+ }
8643
+ } catch {
8644
+ }
8645
+ }
8646
+ const busy = conversationBusy({
8647
+ conversationId: sessionId,
8648
+ projectPath,
8649
+ jsonlPath,
8650
+ discovered,
8651
+ windowMs: resolveResumeBusyWindowMs(),
8652
+ selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
8653
+ });
8654
+ if (busy.busy && body.force !== true) {
8655
+ json(res, 409, {
8656
+ error: "This conversation looks active in another session",
8657
+ code: "CONVERSATION_BUSY",
8658
+ detectedBy: busy.detectedBy,
8659
+ lastActivityMs: busy.lastActivityMs,
8660
+ likelyOwner: busy.likelyOwner
8661
+ });
8662
+ return;
8663
+ }
8664
+ if (busy.busy) {
8665
+ this.contendedSessions.add(sessionId);
8666
+ }
7713
8667
  const cachedConvMeta = this.cache?.getMetaById(sessionId);
7714
8668
  const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
8669
+ this.discoveryCache = null;
7715
8670
  const session = await this.ptyManager.start(sessionId, {
7716
8671
  provider,
7717
8672
  projectPath,
7718
8673
  projectName: body.projectName,
7719
8674
  branch: body.branch,
7720
- permissionMode: this.defaultPermissionMode
8675
+ permissionMode: this.defaultPermissionMode,
8676
+ model: this.defaultModel,
8677
+ effort: this.defaultEffort
7721
8678
  });
7722
8679
  this.sessionStore.addManaged(session);
7723
8680
  void this.watchConversationFile(sessionId);
@@ -7843,6 +8800,45 @@ var StreamerServer = class {
7843
8800
  json(res, 400, { error: message });
7844
8801
  }
7845
8802
  }
8803
+ // Store + broadcast AskUserQuestion cards found in a JSONL batch for a watched
8804
+ // session. Two P0 safety guards on top of the screen/JSONL de-dupe:
8805
+ // (a) contended file → suppress JSONL-derived cards entirely (a line may be
8806
+ // the OTHER owner's question); the streamer's own PTY questions still
8807
+ // arrive via the live-screen path (handleLiveQuestion), not suppressed.
8808
+ // (b) a JSONL question must never clobber a PTY-screen question that is a
8809
+ // DIFFERENT question — answering it would type into this streamer's PTY.
8810
+ // Same-content re-syncs (screen synthetic id → real toolUseId) still pass.
8811
+ processJsonlQuestions(sessionId, lines) {
8812
+ const priorPending = this.pendingQuestions.get(sessionId);
8813
+ const priorToolUseId = priorPending?.toolUseId;
8814
+ const contended = this.contendedSessions.has(sessionId);
8815
+ const priorPtyKey = priorPending?.origin === "pty" ? questionContentKey(priorPending.questions) : null;
8816
+ const foreignVsPty = (questions) => priorPtyKey !== null && questionContentKey(questions) !== priorPtyKey;
8817
+ const { messages, pending } = questionsFromLines(sessionId, lines);
8818
+ for (const p of pending) {
8819
+ if (contended || foreignVsPty(p.questions)) continue;
8820
+ const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
8821
+ this.pendingQuestions.set(sessionId, { ...p, origin });
8822
+ const t = setTimeout(() => {
8823
+ if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
8824
+ this.cancelPendingQuestion(sessionId);
8825
+ }
8826
+ }, 6e4);
8827
+ t.unref();
8828
+ }
8829
+ for (const m of messages) {
8830
+ if (contended || foreignVsPty(m.questions)) continue;
8831
+ const key = questionContentKey(m.questions);
8832
+ const broadcast = shouldBroadcastQuestion({
8833
+ newContentKey: key,
8834
+ lastContentKey: this.pendingQuestionKey.get(sessionId),
8835
+ newToolUseId: m.toolUseId,
8836
+ priorToolUseId
8837
+ });
8838
+ this.pendingQuestionKey.set(sessionId, key);
8839
+ if (broadcast) this.wsHub.broadcast(m);
8840
+ }
8841
+ }
7846
8842
  cancelPendingQuestion(sessionId) {
7847
8843
  const pq = this.pendingQuestions.get(sessionId);
7848
8844
  if (!pq) return;
@@ -7859,7 +8855,7 @@ var StreamerServer = class {
7859
8855
  const key = questionContentKey(questions);
7860
8856
  if (this.pendingQuestionKey.get(sessionId) === key) return;
7861
8857
  const toolUseId = `screen:${sessionId}:${key.length}`;
7862
- this.pendingQuestions.set(sessionId, { toolUseId, questions });
8858
+ this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
7863
8859
  this.pendingQuestionKey.set(sessionId, key);
7864
8860
  this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
7865
8861
  }
@@ -8042,12 +9038,40 @@ var StreamerServer = class {
8042
9038
  json(res, 400, { error: "Session has no known PID" });
8043
9039
  return;
8044
9040
  }
9041
+ if (!projectPath) {
9042
+ this.log.warn("adopt: refusing, working directory unknown", {
9043
+ event: "adopt.no_project_path",
9044
+ sessionId,
9045
+ pid: discSession.pid
9046
+ });
9047
+ json(res, 400, {
9048
+ error: "Cannot take over this session: its working directory could not be determined on this platform",
9049
+ code: "ADOPT_NO_PROJECT_PATH"
9050
+ });
9051
+ return;
9052
+ }
8045
9053
  this.ptyManager.killPid(discSession.pid);
9054
+ const exited = await waitForProcessExit(discSession.pid, ADOPT_KILL_TIMEOUT_MS);
9055
+ if (!exited) {
9056
+ this.log.warn("adopt: external process did not exit; refusing to double-write", {
9057
+ event: "adopt.kill_timeout",
9058
+ sessionId,
9059
+ pid: discSession.pid
9060
+ });
9061
+ json(res, 409, {
9062
+ error: "The existing process did not exit; not starting a second agent on this conversation",
9063
+ code: "ADOPT_KILL_TIMEOUT",
9064
+ pid: discSession.pid
9065
+ });
9066
+ return;
9067
+ }
8046
9068
  const session = await this.ptyManager.start(convId, {
8047
9069
  projectPath,
8048
9070
  projectName,
8049
9071
  branch,
8050
- permissionMode: this.defaultPermissionMode
9072
+ permissionMode: this.defaultPermissionMode,
9073
+ model: this.defaultModel,
9074
+ effort: this.defaultEffort
8051
9075
  });
8052
9076
  this.sessionStore.addManaged(session);
8053
9077
  void this.watchConversationFile(session.id);
@@ -8071,7 +9095,7 @@ var StreamerServer = class {
8071
9095
  sessionStore: this.sessionStore,
8072
9096
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
8073
9097
  agentClient: this.agentClient,
8074
- conversationsDir: this.cacheDir ? join15(dirname8(this.cacheDir), "conversations") : "",
9098
+ conversationsDir: this.cacheDir ? join17(dirname9(this.cacheDir), "conversations") : "",
8075
9099
  agentConfig: this.agentConfig
8076
9100
  });
8077
9101
  json(res, result.status, result.body);
@@ -8118,7 +9142,9 @@ var StreamerServer = class {
8118
9142
  projectPath: resolvedPath,
8119
9143
  projectName: body.projectName,
8120
9144
  systemPrompt: systemPromptParts.join("\n"),
8121
- permissionMode: this.defaultPermissionMode
9145
+ permissionMode: this.defaultPermissionMode,
9146
+ model: this.defaultModel,
9147
+ effort: this.defaultEffort
8122
9148
  });
8123
9149
  this.sessionStore.addManaged(session);
8124
9150
  const readyOrFailed = new Promise((resolve2) => {
@@ -8207,14 +9233,30 @@ var StreamerServer = class {
8207
9233
  } catch {
8208
9234
  }
8209
9235
  }
9236
+ // Read just the `sessionId` field from a JSONL's first line, used by the
9237
+ // watchForJsonl fallback to confirm a candidate file's identity before
9238
+ // binding it. Reads only up to the first newline so a large actively-written
9239
+ // file isn't slurped in full.
9240
+ readFirstLineSessionId(filePath) {
9241
+ try {
9242
+ const content = readFileSync8(filePath, "utf8");
9243
+ const nl = content.indexOf("\n");
9244
+ const firstLine = nl === -1 ? content : content.slice(0, nl);
9245
+ if (!firstLine.trim()) return null;
9246
+ const obj = JSON.parse(firstLine);
9247
+ return typeof obj.sessionId === "string" ? obj.sessionId : null;
9248
+ } catch {
9249
+ return null;
9250
+ }
9251
+ }
8210
9252
  // Watch the project directory for the JSONL file Claude creates for sessionId.
8211
9253
  // Once found, wire up structured event streaming. No rekeying needed — the UUID
8212
9254
  // was passed to Claude via --session-id so the filename matches from the start.
8213
9255
  watchForJsonl(sessionId, projectPath) {
8214
9256
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
8215
- const projectsDir = join15(homedir7(), ".claude", "projects", encoded);
9257
+ const projectsDir = join17(homedir8(), ".claude", "projects", encoded);
8216
9258
  const expectedFile = `${sessionId}.jsonl`;
8217
- const filePath = join15(projectsDir, expectedFile);
9259
+ const filePath = join17(projectsDir, expectedFile);
8218
9260
  const deadline = Date.now() + 12e4;
8219
9261
  let watcher = null;
8220
9262
  const cleanup = () => {
@@ -8232,26 +9274,28 @@ var StreamerServer = class {
8232
9274
  cleanup();
8233
9275
  return;
8234
9276
  }
8235
- let resolvedFilePath = existsSync8(filePath) ? filePath : null;
8236
- if (!resolvedFilePath && existsSync8(projectsDir)) {
9277
+ let resolvedFilePath = existsSync10(filePath) ? filePath : null;
9278
+ if (!resolvedFilePath && existsSync10(projectsDir)) {
8237
9279
  try {
8238
9280
  const now = Date.now();
8239
- const recent = readdirSync4(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync6(join15(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
8240
- if (recent) resolvedFilePath = join15(projectsDir, recent.f);
9281
+ const match = readdirSync5(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync8(join17(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
9282
+ ({ f }) => basename5(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join17(projectsDir, f)) === sessionId
9283
+ ).sort((a, b) => b.mtime - a.mtime)[0];
9284
+ if (match) resolvedFilePath = join17(projectsDir, match.f);
8241
9285
  } catch {
8242
9286
  }
8243
9287
  }
8244
9288
  if (!resolvedFilePath) return;
8245
9289
  cleanup();
8246
9290
  this.sessionFileMap.set(sessionId, resolvedFilePath);
8247
- this.fileWatcher.watch(resolvedFilePath);
8248
9291
  try {
8249
- const existing = readFileSync7(resolvedFilePath, "utf8").split("\n").filter(Boolean);
9292
+ const existing = readFileSync8(resolvedFilePath, "utf8").split("\n").filter(Boolean);
8250
9293
  if (existing.length > 0) {
8251
9294
  this.broadcastConversationLines(sessionId, existing);
8252
9295
  }
8253
9296
  } catch {
8254
9297
  }
9298
+ this.fileWatcher.watch(resolvedFilePath);
8255
9299
  if (this.scannerReady) {
8256
9300
  this.scannerStale = true;
8257
9301
  } else {
@@ -8285,7 +9329,7 @@ var StreamerServer = class {
8285
9329
  watchForCodexRollout(sessionId, projectPath) {
8286
9330
  const deadline = Date.now() + 12e4;
8287
9331
  const now = /* @__PURE__ */ new Date();
8288
- const dateDir = join15(
9332
+ const dateDir = join17(
8289
9333
  String(now.getFullYear()),
8290
9334
  String(now.getMonth() + 1).padStart(2, "0"),
8291
9335
  String(now.getDate()).padStart(2, "0")
@@ -8298,7 +9342,7 @@ var StreamerServer = class {
8298
9342
  };
8299
9343
  const matchesProjectPath = (candidatePath) => {
8300
9344
  try {
8301
- const firstLine = readFileSync7(candidatePath, "utf8").split("\n", 1)[0];
9345
+ const firstLine = readFileSync8(candidatePath, "utf8").split("\n", 1)[0];
8302
9346
  if (!firstLine) return null;
8303
9347
  const parsed = JSON.parse(firstLine);
8304
9348
  if (parsed?.type !== "session_meta") return null;
@@ -8326,18 +9370,18 @@ var StreamerServer = class {
8326
9370
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
8327
9371
  );
8328
9372
  for (const root of this.codexRoots) {
8329
- const sessionsDir = join15(root, dateDir);
8330
- if (!existsSync8(sessionsDir)) continue;
9373
+ const sessionsDir = join17(root, dateDir);
9374
+ if (!existsSync10(sessionsDir)) continue;
8331
9375
  let candidateFiles;
8332
9376
  try {
8333
- candidateFiles = readdirSync4(sessionsDir).filter((f) => f.endsWith(".jsonl"));
9377
+ candidateFiles = readdirSync5(sessionsDir).filter((f) => f.endsWith(".jsonl"));
8334
9378
  } catch {
8335
9379
  continue;
8336
9380
  }
8337
9381
  const nowMs = Date.now();
8338
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync6(join15(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
9382
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync8(join17(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
8339
9383
  for (const { f } of recentCandidates) {
8340
- const candidatePath = join15(sessionsDir, f);
9384
+ const candidatePath = join17(sessionsDir, f);
8341
9385
  const match = matchesProjectPath(candidatePath);
8342
9386
  if (!match) continue;
8343
9387
  if (boundElsewhere.has(match.id)) continue;
@@ -8347,7 +9391,7 @@ var StreamerServer = class {
8347
9391
  this.sessionFileMap.set(sessionId, candidatePath);
8348
9392
  this.fileWatcher.watch(candidatePath);
8349
9393
  try {
8350
- const existing = readFileSync7(candidatePath, "utf8").split("\n").filter(Boolean);
9394
+ const existing = readFileSync8(candidatePath, "utf8").split("\n").filter(Boolean);
8351
9395
  if (existing.length > 0) {
8352
9396
  this.broadcastConversationLines(sessionId, existing);
8353
9397
  }
@@ -8467,9 +9511,21 @@ var StreamerServer = class {
8467
9511
  json(res, 200, this.cache.listSessionNames());
8468
9512
  }
8469
9513
  };
9514
+ async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
9515
+ const deadline = Date.now() + timeoutMs;
9516
+ for (; ; ) {
9517
+ try {
9518
+ process.kill(pid, 0);
9519
+ } catch {
9520
+ return true;
9521
+ }
9522
+ if (Date.now() >= deadline) return false;
9523
+ await new Promise((resolve2) => setTimeout(resolve2, pollMs));
9524
+ }
9525
+ }
8470
9526
  function classifyResumability(cwd) {
8471
9527
  if (!cwd) return { resumable: true };
8472
- if (existsSync8(cwd)) return { resumable: true };
9528
+ if (existsSync10(cwd)) return { resumable: true };
8473
9529
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
8474
9530
  return {
8475
9531
  resumable: false,
@@ -8484,6 +9540,9 @@ function conversationToResumableSession(c) {
8484
9540
  id: c.id,
8485
9541
  conversationId: c.id,
8486
9542
  status: "on_hold",
9543
+ // A cached conversation with no process behind it. Distinguishes "nobody is
9544
+ // running this" from an external session that IS live (ownership "external").
9545
+ ownership: "historical",
8487
9546
  ptyAttached: false,
8488
9547
  projectId: c.projectId ?? void 0,
8489
9548
  projectPath: c.projectPath ?? "",