@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.
- package/README.md +123 -2
- package/dist/cli/index.mjs +987 -71
- package/dist/cli/index.mjs.map +1 -1
- package/dist/hooks/stop-hook.mjs +69 -0
- package/dist/hooks/stop-hook.mjs.map +2 -2
- package/package.json +1 -1
- package/src/hooks/ts/stop/stop-hook.ts +80 -0
package/dist/cli/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
|
2475
|
-
const skippedCount = allLiveSessions.length -
|
|
2476
|
-
if (
|
|
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
|
-
"
|
|
2784
|
+
"id",
|
|
2481
2785
|
"name",
|
|
2482
2786
|
"at prompt",
|
|
2483
|
-
"
|
|
2787
|
+
"kind"
|
|
2484
2788
|
];
|
|
2485
|
-
const liveRows =
|
|
2789
|
+
const liveRows = displayed.map((s, idx) => {
|
|
2486
2790
|
const shortId = s.sessionId.slice(0, 8);
|
|
2487
|
-
const
|
|
2488
|
-
const
|
|
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
|
-
|
|
2800
|
+
kind
|
|
2496
2801
|
];
|
|
2497
2802
|
});
|
|
2498
2803
|
console.log(renderTable(liveHeaders, liveRows));
|
|
2499
|
-
if (skippedCount > 0) console.log(dim(` (${skippedCount}
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
9039
|
-
* pai sessions
|
|
9040
|
-
* pai
|
|
9041
|
-
* pai
|
|
9042
|
-
* pai
|
|
9043
|
-
* pai
|
|
9044
|
-
* pai
|
|
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
|
-
*
|
|
9048
|
-
* pai
|
|
9049
|
-
* pai
|
|
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
|
-
|
|
9072
|
-
pai
|
|
9073
|
-
pai
|
|
9074
|
-
pai
|
|
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
|
-
|
|
9078
|
-
pai
|
|
9079
|
-
pai
|
|
9080
|
-
pai
|
|
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
|
-
|
|
9083
|
-
pai sessions ...
|
|
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.\
|
|
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
|
|
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.
|
|
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("
|
|
9174
|
-
|
|
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 { };
|