@tekmidian/pai 0.9.16 → 0.10.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.
@@ -16,7 +16,7 @@ import { t as PaiClient } from "../ipc-client-C3pjwy2m.mjs";
16
16
  import { a as expandHome, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, t as CONFIG_DIR } from "../config-DqBY3aT0.mjs";
17
17
  import { t as createStorageBackend } from "../factory-BufouUQ1.mjs";
18
18
  import { s as kgQuery } from "../kg-entity-DVzTsy6G.mjs";
19
- import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { homedir, platform, tmpdir } from "node:os";
21
21
  import { basename, dirname, join, relative, resolve } from "node:path";
22
22
  import chalk from "chalk";
@@ -218,6 +218,117 @@ function findProjectNotesDirs(project) {
218
218
  } catch {}
219
219
  return results;
220
220
  }
221
+ const SKIP_DIRS = new Set([
222
+ ".git",
223
+ "node_modules",
224
+ ".next",
225
+ ".nuxt",
226
+ "dist",
227
+ "build",
228
+ "coverage",
229
+ ".cache",
230
+ "__pycache__",
231
+ "vendor",
232
+ ".svn",
233
+ ".hg",
234
+ "venv",
235
+ ".venv",
236
+ "target",
237
+ "Notes"
238
+ ]);
239
+ /**
240
+ * Walk `dir` up to `maxDepth` levels deep, collecting all subdirectory paths
241
+ * whose basename matches `targetBasename` (case-insensitive on macOS/case-sensitive
242
+ * on Linux, whichever is appropriate — we use exact case matching to keep it
243
+ * correct and fast).
244
+ *
245
+ * Skips hidden directories and known build/tool dirs.
246
+ * Stops walking a branch if the deadline is exceeded (returns partial results).
247
+ */
248
+ function walkForBasename(dir, targetBasename, maxDepth, deadline, results) {
249
+ if (Date.now() > deadline) return;
250
+ if (maxDepth <= 0) return;
251
+ let entries;
252
+ try {
253
+ entries = readdirSync(dir);
254
+ } catch {
255
+ return;
256
+ }
257
+ for (const entry of entries) {
258
+ if (Date.now() > deadline) return;
259
+ if (entry.startsWith(".")) continue;
260
+ if (SKIP_DIRS.has(entry)) continue;
261
+ const full = join(dir, entry);
262
+ let isDir = false;
263
+ try {
264
+ isDir = statSync(full).isDirectory();
265
+ } catch {
266
+ continue;
267
+ }
268
+ if (!isDir) continue;
269
+ if (entry.toLowerCase() === targetBasename.toLowerCase()) {
270
+ results.push(full);
271
+ continue;
272
+ }
273
+ walkForBasename(full, targetBasename, maxDepth - 1, deadline, results);
274
+ }
275
+ }
276
+ /**
277
+ * Try to find where a project has moved.
278
+ *
279
+ * Searches scan_dirs from config (plus a set of common fallback dirs) for a
280
+ * directory whose basename matches the basename of the registered root_path.
281
+ *
282
+ * Returns:
283
+ * { found: string } — exactly one match found
284
+ * { ambiguous: string[] } — multiple matches found
285
+ * { found: undefined } — no match found (or timed out)
286
+ */
287
+ function findMovedPath(registeredPath) {
288
+ const targetBasename = basename(registeredPath);
289
+ const config = loadScanConfig();
290
+ const deadline = Date.now() + 5e3;
291
+ const home = homedir();
292
+ const configDirs = (config.scan_dirs ?? []).map((d) => resolveHome(d));
293
+ function deepestExistingAncestor(p) {
294
+ let current = p;
295
+ while (true) {
296
+ const parent = join(current, "..");
297
+ if (parent === current) return null;
298
+ if (parent === home) return null;
299
+ if (existsSync(parent)) return parent;
300
+ current = parent;
301
+ }
302
+ }
303
+ const ancestorRoot = deepestExistingAncestor(registeredPath);
304
+ const fallbackDirs = [
305
+ join(home, "dev"),
306
+ join(home, "Desktop"),
307
+ join(home, "Projects"),
308
+ join(home, "Documents"),
309
+ join(home, "Cloud"),
310
+ join(home, "Daten", "Cloud"),
311
+ join(home, "Daten"),
312
+ join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"),
313
+ ...ancestorRoot ? [ancestorRoot] : []
314
+ ];
315
+ const searchRoots = [...new Set([...configDirs, ...fallbackDirs])].map((d) => {
316
+ try {
317
+ return resolve(d);
318
+ } catch {
319
+ return null;
320
+ }
321
+ }).filter((d) => d !== null && existsSync(d));
322
+ const results = [];
323
+ for (const root of searchRoots) {
324
+ if (Date.now() > deadline) break;
325
+ walkForBasename(root, targetBasename, 6, deadline, results);
326
+ }
327
+ const unique = [...new Set(results)];
328
+ if (unique.length === 0) return {};
329
+ if (unique.length === 1) return { found: unique[0] };
330
+ return { ambiguous: unique };
331
+ }
221
332
  function cmdAdd(db, rawPath, opts) {
222
333
  const rootPath = resolvePath(rawPath);
223
334
  const slug = opts.slug ?? slugFromPath(rootPath);
@@ -272,7 +383,7 @@ function cmdList$3(db, opts) {
272
383
  if (opts.status) {
273
384
  where.push("p.status = ?");
274
385
  params.push(opts.status);
275
- }
386
+ } else if (!opts.all) where.push("p.status = 'active'");
276
387
  if (opts.type) {
277
388
  where.push("p.type = ?");
278
389
  params.push(opts.type);
@@ -310,7 +421,9 @@ function cmdList$3(db, opts) {
310
421
  "Last Active"
311
422
  ], tableRows));
312
423
  console.log();
313
- console.log(dim(` ${rows.length} project(s)`));
424
+ const hiddenCount = db.prepare("SELECT COUNT(*) AS cnt FROM projects").get().cnt - rows.length;
425
+ if (!opts.all && !opts.status && hiddenCount > 0) console.log(dim(` ${rows.length} active project(s) (${hiddenCount} archived — use --all to show)`));
426
+ else console.log(dim(` ${rows.length} project(s)`));
314
427
  }
