@tekmidian/pai 0.9.15 → 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.
Files changed (26) hide show
  1. package/README.md +123 -2
  2. package/dist/cli/index.mjs +993 -73
  3. package/dist/cli/index.mjs.map +1 -1
  4. package/dist/daemon/index.mjs +5 -5
  5. package/dist/{daemon-B-vBWU1i.mjs → daemon-CSKj8Xm9.mjs} +9 -9
  6. package/dist/{daemon-B-vBWU1i.mjs.map → daemon-CSKj8Xm9.mjs.map} +1 -1
  7. package/dist/{helpers-OCVFgprQ.mjs → helpers-crDEr6S2.mjs} +3 -3
  8. package/dist/helpers-crDEr6S2.mjs.map +1 -0
  9. package/dist/hooks/stop-hook.mjs +69 -0
  10. package/dist/hooks/stop-hook.mjs.map +2 -2
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -2
  13. package/dist/{indexer-backend-BrrCnasS.mjs → indexer-backend-DcYThufd.mjs} +2 -2
  14. package/dist/{indexer-backend-BrrCnasS.mjs.map → indexer-backend-DcYThufd.mjs.map} +1 -1
  15. package/dist/{kg-extraction-r5KkTmWY.mjs → kg-extraction-iR1BKKWK.mjs} +37 -3
  16. package/dist/kg-extraction-iR1BKKWK.mjs.map +1 -0
  17. package/dist/{sync-CdHSL9Kc.mjs → sync-CmBKOL3K.mjs} +2 -2
  18. package/dist/{sync-CdHSL9Kc.mjs.map → sync-CmBKOL3K.mjs.map} +1 -1
  19. package/dist/{vault-indexer-DsPC6wQf.mjs → vault-indexer-vNnL3Azb.mjs} +2 -2
  20. package/dist/{vault-indexer-DsPC6wQf.mjs.map → vault-indexer-vNnL3Azb.mjs.map} +1 -1
  21. package/dist/{work-queue-worker-94INtUiz.mjs → work-queue-worker-BxsBtz6j.mjs} +2 -2
  22. package/dist/{work-queue-worker-94INtUiz.mjs.map → work-queue-worker-BxsBtz6j.mjs.map} +1 -1
  23. package/package.json +1 -1
  24. package/src/hooks/ts/stop/stop-hook.ts +80 -0
  25. package/dist/helpers-OCVFgprQ.mjs.map +0 -1
  26. package/dist/kg-extraction-r5KkTmWY.mjs.map +0 -1
@@ -4,19 +4,19 @@ import { _ as warn, a as fmtDate, c as ok, d as scaffoldProjectDirs, f as shorte
4
4
  import { a as slugify$1, i as parseSessionFilename } from "../migrate-fLD6rAdO.mjs";
5
5
  import { n as ensurePaiMarker } from "../pai-marker-CXQPX2P6.mjs";
6
6
  import { n as openFederation } from "../db-CYmBWcjh.mjs";
7
- import "../helpers-OCVFgprQ.mjs";
8
- import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-CdHSL9Kc.mjs";
7
+ import "../helpers-crDEr6S2.mjs";
8
+ import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-CmBKOL3K.mjs";
9
9
  import "../embeddings-DGRAPAYb.mjs";
10
10
  import { t as STOP_WORDS } from "../stop-words-BaMEGVeY.mjs";
11
11
  import { n as populateSlugs, r as searchMemory } from "../search-i2nlQ-JM.mjs";
12
12
  import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "../detect-CdaA48EI.mjs";
13
- import { a as resolveHome, c as upsertSession, n as cmdScan, o as saveScanConfig, r as loadScanConfig, s as upsertProject, t as extractAndStoreTriples } from "../kg-extraction-r5KkTmWY.mjs";
13
+ import { a as resolveHome, c as upsertSession, n as cmdScan, o as saveScanConfig, r as loadScanConfig, s as upsertProject, t as extractAndStoreTriples } from "../kg-extraction-iR1BKKWK.mjs";
14
14
  import "../indexer-D7MvSQPY.mjs";
15
15
  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,34 +2774,39 @@ function fmtStatus(status) {
2470
2774
  case "orphan": return chalk.dim("orphan");
2471
2775
  }
2472
2776
  }