315
428
  function cmdInfo$1(db, identifier) {
316
429
  const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
@@ -518,6 +631,32 @@ function cmdConsolidate(db, identifier, opts) {
518
631
  console.log();
519
632
  console.log(ok(` Consolidated ${movedCount} file(s) into ${canonicalNotes}`));
520
633
  }
634
+ /**
635
+ * Attempt moved-path recovery for a project whose registered root_path is
636
+ * missing. Updates the DB if exactly one candidate is found.
637
+ *
638
+ * Returns the new path on success, or undefined if ambiguous/not found.
639
+ * Prints all messages to stderr (safe for shell-wrapper stdout capture).
640
+ */
641
+ function tryRecoverMovedProject(db, project) {
642
+ process.stderr.write(warn(`Path not found: ${project.root_path}\n`) + dim(" Searching for moved location...\n"));
643
+ const result = findMovedPath(project.root_path);
644
+ if (result.found) {
645
+ const newPath = result.found;
646
+ const newEncoded = encodeDir(newPath);
647
+ const ts = now();
648
+ db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
649
+ process.stderr.write(ok(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim(` → ${newPath}\n`) + ok("Registry updated.\n"));
650
+ return newPath;
651
+ }
652
+ if (result.ambiguous) {
653
+ process.stderr.write(warn(`Multiple directories named "${basename(project.root_path)}" found:\n`));
654
+ for (const candidate of result.ambiguous) process.stderr.write(dim(` ${candidate}\n`));
655
+ process.stderr.write(dim(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
656
+ return;
657
+ }
658
+ process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
659
+ }
521
660
  function cmdGo(db, query) {
522
661
  const all = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC").all();
523
662
  if (!all.length) {
@@ -527,12 +666,31 @@ function cmdGo(db, query) {
527
666
  const q = query.trim().toLowerCase();
528
667
  const exact = getProject$2(db, query);
529
668
  if (exact) {
669
+ if (!existsSync(exact.root_path)) {
670
+ const recovered = tryRecoverMovedProject(db, exact);
671
+ if (!recovered) {
672
+ process.exitCode = 1;
673
+ return;
674
+ }
675
+ process.stdout.write(recovered + "\n");
676
+ return;
677
+ }
530
678
  process.stdout.write(exact.root_path + "\n");
531
679
  return;
532
680
  }
533
681
  const partial = all.filter((p) => containsIgnoreCase(p.slug, q) || containsIgnoreCase(p.display_name, q) || containsIgnoreCase(basename(p.root_path), q));
534
682
  if (partial.length === 1) {
535
- process.stdout.write(partial[0].root_path + "\n");
683
+ const p = partial[0];
684
+ if (!existsSync(p.root_path)) {
685
+ const recovered = tryRecoverMovedProject(db, p);
686
+ if (!recovered) {
687
+ process.exitCode = 1;
688
+ return;
689
+ }
690
+ process.stdout.write(recovered + "\n");
691
+ return;
692
+ }
693
+ process.stdout.write(p.root_path + "\n");
536
694
  return;
537
695
  }
538
696
  if (partial.length > 1) {
@@ -563,6 +721,34 @@ function cmdGo(db, query) {
563
721
  }
564
722
  process.exit(1);
565
723
  }
724
+ function cmdRebind(db, slug, newPath) {
725
+ const project = requireProject(db, slug);
726
+ const resolved = resolve(newPath.startsWith("~/") ? join(homedir(), newPath.slice(2)) : newPath);
727
+ if (!existsSync(resolved)) {
728
+ console.error(err(`Path does not exist: ${resolved}`));
729
+ process.exit(1);
730
+ }
731
+ let isDir = false;
732
+ try {
733
+ isDir = statSync(resolved).isDirectory();
734
+ } catch {}
735
+ if (!isDir) {
736
+ console.error(err(`Path is not a directory: ${resolved}`));
737
+ process.exit(1);
738
+ }
739
+ const newEncoded = encodeDir(resolved);
740
+ const conflict = db.prepare("SELECT slug FROM projects WHERE encoded_dir = ? AND id != ?").get(newEncoded, project.id);
741
+ if (conflict) {
742
+ console.error(err(`Path is already registered to project: ${bold(conflict.slug)}\n`) + dim(` ${resolved}\n`) + dim(` Archive or move that project first, or choose a different path.`));
743
+ process.exit(1);
744
+ }
745
+ const ts = now();
746
+ db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(resolved, newEncoded, ts, project.id);
747
+ console.log(ok(`Rebound: ${bold(slug)}`));
748
+ console.log(dim(` Old path: ${project.root_path}`));
749
+ console.log(dim(` New path: ${resolved}`));
750
+ console.log(dim(` Encoded: ${newEncoded}`));
751
+ }
566
752
 
567
753
  //#endregion
568
754
  //#region src/cli/commands/project/session-config.ts
@@ -1099,16 +1285,41 @@ function cmdHealth$1(db, opts) {
1099
1285
  //#endregion
1100
1286
  //#region src/cli/commands/project/projects-index.ts
1101
1287
  function registerProjectsCommands(projectsCmd, getDb) {
1102
- projectsCmd.command("list", { isDefault: true }).description("List registered projects. Short form: pai projects (bare, no subcommand)").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
1288
+ projectsCmd.command("list", { isDefault: true }).description("List registered projects. Short form: pai projects (bare, no subcommand)\nDefault: active projects only. Use --all to include archived.").option("--all", "Include archived projects (default: active only)").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
1103
1289
  cmdList$3(getDb(), opts);
1104
1290
  });
1105
- projectsCmd.command("cd <identifier>").description("cd to a project directory. Short form: pai cd <name>\n(The shell wrapper handles the actual cd; pure output here.)").action((identifier) => {
1106
- const project = resolveIdentifier(getDb(), identifier);
1291
+ projectsCmd.command("cd <identifier>").description("cd to a project directory. Short form: pai cd <name>\n(The shell wrapper handles the actual cd; pure output here.)\nAuto-detects moved projects when the registered path no longer exists.").action((identifier) => {
1292
+ const db = getDb();
1293
+ const project = resolveIdentifier(db, identifier);
1107
1294
  if (!project) {
1108
1295
  console.error(`Project not found: ${identifier}`);
1109
1296
  process.exit(1);
1297
+ return;
1110
1298
  }
1111
- process.stdout.write(project.root_path + "\n");
1299
+ if (existsSync(project.root_path)) {
1300
+ process.stdout.write(project.root_path + "\n");
1301
+ return;
1302
+ }
1303
+ process.stderr.write(warn(`Path not found: ${project.root_path}\n`) + dim(" Searching for moved location...\n"));
1304
+ const result = findMovedPath(project.root_path);
1305
+ if (result.found) {
1306
+ const newPath = result.found;
1307
+ const newEncoded = encodeDir(newPath);
1308
+ const ts = now();
1309
+ db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
1310
+ process.stderr.write(ok(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim(` → ${newPath}\n`) + ok("Registry updated.\n"));
1311
+ process.stdout.write(newPath + "\n");
1312
+ return;
1313
+ }
1314
+ if (result.ambiguous) {
1315
+ process.stderr.write(warn(`Multiple directories named "${basename(project.root_path)}" found:\n`));
1316
+ for (const candidate of result.ambiguous) process.stderr.write(dim(` ${candidate}\n`));
1317
+ process.stderr.write(dim(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
1318
+ process.exitCode = 1;
1319
+ return;
1320
+ }
1321
+ process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
1322
+ process.exitCode = 1;
1112
1323
  });
1113
1324
  projectsCmd.command("add <path>").description("Register a project directory in the PAI registry").option("--slug <slug>", "Override auto-generated slug").option("--type <type>", "Project type: local | central | obsidian-linked | external", "local").option("--display-name <name>", "Human-readable display name").action((rawPath, opts) => {
1114
1325
  cmdAdd(getDb(), rawPath, opts);
@@ -1125,6 +1336,9 @@ function registerProjectsCommands(projectsCmd, getDb) {
1125
1336
  projectsCmd.command("move <slug> <new-path>").description("Update the root path for a project").action((slug, newPath) => {
1126
1337
  cmdMove(getDb(), slug, newPath);
1127
1338
  });
1339
+ projectsCmd.command("rebind <slug> <new-path>").description("Manually update the root_path for a project (for when auto-detect found multiple matches).\nValidates the new path exists and is a directory, then updates the registry.").action((slug, newPath) => {
1340
+ cmdRebind(getDb(), slug, newPath);
1341
+ });
1128
1342
  projectsCmd.command("tag <slug> <tags...>").description("Add one or more tags to a project").action((slug, tags) => {
1129
1343
  cmdTag$1(getDb(), slug, tags);
1130
1344
  });
@@ -2309,6 +2523,81 @@ function fmtAge(mtime) {
2309
2523
  return `${Math.floor(diffDay / 30)}mo`;
2310
2524
  }
2311
2525
  /**
2526
+ * Filesystem-level UUID scan: walk ALL ~/.claude/projects/<encoded-dir>/<uuid>.jsonl
2527
+ * looking for top-level files whose UUID starts with `prefix`.
2528
+ *
2529
+ * Returns a ScannedSession-like object (minimal fields) for each match, or an empty
2530
+ * array if nothing is found. This is used as a fallback when the regular catalog
2531
+ * (limited to named/recent sessions) doesn't contain the requested UUID.
2532
+ *
2533
+ * Complexity: O(number of project directories + files per dir). Typically <200 dirs
2534
+ * with a handful of top-level jsonl each — fast enough for an interactive CLI.
2535
+ */
2536
+ function scanFilesystemForUuidPrefix(prefix) {
2537
+ if (!existsSync(CLAUDE_PROJECTS_DIR)) return [];
2538
+ const prefixLower = prefix.toLowerCase();
2539
+ const matches = [];
2540
+ let encodedDirs;
2541
+ try {
2542
+ encodedDirs = readdirSync(CLAUDE_PROJECTS_DIR);
2543
+ } catch {
2544
+ return [];
2545
+ }
2546
+ for (const encodedDir of encodedDirs) {
2547
+ const projectDir = join(CLAUDE_PROJECTS_DIR, encodedDir);
2548
+ try {
2549
+ if (!statSync(projectDir).isDirectory()) continue;
2550
+ } catch {
2551
+ continue;
2552
+ }
2553
+ let files;
2554
+ try {
2555
+ files = readdirSync(projectDir);
2556
+ } catch {
2557
+ continue;
2558
+ }
2559
+ for (const file of files) {
2560
+ if (!file.endsWith(".jsonl")) continue;
2561
+ const uuid = file.slice(0, -6);
2562
+ if (!UUID_RE.test(uuid)) continue;
2563
+ if (!uuid.toLowerCase().startsWith(prefixLower)) continue;
2564
+ const topLevelPath = join(projectDir, file);
2565
+ const topInfo = parseTopLevel(topLevelPath);
2566
+ const resumable = topInfo.systemLines > 0;
2567
+ const decodedPath = smartDecodeDir(encodedDir) ?? encodedDir.replace(/-/g, "/");
2568
+ const sessionJsonlPath = join(projectDir, "sessions", `${uuid}.jsonl`);
2569
+ const hasTranscript = existsSync(sessionJsonlPath);
2570
+ const transcript = hasTranscript ? parseTranscript(sessionJsonlPath) : {
2571
+ userLines: 0,
2572
+ lastUserPrompt: "",
2573
+ msgCount: 0,
2574
+ mtime: 0
2575
+ };
2576
+ matches.push({
2577
+ uuid,
2578
+ shortId: uuid.slice(0, 8),
2579
+ encodedDir,
2580
+ decodedPath,
2581
+ topLevelPath,
2582
+ topLevelSystemLines: topInfo.systemLines,
2583
+ topLevelSize: topInfo.size,
2584
+ resumable,
2585
+ sessionStatus: resumable ? "resumable" : "stub",
2586
+ sessionJsonlPath: hasTranscript ? sessionJsonlPath : void 0,
2587
+ userLines: transcript.userLines,
2588
+ lastUserPrompt: transcript.lastUserPrompt,
2589
+ msgCount: transcript.msgCount,
2590
+ aiTitle: transcript.aiTitle,
2591
+ mtime: topInfo.mtime || transcript.mtime,
2592
+ friendlyName: transcript.aiTitle ?? basename(decodedPath),
2593
+ clcDirectory: void 0,
2594
+ registryRootPath: void 0
2595
+ });
2596
+ }
2597
+ }
2598
+ return matches.sort((a, b) => b.mtime - a.mtime);
2599
+ }
2600
+ /**
2312
2601
  * Resolve a name-or-id-or-prefix to a single ScannedSession.
2313
2602
  *
2314
2603
  * Comparisons are case-insensitive; stored casing is preserved in output.
@@ -2316,7 +2605,8 @@ function fmtAge(mtime) {
2316
2605
  * Priority:
2317
2606
  * 1. Exact case-insensitive match on friendlyName
2318
2607
  * 2. Partial case-insensitive match (contains)
2319
- * 3. UUID prefix match
2608
+ * 3. UUID prefix match against the in-memory catalog
2609
+ * 4. UUID prefix match against the full filesystem (fallback for any session)
2320
2610
  */
2321
2611
  function resolveSessionByNameOrId(sessions, query) {
2322
2612
  const qLower = query.toLowerCase().trim();
@@ -2343,7 +2633,18 @@ function resolveSessionByNameOrId(sessions, query) {
2343
2633
  const candidates = byUuid.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
2344
2634
  throw new Error(`UUID prefix "${query}" is ambiguous — ${byUuid.length} matches:\n${candidates}\n\nProvide more characters.`);
2345
2635
  }
2346
- throw new Error(`No session found matching "${query}".\n\nRun: pai sessions to list sessions.\nRun: pai sessions --all to include transcript-only sessions.`);
2636
+ if (/^[0-9a-f-]{4,36}$/i.test(qLower)) {
2637
+ const fsSessions = scanFilesystemForUuidPrefix(qLower);
2638
+ if (fsSessions.length === 1) return {
2639
+ session: fsSessions[0],
2640
+ friendlyName: fsSessions[0].friendlyName
2641
+ };
2642
+ if (fsSessions.length > 1) {
2643
+ const candidates = fsSessions.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
2644
+ throw new Error(`UUID prefix "${query}" is ambiguous — ${fsSessions.length} matches:\n${candidates}\n\nProvide more characters.`);
2645
+ }
2646
+ }
2647
+ throw new Error(`No session found matching "${query}".\n\nRun: pai sessions to list sessions.\nRun: pai sessions --all to include transcript-only sessions.\nRun: pai find <words> to search prompt history.`);
2347
2648
  }
2348
2649
 
2349
2650
  //#endregion
@@ -2425,12 +2726,15 @@ function callAiBroker(method, params = {}, timeoutMs = 8e3) {
2425
2726
  });
2426
2727
  }
2427
2728
  /**
2428
- * Fetch all live iTerm2 sessions from AIBroker.
2729
+ * Fetch all live iTerm2 session metadata from AIBroker via the `sessions` method.
2429
2730
  * Returns an empty array if AIBroker is not running.
2731
+ *
2732
+ * This is metadata-only (no scrollback). It is faster than `session_content`
2733
+ * and the correct source for listing/routing purposes.
2430
2734
  */
2431
2735
  async function fetchLiveSessions() {
2432
2736
  try {
2433
- const sessions = (await callAiBroker("session_content", { lineCount: 0 })).sessions;
2737
+ const sessions = (await callAiBroker("sessions", {})).sessions;
2434
2738
  if (!Array.isArray(sessions)) return [];
2435
2739
  return sessions;
2436
2740
  } catch {
@@ -2470,37 +2774,39 @@ function fmtStatus(status) {
2470
2774
  case "orphan": return chalk.dim("orphan");
2471
2775
  }
2472
2776
  }
2473
- function renderLiveSessions(allLiveSessions) {
2474
- const claudeSessions = allLiveSessions.filter((s) => s.paiName !== null && s.paiName !== void 0 && s.paiName !== "");
2475
- const skippedCount = allLiveSessions.length - claudeSessions.length;
2476
- if (claudeSessions.length === 0) return;
2777
+ function renderLiveSessions(allLiveSessions, allTabs) {
2778
+ const displayed = allTabs ? allLiveSessions : allLiveSessions.filter((s) => s.kind !== "shell");
2779
+ const skippedCount = allLiveSessions.length - displayed.length;
2780
+ if (displayed.length === 0) return;
2477
2781
  console.log("\n" + header("Live Sessions") + "\n");
2478
2782
  const liveHeaders = [
2479
2783
  "#",
2480
- "iTerm2 id",
2784
+ "id",
2481
2785
  "name",
2482
2786
  "at prompt",
2483
- "paiName"
2787
+ "kind"
2484
2788
  ];
2485
- const liveRows = claudeSessions.map((s, idx) => {
2789
+ const liveRows = displayed.map((s, idx) => {
2486
2790
  const shortId = s.sessionId.slice(0, 8);
2487
- const name = s.name.length > 32 ? s.name.slice(0, 31) + "…" : s.name;
2488
- const paiName = s.paiName.length > 20 ? s.paiName.slice(0, 19) + "…" : s.paiName;
2791
+ const rawName = s.paiName ?? s.name;
2792
+ const name = rawName.length > 36 ? rawName.slice(0, 35) + "…" : rawName;
2489
2793
  const atPrompt = s.atPrompt ? chalk.green("yes") : chalk.yellow("busy");
2794
+ const kind = s.kind === "claude" ? chalk.cyan(s.kind) : chalk.dim(s.kind);
2490
2795
  return [
2491
2796
  chalk.dim(String(idx + 1)),
2492
2797
  chalk.cyan(shortId),
2493
2798
  name,
2494
2799
  atPrompt,
2495
- paiName
2800
+ kind
2496
2801
  ];
2497
2802
  });
2498
2803
  console.log(renderTable(liveHeaders, liveRows));
2499
- if (skippedCount > 0) console.log(dim(` (${skippedCount} non-Claude tab${skippedCount === 1 ? "" : "s"} hidden — use pai pause all to see full list)`));
2804
+ if (skippedCount > 0) console.log(dim(` (${skippedCount} shell tab${skippedCount === 1 ? "" : "s"} hidden — use --all-tabs to show)`));
2500
2805
  }
2501
2806
  async function cmdRecent(db, opts) {
2502
2807
  const limit = parseInt(opts.n ?? "20", 10);
2503
2808
  const includeAll = opts.all === true;
2809
+ const allTabs = opts.allTabs === true;
2504
2810
  const liveSessions = await fetchLiveSessions();
2505
2811
  const sessions = scanSessions(db, {
2506
2812
  limit,
@@ -2509,10 +2815,13 @@ async function cmdRecent(db, opts) {
2509
2815
  if (opts.json) {
2510
2816
  const output = {
2511
2817
  live: liveSessions.map((s) => ({
2818
+ index: s.index,
2512
2819
  sessionId: s.sessionId,
2513
2820
  name: s.name,
2514
- paiName: s.paiName ?? null,
2515
- atPrompt: s.atPrompt
2821
+ paiName: s.paiName,
2822
+ atPrompt: s.atPrompt,
2823
+ kind: s.kind,
2824
+ active: s.active
2516
2825
  })),
2517
2826
  paused: sessions.map((s, idx) => ({
2518
2827
  idx: idx + 1,
@@ -2533,8 +2842,8 @@ async function cmdRecent(db, opts) {
2533
2842
  console.log(JSON.stringify(output, null, 2));
2534
2843
  return;
2535
2844
  }
2536
- const hasClaudeLive = liveSessions.some((s) => s.paiName !== null && s.paiName !== void 0 && s.paiName !== "");
2537
- if (liveSessions.length > 0) renderLiveSessions(liveSessions);
2845
+ const hasClaudeLive = liveSessions.some((s) => s.kind === "claude");
2846
+ if (liveSessions.length > 0) renderLiveSessions(liveSessions, allTabs);
2538
2847
  if (sessions.length === 0) {
2539
2848
  if (!hasClaudeLive) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
2540
2849
  else console.log(err("No named sessions found.\n\n Named sessions appear when you have entries in ~/.claude/session.json\n (set via /Name inside Claude Code) or resumable top-level jsonl files.\n Run: pai session recent --all to list all sessions including unnamed orphans."));
@@ -2577,7 +2886,7 @@ async function cmdRecent(db, opts) {
2577
2886
  * Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
2578
2887
  * in stderr). Timeout: 5 seconds.
2579
2888
  */
2580
- function probeResume(uuid, cwd) {
2889
+ function probeResume$1(uuid, cwd) {
2581
2890
  const result = spawnSync("claude", [
2582
2891
  "--resume",
2583
2892
  uuid,
@@ -2618,7 +2927,9 @@ function cmdGoto(db, query, opts) {
2618
2927
  try {
2619
2928
  resolved = resolveSessionByNameOrId(allSessions, query);
2620
2929
  } catch (resolveErr) {
2621
- console.error(err(String(resolveErr).replace(/^Error: /, "")));
2930
+ let msg = String(resolveErr).replace(/^Error: /, "");
2931
+ if (msg.includes("No session found matching")) msg += `\n\nTip: pai find "${query}" — search prompt history by keywords`;
2932
+ console.error(err(msg));
2622
2933
  process.exit(1);
2623
2934
  }
2624
2935
  const { session: matchedSession, friendlyName } = resolved;
@@ -2670,7 +2981,7 @@ function cmdGoto(db, query, opts) {
2670
2981
  return;
2671
2982
  }
2672
2983
  if (resumableUuid) {
2673
- const probe = probeResume(resumableUuid, projectDir);
2984
+ const probe = probeResume$1(resumableUuid, projectDir);
2674
2985
  if (probe.ok) {
2675
2986
  const result = spawnSync("claude", [
2676
2987
  "--resume",
@@ -3006,7 +3317,7 @@ async function cmdPauseAll(opts) {
3006
3317
  process.exitCode = 1;
3007
3318
  return;
3008
3319
  }
3009
- const claudeSessions = liveSessions.filter((s) => s.paiName !== null && s.paiName !== void 0 && s.paiName !== "");
3320
+ const claudeSessions = liveSessions.filter((s) => s.kind === "claude");
3010
3321
  const skipped = liveSessions.length - claudeSessions.length;
3011
3322
  if (skipped > 0) process.stderr.write(`Skipping ${skipped} non-Claude tab${skipped === 1 ? "" : "s"} (bare shells).\n`);
3012
3323
  if (claudeSessions.length === 0) {
@@ -3067,7 +3378,7 @@ async function cmdPauseAll(opts) {
3067
3378
  else {
3068
3379
  console.log(warn(`${succeeded}/${total} session(s) paused. `) + err(`${failed} failed.`));
3069
3380
  for (const r of results.filter((r) => !r.pauseOk)) {
3070
- const label = r.session.paiName ?? r.session.name;
3381
+ const label = r.session.paiName ?? r.session.name ?? r.session.sessionId.slice(0, 8);
3071
3382
  console.log(err(` ${label}: ${r.error ?? "unknown error"}`));
3072
3383
  }
3073
3384
  }
@@ -3081,7 +3392,7 @@ async function cmdPauseAll(opts) {
3081
3392
  //#endregion
3082
3393
  //#region src/cli/commands/session/sessions-index.ts
3083
3394
  function registerSessionsCommands(sessionsCmd, getDb) {
3084
- sessionsCmd.command("list", { isDefault: true }).description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions (bare, no subcommand)\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--json", "Output raw JSON instead of formatted table").action(async (opts) => {
3395
+ sessionsCmd.command("list", { isDefault: true }).description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions (bare, no subcommand)\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--all-tabs", "Show all iTerm2 tabs in Live Sessions, including bare shells").option("--json", "Output raw JSON instead of formatted table").action(async (opts) => {
3085
3396
  await cmdRecent(getDb(), opts);
3086
3397
  });
3087
3398
  sessionsCmd.command("goto <name-or-id>").description("Go to a session: resume if a resumable snapshot exists, start fresh otherwise.\nRecommended short form: pai resume <name>\nResolves by clc/registry name (case-insensitive) or UUID prefix.").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((nameOrId, opts) => {
@@ -7736,7 +8047,7 @@ function typeColor(type) {
7736
8047
  default: return chalk.white(type);
7737
8048
  }
7738
8049
  }
7739
- function fmtTs(ts) {
8050
+ function fmtTs$2(ts) {
7740
8051
  if (!ts) return dim("—");
7741
8052
  try {
7742
8053
  const d = new Date(ts);
@@ -7790,7 +8101,7 @@ async function cmdList$1(opts) {
7790
8101
  const typeStr = typeColor(obs.type ?? "").padEnd(TYPE_W + (typeColor(obs.type ?? "").length - (obs.type ?? "").length));
7791
8102
  const titleStr = trunc(obs.title ?? "", TITLE_W).padEnd(TITLE_W);
7792
8103
  const projStr = trunc(obs.project_slug ?? "—", PROJ_W).padEnd(PROJ_W);
7793
- const tsStr = fmtTs(obs.created_at);
8104
+ const tsStr = fmtTs$2(obs.created_at);
7794
8105
  console.log(` ${idStr} ${typeStr} ${titleStr} ${projStr} ${dim(tsStr)}`);
7795
8106
  }
7796
8107
  console.log();
@@ -7834,7 +8145,7 @@ async function cmdSearch(query, opts) {
7834
8145
  const type = typeColor(obs.type ?? "");
7835
8146
  const title = bold(obs.title ?? "(untitled)");
7836
8147
  const proj = obs.project_slug ? dim(`[${obs.project_slug}]`) : dim("[—]");
7837
- const ts = dim(fmtTs(obs.created_at));
8148
+ const ts = dim(fmtTs$2(obs.created_at));
7838
8149
  const id = dim(`#${obs.id}`);
7839
8150
  console.log(` ${idx} ${type.padEnd(12)} ${title}`);
7840
8151
  console.log(` ${proj} ${ts} ${id}`);
@@ -7860,7 +8171,7 @@ async function cmdStats$1() {
7860
8171
  console.log(header(" PAI Observation Statistics"));
7861
8172
  console.log();
7862
8173
  console.log(` ${bold("Total observations:")} ${chalk.cyan(String(stats.total ?? 0))}`);
7863
- if (stats.most_recent) console.log(` ${bold("Most recent:")} ${dim(fmtTs(stats.most_recent))}`);
8174
+ if (stats.most_recent) console.log(` ${bold("Most recent:")} ${dim(fmtTs$2(stats.most_recent))}`);
7864
8175
  console.log();
7865
8176
  if (stats.by_type && stats.by_type.length > 0) {
7866
8177
  console.log(bold(" By type:"));
@@ -9030,23 +9341,597 @@ function registerDbCommands(dbCmd) {
9030
9341
  });
9031
9342
  }
9032
9343
 
9344
+ //#endregion
9345
+ //#region src/cli/commands/find.ts
9346
+ /**
9347
+ * pai find <query> [--n=20] [--json]
9348
+ *
9349
+ * Content-based search across ~/.claude/history.jsonl.
9350
+ * Finds user prompts matching the query (case-insensitive substring),
9351
+ * groups by sessionId, sorts by most-recent match, and shows a table
9352
+ * with short UUID, date, project path, and the matching prompt snippet.
9353
+ *
9354
+ * The sessionId from history.jsonl is the same UUID used by claude --resume,
9355
+ * so rows are directly resumable via: pai resume <id>
9356
+ *
9357
+ * Note: older history.jsonl entries (before Claude Code ~2.0) may lack a
9358
+ * sessionId field. These are grouped under a synthetic "no-session" bucket
9359
+ * and shown in a separate note at the bottom.
9360
+ */
9361
+ const HISTORY_FILE$1 = join(homedir(), ".claude", "history.jsonl");
9362
+ function shortenProject$1(p, maxLen = 42) {
9363
+ if (p.length <= maxLen) return p;
9364
+ return "…" + p.slice(-(maxLen - 1));
9365
+ }
9366
+ function fmtTs$1(ts) {
9367
+ const d = new Date(ts);
9368
+ const pad = (n) => String(n).padStart(2, "0");
9369
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
9370
+ }
9371
+ async function searchHistory$1(query, maxResults) {
9372
+ if (!existsSync(HISTORY_FILE$1)) return [];
9373
+ const queryLower = query.toLowerCase();
9374
+ const bySession = /* @__PURE__ */ new Map();
9375
+ const noSession = {
9376
+ sessionId: null,
9377
+ lastMatchTs: 0,
9378
+ lastMatchDisplay: "",
9379
+ project: "",
9380
+ matchCount: 0
9381
+ };
9382
+ const rl = createInterface({
9383
+ input: createReadStream(HISTORY_FILE$1, { encoding: "utf8" }),
9384
+ crlfDelay: Infinity
9385
+ });
9386
+ for await (const line of rl) {
9387
+ const t = line.trim();
9388
+ if (!t) continue;
9389
+ let entry;
9390
+ try {
9391
+ entry = JSON.parse(t);
9392
+ } catch {
9393
+ continue;
9394
+ }
9395
+ const display = entry.display ?? "";
9396
+ if (!display.toLowerCase().includes(queryLower)) continue;
9397
+ const ts = entry.timestamp ?? 0;
9398
+ const project = entry.project ?? "";
9399
+ const sessionId = entry.sessionId ?? null;
9400
+ if (!sessionId) {
9401
+ if (ts > noSession.lastMatchTs) {
9402
+ noSession.lastMatchTs = ts;
9403
+ noSession.lastMatchDisplay = display;
9404
+ noSession.project = project;
9405
+ }
9406
+ noSession.matchCount++;
9407
+ continue;
9408
+ }
9409
+ const existing = bySession.get(sessionId);
9410
+ if (!existing) bySession.set(sessionId, {
9411
+ sessionId,
9412
+ lastMatchTs: ts,
9413
+ lastMatchDisplay: display,
9414
+ project,
9415
+ matchCount: 1
9416
+ });
9417
+ else {
9418
+ existing.matchCount++;
9419
+ if (ts > existing.lastMatchTs) {
9420
+ existing.lastMatchTs = ts;
9421
+ existing.lastMatchDisplay = display;
9422
+ existing.project = project;
9423
+ }
9424
+ }
9425
+ }
9426
+ return [...bySession.values()].sort((a, b) => b.lastMatchTs - a.lastMatchTs).slice(0, maxResults);
9427
+ }
9428
+ async function cmdFind(query, opts) {
9429
+ const maxResults = parseInt(opts.n ?? "20", 10);
9430
+ if (!existsSync(HISTORY_FILE$1)) {
9431
+ console.error(err(`~/.claude/history.jsonl not found.`));
9432
+ process.exitCode = 1;
9433
+ return;
9434
+ }
9435
+ const matches = await searchHistory$1(query, maxResults);
9436
+ if (opts.json) {
9437
+ console.log(JSON.stringify(matches, null, 2));
9438
+ return;
9439
+ }
9440
+ if (matches.length === 0) {
9441
+ console.log(warn(`No sessions found matching "${query}" in ~/.claude/history.jsonl.`));
9442
+ console.log(dim(` Try a shorter or different search term.`));
9443
+ return;
9444
+ }
9445
+ console.log("\n" + header("Session Search Results") + "\n");
9446
+ console.log(dim(` Query: "${query}" (${matches.length} session(s) with matching prompts)\n`));
9447
+ const headers = [
9448
+ "#",
9449
+ "id",
9450
+ "when",
9451
+ "project",
9452
+ "last matching prompt"
9453
+ ];
9454
+ const rows = matches.map((m, idx) => {
9455
+ const shortId = (m.sessionId ?? "no-session").slice(0, 8);
9456
+ const when = m.lastMatchTs > 0 ? fmtTs$1(m.lastMatchTs) : dim("—");
9457
+ const project = shortenProject$1(m.project || dim("—"));
9458
+ const snippet = m.lastMatchDisplay.replace(/\n+/g, " ").trim().slice(0, 48);
9459
+ const display = snippet.length < m.lastMatchDisplay.replace(/\n+/g, " ").trim().length ? `"${snippet}…"` : `"${snippet}"`;
9460
+ return [
9461
+ dim(String(idx + 1)),
9462
+ chalk.cyan(shortId),
9463
+ when,
9464
+ dim(project),
9465
+ chalk.dim(display)
9466
+ ];
9467
+ });
9468
+ console.log(renderTable(headers, rows));
9469
+ console.log();
9470
+ console.log(dim(" Resume: ") + chalk.white("pai resume <id>"));
9471
+ console.log();
9472
+ }
9473
+
9474
+ //#endregion
9475
+ //#region src/cli/lib/history-search.ts
9476
+ /**
9477
+ * history-search.ts
9478
+ *
9479
+ * Content-based search across ~/.claude/history.jsonl.
9480
+ * Streams the file (avoids loading 26k+ lines into memory), groups matching
9481
+ * lines by sessionId, and returns results sorted by most-recent match.
9482
+ *
9483
+ * Used by: pai <query> (main resolver) and pai find (compat alias)
9484
+ */
9485
+ const HISTORY_FILE = join(homedir(), ".claude", "history.jsonl");
9486
+ /**
9487
+ * Search ~/.claude/history.jsonl for prompts matching the query (case-insensitive
9488
+ * substring). Results are grouped by sessionId and sorted by most-recent match.
9489
+ *
9490
+ * Entries without a sessionId (old Claude Code versions) are excluded from
9491
+ * results since they can't be resumed.
9492
+ */
9493
+ async function searchHistory(query, maxResults) {
9494
+ if (!existsSync(HISTORY_FILE)) return [];
9495
+ const queryLower = query.toLowerCase();
9496
+ const bySession = /* @__PURE__ */ new Map();
9497
+ const rl = createInterface({
9498
+ input: createReadStream(HISTORY_FILE, { encoding: "utf8" }),
9499
+ crlfDelay: Infinity
9500
+ });
9501
+ for await (const line of rl) {
9502
+ const t = line.trim();
9503
+ if (!t) continue;
9504
+ let entry;
9505
+ try {
9506
+ entry = JSON.parse(t);
9507
+ } catch {
9508
+ continue;
9509
+ }
9510
+ const display = entry.display ?? "";
9511
+ if (!display.toLowerCase().includes(queryLower)) continue;
9512
+ const ts = entry.timestamp ?? 0;
9513
+ const project = entry.project ?? "";
9514
+ const sessionId = entry.sessionId ?? null;
9515
+ if (!sessionId) continue;
9516
+ const existing = bySession.get(sessionId);
9517
+ if (!existing) bySession.set(sessionId, {
9518
+ sessionId,
9519
+ lastMatchTs: ts,
9520
+ lastMatchDisplay: display,
9521
+ project,
9522
+ matchCount: 1
9523
+ });
9524
+ else {
9525
+ existing.matchCount++;
9526
+ if (ts > existing.lastMatchTs) {
9527
+ existing.lastMatchTs = ts;
9528
+ existing.lastMatchDisplay = display;
9529
+ existing.project = project;
9530
+ }
9531
+ }
9532
+ }
9533
+ return [...bySession.values()].sort((a, b) => b.lastMatchTs - a.lastMatchTs).slice(0, maxResults);
9534
+ }
9535
+
9536
+ //#endregion
9537
+ //#region src/cli/commands/main-resolver.ts
9538
+ function probeResume(uuid, cwd) {
9539
+ const result = spawnSync("claude", [
9540
+ "--resume",
9541
+ uuid,
9542
+ "--print",
9543
+ "--output-format=json",
9544
+ "_"
9545
+ ], {
9546
+ cwd,
9547
+ timeout: 5e3,
9548
+ env: process.env,
9549
+ stdio: [
9550
+ "ignore",
9551
+ "ignore",
9552
+ "pipe"
9553
+ ]
9554
+ });
9555
+ if (result.error) return {
9556
+ ok: false,
9557
+ reason: `spawn error: ${result.error.message}`
9558
+ };
9559
+ const stderr = result.stderr?.toString("utf8") ?? "";
9560
+ if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
9561
+ ok: false,
9562
+ reason: "No conversation found for this UUID"
9563
+ };
9564
+ if (result.status !== 0) return {
9565
+ ok: false,
9566
+ reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
9567
+ };
9568
+ return { ok: true };
9569
+ }
9570
+ /**
9571
+ * Launch Claude for the given session. Handles resume probe + fallback to fresh.
9572
+ * Never returns on success (process.exit inside spawnSync block).
9573
+ */
9574
+ function launchSession(session, allSessions, dryRun) {
9575
+ let resumableUuid;
9576
+ let resumableSession;
9577
+ if (session.resumable) {
9578
+ resumableUuid = session.uuid;
9579
+ resumableSession = session;
9580
+ } else if (session.encodedDir) {
9581
+ const sameProject = allSessions.filter((s) => s.encodedDir === session.encodedDir && s.resumable);
9582
+ sameProject.sort((a, b) => b.mtime - a.mtime);
9583
+ if (sameProject.length > 0) {
9584
+ resumableSession = sameProject[0];
9585
+ resumableUuid = resumableSession.uuid;
9586
+ }
9587
+ }
9588
+ const rawDir = session.clcDirectory ?? session.registryRootPath ?? session.decodedPath;
9589
+ let projectDir;
9590
+ try {
9591
+ projectDir = realpathSync(rawDir);
9592
+ } catch {
9593
+ console.error(err(`Session directory does not exist or cannot be resolved.\n Path: ${rawDir}\n The directory may have moved or been deleted.`));
9594
+ process.exit(1);
9595
+ return;
9596
+ }
9597
+ const name = session.friendlyName ?? session.shortId;
9598
+ const promptArg = `/Name ${name}\ngo`;
9599
+ if (dryRun) {
9600
+ if (resumableUuid) {
9601
+ console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
9602
+ console.log(` cwd: ${chalk.cyan(projectDir)}`);
9603
+ console.log(` probe: claude --resume ${resumableUuid} --print --output-format=json "_"`);
9604
+ console.log(` argv: claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`);
9605
+ console.log(` fallback: claude --name "${name}" "/Name ${name}\\ngo"`);
9606
+ } else {
9607
+ console.log("\n" + chalk.bold("Dry run — would exec (FRESH path):") + "\n");
9608
+ console.log(` cwd: ${chalk.cyan(projectDir)}`);
9609
+ console.log(` argv: claude --name "${name}" "/Name ${name}\\ngo"`);
9610
+ }
9611
+ console.log();
9612
+ return;
9613
+ }
9614
+ if (resumableUuid) {
9615
+ const probe = probeResume(resumableUuid, projectDir);
9616
+ if (probe.ok) {
9617
+ const result = spawnSync("claude", [
9618
+ "--resume",
9619
+ resumableUuid,
9620
+ "--name",
9621
+ name,
9622
+ promptArg
9623
+ ], {
9624
+ cwd: projectDir,
9625
+ stdio: "inherit",
9626
+ env: process.env
9627
+ });
9628
+ if (result.error) {
9629
+ console.error(err(`Failed to launch claude: ${result.error.message}`));
9630
+ process.exit(1);
9631
+ }
9632
+ process.exit(result.status ?? 0);
9633
+ } else {
9634
+ process.stderr.write(chalk.yellow(`\n Resume failed for ${resumableUuid.slice(0, 8)}: ${probe.reason ?? "unknown error"}\n Starting fresh session in same directory.\n\n`));
9635
+ const result = spawnSync("claude", [
9636
+ "--name",
9637
+ name,
9638
+ promptArg
9639
+ ], {
9640
+ cwd: projectDir,
9641
+ stdio: "inherit",
9642
+ env: process.env
9643
+ });
9644
+ if (result.error) {
9645
+ console.error(err(`Failed to launch claude: ${result.error.message}`));
9646
+ process.exit(1);
9647
+ }
9648
+ process.exit(result.status ?? 0);
9649
+ }
9650
+ } else {
9651
+ const result = spawnSync("claude", [
9652
+ "--name",
9653
+ name,
9654
+ promptArg
9655
+ ], {
9656
+ cwd: projectDir,
9657
+ stdio: "inherit",
9658
+ env: process.env
9659
+ });
9660
+ if (result.error) {
9661
+ console.error(err(`Failed to launch claude: ${result.error.message}`));
9662
+ process.exit(1);
9663
+ }
9664
+ process.exit(result.status ?? 0);
9665
+ }
9666
+ }
9667
+ /**
9668
+ * Given a SessionMatch from history search, find the corresponding ScannedSession
9669
+ * (for launch). Falls back to a minimal synthetic session using the decodedPath
9670
+ * from the history entry's project field.
9671
+ */
9672
+ function matchToSession(match, allSessions) {
9673
+ if (!match.sessionId) return null;
9674
+ const catalogMatch = allSessions.find((s) => s.uuid === match.sessionId);
9675
+ if (catalogMatch) return catalogMatch;
9676
+ if (!match.project) return null;
9677
+ return {
9678
+ uuid: match.sessionId,
9679
+ shortId: match.sessionId.slice(0, 8),
9680
+ encodedDir: "",
9681
+ decodedPath: match.project,
9682
+ topLevelPath: "",
9683
+ topLevelSystemLines: 0,
9684
+ topLevelSize: 0,
9685
+ resumable: false,
9686
+ sessionStatus: "transcript-only",
9687
+ sessionJsonlPath: void 0,
9688
+ userLines: 0,
9689
+ lastUserPrompt: match.lastMatchDisplay.slice(0, 80),
9690
+ msgCount: 0,
9691
+ mtime: match.lastMatchTs,
9692
+ friendlyName: void 0,
9693
+ clcDirectory: void 0,
9694
+ registryRootPath: match.project
9695
+ };
9696
+ }
9697
+ function fmtTs(ts) {
9698
+ const d = new Date(ts);
9699
+ const pad = (n) => String(n).padStart(2, "0");
9700
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
9701
+ }
9702
+ function shortenProject(p, maxLen = 44) {
9703
+ if (!p || p.length <= maxLen) return p || dim("—");
9704
+ return "…" + p.slice(-(maxLen - 1));
9705
+ }
9706
+ async function askForChoice(max) {
9707
+ return new Promise((resolve) => {
9708
+ const rl = createInterface({
9709
+ input: process.stdin,
9710
+ output: process.stdout
9711
+ });
9712
+ rl.question(dim(`\n Enter # to launch (1-${max}), or press Enter to cancel: `), (answer) => {
9713
+ rl.close();
9714
+ const n = parseInt(answer.trim(), 10);
9715
+ if (!isNaN(n) && n >= 1 && n <= max) resolve(n);
9716
+ else resolve(null);
9717
+ });
9718
+ });
9719
+ }
9720
+ async function cmdMain(db, query, pickN, opts) {
9721
+ const maxResults = parseInt(opts.n ?? "20", 10);
9722
+ const allSessions = scanSessions(db, {
9723
+ limit: 500,
9724
+ filter: "named"
9725
+ });
9726
+ if (!query) {
9727
+ let liveSessions = [];
9728
+ try {
9729
+ liveSessions = await fetchLiveSessions();
9730
+ } catch {}
9731
+ const claudeLive = liveSessions.filter((s) => s.kind !== "shell");
9732
+ const recentDisk = allSessions.slice(0, maxResults);
9733
+ if (claudeLive.length === 0 && recentDisk.length === 0) {
9734
+ console.log(warn("No sessions found. Start Claude Code in a project directory first."));
9735
+ return;
9736
+ }
9737
+ if (claudeLive.length > 0) {
9738
+ console.log("\n" + header("Live Sessions") + "\n");
9739
+ const liveHeaders = [
9740
+ "#",
9741
+ "id",
9742
+ "name",
9743
+ "at prompt"
9744
+ ];
9745
+ const liveRows = claudeLive.map((s, i) => [
9746
+ dim(String(i + 1)),
9747
+ chalk.cyan(s.sessionId.slice(0, 8)),
9748
+ s.paiName ?? s.name ?? dim("—"),
9749
+ s.atPrompt ? chalk.green("yes") : chalk.dim("no")
9750
+ ]);
9751
+ console.log(renderTable(liveHeaders, liveRows));
9752
+ }
9753
+ if (recentDisk.length > 0) {
9754
+ console.log("\n" + header("Recent Sessions") + "\n");
9755
+ const diskHeaders = [
9756
+ "#",
9757
+ "id",
9758
+ "age",
9759
+ "project",
9760
+ "last prompt"
9761
+ ];
9762
+ const diskRows = recentDisk.map((s, i) => {
9763
+ const snippet = s.lastUserPrompt.replace(/\n+/g, " ").trim().slice(0, 40);
9764
+ return [
9765
+ dim(String(i + 1)),
9766
+ chalk.cyan(s.shortId),
9767
+ dim(fmtAge(s.mtime)),
9768
+ dim(shortenProject(s.friendlyName ?? s.decodedPath, 30)),
9769
+ chalk.dim(snippet ? `"${snippet}"` : "—")
9770
+ ];
9771
+ });
9772
+ console.log(renderTable(diskHeaders, diskRows));
9773
+ }
9774
+ console.log();
9775
+ console.log(dim(" Resume a session: ") + chalk.white("pai <topic>") + dim(" or ") + chalk.white("pai <id>"));
9776
+ console.log();
9777
+ return;
9778
+ }
9779
+ if (/^[0-9a-f-]{8,36}$/i.test(query)) {
9780
+ const byUuid = allSessions.filter((s) => s.uuid.startsWith(query.toLowerCase()));
9781
+ if (byUuid.length === 1) {
9782
+ launchSession(byUuid[0], allSessions, opts.dryRun ?? false);
9783
+ return;
9784
+ }
9785
+ if (byUuid.length > 1) {
9786
+ console.error(err(`UUID prefix "${query}" is ambiguous — ${byUuid.length} catalog matches.`));
9787
+ process.exitCode = 1;
9788
+ return;
9789
+ }
9790
+ }
9791
+ {
9792
+ const qLower = query.toLowerCase();
9793
+ const byExact = allSessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase() === qLower);
9794
+ if (byExact.length >= 1) {
9795
+ launchSession(byExact[0], allSessions, opts.dryRun ?? false);
9796
+ return;
9797
+ }
9798
+ const byPartial = allSessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase().includes(qLower));
9799
+ if (byPartial.length === 1) {
9800
+ launchSession(byPartial[0], allSessions, opts.dryRun ?? false);
9801
+ return;
9802
+ }
9803
+ if (byPartial.length > 1) {
9804
+ console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
9805
+ const headers = [
9806
+ "#",
9807
+ "id",
9808
+ "age",
9809
+ "name",
9810
+ "project"
9811
+ ];
9812
+ const rows = byPartial.slice(0, maxResults).map((s, i) => [
9813
+ dim(String(i + 1)),
9814
+ chalk.cyan(s.shortId),
9815
+ dim(fmtAge(s.mtime)),
9816
+ s.friendlyName ?? dim("—"),
9817
+ dim(shortenProject(s.decodedPath, 36))
9818
+ ]);
9819
+ console.log(renderTable(headers, rows));
9820
+ console.log();
9821
+ if (pickN !== void 0) {
9822
+ const idx = pickN - 1;
9823
+ if (idx >= 0 && idx < byPartial.length) {
9824
+ launchSession(byPartial[idx], allSessions, opts.dryRun ?? false);
9825
+ return;
9826
+ }
9827
+ console.error(err(`Invalid choice: ${pickN}`));
9828
+ process.exitCode = 1;
9829
+ return;
9830
+ }
9831
+ if (opts.auto) {
9832
+ launchSession(byPartial[0], allSessions, opts.dryRun ?? false);
9833
+ return;
9834
+ }
9835
+ const choice = await askForChoice(Math.min(byPartial.length, maxResults));
9836
+ if (choice !== null) launchSession(byPartial[choice - 1], allSessions, opts.dryRun ?? false);
9837
+ return;
9838
+ }
9839
+ }
9840
+ if (!existsSync(HISTORY_FILE)) {
9841
+ console.error(err("~/.claude/history.jsonl not found."));
9842
+ console.error(dim(" No prompt history available for search."));
9843
+ process.exitCode = 1;
9844
+ return;
9845
+ }
9846
+ process.stderr.write(dim(` Searching prompt history for "${query}"...\n`));
9847
+ const matches = await searchHistory(query, maxResults);
9848
+ if (matches.length === 0) {
9849
+ console.log(warn(`No sessions found matching "${query}".`));
9850
+ console.log(dim(` Try a shorter or different search term.`));
9851
+ console.log(dim(` Or run: `) + chalk.white("pai") + dim(" (no args) to see all recent sessions."));
9852
+ return;
9853
+ }
9854
+ console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
9855
+ const headers = [
9856
+ "#",
9857
+ "id",
9858
+ "when",
9859
+ "project",
9860
+ "last matching prompt"
9861
+ ];
9862
+ const rows = matches.map((m, idx) => {
9863
+ const shortId = (m.sessionId ?? "—").slice(0, 8);
9864
+ const when = m.lastMatchTs > 0 ? fmtTs(m.lastMatchTs) : dim("—");
9865
+ const project = shortenProject(m.project || "—");
9866
+ const snippet = m.lastMatchDisplay.replace(/\n+/g, " ").trim().slice(0, 48);
9867
+ const fullSnippet = m.lastMatchDisplay.replace(/\n+/g, " ").trim();
9868
+ const display = snippet.length < fullSnippet.length ? `"${snippet}…"` : `"${snippet}"`;
9869
+ return [
9870
+ dim(String(idx + 1)),
9871
+ chalk.cyan(shortId),
9872
+ when,
9873
+ dim(project),
9874
+ chalk.dim(display)
9875
+ ];
9876
+ });
9877
+ console.log(renderTable(headers, rows));
9878
+ console.log();
9879
+ if (pickN !== void 0) {
9880
+ const idx = pickN - 1;
9881
+ if (idx >= 0 && idx < matches.length) {
9882
+ const session = matchToSession(matches[idx], allSessions);
9883
+ if (!session) {
9884
+ console.error(err("Could not resolve session for launch (no project path)."));
9885
+ process.exitCode = 1;
9886
+ return;
9887
+ }
9888
+ launchSession(session, allSessions, opts.dryRun ?? false);
9889
+ return;
9890
+ }
9891
+ console.error(err(`Invalid choice: ${pickN}`));
9892
+ process.exitCode = 1;
9893
+ return;
9894
+ }
9895
+ if (opts.auto) {
9896
+ const session = matchToSession(matches[0], allSessions);
9897
+ if (!session) {
9898
+ console.error(err("Could not resolve session for launch (no project path)."));
9899
+ process.exitCode = 1;
9900
+ return;
9901
+ }
9902
+ launchSession(session, allSessions, opts.dryRun ?? false);
9903
+ return;
9904
+ }
9905
+ const choice = await askForChoice(matches.length);
9906
+ if (choice !== null) {
9907
+ const session = matchToSession(matches[choice - 1], allSessions);
9908
+ if (!session) {
9909
+ console.error(err("Could not resolve session for launch (no project path)."));
9910
+ process.exitCode = 1;
9911
+ return;
9912
+ }
9913
+ launchSession(session, allSessions, opts.dryRun ?? false);
9914
+ }
9915
+ }
9916
+
9033
9917
  //#endregion
9034
9918
  //#region src/cli/index.ts
9035
9919
  /**
9036
- * PAI Knowledge OS — CLI entry point
9920
+ * PAI Knowledge OS — CLI entry point (v0.10.0 topic-first redesign)
9037
9921
  *
9038
- * Command tree:
9039
- * pai sessions list|goto|pause|info|rename|... (canonical plural namespace)
9040
- * pai projects list|cd|add|info|... (canonical plural namespace)
9041
- * pai registry scan|migrate|stats|rebuild
9042
- * pai memory index|search|status
9043
- * pai search <query> (placeholder Phase 3)
9044
- * pai update
9045
- * pai version
9922
+ * Top-level surface:
9923
+ * pai recent sessions picker
9924
+ * pai <topic> → history search + candidate picker + launch
9925
+ * pai <uuid-prefix> → direct session resume via filesystem scan
9926
+ * pai cd <name> → cd to project directory (no Claude launch)
9927
+ * pai pause [all] → save state (or mass-pause every live session)
9928
+ * pai end → finalize session
9046
9929
  *
9047
- * Daily verb shortcuts (top-level):
9048
- * pai pause / pai end / pai resume / pai cd
9049
- * pai sessions / pai projects / pai notes
9930
+ * Power-user namespaces (still accessible, hidden from main help):
9931
+ * pai sessions ... → full session management
9932
+ * pai projects ... → project management
9933
+ * pai registry ... → registry maintenance
9934
+ * pai memory ... → memory engine
9050
9935
  */
9051
9936
  function getVersion() {
9052
9937
  try {
@@ -9068,20 +9953,23 @@ function getDb() {
9068
9953
  }
9069
9954
  const program = new Command();
9070
9955
  program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit").addHelpText("after", `
9071
- Daily verbs (short forms):
9072
- pai pause Save state + display safe-exit reminder
9073
- pai end Finalize session: pause + mark note Completed
9074
- pai resume <name> Go to a session (resume or start fresh)
9075
- pai cd <name> cd to a project directory
9956
+ Usage:
9957
+ pai Show recent sessions (interactive picker)
9958
+ pai <topic> Find sessions by topic and launch the chosen one
9959
+ pai <uuid-prefix> Resume a specific session by UUID
9960
+ pai cd <name> cd to a project directory (no Claude launch)
9961
+ pai pause [all] Save state (or mass-pause every live session)
9962
+ pai end Finalize session: save state + mark note Completed
9076
9963
 
9077
- Listings:
9078
- pai sessions Resumable sessions catalog
9079
- pai projects Known project directories
9080
- pai notes Markdown session notes
9964
+ Examples:
9965
+ pai mdf Find all sessions where you worked on MDF
9966
+ pai solar panels Free-text search across your prompt history
9967
+ pai 81c5c3dc Resume session by UUID prefix
9968
+ pai See the 20 most recent sessions
9081
9969
 
9082
- Subcommands (power users):
9083
- pai sessions ... Session management (list, goto, pause, end, ...)
9084
- pai projects ... Project management (cd, list, ...)
9970
+ Power-user namespaces (run "pai sessions --help" etc. for details):
9971
+ pai sessions ... Full session management (list, goto, info, ...)
9972
+ pai projects ... Project management (cd, rebind, list, ...)
9085
9973
  pai registry ... Registry maintenance (scan, ...)
9086
9974
  pai memory ... Memory engine (index, search, ...)
9087
9975
 
@@ -9106,9 +9994,6 @@ registerDbCommands(program.command("db").description("Database inspection: query
9106
9994
  registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
9107
9995
  registerZettelCommands(program.command("zettel").description("Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest"), getDb);
9108
9996
  registerObservationCommands(program.command("observation").description("Observation capture: list, search, and stats"));
9109
- program.command("go <query>").description("Jump to a project directory by slug or partial name.\nPrints the root path to stdout — use with: cd $(pai go <query>)\nExample shell function in ~/.zshrc:\n pcd() { cd \"$(pai go \"$@\")\" }").action((query) => {
9110
- cmdGo(getDb(), query);
9111
- });
9112
9997
  program.command("pause [target]").description("Save state and display safe-exit instructions for the current session.\nWrites a ## Continue checkpoint to the project's TODO.md.\nUse `pai pause all` to pause every live session via AIBroker.\nLong form: pai sessions pause").option("--dry-run", "Preview changes without writing them").option("--exit", "(pause all only) Also send /exit to each session after pausing").option("--wait <ms>", "(pause all only) Milliseconds to wait before /exit (default: 5000)", "5000").action(async (target, opts) => {
9113
9998
  if (target === "all") await cmdPauseAll({
9114
9999
  exit: opts.exit,
@@ -9127,22 +10012,50 @@ program.command("pause [target]").description("Save state and display safe-exit
9127
10012
  program.command("end").description("Finalize a session: save state, mark note Completed, display safe-exit instructions.\nLong form: pai sessions end").option("--dry-run", "Preview all changes without writing them").action((opts) => {
9128
10013
  cmdEnd(getDb(), opts);
9129
10014
  });
9130
- program.command("resume <name>").description("Go to a session by name: resume if resumable, start fresh otherwise.\nLong form: pai sessions goto <name>").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
10015
+ program.command("resume <name>").description("Go to a session by name or UUID: resume if resumable, start fresh otherwise.\nAlso: pai <name> (shorter) | pai sessions goto <name> (long form)").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
9131
10016
  cmdGoto(getDb(), name, { dryRun: opts.dryRun });
9132
10017
  });
9133
- program.command("cd <identifier>").description("cd to a project directory (shell wrapper handles the actual cd).\nLong form: pai projects cd <identifier>\nThe shell function installed by pai shell-init intercepts this command\nand calls builtin cd with the resolved path.").action((identifier) => {
9134
- const project = resolveIdentifier(getDb(), identifier);
10018
+ program.command("cd <identifier>").description("cd to a project directory (shell wrapper handles the actual cd).\nLong form: pai projects cd <identifier>\nThe shell function installed by pai shell-init intercepts this command\nand calls builtin cd with the resolved path.\nAuto-detects moved projects when the registered path no longer exists.").action((identifier) => {
10019
+ const db = getDb();
10020
+ const project = resolveIdentifier(db, identifier);
9135
10021
  if (!project) {
9136
10022
  console.error(`Project not found: ${identifier}`);
9137
10023
  process.exit(1);
10024
+ return;
10025
+ }
10026
+ if (existsSync(project.root_path)) {
10027
+ process.stdout.write(project.root_path + "\n");
10028
+ return;
9138
10029
  }
9139
- process.stdout.write(project.root_path + "\n");
10030
+ process.stderr.write(warn(`Path not found: ${project.root_path}\n`) + dim(" Searching for moved location...\n"));
10031
+ const result = findMovedPath(project.root_path);
10032
+ if (result.found) {
10033
+ const newPath = result.found;
10034
+ const newEncoded = encodeDir(newPath);
10035
+ const ts = now();
10036
+ db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
10037
+ process.stderr.write(ok(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim(` → ${newPath}\n`) + ok("Registry updated.\n"));
10038
+ process.stdout.write(newPath + "\n");
10039
+ return;
10040
+ }
10041
+ if (result.ambiguous) {
10042
+ process.stderr.write(warn(`Multiple directories named "${basename(project.root_path)}" found:\n`));
10043
+ for (const candidate of result.ambiguous) process.stderr.write(dim(` ${candidate}\n`));
10044
+ process.stderr.write(dim(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
10045
+ process.exitCode = 1;
10046
+ return;
10047
+ }
10048
+ process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
10049
+ process.exitCode = 1;
9140
10050
  });
9141
10051
  program.command("notes [project-slug]").description("Markdown session notes — list notes, optionally filtered to a project.").option("--limit <n>", "Maximum number of notes to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
9142
10052
  cmdList$2(getDb(), projectSlug, opts);
9143
10053
  }).command("list [project-slug]").description("List markdown session notes (same as: pai notes)").option("--limit <n>", "Maximum number of notes to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
9144
10054
  cmdList$2(getDb(), projectSlug, opts);
9145
10055
  });
10056
+ program.command("find <query>").description("Search prompt history for matching sessions.\nSame as: pai <query> (the default command does history search too)").option("-n, --n <count>", "Maximum number of sessions to show", "20").option("--json", "Output as JSON array").action(async (query, opts) => {
10057
+ await cmdFind(query, opts);
10058
+ });
9146
10059
  program.command("shell-init").description("Emit shell integration code. Add to ~/.zshrc:\n eval \"$(pai shell-init)\"").action(() => {
9147
10060
  process.stdout.write(`# PAI shell integration — generated by: pai shell-init
9148
10061
  pai() {
@@ -9170,8 +10083,12 @@ pai() {
9170
10083
  }
9171
10084
  `);
9172
10085
  });
9173
- program.command("search <query>").description("Full-text search across sessions and notes (Phase 3)").option("--projects <p1,p2>", "Restrict search to these project slugs (comma-separated)").option("--limit <n>", "Maximum results to return", "10").action((query, opts) => {
9174
- console.log(`\n Search is coming in Phase 3.\n\n Query: ${query}\n Projects: ${opts.projects ?? "(all)"}\n Limit: ${opts.limit ?? 10}\n`);
10086
+ program.command("query [query] [pick]", {
10087
+ isDefault: true,
10088
+ hidden: true
10089
+ }).description("Find and launch a session by topic, name, or UUID.\nNo arg → recent sessions picker\nTopic → history search → candidate list → pick #\nUUID → direct resume via filesystem scan").option("-y, --auto", "Auto-pick #1 without prompting").option("--dry-run", "Print what would happen without launching").option("-n, --n <count>", "Max candidates for history search", "20").action(async (query, pick, opts) => {
10090
+ const pickN = pick !== void 0 ? parseInt(pick, 10) : void 0;
10091
+ await cmdMain(getDb(), query, pickN, opts);
9175
10092
  });
9176
10093
  program.configureOutput({ writeErr: (str) => process.stderr.write(err(str)) });
9177
10094
  program.exitOverride((error) => {
@@ -9181,7 +10098,6 @@ program.exitOverride((error) => {
9181
10098
  process.exit(1);
9182
10099
  });
9183
10100
  program.parse(process.argv);
9184
- if (process.argv.length <= 2) program.help();
9185
10101
 
9186
10102
  //#endregion
9187
10103
  export { };