2473
- function renderLiveSessions(liveSessions) {
2474
- if (liveSessions.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;
2475
2781
  console.log("\n" + header("Live Sessions") + "\n");
2476
2782
  const liveHeaders = [
2477
2783
  "#",
2478
- "iTerm2 id",
2784
+ "id",
2479
2785
  "name",
2480
2786
  "at prompt",
2481
- "paiName"
2787
+ "kind"
2482
2788
  ];
2483
- const liveRows = liveSessions.map((s, idx) => {
2789
+ const liveRows = displayed.map((s, idx) => {
2484
2790
  const shortId = s.sessionId.slice(0, 8);
2485
- const name = s.name.length > 32 ? s.name.slice(0, 31) + "…" : s.name;
2486
- const paiName = s.paiName ? s.paiName.length > 20 ? s.paiName.slice(0, 19) + "…" : s.paiName : dim("—");
2791
+ const rawName = s.paiName ?? s.name;
2792
+ const name = rawName.length > 36 ? rawName.slice(0, 35) + "…" : rawName;
2487
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);
2488
2795
  return [
2489
2796
  chalk.dim(String(idx + 1)),
2490
2797
  chalk.cyan(shortId),
2491
2798
  name,
2492
2799
  atPrompt,
2493
- paiName
2800
+ kind
2494
2801
  ];
2495
2802
  });
2496
2803
  console.log(renderTable(liveHeaders, liveRows));
2804
+ if (skippedCount > 0) console.log(dim(` (${skippedCount} shell tab${skippedCount === 1 ? "" : "s"} hidden — use --all-tabs to show)`));
2497
2805
  }
2498
2806
  async function cmdRecent(db, opts) {
2499
2807
  const limit = parseInt(opts.n ?? "20", 10);
2500
2808
  const includeAll = opts.all === true;
2809
+ const allTabs = opts.allTabs === true;
2501
2810
  const liveSessions = await fetchLiveSessions();
2502
2811
  const sessions = scanSessions(db, {
2503
2812
  limit,
@@ -2506,10 +2815,13 @@ async function cmdRecent(db, opts) {
2506
2815
  if (opts.json) {
2507
2816
  const output = {
2508
2817
  live: liveSessions.map((s) => ({
2818
+ index: s.index,
2509
2819
  sessionId: s.sessionId,
2510
2820
  name: s.name,
2511
- paiName: s.paiName ?? null,
2512
- atPrompt: s.atPrompt
2821
+ paiName: s.paiName,
2822
+ atPrompt: s.atPrompt,
2823
+ kind: s.kind,
2824
+ active: s.active
2513
2825
  })),
2514
2826
  paused: sessions.map((s, idx) => ({
2515
2827
  idx: idx + 1,
@@ -2530,9 +2842,10 @@ async function cmdRecent(db, opts) {
2530
2842
  console.log(JSON.stringify(output, null, 2));
2531
2843
  return;
2532
2844
  }
2533
- if (liveSessions.length > 0) renderLiveSessions(liveSessions);
2845
+ const hasClaudeLive = liveSessions.some((s) => s.kind === "claude");
2846
+ if (liveSessions.length > 0) renderLiveSessions(liveSessions, allTabs);
2534
2847
  if (sessions.length === 0) {
2535
- if (liveSessions.length === 0) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
2848
+ if (!hasClaudeLive) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
2536
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."));
2537
2850
  return;
2538
2851
  }
@@ -2563,7 +2876,7 @@ async function cmdRecent(db, opts) {
2563
2876
  ];
2564
2877
  });
2565
2878
  console.log(renderTable(headers, rows));
2566
- console.log("\n" + dim("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (liveSessions.length > 0 ? dim("Pause all live: ") + chalk.white("pai pause all") + "\n" : "") + (includeAll ? "" : dim("Show all sessions: ") + chalk.white("pai sessions --all") + "\n"));
2879
+ console.log("\n" + dim("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (hasClaudeLive ? dim("Pause all live: ") + chalk.white("pai pause all") + "\n" : "") + (includeAll ? "" : dim("Show all sessions: ") + chalk.white("pai sessions --all") + "\n"));
2567
2880
  }
2568
2881
 
2569
2882
  //#endregion
@@ -2573,7 +2886,7 @@ async function cmdRecent(db, opts) {
2573
2886
  * Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
2574
2887
  * in stderr). Timeout: 5 seconds.
2575
2888
  */
2576
- function probeResume(uuid, cwd) {
2889
+ function probeResume$1(uuid, cwd) {
2577
2890
  const result = spawnSync("claude", [
2578
2891
  "--resume",
2579
2892
  uuid,
@@ -2614,7 +2927,9 @@ function cmdGoto(db, query, opts) {
2614
2927
  try {
2615
2928
  resolved = resolveSessionByNameOrId(allSessions, query);
2616
2929
  } catch (resolveErr) {
2617
- 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));
2618
2933
  process.exit(1);
2619
2934
  }
2620
2935
  const { session: matchedSession, friendlyName } = resolved;
@@ -2666,7 +2981,7 @@ function cmdGoto(db, query, opts) {
2666
2981
  return;
2667
2982
  }
2668
2983
  if (resumableUuid) {
2669
- const probe = probeResume(resumableUuid, projectDir);
2984
+ const probe = probeResume$1(resumableUuid, projectDir);
2670
2985
  if (probe.ok) {
2671
2986
  const result = spawnSync("claude", [
2672
2987
  "--resume",
@@ -3002,7 +3317,7 @@ async function cmdPauseAll(opts) {
3002
3317
  process.exitCode = 1;
3003
3318
  return;
3004
3319
  }
3005
- const claudeSessions = liveSessions.filter((s) => s.paiName !== null && s.paiName !== void 0 && s.paiName !== "");
3320
+ const claudeSessions = liveSessions.filter((s) => s.kind === "claude");
3006
3321
  const skipped = liveSessions.length - claudeSessions.length;
3007
3322
  if (skipped > 0) process.stderr.write(`Skipping ${skipped} non-Claude tab${skipped === 1 ? "" : "s"} (bare shells).\n`);
3008
3323
  if (claudeSessions.length === 0) {
@@ -3063,7 +3378,7 @@ async function cmdPauseAll(opts) {
3063
3378
  else {
3064
3379
  console.log(warn(`${succeeded}/${total} session(s) paused. `) + err(`${failed} failed.`));
3065
3380
  for (const r of results.filter((r) => !r.pauseOk)) {
3066
- const label = r.session.paiName ?? r.session.name;
3381
+ const label = r.session.paiName ?? r.session.name ?? r.session.sessionId.slice(0, 8);
3067
3382
  console.log(err(` ${label}: ${r.error ?? "unknown error"}`));
3068
3383
  }
3069
3384
  }
@@ -3077,7 +3392,7 @@ async function cmdPauseAll(opts) {
3077
3392
  //#endregion
3078
3393
  //#region src/cli/commands/session/sessions-index.ts
3079
3394
  function registerSessionsCommands(sessionsCmd, getDb) {
3080
- 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) => {
3081
3396
  await cmdRecent(getDb(), opts);
3082
3397
  });
3083
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) => {
@@ -4697,7 +5012,7 @@ function cmdLogs(opts) {
4697
5012
  }
4698
5013
  function registerDaemonCommands(daemonCmd) {
4699
5014
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
4700
- const { serve } = await import("../daemon-B-vBWU1i.mjs").then((n) => n.t);
5015
+ const { serve } = await import("../daemon-CSKj8Xm9.mjs").then((n) => n.t);
4701
5016
  const { loadConfig: lc, ensureConfigDir } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
4702
5017
  ensureConfigDir();
4703
5018
  await serve(lc());
@@ -7732,7 +8047,7 @@ function typeColor(type) {
7732
8047
  default: return chalk.white(type);
7733
8048
  }
7734
8049
  }
7735
- function fmtTs(ts) {
8050
+ function fmtTs$2(ts) {
7736
8051
  if (!ts) return dim("—");
7737
8052
  try {
7738
8053
  const d = new Date(ts);
@@ -7786,7 +8101,7 @@ async function cmdList$1(opts) {
7786
8101
  const typeStr = typeColor(obs.type ?? "").padEnd(TYPE_W + (typeColor(obs.type ?? "").length - (obs.type ?? "").length));
7787
8102
  const titleStr = trunc(obs.title ?? "", TITLE_W).padEnd(TITLE_W);
7788
8103
  const projStr = trunc(obs.project_slug ?? "—", PROJ_W).padEnd(PROJ_W);
7789
- const tsStr = fmtTs(obs.created_at);
8104
+ const tsStr = fmtTs$2(obs.created_at);
7790
8105
  console.log(` ${idStr} ${typeStr} ${titleStr} ${projStr} ${dim(tsStr)}`);
7791
8106
  }
7792
8107
  console.log();
@@ -7830,7 +8145,7 @@ async function cmdSearch(query, opts) {
7830
8145
  const type = typeColor(obs.type ?? "");
7831
8146
  const title = bold(obs.title ?? "(untitled)");
7832
8147
  const proj = obs.project_slug ? dim(`[${obs.project_slug}]`) : dim("[—]");
7833
- const ts = dim(fmtTs(obs.created_at));
8148
+ const ts = dim(fmtTs$2(obs.created_at));
7834
8149
  const id = dim(`#${obs.id}`);
7835
8150
  console.log(` ${idx} ${type.padEnd(12)} ${title}`);
7836
8151
  console.log(` ${proj} ${ts} ${id}`);
@@ -7856,7 +8171,7 @@ async function cmdStats$1() {
7856
8171
  console.log(header(" PAI Observation Statistics"));
7857
8172
  console.log();
7858
8173
  console.log(` ${bold("Total observations:")} ${chalk.cyan(String(stats.total ?? 0))}`);
7859
- 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))}`);
7860
8175
  console.log();
7861
8176
  if (stats.by_type && stats.by_type.length > 0) {
7862
8177
  console.log(bold(" By type:"));
@@ -9026,23 +9341,597 @@ function registerDbCommands(dbCmd) {
9026
9341
  });
9027
9342
  }
9028
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
+
9029
9917
  //#endregion
9030
9918
  //#region src/cli/index.ts
9031
9919
  /**
9032
- * PAI Knowledge OS — CLI entry point
9920
+ * PAI Knowledge OS — CLI entry point (v0.10.0 topic-first redesign)
9033
9921
  *
9034
- * Command tree:
9035
- * pai sessions list|goto|pause|info|rename|... (canonical plural namespace)
9036
- * pai projects list|cd|add|info|... (canonical plural namespace)
9037
- * pai registry scan|migrate|stats|rebuild
9038
- * pai memory index|search|status
9039
- * pai search <query> (placeholder Phase 3)
9040
- * pai update
9041
- * 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
9042
9929
  *
9043
- * Daily verb shortcuts (top-level):
9044
- * pai pause / pai end / pai resume / pai cd
9045
- * 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
9046
9935
  */
9047
9936
  function getVersion() {
9048
9937
  try {
@@ -9064,20 +9953,23 @@ function getDb() {
9064
9953
  }
9065
9954
  const program = new Command();
9066
9955
  program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit").addHelpText("after", `
9067
- Daily verbs (short forms):
9068
- pai pause Save state + display safe-exit reminder
9069
- pai end Finalize session: pause + mark note Completed
9070
- pai resume <name> Go to a session (resume or start fresh)
9071
- 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
9072
9963
 
9073
- Listings:
9074
- pai sessions Resumable sessions catalog
9075
- pai projects Known project directories
9076
- 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
9077
9969
 
9078
- Subcommands (power users):
9079
- pai sessions ... Session management (list, goto, pause, end, ...)
9080
- 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, ...)
9081
9973
  pai registry ... Registry maintenance (scan, ...)
9082
9974
  pai memory ... Memory engine (index, search, ...)
9083
9975
 
@@ -9102,9 +9994,6 @@ registerDbCommands(program.command("db").description("Database inspection: query
9102
9994
  registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
9103
9995
  registerZettelCommands(program.command("zettel").description("Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest"), getDb);
9104
9996
  registerObservationCommands(program.command("observation").description("Observation capture: list, search, and stats"));
9105
- 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) => {
9106
- cmdGo(getDb(), query);
9107
- });
9108
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) => {
9109
9998
  if (target === "all") await cmdPauseAll({
9110
9999
  exit: opts.exit,
@@ -9123,22 +10012,50 @@ program.command("pause [target]").description("Save state and display safe-exit
9123
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) => {
9124
10013
  cmdEnd(getDb(), opts);
9125
10014
  });
9126
- 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) => {
9127
10016
  cmdGoto(getDb(), name, { dryRun: opts.dryRun });
9128
10017
  });
9129
- 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) => {
9130
- 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);
9131
10021
  if (!project) {
9132
10022
  console.error(`Project not found: ${identifier}`);
9133
10023
  process.exit(1);
10024
+ return;
10025
+ }
10026
+ if (existsSync(project.root_path)) {
10027
+ process.stdout.write(project.root_path + "\n");
10028
+ return;
9134
10029
  }
9135
- 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;
9136
10050
  });
9137
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) => {
9138
10052
  cmdList$2(getDb(), projectSlug, opts);
9139
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) => {
9140
10054
  cmdList$2(getDb(), projectSlug, opts);
9141
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
+ });
9142
10059
  program.command("shell-init").description("Emit shell integration code. Add to ~/.zshrc:\n eval \"$(pai shell-init)\"").action(() => {
9143
10060
  process.stdout.write(`# PAI shell integration — generated by: pai shell-init
9144
10061
  pai() {
@@ -9166,8 +10083,12 @@ pai() {
9166
10083
  }
9167
10084
  `);
9168
10085
  });
9169
- 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) => {
9170
- 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);
9171
10092
  });
9172
10093
  program.configureOutput({ writeErr: (str) => process.stderr.write(err(str)) });
9173
10094
  program.exitOverride((error) => {
@@ -9177,7 +10098,6 @@ program.exitOverride((error) => {
9177
10098
  process.exit(1);
9178
10099
  });
9179
10100
  program.parse(process.argv);
9180
- if (process.argv.length <= 2) program.help();
9181
10101
 
9182
10102
  //#endregion
9183
10103
  export { };