@tekmidian/pai 0.9.13 → 0.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auto-route-DL4ViDKC.mjs → auto-route-D54Kv7yI.mjs} +2 -2
- package/dist/{auto-route-DL4ViDKC.mjs.map → auto-route-D54Kv7yI.mjs.map} +1 -1
- package/dist/cli/index.mjs +1365 -378
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{clusters-CRlPBpq8.mjs → clusters-BwmdKB-V.mjs} +1 -1
- package/dist/{clusters-CRlPBpq8.mjs.map → clusters-BwmdKB-V.mjs.map} +1 -1
- package/dist/{config-BuhHWyOK.mjs → config-DqBY3aT0.mjs} +1 -1
- package/dist/{config-BuhHWyOK.mjs.map → config-DqBY3aT0.mjs.map} +1 -1
- package/dist/daemon/index.mjs +12 -8
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-B-vBWU1i.mjs +1426 -0
- package/dist/daemon-B-vBWU1i.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +2 -2
- package/dist/{detector-AzVtGLtL.mjs → detector-Q_lVXtj-.mjs} +1 -1
- package/dist/{detector-AzVtGLtL.mjs.map → detector-Q_lVXtj-.mjs.map} +1 -1
- package/dist/{factory-BDBIfTDC.mjs → factory-BufouUQ1.mjs} +3 -3
- package/dist/{factory-BDBIfTDC.mjs.map → factory-BufouUQ1.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +39 -0
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/stop-hook.mjs +36 -0
- package/dist/hooks/stop-hook.mjs.map +2 -2
- package/dist/index.mjs +2 -2
- package/dist/indexer-D7MvSQPY.mjs +1 -0
- package/dist/{indexer-backend-DXkDh-kc.mjs → indexer-backend-BrrCnasS.mjs} +1 -1
- package/dist/{indexer-backend-DXkDh-kc.mjs.map → indexer-backend-BrrCnasS.mjs.map} +1 -1
- package/dist/{ipc-client-CoyUHPod.mjs → ipc-client-C3pjwy2m.mjs} +1 -1
- package/dist/{ipc-client-CoyUHPod.mjs.map → ipc-client-C3pjwy2m.mjs.map} +1 -1
- package/dist/{kg-entity-D5v7RCDi.mjs → kg-entity-DVzTsy6G.mjs} +1 -1
- package/dist/{kg-entity-D5v7RCDi.mjs.map → kg-entity-DVzTsy6G.mjs.map} +1 -1
- package/dist/kg-extraction-r5KkTmWY.mjs +524 -0
- package/dist/kg-extraction-r5KkTmWY.mjs.map +1 -0
- package/dist/{latent-ideas-DvWBRHsy.mjs → latent-ideas-dWVuI_dg.mjs} +2 -2
- package/dist/{latent-ideas-DvWBRHsy.mjs.map → latent-ideas-dWVuI_dg.mjs.map} +1 -1
- package/dist/{migrate-jokLenje.mjs → migrate-fLD6rAdO.mjs} +2 -2
- package/dist/{migrate-jokLenje.mjs.map → migrate-fLD6rAdO.mjs.map} +1 -1
- package/dist/{neighborhood-u8ytjmWq.mjs → neighborhood-DixmpfPT.mjs} +1 -1
- package/dist/{neighborhood-u8ytjmWq.mjs.map → neighborhood-DixmpfPT.mjs.map} +1 -1
- package/dist/{note-context-CG2_e-0W.mjs → note-context-BY7rfGdl.mjs} +1 -1
- package/dist/{note-context-CG2_e-0W.mjs.map → note-context-BY7rfGdl.mjs.map} +1 -1
- package/dist/{postgres-BGERehmX.mjs → postgres-Cp25xZes.mjs} +1 -1
- package/dist/{postgres-BGERehmX.mjs.map → postgres-Cp25xZes.mjs.map} +1 -1
- package/dist/{query-feedback-CQSumXDy.mjs → query-feedback-C0hKUHVz.mjs} +1 -1
- package/dist/{query-feedback-CQSumXDy.mjs.map → query-feedback-C0hKUHVz.mjs.map} +1 -1
- package/dist/{sqlite-xUe94oUZ.mjs → sqlite-B3JHfJc2.mjs} +1 -1
- package/dist/{sqlite-xUe94oUZ.mjs.map → sqlite-B3JHfJc2.mjs.map} +1 -1
- package/dist/{state-BIlxNRUn.mjs → state-BHZAhLou.mjs} +1 -1
- package/dist/{state-BIlxNRUn.mjs.map → state-BHZAhLou.mjs.map} +1 -1
- package/dist/{themes-9jxFn3Rf.mjs → themes-DO8mUVwD.mjs} +1 -1
- package/dist/{themes-9jxFn3Rf.mjs.map → themes-DO8mUVwD.mjs.map} +1 -1
- package/dist/{tools-CF0lB5lW.mjs → tools-C1JNgya7.mjs} +14 -14
- package/dist/{tools-CF0lB5lW.mjs.map → tools-C1JNgya7.mjs.map} +1 -1
- package/dist/{trace-C2XrzssW.mjs → trace-TYCLMCkY.mjs} +1 -1
- package/dist/{trace-C2XrzssW.mjs.map → trace-TYCLMCkY.mjs.map} +1 -1
- package/dist/{utils-QSfKagcj.mjs → utils-BAxjW3j8.mjs} +2 -2
- package/dist/{utils-QSfKagcj.mjs.map → utils-BAxjW3j8.mjs.map} +1 -1
- package/dist/{vault-indexer-iHRjcy6e.mjs → vault-indexer-DsPC6wQf.mjs} +1 -1
- package/dist/{vault-indexer-iHRjcy6e.mjs.map → vault-indexer-DsPC6wQf.mjs.map} +1 -1
- package/dist/{daemon-w2fE3UMz.mjs → work-queue-worker-94INtUiz.mjs} +54 -1398
- package/dist/work-queue-worker-94INtUiz.mjs.map +1 -0
- package/dist/{zettelkasten-BdaMzTGQ.mjs → zettelkasten-B-94vghH.mjs} +3 -3
- package/dist/{zettelkasten-BdaMzTGQ.mjs.map → zettelkasten-B-94vghH.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +48 -0
- package/src/hooks/ts/stop/stop-hook.ts +40 -0
- package/dist/daemon-w2fE3UMz.mjs.map +0 -1
- package/dist/kg-extraction-Uvr9fTlz.mjs +0 -246
- package/dist/kg-extraction-Uvr9fTlz.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { n as openRegistry } from "../db-BtuN768f.mjs";
|
|
3
|
-
import { _ as warn, a as fmtDate, c as ok, d as scaffoldProjectDirs, f as shortenPath, i as err, l as renderTable, m as slugify, n as dim, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold, u as resolvePath } from "../utils-
|
|
4
|
-
import { a as slugify$1, i as parseSessionFilename
|
|
5
|
-
import { n as ensurePaiMarker
|
|
3
|
+
import { _ as warn, a as fmtDate, c as ok, d as scaffoldProjectDirs, f as shortenPath, h as smartDecodeDir, i as err, l as renderTable, m as slugify, n as dim, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold, u as resolvePath } from "../utils-BAxjW3j8.mjs";
|
|
4
|
+
import { a as slugify$1, i as parseSessionFilename } from "../migrate-fLD6rAdO.mjs";
|
|
5
|
+
import { n as ensurePaiMarker } from "../pai-marker-CXQPX2P6.mjs";
|
|
6
6
|
import { n as openFederation } from "../db-CYmBWcjh.mjs";
|
|
7
7
|
import "../helpers-OCVFgprQ.mjs";
|
|
8
8
|
import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-CdHSL9Kc.mjs";
|
|
@@ -10,17 +10,20 @@ 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 { t as extractAndStoreTriples } from "../kg-extraction-
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import { t as
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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";
|
|
14
|
+
import "../indexer-D7MvSQPY.mjs";
|
|
15
|
+
import { t as PaiClient } from "../ipc-client-C3pjwy2m.mjs";
|
|
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
|
+
import { t as createStorageBackend } from "../factory-BufouUQ1.mjs";
|
|
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
20
|
import { homedir, platform, tmpdir } from "node:os";
|
|
20
21
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
21
22
|
import chalk from "chalk";
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
22
24
|
import { Command } from "commander";
|
|
23
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { connect } from "node:net";
|
|
24
27
|
import { execSync, spawnSync } from "node:child_process";
|
|
25
28
|
import { createInterface } from "node:readline";
|
|
26
29
|
import { createConnection } from "net";
|
|
@@ -1094,68 +1097,68 @@ function cmdHealth$1(db, opts) {
|
|
|
1094
1097
|
}
|
|
1095
1098
|
|
|
1096
1099
|
//#endregion
|
|
1097
|
-
//#region src/cli/commands/project/index.ts
|
|
1098
|
-
function
|
|
1099
|
-
|
|
1100
|
-
cmdAdd(getDb(), rawPath, opts);
|
|
1101
|
-
});
|
|
1102
|
-
projectCmd.command("list").description("List registered projects").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
|
|
1100
|
+
//#region src/cli/commands/project/projects-index.ts
|
|
1101
|
+
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) => {
|
|
1103
1103
|
cmdList$3(getDb(), opts);
|
|
1104
1104
|
});
|
|
1105
|
-
|
|
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);
|
|
1107
|
+
if (!project) {
|
|
1108
|
+
console.error(`Project not found: ${identifier}`);
|
|
1109
|
+
process.exit(1);
|
|
1110
|
+
}
|
|
1111
|
+
process.stdout.write(project.root_path + "\n");
|
|
1112
|
+
});
|
|
1113
|
+
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
|
+
cmdAdd(getDb(), rawPath, opts);
|
|
1115
|
+
});
|
|
1116
|
+
projectsCmd.command("info <slug>").description("Show full details for a project").action((slug) => {
|
|
1106
1117
|
cmdInfo$1(getDb(), slug);
|
|
1107
1118
|
});
|
|
1108
|
-
|
|
1119
|
+
projectsCmd.command("archive <slug>").description("Archive a project").action((slug) => {
|
|
1109
1120
|
cmdArchive(getDb(), slug);
|
|
1110
1121
|
});
|
|
1111
|
-
|
|
1122
|
+
projectsCmd.command("unarchive <slug>").description("Restore an archived project to active status").action((slug) => {
|
|
1112
1123
|
cmdUnarchive(getDb(), slug);
|
|
1113
1124
|
});
|
|
1114
|
-
|
|
1125
|
+
projectsCmd.command("move <slug> <new-path>").description("Update the root path for a project").action((slug, newPath) => {
|
|
1115
1126
|
cmdMove(getDb(), slug, newPath);
|
|
1116
1127
|
});
|
|
1117
|
-
|
|
1128
|
+
projectsCmd.command("tag <slug> <tags...>").description("Add one or more tags to a project").action((slug, tags) => {
|
|
1118
1129
|
cmdTag$1(getDb(), slug, tags);
|
|
1119
1130
|
});
|
|
1120
|
-
|
|
1131
|
+
projectsCmd.command("alias <slug> <alias>").description("Register an alternative slug for a project").action((slug, alias) => {
|
|
1121
1132
|
cmdAlias(getDb(), slug, alias);
|
|
1122
1133
|
});
|
|
1123
|
-
|
|
1134
|
+
projectsCmd.command("edit <slug>").description("Edit project metadata").option("--display-name <name>", "New display name").option("--type <type>", "New type").action((slug, opts) => {
|
|
1124
1135
|
cmdEdit(getDb(), slug, opts);
|
|
1125
1136
|
});
|
|
1126
|
-
|
|
1127
|
-
const project = resolveIdentifier(getDb(), identifier);
|
|
1128
|
-
if (!project) {
|
|
1129
|
-
console.error(`Project not found: ${identifier}`);
|
|
1130
|
-
process.exit(1);
|
|
1131
|
-
}
|
|
1132
|
-
process.stdout.write(project.root_path + "\n");
|
|
1133
|
-
});
|
|
1134
|
-
projectCmd.command("detect [path]").description("Detect which registered project the given path (or CWD) belongs to").option("--json", "Output raw JSON instead of human-readable text").action((pathArg, opts) => {
|
|
1137
|
+
projectsCmd.command("detect [path]").description("Detect which registered project the given path (or CWD) belongs to").option("--json", "Output raw JSON instead of human-readable text").action((pathArg, opts) => {
|
|
1135
1138
|
cmdDetect(getDb(), pathArg, opts);
|
|
1136
1139
|
});
|
|
1137
|
-
|
|
1140
|
+
projectsCmd.command("health").description("Audit all registered projects: check which paths still exist, find moved/dead projects").option("--fix", "Auto-remediate where possible").option("--json", "Output raw JSON report").option("--status <category>", "Filter output to: active | stale | dead").action((opts) => {
|
|
1138
1141
|
cmdHealth$1(getDb(), opts);
|
|
1139
1142
|
});
|
|
1140
|
-
|
|
1143
|
+
projectsCmd.command("consolidate <identifier>").description("Consolidate scattered ~/.claude/projects/.../Notes/ directories for a project into its canonical Notes/ location").option("--yes", "Perform consolidation without confirmation prompt").option("--dry-run", "Preview what would be moved without making changes").action((identifier, opts) => {
|
|
1141
1144
|
cmdConsolidate(getDb(), identifier, opts);
|
|
1142
1145
|
});
|
|
1143
|
-
|
|
1146
|
+
projectsCmd.command("promote").description("Promote a session note into a new standalone project").requiredOption("--from-session <path>", "Path to the session note markdown file").requiredOption("--to <path>", "Directory path for the new project (must not exist)").option("--name <name>", "Display name for the new project (derived from filename if omitted)").action((opts) => {
|
|
1144
1147
|
cmdPromote(getDb(), opts);
|
|
1145
1148
|
});
|
|
1146
|
-
|
|
1149
|
+
projectsCmd.command("go <query>").description("Print the root path for a project by slug, partial name, or fuzzy match.\nDesigned for shell integration: cd $(pai projects go <query>)").action((query) => {
|
|
1147
1150
|
cmdGo(getDb(), query);
|
|
1148
1151
|
});
|
|
1149
|
-
|
|
1152
|
+
projectsCmd.command("name <identifier> <shortname>").description("Give a project a short name for quick access").option("--permission <level>", "Permission level: full | trusted | default").action((identifier, shortname, opts) => {
|
|
1150
1153
|
cmdName(getDb(), identifier, shortname, opts);
|
|
1151
1154
|
});
|
|
1152
|
-
|
|
1155
|
+
projectsCmd.command("unname <shortname>").description("Remove a project's short name").action((shortname) => {
|
|
1153
1156
|
cmdUnname(getDb(), shortname);
|
|
1154
1157
|
});
|
|
1155
|
-
|
|
1158
|
+
projectsCmd.command("names").description("List named projects (your curated shortlist)").option("--json", "Output JSON for AIBroker consumption").action((opts) => {
|
|
1156
1159
|
cmdNames(getDb(), opts);
|
|
1157
1160
|
});
|
|
1158
|
-
|
|
1161
|
+
projectsCmd.command("config [identifier]").description("View or modify session launch config for a project.\nUse --options to discover available keys and presets.").option("--set <key=value...>", "Set config values (repeatable)", (v, prev) => [...prev, v], []).option("--unset <key...>", "Remove config keys (repeatable, use env.KEY for env vars)", (v, prev) => [...prev, v], []).option("--preset <name>", "Apply a permission preset: full | trusted | default").option("--defaults", "Manage global session defaults instead of a project").option("--options", "List available config keys and presets").option("--json", "Output JSON").option("--reset", "Reset config to empty (inherit global defaults)").action((identifier, opts) => {
|
|
1159
1162
|
cmdConfig(getDb(), identifier, opts);
|
|
1160
1163
|
});
|
|
1161
1164
|
}
|
|
@@ -1682,10 +1685,10 @@ function cmdActive(db, opts) {
|
|
|
1682
1685
|
], rows));
|
|
1683
1686
|
}
|
|
1684
1687
|
async function cmdAutoRoute(opts) {
|
|
1685
|
-
const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-
|
|
1688
|
+
const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-D54Kv7yI.mjs");
|
|
1686
1689
|
const { openRegistry } = await import("../db-BtuN768f.mjs").then((n) => n.t);
|
|
1687
|
-
const { createStorageBackend } = await import("../factory-
|
|
1688
|
-
const { loadConfig } = await import("../config-
|
|
1690
|
+
const { createStorageBackend } = await import("../factory-BufouUQ1.mjs").then((n) => n.n);
|
|
1691
|
+
const { loadConfig } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
|
|
1689
1692
|
const config = loadConfig();
|
|
1690
1693
|
const registryDb = openRegistry();
|
|
1691
1694
|
const federation = await createStorageBackend(config);
|
|
@@ -1811,12 +1814,913 @@ function cmdCheckpoint(message, opts) {
|
|
|
1811
1814
|
} catch {}
|
|
1812
1815
|
process.exit(0);
|
|
1813
1816
|
}
|
|
1814
|
-
touchCheckpointSentinel(notesDir);
|
|
1815
|
-
process.exit(0);
|
|
1817
|
+
touchCheckpointSentinel(notesDir);
|
|
1818
|
+
process.exit(0);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region src/cli/commands/session/handover.ts
|
|
1823
|
+
const HANDOVER_TODO_LOCATIONS$1 = [
|
|
1824
|
+
"Notes/TODO.md",
|
|
1825
|
+
".claude/Notes/TODO.md",
|
|
1826
|
+
"tasks/todo.md",
|
|
1827
|
+
"TODO.md"
|
|
1828
|
+
];
|
|
1829
|
+
function findProjectTodo$1(rootPath) {
|
|
1830
|
+
for (const rel of HANDOVER_TODO_LOCATIONS$1) {
|
|
1831
|
+
const full = join(rootPath, rel);
|
|
1832
|
+
if (existsSync(full)) try {
|
|
1833
|
+
return {
|
|
1834
|
+
path: full,
|
|
1835
|
+
content: readFileSync(full, "utf8")
|
|
1836
|
+
};
|
|
1837
|
+
} catch {}
|
|
1838
|
+
}
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
function stripContinueSection$1(content) {
|
|
1842
|
+
const lines = content.split("\n");
|
|
1843
|
+
const startIdx = lines.findIndex((l) => l.trim() === "## Continue");
|
|
1844
|
+
if (startIdx === -1) return content;
|
|
1845
|
+
let endIdx = lines.length;
|
|
1846
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
1847
|
+
const trimmed = lines[i].trim();
|
|
1848
|
+
if (trimmed === "---" || trimmed.startsWith("##") && trimmed !== "## Continue") {
|
|
1849
|
+
endIdx = i;
|
|
1850
|
+
break;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
let trailingEnd = endIdx;
|
|
1854
|
+
if (trailingEnd < lines.length && lines[trailingEnd].trim() === "---") trailingEnd += 1;
|
|
1855
|
+
const before = lines.slice(0, startIdx);
|
|
1856
|
+
const after = lines.slice(trailingEnd);
|
|
1857
|
+
while (after.length > 0 && after[0].trim() === "") after.shift();
|
|
1858
|
+
return [...before, ...after].join("\n");
|
|
1859
|
+
}
|
|
1860
|
+
function cmdHandover(db, projectSlug, numberOrLatest) {
|
|
1861
|
+
let project;
|
|
1862
|
+
if (projectSlug) {
|
|
1863
|
+
project = db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(projectSlug);
|
|
1864
|
+
if (!project) process.exit(0);
|
|
1865
|
+
} else {
|
|
1866
|
+
const cwd = process.cwd();
|
|
1867
|
+
const row = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir
|
|
1868
|
+
FROM projects
|
|
1869
|
+
WHERE ? LIKE root_path || '%'
|
|
1870
|
+
ORDER BY length(root_path) DESC
|
|
1871
|
+
LIMIT 1`).get(cwd);
|
|
1872
|
+
if (!row) process.exit(0);
|
|
1873
|
+
project = row;
|
|
1874
|
+
}
|
|
1875
|
+
let session;
|
|
1876
|
+
const nol = numberOrLatest ?? "latest";
|
|
1877
|
+
if (nol === "latest") session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
|
|
1878
|
+
else {
|
|
1879
|
+
const num = parseInt(nol, 10);
|
|
1880
|
+
if (!isNaN(num)) session = db.prepare("SELECT * FROM sessions WHERE project_id = ? AND number = ?").get(project.id, num);
|
|
1881
|
+
}
|
|
1882
|
+
const todo = findProjectTodo$1(project.root_path);
|
|
1883
|
+
let todoPath;
|
|
1884
|
+
let existingContent;
|
|
1885
|
+
if (todo) {
|
|
1886
|
+
todoPath = todo.path;
|
|
1887
|
+
existingContent = todo.content;
|
|
1888
|
+
} else {
|
|
1889
|
+
const notesDir = join(project.root_path, "Notes");
|
|
1890
|
+
try {
|
|
1891
|
+
if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
|
|
1892
|
+
} catch {
|
|
1893
|
+
process.exit(0);
|
|
1894
|
+
}
|
|
1895
|
+
todoPath = join(notesDir, "TODO.md");
|
|
1896
|
+
existingContent = "";
|
|
1897
|
+
}
|
|
1898
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1899
|
+
const cwd = process.cwd();
|
|
1900
|
+
let sessionLine;
|
|
1901
|
+
if (session) {
|
|
1902
|
+
const num = String(session.number).padStart(4, "0");
|
|
1903
|
+
const titlePart = session.title || session.slug || "Session";
|
|
1904
|
+
sessionLine = `${num} - ${session.date} - ${titlePart}`;
|
|
1905
|
+
} else sessionLine = "Unknown session";
|
|
1906
|
+
const newContent = [
|
|
1907
|
+
"## Continue",
|
|
1908
|
+
"",
|
|
1909
|
+
`> **Last session:** ${sessionLine}`,
|
|
1910
|
+
`> **Paused at:** ${timestamp}`,
|
|
1911
|
+
">",
|
|
1912
|
+
`> Working directory: ${cwd}. Check the latest session note for details.`,
|
|
1913
|
+
"",
|
|
1914
|
+
"---",
|
|
1915
|
+
""
|
|
1916
|
+
].join("\n") + stripContinueSection$1(existingContent).trimStart();
|
|
1917
|
+
const tmpPath = `${todoPath}.handover.tmp`;
|
|
1918
|
+
try {
|
|
1919
|
+
writeFileSync(tmpPath, newContent, "utf8");
|
|
1920
|
+
renameSync(tmpPath, todoPath);
|
|
1921
|
+
} catch {
|
|
1922
|
+
try {
|
|
1923
|
+
if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
|
|
1924
|
+
} catch {}
|
|
1925
|
+
process.exit(0);
|
|
1926
|
+
}
|
|
1927
|
+
process.exit(0);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
//#endregion
|
|
1931
|
+
//#region src/cli/lib/session-scan.ts
|
|
1932
|
+
/**
|
|
1933
|
+
* Shared scanner for Claude Code sessions stored in ~/.claude/projects/ .
|
|
1934
|
+
*
|
|
1935
|
+
* Storage layout (Claude Code v2.1.143+):
|
|
1936
|
+
*
|
|
1937
|
+
* <project>/<uuid>.jsonl — top-level: metadata, system snapshots, hook events.
|
|
1938
|
+
* REQUIRED for claude --resume to work.
|
|
1939
|
+
* <project>/sessions/<uuid>.jsonl — full transcript: user/assistant/attachment lines.
|
|
1940
|
+
* 3000+ files, almost none resumable on their own.
|
|
1941
|
+
*
|
|
1942
|
+
* Resumability rule (empirically verified):
|
|
1943
|
+
* A session is resumable iff the TOP-LEVEL jsonl exists AND contains at least one
|
|
1944
|
+
* line of type "system". Sessions that only have a sessions/ counterpart cannot be
|
|
1945
|
+
* resumed by Claude Code regardless of how much transcript content they have.
|
|
1946
|
+
*
|
|
1947
|
+
* Stale-UUID problem (fixed in this version):
|
|
1948
|
+
* The clc session.json registry stores ONE uuid per named session — the uuid Claude Code
|
|
1949
|
+
* had when the user last named the session. If the user resumes and the session gets a
|
|
1950
|
+
* new uuid (or they Ctrl+C and start fresh), the registry entry still points to the OLD
|
|
1951
|
+
* uuid. The scanner now resolves names to the MOST RECENT top-level jsonl in the project
|
|
1952
|
+
* directory, not the clc-cached uuid.
|
|
1953
|
+
*
|
|
1954
|
+
* Resolution strategy:
|
|
1955
|
+
* 1. Walk top-level <project>/<uuid>.jsonl files (Pass 1).
|
|
1956
|
+
* 2. For each, attach the clc name if the uuid matches (exact hit).
|
|
1957
|
+
* 3. After Pass 1, for every clc registry entry:
|
|
1958
|
+
* a. If the cached uuid was found → already handled.
|
|
1959
|
+
* b. Find the encodedDir for this entry's directory.
|
|
1960
|
+
* c. Check ALL sessions in that encodedDir (already in our Pass-1 results).
|
|
1961
|
+
* d. Pick the MOST RECENT resumable session in that dir and attach the name to it.
|
|
1962
|
+
* If no resumable session, fall through to transcript-only pass.
|
|
1963
|
+
* 4. This means the displayed uuid for "Jobs Matthias" is always today's active session,
|
|
1964
|
+
* not the stale cached one.
|
|
1965
|
+
*
|
|
1966
|
+
* Used by: pai sessions, pai resume
|
|
1967
|
+
*/
|
|
1968
|
+
const CLC_SESSIONS_FILE = join(homedir(), ".claude", "session.json");
|
|
1969
|
+
/** Load clc's session registry → uuid → ClcInfo map. Verbatim, never slugified. */
|
|
1970
|
+
function buildClcInfoMap() {
|
|
1971
|
+
const map = /* @__PURE__ */ new Map();
|
|
1972
|
+
try {
|
|
1973
|
+
const raw = readFileSync(CLC_SESSIONS_FILE, "utf8");
|
|
1974
|
+
const data = JSON.parse(raw);
|
|
1975
|
+
for (const entry of data.sessions ?? []) {
|
|
1976
|
+
const name = entry.name?.trim();
|
|
1977
|
+
const uuid = (entry.resume ?? entry.session ?? "").trim();
|
|
1978
|
+
if (name && uuid) map.set(uuid, {
|
|
1979
|
+
name,
|
|
1980
|
+
directory: entry.directory?.trim() || void 0
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
} catch {}
|
|
1984
|
+
return map;
|
|
1985
|
+
}
|
|
1986
|
+
/** Build encoded_dir → root_path map from PAI registry (authoritative cwd). */
|
|
1987
|
+
function buildRegistryRootPathMap(db) {
|
|
1988
|
+
try {
|
|
1989
|
+
const rows = db.prepare(`SELECT root_path, encoded_dir FROM projects
|
|
1990
|
+
WHERE encoded_dir IS NOT NULL AND encoded_dir != ''`).all();
|
|
1991
|
+
const map = /* @__PURE__ */ new Map();
|
|
1992
|
+
for (const row of rows) map.set(row.encoded_dir, row.root_path);
|
|
1993
|
+
return map;
|
|
1994
|
+
} catch {
|
|
1995
|
+
return /* @__PURE__ */ new Map();
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
function parseTopLevel(filePath) {
|
|
1999
|
+
let systemLines = 0;
|
|
2000
|
+
let size = 0;
|
|
2001
|
+
let mtime = 0;
|
|
2002
|
+
try {
|
|
2003
|
+
const st = statSync(filePath);
|
|
2004
|
+
size = st.size;
|
|
2005
|
+
mtime = st.mtimeMs;
|
|
2006
|
+
const content = readFileSync(filePath, "utf8");
|
|
2007
|
+
for (const line of content.split("\n")) {
|
|
2008
|
+
const t = line.trim();
|
|
2009
|
+
if (!t) continue;
|
|
2010
|
+
if (t.includes("\"type\":\"system\"") || t.includes("\"type\": \"system\"")) systemLines++;
|
|
2011
|
+
}
|
|
2012
|
+
} catch {}
|
|
2013
|
+
return {
|
|
2014
|
+
systemLines,
|
|
2015
|
+
size,
|
|
2016
|
+
mtime
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
function parseTranscript(filePath) {
|
|
2020
|
+
let userLines = 0;
|
|
2021
|
+
let lastUserPrompt = "";
|
|
2022
|
+
let msgCount = 0;
|
|
2023
|
+
let aiTitle;
|
|
2024
|
+
let mtime = 0;
|
|
2025
|
+
try {
|
|
2026
|
+
mtime = statSync(filePath).mtimeMs;
|
|
2027
|
+
} catch {
|
|
2028
|
+
return {
|
|
2029
|
+
userLines: 0,
|
|
2030
|
+
lastUserPrompt: "",
|
|
2031
|
+
msgCount: 0,
|
|
2032
|
+
mtime: 0
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
let content;
|
|
2036
|
+
try {
|
|
2037
|
+
content = readFileSync(filePath, "utf8");
|
|
2038
|
+
} catch {
|
|
2039
|
+
return {
|
|
2040
|
+
userLines: 0,
|
|
2041
|
+
lastUserPrompt: "",
|
|
2042
|
+
msgCount: 0,
|
|
2043
|
+
mtime
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
for (const line of content.split("\n")) {
|
|
2047
|
+
const t = line.trim();
|
|
2048
|
+
if (!t) continue;
|
|
2049
|
+
msgCount++;
|
|
2050
|
+
const hasUser = t.includes("\"type\":\"user\"") || t.includes("\"type\": \"user\"");
|
|
2051
|
+
const hasTitle = t.includes("\"type\":\"ai-title\"") || t.includes("\"type\": \"ai-title\"");
|
|
2052
|
+
if (!hasUser && !hasTitle) continue;
|
|
2053
|
+
let parsed;
|
|
2054
|
+
try {
|
|
2055
|
+
parsed = JSON.parse(t);
|
|
2056
|
+
} catch {
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
if (parsed.type === "ai-title") {
|
|
2060
|
+
const v = parsed.title ?? parsed.name;
|
|
2061
|
+
if (typeof v === "string" && v.trim()) aiTitle = v.trim();
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
if (parsed.type !== "user") continue;
|
|
2065
|
+
userLines++;
|
|
2066
|
+
const msg = parsed.message;
|
|
2067
|
+
if (!msg) continue;
|
|
2068
|
+
const c = msg.content;
|
|
2069
|
+
let text = "";
|
|
2070
|
+
if (typeof c === "string") text = c;
|
|
2071
|
+
else if (Array.isArray(c) && c.length > 0) {
|
|
2072
|
+
const first = c[0];
|
|
2073
|
+
if (typeof first.text === "string") text = first.text;
|
|
2074
|
+
}
|
|
2075
|
+
if (text) lastUserPrompt = text.slice(0, 80).replace(/\n/g, " ");
|
|
2076
|
+
}
|
|
2077
|
+
return {
|
|
2078
|
+
userLines,
|
|
2079
|
+
lastUserPrompt,
|
|
2080
|
+
msgCount,
|
|
2081
|
+
aiTitle,
|
|
2082
|
+
mtime
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
2086
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2087
|
+
/**
|
|
2088
|
+
* Compute the effective filter mode, resolving the legacy resumableOnly flag.
|
|
2089
|
+
*/
|
|
2090
|
+
function resolveFilter(opts) {
|
|
2091
|
+
if (opts.filter) return opts.filter;
|
|
2092
|
+
if (opts.resumableOnly === true) return "resumable";
|
|
2093
|
+
if (opts.resumableOnly === false) return "all";
|
|
2094
|
+
return "named";
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Scan ~/.claude/projects/ for all Claude Code sessions.
|
|
2098
|
+
*
|
|
2099
|
+
* Pass 1: walk top-level <project>/<uuid>.jsonl files (the resumability source).
|
|
2100
|
+
* Pass 2: handle clc registry entries whose cached UUID was not found in Pass 1.
|
|
2101
|
+
* For each such entry, scan the entry's project dir for the FRESHEST session
|
|
2102
|
+
* and attach the registry name to it. This fixes the stale-UUID bug where
|
|
2103
|
+
* clc's session.json points to an old uuid after a fresh start.
|
|
2104
|
+
* Pass 3: any remaining clc entries with truly no top-level jsonl → transcript-only.
|
|
2105
|
+
*
|
|
2106
|
+
* Filter modes:
|
|
2107
|
+
* "named" — resumable + registry-known stubs + transcript-only (default)
|
|
2108
|
+
* "all" — all of the above + unnamed orphans from sessions/ subdirs
|
|
2109
|
+
* "resumable" — only sessions claude --resume accepts
|
|
2110
|
+
*
|
|
2111
|
+
* Returns results sorted by mtime descending.
|
|
2112
|
+
*/
|
|
2113
|
+
function scanSessions(db, opts = {}) {
|
|
2114
|
+
const limit = opts.limit ?? 200;
|
|
2115
|
+
const filterMode = resolveFilter(opts);
|
|
2116
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) return [];
|
|
2117
|
+
const clcInfoMap = buildClcInfoMap();
|
|
2118
|
+
const rootPathMap = buildRegistryRootPathMap(db);
|
|
2119
|
+
const results = [];
|
|
2120
|
+
const seenUuids = /* @__PURE__ */ new Set();
|
|
2121
|
+
const attachedClcUuids = /* @__PURE__ */ new Set();
|
|
2122
|
+
let encodedDirs;
|
|
2123
|
+
try {
|
|
2124
|
+
encodedDirs = readdirSync(CLAUDE_PROJECTS_DIR);
|
|
2125
|
+
} catch {
|
|
2126
|
+
return [];
|
|
2127
|
+
}
|
|
2128
|
+
const sessionsByEncodedDir = /* @__PURE__ */ new Map();
|
|
2129
|
+
for (const encodedDir of encodedDirs) {
|
|
2130
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, encodedDir);
|
|
2131
|
+
try {
|
|
2132
|
+
if (!statSync(projectDir).isDirectory()) continue;
|
|
2133
|
+
} catch {
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
let files;
|
|
2137
|
+
try {
|
|
2138
|
+
files = readdirSync(projectDir);
|
|
2139
|
+
} catch {
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
const decodedPath = smartDecodeDir(encodedDir) ?? encodedDir.replace(/-/g, "/");
|
|
2143
|
+
const registryRootPath = rootPathMap.get(encodedDir);
|
|
2144
|
+
const projectBasename = basename(decodedPath);
|
|
2145
|
+
for (const file of files) {
|
|
2146
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
2147
|
+
const uuid = file.slice(0, -6);
|
|
2148
|
+
if (!UUID_RE.test(uuid)) continue;
|
|
2149
|
+
const topLevelPath = join(projectDir, file);
|
|
2150
|
+
const topInfo = parseTopLevel(topLevelPath);
|
|
2151
|
+
const resumable = topInfo.systemLines > 0;
|
|
2152
|
+
const clcInfo = clcInfoMap.get(uuid);
|
|
2153
|
+
const inRegistry = !!clcInfo;
|
|
2154
|
+
const sessionStatus = resumable ? "resumable" : inRegistry ? "stub" : "orphan";
|
|
2155
|
+
const passesFilter = filterMode === "resumable" ? resumable : filterMode === "named" ? resumable || inRegistry : true;
|
|
2156
|
+
const sessionJsonlPath = join(projectDir, "sessions", `${uuid}.jsonl`);
|
|
2157
|
+
const hasTranscript = existsSync(sessionJsonlPath);
|
|
2158
|
+
const transcript = hasTranscript ? parseTranscript(sessionJsonlPath) : {
|
|
2159
|
+
userLines: 0,
|
|
2160
|
+
lastUserPrompt: "",
|
|
2161
|
+
msgCount: 0,
|
|
2162
|
+
mtime: 0
|
|
2163
|
+
};
|
|
2164
|
+
const mtime = topInfo.mtime || transcript.mtime;
|
|
2165
|
+
const friendlyName = clcInfo?.name ?? transcript.aiTitle ?? projectBasename ?? void 0;
|
|
2166
|
+
const session = {
|
|
2167
|
+
uuid,
|
|
2168
|
+
shortId: uuid.slice(0, 8),
|
|
2169
|
+
encodedDir,
|
|
2170
|
+
decodedPath,
|
|
2171
|
+
topLevelPath,
|
|
2172
|
+
topLevelSystemLines: topInfo.systemLines,
|
|
2173
|
+
topLevelSize: topInfo.size,
|
|
2174
|
+
resumable,
|
|
2175
|
+
sessionStatus,
|
|
2176
|
+
sessionJsonlPath: hasTranscript ? sessionJsonlPath : void 0,
|
|
2177
|
+
userLines: transcript.userLines,
|
|
2178
|
+
lastUserPrompt: transcript.lastUserPrompt,
|
|
2179
|
+
msgCount: transcript.msgCount,
|
|
2180
|
+
aiTitle: transcript.aiTitle,
|
|
2181
|
+
mtime,
|
|
2182
|
+
friendlyName,
|
|
2183
|
+
clcDirectory: clcInfo?.directory,
|
|
2184
|
+
registryRootPath
|
|
2185
|
+
};
|
|
2186
|
+
if (!sessionsByEncodedDir.has(encodedDir)) sessionsByEncodedDir.set(encodedDir, []);
|
|
2187
|
+
sessionsByEncodedDir.get(encodedDir).push(session);
|
|
2188
|
+
seenUuids.add(uuid);
|
|
2189
|
+
if (inRegistry) attachedClcUuids.add(uuid);
|
|
2190
|
+
if (passesFilter) results.push(session);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
if (filterMode !== "resumable") for (const [cachedUuid, clcInfo] of clcInfoMap) {
|
|
2194
|
+
if (attachedClcUuids.has(cachedUuid)) continue;
|
|
2195
|
+
let foundEncodedDir;
|
|
2196
|
+
if (clcInfo.directory) {
|
|
2197
|
+
const real = realpathSyncSafe(clcInfo.directory);
|
|
2198
|
+
if (real) {
|
|
2199
|
+
const encoded = encodeProjectDir(real);
|
|
2200
|
+
if (existsSync(join(CLAUDE_PROJECTS_DIR, encoded))) foundEncodedDir = encoded;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
if (!foundEncodedDir) {
|
|
2204
|
+
for (const encodedDir of encodedDirs) if (existsSync(join(CLAUDE_PROJECTS_DIR, encodedDir, "sessions", `${cachedUuid}.jsonl`))) {
|
|
2205
|
+
foundEncodedDir = encodedDir;
|
|
2206
|
+
break;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (foundEncodedDir) {
|
|
2210
|
+
const freshestResumable = (sessionsByEncodedDir.get(foundEncodedDir) ?? []).filter((s) => s.resumable && !s.friendlyName).sort((a, b) => b.mtime - a.mtime)[0];
|
|
2211
|
+
if (freshestResumable) {
|
|
2212
|
+
freshestResumable.friendlyName = clcInfo.name;
|
|
2213
|
+
freshestResumable.clcDirectory = freshestResumable.clcDirectory ?? clcInfo.directory;
|
|
2214
|
+
freshestResumable.sessionStatus = "resumable";
|
|
2215
|
+
attachedClcUuids.add(freshestResumable.uuid);
|
|
2216
|
+
if (!seenUuids.has(freshestResumable.uuid) || !results.includes(freshestResumable)) {
|
|
2217
|
+
if (!results.includes(freshestResumable)) results.push(freshestResumable);
|
|
2218
|
+
}
|
|
2219
|
+
continue;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
if (!foundEncodedDir) {
|
|
2223
|
+
const encodedDir = "";
|
|
2224
|
+
const decodedPath = clcInfo.directory ?? cachedUuid;
|
|
2225
|
+
const registryRootPath = void 0;
|
|
2226
|
+
const mtime = {
|
|
2227
|
+
userLines: 0,
|
|
2228
|
+
lastUserPrompt: "",
|
|
2229
|
+
msgCount: 0,
|
|
2230
|
+
mtime: 0
|
|
2231
|
+
}.mtime;
|
|
2232
|
+
seenUuids.add(cachedUuid);
|
|
2233
|
+
results.push({
|
|
2234
|
+
uuid: cachedUuid,
|
|
2235
|
+
shortId: cachedUuid.slice(0, 8),
|
|
2236
|
+
encodedDir,
|
|
2237
|
+
decodedPath,
|
|
2238
|
+
topLevelPath: "",
|
|
2239
|
+
topLevelSystemLines: 0,
|
|
2240
|
+
topLevelSize: 0,
|
|
2241
|
+
resumable: false,
|
|
2242
|
+
sessionStatus: "transcript-only",
|
|
2243
|
+
sessionJsonlPath: void 0,
|
|
2244
|
+
userLines: 0,
|
|
2245
|
+
lastUserPrompt: "",
|
|
2246
|
+
msgCount: 0,
|
|
2247
|
+
mtime,
|
|
2248
|
+
friendlyName: clcInfo.name,
|
|
2249
|
+
clcDirectory: clcInfo.directory,
|
|
2250
|
+
registryRootPath
|
|
2251
|
+
});
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
const foundTranscriptPath = existsSync(join(CLAUDE_PROJECTS_DIR, foundEncodedDir, "sessions", `${cachedUuid}.jsonl`)) ? join(CLAUDE_PROJECTS_DIR, foundEncodedDir, "sessions", `${cachedUuid}.jsonl`) : void 0;
|
|
2255
|
+
const decodedPath = clcInfo.directory ?? smartDecodeDir(foundEncodedDir) ?? foundEncodedDir.replace(/-/g, "/");
|
|
2256
|
+
const registryRootPath = rootPathMap.get(foundEncodedDir);
|
|
2257
|
+
const topLevelPath = join(CLAUDE_PROJECTS_DIR, foundEncodedDir, `${cachedUuid}.jsonl`);
|
|
2258
|
+
const transcript = foundTranscriptPath ? parseTranscript(foundTranscriptPath) : {
|
|
2259
|
+
userLines: 0,
|
|
2260
|
+
lastUserPrompt: "",
|
|
2261
|
+
msgCount: 0,
|
|
2262
|
+
mtime: 0
|
|
2263
|
+
};
|
|
2264
|
+
seenUuids.add(cachedUuid);
|
|
2265
|
+
results.push({
|
|
2266
|
+
uuid: cachedUuid,
|
|
2267
|
+
shortId: cachedUuid.slice(0, 8),
|
|
2268
|
+
encodedDir: foundEncodedDir,
|
|
2269
|
+
decodedPath,
|
|
2270
|
+
topLevelPath,
|
|
2271
|
+
topLevelSystemLines: 0,
|
|
2272
|
+
topLevelSize: 0,
|
|
2273
|
+
resumable: false,
|
|
2274
|
+
sessionStatus: "transcript-only",
|
|
2275
|
+
sessionJsonlPath: foundTranscriptPath,
|
|
2276
|
+
userLines: transcript.userLines,
|
|
2277
|
+
lastUserPrompt: transcript.lastUserPrompt,
|
|
2278
|
+
msgCount: transcript.msgCount,
|
|
2279
|
+
aiTitle: transcript.aiTitle,
|
|
2280
|
+
mtime: transcript.mtime,
|
|
2281
|
+
friendlyName: clcInfo.name,
|
|
2282
|
+
clcDirectory: clcInfo.directory,
|
|
2283
|
+
registryRootPath
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
results.sort((a, b) => b.mtime - a.mtime);
|
|
2287
|
+
return results.slice(0, limit);
|
|
2288
|
+
}
|
|
2289
|
+
/** Claude Code's project-dir encoding: replace / . - (space) with - */
|
|
2290
|
+
function encodeProjectDir(realPath) {
|
|
2291
|
+
return realPath.replace(/[/.\- ]/g, "-");
|
|
2292
|
+
}
|
|
2293
|
+
/** realpathSync that returns null instead of throwing */
|
|
2294
|
+
function realpathSyncSafe(p) {
|
|
2295
|
+
try {
|
|
2296
|
+
return realpathSync(p);
|
|
2297
|
+
} catch {
|
|
2298
|
+
return null;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
function fmtAge(mtime) {
|
|
2302
|
+
const diffMs = Date.now() - mtime;
|
|
2303
|
+
const diffMin = Math.floor(diffMs / 6e4);
|
|
2304
|
+
const diffHr = Math.floor(diffMs / 36e5);
|
|
2305
|
+
const diffDay = Math.floor(diffMs / 864e5);
|
|
2306
|
+
if (diffMin < 60) return `${diffMin}m`;
|
|
2307
|
+
if (diffHr < 24) return `${diffHr}h`;
|
|
2308
|
+
if (diffDay < 30) return `${diffDay}d`;
|
|
2309
|
+
return `${Math.floor(diffDay / 30)}mo`;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Resolve a name-or-id-or-prefix to a single ScannedSession.
|
|
2313
|
+
*
|
|
2314
|
+
* Comparisons are case-insensitive; stored casing is preserved in output.
|
|
2315
|
+
*
|
|
2316
|
+
* Priority:
|
|
2317
|
+
* 1. Exact case-insensitive match on friendlyName
|
|
2318
|
+
* 2. Partial case-insensitive match (contains)
|
|
2319
|
+
* 3. UUID prefix match
|
|
2320
|
+
*/
|
|
2321
|
+
function resolveSessionByNameOrId(sessions, query) {
|
|
2322
|
+
const qLower = query.toLowerCase().trim();
|
|
2323
|
+
const byExact = sessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase() === qLower);
|
|
2324
|
+
if (byExact.length >= 1) return {
|
|
2325
|
+
session: byExact[0],
|
|
2326
|
+
friendlyName: byExact[0].friendlyName
|
|
2327
|
+
};
|
|
2328
|
+
const byPartial = sessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase().includes(qLower));
|
|
2329
|
+
if (byPartial.length === 1) return {
|
|
2330
|
+
session: byPartial[0],
|
|
2331
|
+
friendlyName: byPartial[0].friendlyName
|
|
2332
|
+
};
|
|
2333
|
+
if (byPartial.length > 1) {
|
|
2334
|
+
const candidates = byPartial.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
|
|
2335
|
+
throw new Error(`Ambiguous name "${query}" — ${byPartial.length} matches:\n${candidates}\n\nBe more specific or use a UUID prefix.`);
|
|
2336
|
+
}
|
|
2337
|
+
const byUuid = sessions.filter((s) => s.uuid.startsWith(qLower));
|
|
2338
|
+
if (byUuid.length === 1) return {
|
|
2339
|
+
session: byUuid[0],
|
|
2340
|
+
friendlyName: byUuid[0].friendlyName
|
|
2341
|
+
};
|
|
2342
|
+
if (byUuid.length > 1) {
|
|
2343
|
+
const candidates = byUuid.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
|
|
2344
|
+
throw new Error(`UUID prefix "${query}" is ambiguous — ${byUuid.length} matches:\n${candidates}\n\nProvide more characters.`);
|
|
2345
|
+
}
|
|
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.`);
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
//#endregion
|
|
2350
|
+
//#region src/cli/lib/aibroker-client.ts
|
|
2351
|
+
/**
|
|
2352
|
+
* aibroker-client.ts — Lightweight IPC client for AIBroker daemon.
|
|
2353
|
+
*
|
|
2354
|
+
* Connects to the AIBroker Unix Domain Socket, sends a JSON-RPC request,
|
|
2355
|
+
* and reads a single newline-terminated JSON response. No class needed —
|
|
2356
|
+
* just a thin async function matching the WatcherClient protocol.
|
|
2357
|
+
*
|
|
2358
|
+
* Socket path: /tmp/aibroker.sock (default; override via AIBROKER_SOCKET env).
|
|
2359
|
+
*/
|
|
2360
|
+
const DEFAULT_SOCKET = process.env.AIBROKER_SOCKET ?? "/tmp/aibroker.sock";
|
|
2361
|
+
/**
|
|
2362
|
+
* Call an AIBroker IPC method and return the result.
|
|
2363
|
+
*
|
|
2364
|
+
* Resolves with the `result` field of a successful response.
|
|
2365
|
+
* Rejects if the socket is not available, the call times out, or the
|
|
2366
|
+
* daemon returns an error.
|
|
2367
|
+
*
|
|
2368
|
+
* @param method IPC method name (e.g. "session_content", "send_to_session")
|
|
2369
|
+
* @param params Method parameters object
|
|
2370
|
+
* @param timeoutMs Connection + response timeout in milliseconds (default: 8 000)
|
|
2371
|
+
*/
|
|
2372
|
+
function callAiBroker(method, params = {}, timeoutMs = 8e3) {
|
|
2373
|
+
return new Promise((resolve, reject) => {
|
|
2374
|
+
const socketPath = DEFAULT_SOCKET;
|
|
2375
|
+
let done = false;
|
|
2376
|
+
let buffer = "";
|
|
2377
|
+
let timer = null;
|
|
2378
|
+
function finish(err, value) {
|
|
2379
|
+
if (done) return;
|
|
2380
|
+
done = true;
|
|
2381
|
+
if (timer !== null) {
|
|
2382
|
+
clearTimeout(timer);
|
|
2383
|
+
timer = null;
|
|
2384
|
+
}
|
|
2385
|
+
try {
|
|
2386
|
+
socket.destroy();
|
|
2387
|
+
} catch {}
|
|
2388
|
+
if (err) reject(err);
|
|
2389
|
+
else resolve(value);
|
|
2390
|
+
}
|
|
2391
|
+
const socket = connect(socketPath, () => {
|
|
2392
|
+
const request = {
|
|
2393
|
+
id: randomUUID(),
|
|
2394
|
+
sessionId: process.env.TERM_SESSION_ID ?? "pai-cli",
|
|
2395
|
+
method,
|
|
2396
|
+
params
|
|
2397
|
+
};
|
|
2398
|
+
const itermId = process.env.ITERM_SESSION_ID;
|
|
2399
|
+
if (itermId) Object.assign(request, { itermSessionId: itermId });
|
|
2400
|
+
socket.write(JSON.stringify(request) + "\n");
|
|
2401
|
+
});
|
|
2402
|
+
socket.on("data", (chunk) => {
|
|
2403
|
+
buffer += chunk.toString("utf8");
|
|
2404
|
+
const nl = buffer.indexOf("\n");
|
|
2405
|
+
if (nl === -1) return;
|
|
2406
|
+
const line = buffer.slice(0, nl);
|
|
2407
|
+
let response;
|
|
2408
|
+
try {
|
|
2409
|
+
response = JSON.parse(line);
|
|
2410
|
+
} catch {
|
|
2411
|
+
finish(/* @__PURE__ */ new Error(`AIBroker IPC parse error: ${line.slice(0, 120)}`));
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
if (!response.ok) finish(new Error(response.error ?? "AIBroker IPC call failed"));
|
|
2415
|
+
else finish(null, response.result ?? {});
|
|
2416
|
+
});
|
|
2417
|
+
socket.on("error", (e) => {
|
|
2418
|
+
if (e.code === "ENOENT" || e.code === "ECONNREFUSED") finish(/* @__PURE__ */ new Error("AIBroker not running (socket not found)."));
|
|
2419
|
+
else finish(e);
|
|
2420
|
+
});
|
|
2421
|
+
socket.on("end", () => {
|
|
2422
|
+
if (!done) finish(/* @__PURE__ */ new Error("AIBroker IPC connection closed before response."));
|
|
2423
|
+
});
|
|
2424
|
+
timer = setTimeout(() => finish(/* @__PURE__ */ new Error("AIBroker IPC call timed out.")), timeoutMs);
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Fetch all live iTerm2 sessions from AIBroker.
|
|
2429
|
+
* Returns an empty array if AIBroker is not running.
|
|
2430
|
+
*/
|
|
2431
|
+
async function fetchLiveSessions() {
|
|
2432
|
+
try {
|
|
2433
|
+
const sessions = (await callAiBroker("session_content", { lineCount: 0 })).sessions;
|
|
2434
|
+
if (!Array.isArray(sessions)) return [];
|
|
2435
|
+
return sessions;
|
|
2436
|
+
} catch {
|
|
2437
|
+
return [];
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Send text to a specific AIBroker session by its iTerm2 sessionId.
|
|
2442
|
+
*/
|
|
2443
|
+
async function sendToSession(sessionId, text) {
|
|
2444
|
+
try {
|
|
2445
|
+
await callAiBroker("send_to_session", {
|
|
2446
|
+
sessionId,
|
|
2447
|
+
text
|
|
2448
|
+
});
|
|
2449
|
+
return { ok: true };
|
|
2450
|
+
} catch (e) {
|
|
2451
|
+
return {
|
|
2452
|
+
ok: false,
|
|
2453
|
+
error: String(e)
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
//#endregion
|
|
2459
|
+
//#region src/cli/commands/session/recent.ts
|
|
2460
|
+
function fmtSize(bytes) {
|
|
2461
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
2462
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)}K`;
|
|
2463
|
+
return `${(bytes / 1048576).toFixed(1)}M`;
|
|
2464
|
+
}
|
|
2465
|
+
function fmtStatus(status) {
|
|
2466
|
+
switch (status) {
|
|
2467
|
+
case "resumable": return chalk.green("resumable");
|
|
2468
|
+
case "stub": return chalk.yellow("stub");
|
|
2469
|
+
case "transcript-only": return chalk.yellow("transcript-only");
|
|
2470
|
+
case "orphan": return chalk.dim("orphan");
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
function renderLiveSessions(liveSessions) {
|
|
2474
|
+
if (liveSessions.length === 0) return;
|
|
2475
|
+
console.log("\n" + header("Live Sessions") + "\n");
|
|
2476
|
+
const liveHeaders = [
|
|
2477
|
+
"#",
|
|
2478
|
+
"iTerm2 id",
|
|
2479
|
+
"name",
|
|
2480
|
+
"at prompt",
|
|
2481
|
+
"paiName"
|
|
2482
|
+
];
|
|
2483
|
+
const liveRows = liveSessions.map((s, idx) => {
|
|
2484
|
+
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("—");
|
|
2487
|
+
const atPrompt = s.atPrompt ? chalk.green("yes") : chalk.yellow("busy");
|
|
2488
|
+
return [
|
|
2489
|
+
chalk.dim(String(idx + 1)),
|
|
2490
|
+
chalk.cyan(shortId),
|
|
2491
|
+
name,
|
|
2492
|
+
atPrompt,
|
|
2493
|
+
paiName
|
|
2494
|
+
];
|
|
2495
|
+
});
|
|
2496
|
+
console.log(renderTable(liveHeaders, liveRows));
|
|
2497
|
+
}
|
|
2498
|
+
async function cmdRecent(db, opts) {
|
|
2499
|
+
const limit = parseInt(opts.n ?? "20", 10);
|
|
2500
|
+
const includeAll = opts.all === true;
|
|
2501
|
+
const liveSessions = await fetchLiveSessions();
|
|
2502
|
+
const sessions = scanSessions(db, {
|
|
2503
|
+
limit,
|
|
2504
|
+
filter: includeAll ? "all" : "named"
|
|
2505
|
+
});
|
|
2506
|
+
if (opts.json) {
|
|
2507
|
+
const output = {
|
|
2508
|
+
live: liveSessions.map((s) => ({
|
|
2509
|
+
sessionId: s.sessionId,
|
|
2510
|
+
name: s.name,
|
|
2511
|
+
paiName: s.paiName ?? null,
|
|
2512
|
+
atPrompt: s.atPrompt
|
|
2513
|
+
})),
|
|
2514
|
+
paused: sessions.map((s, idx) => ({
|
|
2515
|
+
idx: idx + 1,
|
|
2516
|
+
uuid: s.uuid,
|
|
2517
|
+
shortId: s.shortId,
|
|
2518
|
+
resumable: s.resumable,
|
|
2519
|
+
sessionStatus: s.sessionStatus,
|
|
2520
|
+
age: fmtAge(s.mtime),
|
|
2521
|
+
mtime: s.mtime,
|
|
2522
|
+
name: s.friendlyName,
|
|
2523
|
+
lastUserPrompt: s.lastUserPrompt,
|
|
2524
|
+
userLines: s.userLines,
|
|
2525
|
+
msgCount: s.msgCount,
|
|
2526
|
+
topLevelSize: s.topLevelSize,
|
|
2527
|
+
decodedPath: s.decodedPath
|
|
2528
|
+
}))
|
|
2529
|
+
};
|
|
2530
|
+
console.log(JSON.stringify(output, null, 2));
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
if (liveSessions.length > 0) renderLiveSessions(liveSessions);
|
|
2534
|
+
if (sessions.length === 0) {
|
|
2535
|
+
if (liveSessions.length === 0) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
|
|
2536
|
+
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
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const title = includeAll ? "Paused / All Sessions (named + orphans)" : "Paused / Resumable Sessions";
|
|
2540
|
+
console.log("\n" + header(title) + "\n");
|
|
2541
|
+
const headers = [
|
|
2542
|
+
"#",
|
|
2543
|
+
"id",
|
|
2544
|
+
"age",
|
|
2545
|
+
"name / project",
|
|
2546
|
+
"last prompt",
|
|
2547
|
+
"msgs",
|
|
2548
|
+
"size",
|
|
2549
|
+
"status"
|
|
2550
|
+
];
|
|
2551
|
+
const rows = sessions.map((s, idx) => {
|
|
2552
|
+
const name = s.friendlyName ?? s.decodedPath;
|
|
2553
|
+
const prompt = s.lastUserPrompt ? chalk.dim(s.lastUserPrompt.length > 40 ? s.lastUserPrompt.slice(0, 39) + "…" : s.lastUserPrompt) : dim("—");
|
|
2554
|
+
return [
|
|
2555
|
+
chalk.dim(String(idx + 1)),
|
|
2556
|
+
chalk.cyan(s.shortId),
|
|
2557
|
+
dim(fmtAge(s.mtime)),
|
|
2558
|
+
name.length > 26 ? name.slice(0, 25) + "…" : name,
|
|
2559
|
+
prompt,
|
|
2560
|
+
s.userLines > 0 ? String(s.userLines) : dim("—"),
|
|
2561
|
+
dim(fmtSize(s.topLevelSize)),
|
|
2562
|
+
fmtStatus(s.sessionStatus)
|
|
2563
|
+
];
|
|
2564
|
+
});
|
|
2565
|
+
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"));
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
//#endregion
|
|
2570
|
+
//#region src/cli/commands/session/goto.ts
|
|
2571
|
+
/**
|
|
2572
|
+
* Run claude --resume <uuid> --print --output-format=json "_" in the given cwd.
|
|
2573
|
+
* Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
|
|
2574
|
+
* in stderr). Timeout: 5 seconds.
|
|
2575
|
+
*/
|
|
2576
|
+
function probeResume(uuid, cwd) {
|
|
2577
|
+
const result = spawnSync("claude", [
|
|
2578
|
+
"--resume",
|
|
2579
|
+
uuid,
|
|
2580
|
+
"--print",
|
|
2581
|
+
"--output-format=json",
|
|
2582
|
+
"_"
|
|
2583
|
+
], {
|
|
2584
|
+
cwd,
|
|
2585
|
+
timeout: 5e3,
|
|
2586
|
+
env: process.env,
|
|
2587
|
+
stdio: [
|
|
2588
|
+
"ignore",
|
|
2589
|
+
"ignore",
|
|
2590
|
+
"pipe"
|
|
2591
|
+
]
|
|
2592
|
+
});
|
|
2593
|
+
if (result.error) return {
|
|
2594
|
+
ok: false,
|
|
2595
|
+
reason: `spawn error: ${result.error.message}`
|
|
2596
|
+
};
|
|
2597
|
+
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
2598
|
+
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
2599
|
+
ok: false,
|
|
2600
|
+
reason: "No conversation found for this UUID"
|
|
2601
|
+
};
|
|
2602
|
+
if (result.status !== 0) return {
|
|
2603
|
+
ok: false,
|
|
2604
|
+
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
2605
|
+
};
|
|
2606
|
+
return { ok: true };
|
|
2607
|
+
}
|
|
2608
|
+
function cmdGoto(db, query, opts) {
|
|
2609
|
+
const allSessions = scanSessions(db, {
|
|
2610
|
+
limit: 500,
|
|
2611
|
+
filter: "named"
|
|
2612
|
+
});
|
|
2613
|
+
let resolved;
|
|
2614
|
+
try {
|
|
2615
|
+
resolved = resolveSessionByNameOrId(allSessions, query);
|
|
2616
|
+
} catch (resolveErr) {
|
|
2617
|
+
console.error(err(String(resolveErr).replace(/^Error: /, "")));
|
|
2618
|
+
process.exit(1);
|
|
2619
|
+
}
|
|
2620
|
+
const { session: matchedSession, friendlyName } = resolved;
|
|
2621
|
+
let resumableUuid;
|
|
2622
|
+
let resumableSession;
|
|
2623
|
+
if (matchedSession.resumable) {
|
|
2624
|
+
resumableUuid = matchedSession.uuid;
|
|
2625
|
+
resumableSession = matchedSession;
|
|
2626
|
+
} else if (matchedSession.encodedDir) {
|
|
2627
|
+
const sameProject = allSessions.filter((s) => s.encodedDir === matchedSession.encodedDir && s.resumable);
|
|
2628
|
+
sameProject.sort((a, b) => b.mtime - a.mtime);
|
|
2629
|
+
if (sameProject.length > 0) {
|
|
2630
|
+
resumableSession = sameProject[0];
|
|
2631
|
+
resumableUuid = resumableSession.uuid;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
const rawDir = matchedSession.clcDirectory ?? matchedSession.registryRootPath ?? matchedSession.decodedPath;
|
|
2635
|
+
let projectDir;
|
|
2636
|
+
try {
|
|
2637
|
+
projectDir = realpathSync(rawDir);
|
|
2638
|
+
} catch {
|
|
2639
|
+
console.error(err(`session "${query}": directory does not exist or cannot be resolved.\n Registry says: ${rawDir}\n The directory may have moved or been deleted.`));
|
|
2640
|
+
process.exit(1);
|
|
2641
|
+
}
|
|
2642
|
+
const name = friendlyName ?? query;
|
|
2643
|
+
const promptArg = `/Name ${name}\ngo`;
|
|
2644
|
+
if (opts.dryRun) {
|
|
2645
|
+
if (resumableUuid) {
|
|
2646
|
+
const argvResume = `claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`;
|
|
2647
|
+
const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
|
|
2648
|
+
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
2649
|
+
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
2650
|
+
console.log(` probe: claude --resume ${resumableUuid} --print --output-format=json "_"`);
|
|
2651
|
+
console.log(` argv: ${chalk.white(argvResume)}`);
|
|
2652
|
+
console.log(` fallback: ${chalk.yellow(argvFresh)}`);
|
|
2653
|
+
if (resumableSession) {
|
|
2654
|
+
console.log(`\n uuid: ${resumableSession.uuid}`);
|
|
2655
|
+
console.log(` age: ${fmtAge(resumableSession.mtime)}`);
|
|
2656
|
+
console.log(` status: ${resumableSession.sessionStatus}`);
|
|
2657
|
+
console.log(` sys: ${resumableSession.topLevelSystemLines} system lines`);
|
|
2658
|
+
}
|
|
2659
|
+
} else {
|
|
2660
|
+
const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
|
|
2661
|
+
console.log("\n" + chalk.bold("Dry run — would exec (FRESH path, no resumable UUID):") + "\n");
|
|
2662
|
+
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
2663
|
+
console.log(` argv: ${chalk.white(argvFresh)}`);
|
|
2664
|
+
}
|
|
2665
|
+
console.log();
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
if (resumableUuid) {
|
|
2669
|
+
const probe = probeResume(resumableUuid, projectDir);
|
|
2670
|
+
if (probe.ok) {
|
|
2671
|
+
const result = spawnSync("claude", [
|
|
2672
|
+
"--resume",
|
|
2673
|
+
resumableUuid,
|
|
2674
|
+
"--name",
|
|
2675
|
+
name,
|
|
2676
|
+
promptArg
|
|
2677
|
+
], {
|
|
2678
|
+
cwd: projectDir,
|
|
2679
|
+
stdio: "inherit",
|
|
2680
|
+
env: process.env
|
|
2681
|
+
});
|
|
2682
|
+
if (result.error) {
|
|
2683
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
2684
|
+
process.exit(1);
|
|
2685
|
+
}
|
|
2686
|
+
process.exit(result.status ?? 0);
|
|
2687
|
+
} else {
|
|
2688
|
+
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`));
|
|
2689
|
+
const result = spawnSync("claude", [
|
|
2690
|
+
"--name",
|
|
2691
|
+
name,
|
|
2692
|
+
promptArg
|
|
2693
|
+
], {
|
|
2694
|
+
cwd: projectDir,
|
|
2695
|
+
stdio: "inherit",
|
|
2696
|
+
env: process.env
|
|
2697
|
+
});
|
|
2698
|
+
if (result.error) {
|
|
2699
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
2700
|
+
process.exit(1);
|
|
2701
|
+
}
|
|
2702
|
+
process.exit(result.status ?? 0);
|
|
2703
|
+
}
|
|
2704
|
+
} else {
|
|
2705
|
+
const result = spawnSync("claude", [
|
|
2706
|
+
"--name",
|
|
2707
|
+
name,
|
|
2708
|
+
promptArg
|
|
2709
|
+
], {
|
|
2710
|
+
cwd: projectDir,
|
|
2711
|
+
stdio: "inherit",
|
|
2712
|
+
env: process.env
|
|
2713
|
+
});
|
|
2714
|
+
if (result.error) {
|
|
2715
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
2716
|
+
process.exit(1);
|
|
2717
|
+
}
|
|
2718
|
+
process.exit(result.status ?? 0);
|
|
2719
|
+
}
|
|
1816
2720
|
}
|
|
1817
2721
|
|
|
1818
2722
|
//#endregion
|
|
1819
|
-
//#region src/cli/commands/session/
|
|
2723
|
+
//#region src/cli/commands/session/pause.ts
|
|
1820
2724
|
const HANDOVER_TODO_LOCATIONS = [
|
|
1821
2725
|
"Notes/TODO.md",
|
|
1822
2726
|
".claude/Notes/TODO.md",
|
|
@@ -1854,28 +2758,19 @@ function stripContinueSection(content) {
|
|
|
1854
2758
|
while (after.length > 0 && after[0].trim() === "") after.shift();
|
|
1855
2759
|
return [...before, ...after].join("\n");
|
|
1856
2760
|
}
|
|
1857
|
-
function
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
LIMIT 1`).get(cwd);
|
|
1869
|
-
if (!row) process.exit(0);
|
|
1870
|
-
project = row;
|
|
1871
|
-
}
|
|
1872
|
-
let session;
|
|
1873
|
-
const nol = numberOrLatest ?? "latest";
|
|
1874
|
-
if (nol === "latest") session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
|
|
1875
|
-
else {
|
|
1876
|
-
const num = parseInt(nol, 10);
|
|
1877
|
-
if (!isNaN(num)) session = db.prepare("SELECT * FROM sessions WHERE project_id = ? AND number = ?").get(project.id, num);
|
|
2761
|
+
function cmdPause(db, opts) {
|
|
2762
|
+
const cwd = process.cwd();
|
|
2763
|
+
const project = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir
|
|
2764
|
+
FROM projects
|
|
2765
|
+
WHERE ? LIKE root_path || '%'
|
|
2766
|
+
ORDER BY length(root_path) DESC
|
|
2767
|
+
LIMIT 1`).get(cwd);
|
|
2768
|
+
if (!project) {
|
|
2769
|
+
console.error(chalk.red("pai session pause: ") + `Current directory is not within a registered PAI project.
|
|
2770
|
+
cwd: ${cwd}`);
|
|
2771
|
+
process.exit(1);
|
|
1878
2772
|
}
|
|
2773
|
+
const session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
|
|
1879
2774
|
const todo = findProjectTodo(project.root_path);
|
|
1880
2775
|
let todoPath;
|
|
1881
2776
|
let existingContent;
|
|
@@ -1884,23 +2779,23 @@ function cmdHandover(db, projectSlug, numberOrLatest) {
|
|
|
1884
2779
|
existingContent = todo.content;
|
|
1885
2780
|
} else {
|
|
1886
2781
|
const notesDir = join(project.root_path, "Notes");
|
|
1887
|
-
try {
|
|
2782
|
+
if (!opts.dryRun) try {
|
|
1888
2783
|
if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
|
|
1889
|
-
} catch {
|
|
1890
|
-
|
|
2784
|
+
} catch (mkdirErr) {
|
|
2785
|
+
console.error(chalk.red("pai session pause: ") + `Could not create Notes directory: ${String(mkdirErr)}`);
|
|
2786
|
+
process.exit(1);
|
|
1891
2787
|
}
|
|
1892
2788
|
todoPath = join(notesDir, "TODO.md");
|
|
1893
2789
|
existingContent = "";
|
|
1894
2790
|
}
|
|
1895
2791
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1896
|
-
const cwd = process.cwd();
|
|
1897
2792
|
let sessionLine;
|
|
1898
2793
|
if (session) {
|
|
1899
2794
|
const num = String(session.number).padStart(4, "0");
|
|
1900
2795
|
const titlePart = session.title || session.slug || "Session";
|
|
1901
2796
|
sessionLine = `${num} - ${session.date} - ${titlePart}`;
|
|
1902
2797
|
} else sessionLine = "Unknown session";
|
|
1903
|
-
const
|
|
2798
|
+
const continueBlock = [
|
|
1904
2799
|
"## Continue",
|
|
1905
2800
|
"",
|
|
1906
2801
|
`> **Last session:** ${sessionLine}`,
|
|
@@ -1910,51 +2805,322 @@ function cmdHandover(db, projectSlug, numberOrLatest) {
|
|
|
1910
2805
|
"",
|
|
1911
2806
|
"---",
|
|
1912
2807
|
""
|
|
1913
|
-
].join("\n")
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2808
|
+
].join("\n");
|
|
2809
|
+
const newContent = continueBlock + stripContinueSection(existingContent).trimStart();
|
|
2810
|
+
if (opts.dryRun) {
|
|
2811
|
+
console.log("\n" + chalk.bold("Dry run — would write to:") + " " + chalk.cyan(todoPath));
|
|
2812
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
2813
|
+
console.log(chalk.dim(continueBlock));
|
|
2814
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
2815
|
+
} else {
|
|
2816
|
+
const tmpPath = `${todoPath}.pause.tmp`;
|
|
1919
2817
|
try {
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2818
|
+
writeFileSync(tmpPath, newContent, "utf8");
|
|
2819
|
+
renameSync(tmpPath, todoPath);
|
|
2820
|
+
} catch (writeErr) {
|
|
2821
|
+
try {
|
|
2822
|
+
if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
|
|
2823
|
+
} catch {}
|
|
2824
|
+
console.error(chalk.red("pai session pause: ") + `Failed to write TODO.md: ${String(writeErr)}`);
|
|
2825
|
+
process.exit(1);
|
|
2826
|
+
}
|
|
2827
|
+
console.log(chalk.green(" ## Continue written to: ") + chalk.cyan(todoPath));
|
|
1923
2828
|
}
|
|
1924
|
-
|
|
2829
|
+
const box = [
|
|
2830
|
+
"",
|
|
2831
|
+
chalk.bgRed.white.bold(" IMPORTANT: How to exit safely "),
|
|
2832
|
+
"",
|
|
2833
|
+
chalk.yellow(" Inside the Claude Code session, type: " + chalk.white.bold("/exit") + chalk.yellow(" (then press Enter)")),
|
|
2834
|
+
"",
|
|
2835
|
+
chalk.red.bold(" DO NOT press Ctrl+C."),
|
|
2836
|
+
"",
|
|
2837
|
+
chalk.dim(" Ctrl+C bypasses PAI's stop-hook, which means:"),
|
|
2838
|
+
chalk.dim(" - No project snapshot is written to the top-level jsonl"),
|
|
2839
|
+
chalk.dim(" - The session becomes orphaned (cannot --resume next time)"),
|
|
2840
|
+
chalk.dim(" - pai session goto will launch a fresh session instead of resuming"),
|
|
2841
|
+
"",
|
|
2842
|
+
chalk.dim(" The ## Continue checkpoint in TODO.md is already saved."),
|
|
2843
|
+
chalk.dim(" Use /exit to preserve full session resumability."),
|
|
2844
|
+
""
|
|
2845
|
+
].join("\n");
|
|
2846
|
+
console.log(box);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
//#endregion
|
|
2850
|
+
//#region src/cli/commands/session/end.ts
|
|
2851
|
+
/** PAI_DIR — mirrors pai-paths.ts resolution. */
|
|
2852
|
+
function getPaiDir() {
|
|
2853
|
+
const envDir = process.env.PAI_DIR;
|
|
2854
|
+
if (envDir) try {
|
|
2855
|
+
return realpathSync(envDir);
|
|
2856
|
+
} catch {
|
|
2857
|
+
return envDir;
|
|
2858
|
+
}
|
|
2859
|
+
return join(homedir(), ".claude");
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Find the notes directory for a project — checks local first, then central.
|
|
2863
|
+
* Returns null if neither exists (dry-run safe: don't create).
|
|
2864
|
+
*/
|
|
2865
|
+
function findNotesDir$1(rootPath, encodedDir) {
|
|
2866
|
+
for (const rel of [
|
|
2867
|
+
"Notes",
|
|
2868
|
+
"notes",
|
|
2869
|
+
".claude/Notes"
|
|
2870
|
+
]) {
|
|
2871
|
+
const p = join(rootPath, rel);
|
|
2872
|
+
if (existsSync(p)) return p;
|
|
2873
|
+
}
|
|
2874
|
+
const central = join(getPaiDir(), "projects", encodedDir, "Notes");
|
|
2875
|
+
if (existsSync(central)) return central;
|
|
2876
|
+
return null;
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Find the current (latest) session note inside notesDir.
|
|
2880
|
+
* Searches YYYY/MM subdirectory (current month, then previous month),
|
|
2881
|
+
* then flat notesDir as legacy fallback.
|
|
2882
|
+
*/
|
|
2883
|
+
function findLatestNote(notesDir) {
|
|
2884
|
+
const findIn = (dir) => {
|
|
2885
|
+
if (!existsSync(dir)) return null;
|
|
2886
|
+
const files = readdirSync(dir).filter((f) => /^\d{3,4}[\s_-].*\.md$/.test(f)).sort((a, b) => {
|
|
2887
|
+
return parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
|
|
2888
|
+
});
|
|
2889
|
+
return files.length > 0 ? join(dir, files[files.length - 1]) : null;
|
|
2890
|
+
};
|
|
2891
|
+
const now = /* @__PURE__ */ new Date();
|
|
2892
|
+
const current = findIn(join(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
|
|
2893
|
+
if (current) return current;
|
|
2894
|
+
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
2895
|
+
const prevFound = findIn(join(notesDir, String(prev.getFullYear()), String(prev.getMonth() + 1).padStart(2, "0")));
|
|
2896
|
+
if (prevFound) return prevFound;
|
|
2897
|
+
return findIn(notesDir);
|
|
2898
|
+
}
|
|
2899
|
+
function finalizeNote(notePath) {
|
|
2900
|
+
const content = readFileSync(notePath, "utf-8");
|
|
2901
|
+
if (content.includes("**Status:** Completed")) return {
|
|
2902
|
+
finalized: false,
|
|
2903
|
+
path: notePath
|
|
2904
|
+
};
|
|
2905
|
+
let updated = content.replace("**Status:** In Progress", "**Status:** Completed");
|
|
2906
|
+
if (!updated.includes("**Completed:**")) {
|
|
2907
|
+
const completionTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
2908
|
+
updated = updated.replace("---\n\n## Work Done", `**Completed:** ${completionTime}\n\n---\n\n## Work Done`);
|
|
2909
|
+
}
|
|
2910
|
+
const tmp = `${notePath}.end.tmp`;
|
|
2911
|
+
writeFileSync(tmp, updated, "utf-8");
|
|
2912
|
+
renameSync(tmp, notePath);
|
|
2913
|
+
return {
|
|
2914
|
+
finalized: true,
|
|
2915
|
+
path: notePath
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
function cmdEnd(db, opts) {
|
|
2919
|
+
cmdPause(db, opts);
|
|
2920
|
+
const cwd = process.cwd();
|
|
2921
|
+
const project = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir
|
|
2922
|
+
FROM projects
|
|
2923
|
+
WHERE ? LIKE root_path || '%'
|
|
2924
|
+
ORDER BY length(root_path) DESC
|
|
2925
|
+
LIMIT 1`).get(cwd);
|
|
2926
|
+
if (!project) return;
|
|
2927
|
+
const notesDir = findNotesDir$1(project.root_path, project.encoded_dir ?? "");
|
|
2928
|
+
if (!notesDir) {
|
|
2929
|
+
console.log(chalk.dim(" (No session note found — notes directory does not exist yet.)"));
|
|
2930
|
+
printEndBox(opts.dryRun);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
const notePath = findLatestNote(notesDir);
|
|
2934
|
+
if (!notePath) {
|
|
2935
|
+
console.log(chalk.dim(" (No session note found in notes directory.)"));
|
|
2936
|
+
printEndBox(opts.dryRun);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
if (opts.dryRun) {
|
|
2940
|
+
console.log("\n" + chalk.bold("Dry run — would finalize session note:") + " " + chalk.cyan(notePath));
|
|
2941
|
+
console.log(chalk.dim(" Would replace: **Status:** In Progress\n with: **Status:** Completed"));
|
|
2942
|
+
console.log(chalk.dim(" Would add: **Completed:** <timestamp>"));
|
|
2943
|
+
} else {
|
|
2944
|
+
const { finalized, path } = finalizeNote(notePath);
|
|
2945
|
+
if (finalized) console.log(chalk.green(" Session note finalized: ") + chalk.cyan(basename(path)));
|
|
2946
|
+
else console.log(chalk.dim(` Session note already marked Completed: ${basename(notePath)}`));
|
|
2947
|
+
}
|
|
2948
|
+
printEndBox(opts.dryRun);
|
|
2949
|
+
}
|
|
2950
|
+
function printEndBox(dryRun) {
|
|
2951
|
+
const label = dryRun ? " (dry-run)" : "";
|
|
2952
|
+
const box = [
|
|
2953
|
+
"",
|
|
2954
|
+
chalk.bgRed.white.bold(` SESSION ENDING${label}: How to exit safely `),
|
|
2955
|
+
"",
|
|
2956
|
+
chalk.yellow(" Inside the Claude Code session, type: " + chalk.white.bold("/exit") + chalk.yellow(" (then press Enter)")),
|
|
2957
|
+
"",
|
|
2958
|
+
chalk.red.bold(" DO NOT press Ctrl+C."),
|
|
2959
|
+
"",
|
|
2960
|
+
chalk.dim(" Ctrl+C bypasses PAI's stop-hook, which means:"),
|
|
2961
|
+
chalk.dim(" - The session note is NOT written to by the stop-hook"),
|
|
2962
|
+
chalk.dim(" - The session becomes orphaned (cannot --resume next time)"),
|
|
2963
|
+
chalk.dim(" - The final session summary is never generated"),
|
|
2964
|
+
"",
|
|
2965
|
+
chalk.dim(" ## Continue checkpoint and session note status are already saved."),
|
|
2966
|
+
chalk.dim(" Use /exit to let PAI's stop-hook finalize the session fully."),
|
|
2967
|
+
""
|
|
2968
|
+
].join("\n");
|
|
2969
|
+
console.log(box);
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
//#endregion
|
|
2973
|
+
//#region src/cli/commands/session/pause-all.ts
|
|
2974
|
+
/**
|
|
2975
|
+
* pai pause all [--exit] [--dry-run]
|
|
2976
|
+
*
|
|
2977
|
+
* Pause every live Claude Code session that AIBroker knows about.
|
|
2978
|
+
*
|
|
2979
|
+
* For each live session returned by AIBroker's session_content IPC method:
|
|
2980
|
+
* 1. Send "pause session" to the iTerm2 pane via send_to_session.
|
|
2981
|
+
* 2. Optionally send "\n/exit\n" after a short delay (--exit flag).
|
|
2982
|
+
* 3. Print a summary of what was sent and to which sessions.
|
|
2983
|
+
*
|
|
2984
|
+
* If AIBroker is not running, prints a clear error and exits.
|
|
2985
|
+
* If no live sessions are found, reports "nothing to pause".
|
|
2986
|
+
*/
|
|
2987
|
+
function sleep(ms) {
|
|
2988
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2989
|
+
}
|
|
2990
|
+
function sessionLabel(s) {
|
|
2991
|
+
const name = s.paiName ?? s.name;
|
|
2992
|
+
return `${chalk.cyan(s.sessionId.slice(0, 8))} ${chalk.bold(name)}`;
|
|
2993
|
+
}
|
|
2994
|
+
async function cmdPauseAll(opts) {
|
|
2995
|
+
const waitMs = opts.wait ?? 5e3;
|
|
2996
|
+
let liveSessions;
|
|
2997
|
+
try {
|
|
2998
|
+
liveSessions = await fetchLiveSessions();
|
|
2999
|
+
} catch (e) {
|
|
3000
|
+
console.error(err("AIBroker is not running. Cannot list live sessions."));
|
|
3001
|
+
console.error(dim(" Start AIBroker or run `pai pause` from each session manually."));
|
|
3002
|
+
process.exitCode = 1;
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
const claudeSessions = liveSessions.filter((s) => s.paiName !== null && s.paiName !== void 0 && s.paiName !== "");
|
|
3006
|
+
const skipped = liveSessions.length - claudeSessions.length;
|
|
3007
|
+
if (skipped > 0) process.stderr.write(`Skipping ${skipped} non-Claude tab${skipped === 1 ? "" : "s"} (bare shells).\n`);
|
|
3008
|
+
if (claudeSessions.length === 0) {
|
|
3009
|
+
console.log(warn("No live Claude sessions found via AIBroker. Nothing to pause."));
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
if (opts.dryRun) {
|
|
3013
|
+
console.log("\n" + header("Dry Run — Would Pause These Sessions") + "\n");
|
|
3014
|
+
for (const s of claudeSessions) {
|
|
3015
|
+
console.log(" " + sessionLabel(s));
|
|
3016
|
+
console.log(dim(" → send: \"pause session\""));
|
|
3017
|
+
if (opts.exit) console.log(dim(` → wait ${waitMs}ms then send: "/exit"`));
|
|
3018
|
+
}
|
|
3019
|
+
console.log();
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
console.log("\n" + header("Pausing All Live Sessions") + "\n" + dim(` ${claudeSessions.length} Claude session(s) via AIBroker`) + "\n");
|
|
3023
|
+
const results = [];
|
|
3024
|
+
for (const s of claudeSessions) {
|
|
3025
|
+
process.stdout.write(" " + sessionLabel(s) + " … ");
|
|
3026
|
+
const pauseResult = await sendToSession(s.sessionId, "pause session\n");
|
|
3027
|
+
if (!pauseResult.ok) {
|
|
3028
|
+
console.log(err("FAILED: " + (pauseResult.error ?? "unknown error")));
|
|
3029
|
+
results.push({
|
|
3030
|
+
session: s,
|
|
3031
|
+
pauseOk: false,
|
|
3032
|
+
error: pauseResult.error
|
|
3033
|
+
});
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
console.log(ok("paused"));
|
|
3037
|
+
results.push({
|
|
3038
|
+
session: s,
|
|
3039
|
+
pauseOk: true
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
if (opts.exit) {
|
|
3043
|
+
const pausedSessions = results.filter((r) => r.pauseOk).map((r) => r.session);
|
|
3044
|
+
if (pausedSessions.length > 0) {
|
|
3045
|
+
console.log("\n" + dim(` Waiting ${waitMs / 1e3}s for sessions to save state…`));
|
|
3046
|
+
await sleep(waitMs);
|
|
3047
|
+
console.log();
|
|
3048
|
+
for (const s of pausedSessions) {
|
|
3049
|
+
process.stdout.write(" " + sessionLabel(s) + " exiting … ");
|
|
3050
|
+
const exitResult = await sendToSession(s.sessionId, "/exit\n");
|
|
3051
|
+
const rec = results.find((r) => r.session.sessionId === s.sessionId);
|
|
3052
|
+
rec.exitOk = exitResult.ok;
|
|
3053
|
+
if (!exitResult.ok) console.log(warn("exit failed: " + (exitResult.error ?? "unknown")));
|
|
3054
|
+
else console.log(ok("exited"));
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
const total = results.length;
|
|
3059
|
+
const succeeded = results.filter((r) => r.pauseOk).length;
|
|
3060
|
+
const failed = total - succeeded;
|
|
3061
|
+
console.log();
|
|
3062
|
+
if (failed === 0) console.log(ok(`All ${total} session(s) paused successfully.`));
|
|
3063
|
+
else {
|
|
3064
|
+
console.log(warn(`${succeeded}/${total} session(s) paused. `) + err(`${failed} failed.`));
|
|
3065
|
+
for (const r of results.filter((r) => !r.pauseOk)) {
|
|
3066
|
+
const label = r.session.paiName ?? r.session.name;
|
|
3067
|
+
console.log(err(` ${label}: ${r.error ?? "unknown error"}`));
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
if (opts.exit) {
|
|
3071
|
+
const exitOk = results.filter((r) => r.exitOk).length;
|
|
3072
|
+
console.log(dim(` /exit sent to ${exitOk} session(s).`));
|
|
3073
|
+
}
|
|
3074
|
+
console.log();
|
|
1925
3075
|
}
|
|
1926
3076
|
|
|
1927
3077
|
//#endregion
|
|
1928
|
-
//#region src/cli/commands/session/index.ts
|
|
1929
|
-
function
|
|
1930
|
-
|
|
1931
|
-
|
|
3078
|
+
//#region src/cli/commands/session/sessions-index.ts
|
|
3079
|
+
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) => {
|
|
3081
|
+
await cmdRecent(getDb(), opts);
|
|
1932
3082
|
});
|
|
1933
|
-
|
|
3083
|
+
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) => {
|
|
3084
|
+
cmdGoto(getDb(), nameOrId, { dryRun: opts.dryRun });
|
|
3085
|
+
});
|
|
3086
|
+
sessionsCmd.command("pause").description("Write a ## Continue checkpoint to the project's TODO.md.\nRecommended short form: pai pause\nUse /exit inside Claude Code to preserve full session resumability.").option("--dry-run", "Preview the ## Continue block without writing it").action((opts) => {
|
|
3087
|
+
cmdPause(getDb(), opts);
|
|
3088
|
+
});
|
|
3089
|
+
sessionsCmd.command("pause-all").description("Pause every live Claude Code session via AIBroker.\nRequires AIBroker to be running. Sends 'pause session' to each live iTerm2 pane.\nTop-level short form: pai pause all").option("--exit", "Also send /exit to each session after it has saved state").option("--wait <ms>", "Milliseconds to wait before /exit (default: 5000)", "5000").option("--dry-run", "Show what would be sent without actually sending").action(async (opts) => {
|
|
3090
|
+
await cmdPauseAll({
|
|
3091
|
+
exit: opts.exit,
|
|
3092
|
+
dryRun: opts.dryRun,
|
|
3093
|
+
wait: opts.wait !== void 0 ? parseInt(opts.wait, 10) : void 0
|
|
3094
|
+
});
|
|
3095
|
+
});
|
|
3096
|
+
sessionsCmd.command("end").description("Finalize a session: write ## Continue checkpoint + mark session note Completed.\nRecommended short form: pai end\nUse /exit inside Claude Code after running this command.").option("--dry-run", "Preview all changes without writing them").action((opts) => {
|
|
3097
|
+
cmdEnd(getDb(), opts);
|
|
3098
|
+
});
|
|
3099
|
+
sessionsCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
|
|
1934
3100
|
cmdInfo(getDb(), projectSlug, number);
|
|
1935
3101
|
});
|
|
1936
|
-
|
|
3102
|
+
sessionsCmd.command("rename <project-slug> <number> <new-slug>").description("Rename a session note — updates file on disk, H1 title, and registry").action((projectSlug, number, newSlug) => {
|
|
1937
3103
|
cmdRename(getDb(), projectSlug, number, newSlug);
|
|
1938
3104
|
});
|
|
1939
|
-
|
|
3105
|
+
sessionsCmd.command("slug <project-slug> <number>").description("Generate a descriptive slug from the session JSONL transcript").option("--apply", "Rename the session note using the generated slug").action((projectSlug, number, opts) => {
|
|
1940
3106
|
cmdSlug(getDb(), projectSlug, number, opts);
|
|
1941
3107
|
});
|
|
1942
|
-
|
|
3108
|
+
sessionsCmd.command("tag <project-slug> <number> [tags...]").description("Set or show tags on a session.").action((projectSlug, number, tags) => {
|
|
1943
3109
|
cmdTag(getDb(), projectSlug, number, tags);
|
|
1944
3110
|
});
|
|
1945
|
-
|
|
3111
|
+
sessionsCmd.command("route <project-slug> <number> <target-project>").description("Create a cross-reference link from a session to a target project").option("--type <type>", "Link type: related | follow-up | reference", "related").action((projectSlug, number, targetProject, opts) => {
|
|
1946
3112
|
cmdRoute(getDb(), projectSlug, number, targetProject, opts);
|
|
1947
3113
|
});
|
|
1948
|
-
|
|
3114
|
+
sessionsCmd.command("handover [project-slug] [session-id]").description("Write a ## Continue section to the project's TODO.md.\nCalled automatically from session-stop and pre-compact hooks.").action((projectSlug, sessionId) => {
|
|
1949
3115
|
cmdHandover(getDb(), projectSlug, sessionId);
|
|
1950
3116
|
});
|
|
1951
|
-
|
|
3117
|
+
sessionsCmd.command("checkpoint <message>").description("Append a timestamped checkpoint to the active session note.\nRate-limited: skips silently if last checkpoint was < --min-gap seconds ago.").option("--min-gap <seconds>", "Minimum seconds between checkpoints (default: 300 = 5 minutes)", "300").action((message, opts) => {
|
|
1952
3118
|
cmdCheckpoint(message, opts);
|
|
1953
3119
|
});
|
|
1954
|
-
|
|
3120
|
+
sessionsCmd.command("active").description("Show currently active Claude Code sessions.\nDetects live sessions by checking which JSONL transcript files\nwere recently modified in ~/.claude/projects/.").option("--minutes <n>", "Consider sessions active if modified within N minutes (default: 60)", "60").option("--json", "Output raw JSON instead of formatted display").action((opts) => {
|
|
1955
3121
|
cmdActive(getDb(), opts);
|
|
1956
3122
|
});
|
|
1957
|
-
|
|
3123
|
+
sessionsCmd.command("auto-route").description("Auto-detect which project this session belongs to.\nTries: (1) path match in registry, (2) Notes/PAI.md marker walk, (3) topic detection.").option("--cwd <path>", "Working directory to detect from (default: process.cwd())").option("--context <text>", "Conversation context for topic-based fallback routing").option("--json", "Output raw JSON instead of formatted display").action(async (opts) => {
|
|
1958
3124
|
await cmdAutoRoute(opts);
|
|
1959
3125
|
});
|
|
1960
3126
|
}
|
|
@@ -2302,8 +3468,8 @@ function analyzeProject(db, project) {
|
|
|
2302
3468
|
async function countVectorDbPaths(oldPaths) {
|
|
2303
3469
|
if (oldPaths.length === 0) return 0;
|
|
2304
3470
|
try {
|
|
2305
|
-
const { loadConfig } = await import("../config-
|
|
2306
|
-
const { PostgresBackend } = await import("../postgres-
|
|
3471
|
+
const { loadConfig } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
|
|
3472
|
+
const { PostgresBackend } = await import("../postgres-Cp25xZes.mjs");
|
|
2307
3473
|
const config = loadConfig();
|
|
2308
3474
|
if (config.storageBackend !== "postgres") return 0;
|
|
2309
3475
|
const pgBackend = new PostgresBackend(config.postgres ?? {});
|
|
@@ -2323,8 +3489,8 @@ async function countVectorDbPaths(oldPaths) {
|
|
|
2323
3489
|
async function updateVectorDbPaths(moves) {
|
|
2324
3490
|
if (moves.length === 0) return 0;
|
|
2325
3491
|
try {
|
|
2326
|
-
const { loadConfig } = await import("../config-
|
|
2327
|
-
const { PostgresBackend } = await import("../postgres-
|
|
3492
|
+
const { loadConfig } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
|
|
3493
|
+
const { PostgresBackend } = await import("../postgres-Cp25xZes.mjs");
|
|
2328
3494
|
const config = loadConfig();
|
|
2329
3495
|
if (config.storageBackend !== "postgres") return 0;
|
|
2330
3496
|
const pgBackend = new PostgresBackend(config.postgres ?? {});
|
|
@@ -2647,269 +3813,6 @@ function registerSessionCleanupCommand(sessionCmd, getDb) {
|
|
|
2647
3813
|
});
|
|
2648
3814
|
}
|
|
2649
3815
|
|
|
2650
|
-
//#endregion
|
|
2651
|
-
//#region src/cli/commands/registry/utils.ts
|
|
2652
|
-
/**
|
|
2653
|
-
* Upsert a project row. Returns { id, isNew }.
|
|
2654
|
-
*
|
|
2655
|
-
* Matching priority:
|
|
2656
|
-
* 1. root_path — most reliable; handles slug collisions
|
|
2657
|
-
* 2. encoded_dir — Claude project dirs are canonical
|
|
2658
|
-
* 3. Insert with suffix-deduplication on slug collision
|
|
2659
|
-
*/
|
|
2660
|
-
function upsertProject(db, slug, rootPath, encodedDir) {
|
|
2661
|
-
const ts = now();
|
|
2662
|
-
const byPath = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
|
|
2663
|
-
if (byPath) {
|
|
2664
|
-
const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
|
|
2665
|
-
if (!encodedOwner || encodedOwner.id === byPath.id) db.prepare("UPDATE projects SET encoded_dir = ?, updated_at = ? WHERE id = ?").run(encodedDir, ts, byPath.id);
|
|
2666
|
-
return {
|
|
2667
|
-
id: byPath.id,
|
|
2668
|
-
isNew: false
|
|
2669
|
-
};
|
|
2670
|
-
}
|
|
2671
|
-
const byEncoded = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
|
|
2672
|
-
if (byEncoded) {
|
|
2673
|
-
const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
|
|
2674
|
-
if (!pathOwner || pathOwner.id === byEncoded.id) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(rootPath, ts, byEncoded.id);
|
|
2675
|
-
return {
|
|
2676
|
-
id: byEncoded.id,
|
|
2677
|
-
isNew: false
|
|
2678
|
-
};
|
|
2679
|
-
}
|
|
2680
|
-
let finalSlug = slug;
|
|
2681
|
-
let attempt = 0;
|
|
2682
|
-
while (true) {
|
|
2683
|
-
if (!db.prepare("SELECT id FROM projects WHERE slug = ?").get(finalSlug)) break;
|
|
2684
|
-
attempt++;
|
|
2685
|
-
finalSlug = `${slug}-${attempt}`;
|
|
2686
|
-
}
|
|
2687
|
-
const result = db.prepare(`INSERT OR IGNORE INTO projects
|
|
2688
|
-
(slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
|
|
2689
|
-
VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`).run(finalSlug, finalSlug, rootPath, encodedDir, ts, ts);
|
|
2690
|
-
if (result.changes === 0) {
|
|
2691
|
-
const fallback = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir) ?? db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
|
|
2692
|
-
if (fallback) return {
|
|
2693
|
-
id: fallback.id,
|
|
2694
|
-
isNew: false
|
|
2695
|
-
};
|
|
2696
|
-
throw new Error(`upsertProject: INSERT OR IGNORE was suppressed but no matching row found for root_path=${rootPath} encoded_dir=${encodedDir}`);
|
|
2697
|
-
}
|
|
2698
|
-
return {
|
|
2699
|
-
id: result.lastInsertRowid,
|
|
2700
|
-
isNew: true
|
|
2701
|
-
};
|
|
2702
|
-
}
|
|
2703
|
-
/** Upsert a session note. Returns true if newly inserted. */
|
|
2704
|
-
function upsertSession(db, projectId, number, date, slug, title, filename) {
|
|
2705
|
-
if (db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number)) return false;
|
|
2706
|
-
const ts = now();
|
|
2707
|
-
db.prepare(`INSERT INTO sessions
|
|
2708
|
-
(project_id, number, date, slug, title, filename, status, created_at)
|
|
2709
|
-
VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`).run(projectId, number, date, slug, title, filename, ts);
|
|
2710
|
-
return true;
|
|
2711
|
-
}
|
|
2712
|
-
|
|
2713
|
-
//#endregion
|
|
2714
|
-
//#region src/cli/commands/registry/scan.ts
|
|
2715
|
-
/** Registry scan command: walk ~/.claude/projects/ and populate the registry. */
|
|
2716
|
-
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
2717
|
-
const PAI_CONFIG_DIR = join(homedir(), ".pai");
|
|
2718
|
-
const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
|
|
2719
|
-
function loadScanConfig() {
|
|
2720
|
-
if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
|
|
2721
|
-
try {
|
|
2722
|
-
return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
|
|
2723
|
-
} catch {
|
|
2724
|
-
return { scan_dirs: [] };
|
|
2725
|
-
}
|
|
2726
|
-
}
|
|
2727
|
-
function saveScanConfig(config) {
|
|
2728
|
-
mkdirSync(PAI_CONFIG_DIR, { recursive: true });
|
|
2729
|
-
writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
2730
|
-
}
|
|
2731
|
-
function resolveHome(p) {
|
|
2732
|
-
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
2733
|
-
return resolve(p);
|
|
2734
|
-
}
|
|
2735
|
-
/**
|
|
2736
|
-
* Recursively find all .md files in a directory, including YYYY/MM subdirectories.
|
|
2737
|
-
* Returns filenames (basename only).
|
|
2738
|
-
*/
|
|
2739
|
-
function findNoteFiles(dir) {
|
|
2740
|
-
const results = [];
|
|
2741
|
-
if (!existsSync(dir)) return results;
|
|
2742
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
|
|
2743
|
-
else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
|
|
2744
|
-
const yearDir = join(dir, entry.name);
|
|
2745
|
-
for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
|
|
2746
|
-
const monthDir = join(yearDir, monthEntry.name);
|
|
2747
|
-
for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
|
|
2748
|
-
}
|
|
2749
|
-
}
|
|
2750
|
-
return results;
|
|
2751
|
-
}
|
|
2752
|
-
function performScan(db) {
|
|
2753
|
-
const result = {
|
|
2754
|
-
projectsScanned: 0,
|
|
2755
|
-
projectsNew: 0,
|
|
2756
|
-
projectsUpdated: 0,
|
|
2757
|
-
sessionsScanned: 0,
|
|
2758
|
-
sessionsNew: 0,
|
|
2759
|
-
skipped: []
|
|
2760
|
-
};
|
|
2761
|
-
if (!existsSync(CLAUDE_PROJECTS_DIR)) throw new Error(`Claude projects directory not found: ${CLAUDE_PROJECTS_DIR}`);
|
|
2762
|
-
const entries = readdirSync(CLAUDE_PROJECTS_DIR).filter((name) => {
|
|
2763
|
-
return statSync(join(CLAUDE_PROJECTS_DIR, name)).isDirectory();
|
|
2764
|
-
});
|
|
2765
|
-
const lookupMap = buildEncodedDirMap();
|
|
2766
|
-
for (const encodedDir of entries) {
|
|
2767
|
-
const rootPath = decodeEncodedDir(encodedDir, lookupMap);
|
|
2768
|
-
if (!existsSync(rootPath)) {
|
|
2769
|
-
result.skipped.push(`${encodedDir} (decoded: ${rootPath} — path not found on disk)`);
|
|
2770
|
-
result.projectsScanned++;
|
|
2771
|
-
continue;
|
|
2772
|
-
}
|
|
2773
|
-
const slug = slugify$1(basename(rootPath) || encodedDir);
|
|
2774
|
-
const { id, isNew } = upsertProject(db, slug, rootPath, encodedDir);
|
|
2775
|
-
result.projectsScanned++;
|
|
2776
|
-
if (isNew) result.projectsNew++;
|
|
2777
|
-
else result.projectsUpdated++;
|
|
2778
|
-
try {
|
|
2779
|
-
ensurePaiMarker(rootPath, slug);
|
|
2780
|
-
} catch {}
|
|
2781
|
-
const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, "Notes");
|
|
2782
|
-
if (existsSync(claudeNotesDir)) {
|
|
2783
|
-
if (claudeNotesDir !== join(rootPath, "Notes")) db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(claudeNotesDir, Date.now(), id);
|
|
2784
|
-
}
|
|
2785
|
-
if (!existsSync(claudeNotesDir)) continue;
|
|
2786
|
-
const noteFiles = findNoteFiles(claudeNotesDir);
|
|
2787
|
-
for (const filename of noteFiles) {
|
|
2788
|
-
const parsed = parseSessionFilename(filename);
|
|
2789
|
-
if (!parsed) continue;
|
|
2790
|
-
result.sessionsScanned++;
|
|
2791
|
-
if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
2792
|
-
}
|
|
2793
|
-
}
|
|
2794
|
-
{
|
|
2795
|
-
const activeProjects = db.prepare("SELECT id, slug, root_path FROM projects WHERE status = 'active'").all();
|
|
2796
|
-
for (const project of activeProjects) {
|
|
2797
|
-
const notesDir = join(project.root_path, "Notes");
|
|
2798
|
-
if (!existsSync(notesDir)) continue;
|
|
2799
|
-
let files;
|
|
2800
|
-
try {
|
|
2801
|
-
files = findNoteFiles(notesDir);
|
|
2802
|
-
} catch {
|
|
2803
|
-
continue;
|
|
2804
|
-
}
|
|
2805
|
-
for (const filename of files) {
|
|
2806
|
-
const parsed = parseSessionFilename(filename);
|
|
2807
|
-
if (!parsed) continue;
|
|
2808
|
-
result.sessionsScanned++;
|
|
2809
|
-
if (upsertSession(db, project.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
}
|
|
2813
|
-
const config = loadScanConfig();
|
|
2814
|
-
if (config.scan_dirs.length) for (const rawDir of config.scan_dirs) {
|
|
2815
|
-
const scanDir = resolveHome(rawDir);
|
|
2816
|
-
if (!existsSync(scanDir)) {
|
|
2817
|
-
result.skipped.push(`${rawDir} (configured scan_dir not found)`);
|
|
2818
|
-
continue;
|
|
2819
|
-
}
|
|
2820
|
-
const children = readdirSync(scanDir).filter((name) => {
|
|
2821
|
-
if (name.startsWith(".")) return false;
|
|
2822
|
-
const full = join(scanDir, name);
|
|
2823
|
-
try {
|
|
2824
|
-
return statSync(full).isDirectory();
|
|
2825
|
-
} catch {
|
|
2826
|
-
return false;
|
|
2827
|
-
}
|
|
2828
|
-
});
|
|
2829
|
-
for (const child of children) {
|
|
2830
|
-
const childPath = join(scanDir, child);
|
|
2831
|
-
const childSlug = slugify$1(child);
|
|
2832
|
-
const childEncoded = encodeDir(childPath);
|
|
2833
|
-
const existing = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(childPath);
|
|
2834
|
-
if (existing) {
|
|
2835
|
-
result.projectsScanned++;
|
|
2836
|
-
result.projectsUpdated++;
|
|
2837
|
-
try {
|
|
2838
|
-
ensurePaiMarker(childPath, childSlug);
|
|
2839
|
-
} catch {}
|
|
2840
|
-
const notesDir = join(childPath, "Notes");
|
|
2841
|
-
if (existsSync(notesDir)) {
|
|
2842
|
-
const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
|
|
2843
|
-
for (const filename of noteFiles) {
|
|
2844
|
-
const parsed = parseSessionFilename(filename);
|
|
2845
|
-
if (!parsed) continue;
|
|
2846
|
-
result.sessionsScanned++;
|
|
2847
|
-
if (upsertSession(db, existing.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
2848
|
-
}
|
|
2849
|
-
}
|
|
2850
|
-
continue;
|
|
2851
|
-
}
|
|
2852
|
-
const { id, isNew } = upsertProject(db, childSlug, childPath, childEncoded);
|
|
2853
|
-
result.projectsScanned++;
|
|
2854
|
-
if (isNew) result.projectsNew++;
|
|
2855
|
-
else result.projectsUpdated++;
|
|
2856
|
-
try {
|
|
2857
|
-
ensurePaiMarker(childPath, childSlug);
|
|
2858
|
-
} catch {}
|
|
2859
|
-
const notesDir = join(childPath, "Notes");
|
|
2860
|
-
if (existsSync(notesDir)) {
|
|
2861
|
-
const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
|
|
2862
|
-
for (const filename of noteFiles) {
|
|
2863
|
-
const parsed = parseSessionFilename(filename);
|
|
2864
|
-
if (!parsed) continue;
|
|
2865
|
-
result.sessionsScanned++;
|
|
2866
|
-
if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
2867
|
-
}
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
|
-
if (config.scan_dirs.length) {
|
|
2872
|
-
const markers = discoverPaiMarkers(config.scan_dirs.map(resolveHome).filter(existsSync));
|
|
2873
|
-
for (const marker of markers) {
|
|
2874
|
-
const registeredRow = db.prepare("SELECT id, root_path, slug FROM projects WHERE slug = ?").get(marker.slug);
|
|
2875
|
-
if (!registeredRow) continue;
|
|
2876
|
-
if (registeredRow.root_path !== marker.projectRoot) {
|
|
2877
|
-
const newEncoded = encodeDir(marker.projectRoot);
|
|
2878
|
-
const now4 = Date.now();
|
|
2879
|
-
const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(newEncoded);
|
|
2880
|
-
const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(marker.projectRoot);
|
|
2881
|
-
const encodedSafe = !encodedOwner || encodedOwner.id === registeredRow.id;
|
|
2882
|
-
const pathSafe = !pathOwner || pathOwner.id === registeredRow.id;
|
|
2883
|
-
if (encodedSafe && pathSafe) db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(marker.projectRoot, newEncoded, now4, registeredRow.id);
|
|
2884
|
-
else if (pathSafe) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(marker.projectRoot, now4, registeredRow.id);
|
|
2885
|
-
}
|
|
2886
|
-
}
|
|
2887
|
-
}
|
|
2888
|
-
return result;
|
|
2889
|
-
}
|
|
2890
|
-
function cmdScan(db) {
|
|
2891
|
-
const config = loadScanConfig();
|
|
2892
|
-
console.log(dim("Scanning ~/.claude/projects/ ..."));
|
|
2893
|
-
if (config.scan_dirs.length) console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(", ")}`));
|
|
2894
|
-
console.log(dim("Scanning project-root Notes/ directories ..."));
|
|
2895
|
-
let result;
|
|
2896
|
-
try {
|
|
2897
|
-
result = performScan(db);
|
|
2898
|
-
} catch (e) {
|
|
2899
|
-
console.error(err(String(e)));
|
|
2900
|
-
process.exit(1);
|
|
2901
|
-
}
|
|
2902
|
-
console.log(ok(`Scanned ${bold(String(result.projectsScanned))} projects, ${bold(String(result.sessionsScanned))} session notes.`));
|
|
2903
|
-
console.log(dim(` Projects: ${result.projectsNew} new, ${result.projectsUpdated} updated`));
|
|
2904
|
-
console.log(dim(` Sessions: ${result.sessionsNew} new`));
|
|
2905
|
-
if (result.skipped.length) {
|
|
2906
|
-
console.log();
|
|
2907
|
-
console.log(warn(` ${result.skipped.length} project(s) skipped (path not found on disk):`));
|
|
2908
|
-
for (const s of result.skipped.slice(0, 10)) console.log(dim(` ${s}`));
|
|
2909
|
-
if (result.skipped.length > 10) console.log(dim(` ... and ${result.skipped.length - 10} more`));
|
|
2910
|
-
}
|
|
2911
|
-
}
|
|
2912
|
-
|
|
2913
3816
|
//#endregion
|
|
2914
3817
|
//#region src/cli/commands/registry/migrate.ts
|
|
2915
3818
|
/** Registry migrate command: import data from ~/.claude/session-registry.json. */
|
|
@@ -3015,7 +3918,7 @@ function cmdLookup(db, fsPath) {
|
|
|
3015
3918
|
process.stdout.write(row.slug + "\n");
|
|
3016
3919
|
}
|
|
3017
3920
|
function registerRegistryCommands(registryCmd, getDb) {
|
|
3018
|
-
registryCmd.command("scan").description("Walk ~/.claude/projects/ and configured scan_dirs, upsert all projects").option("--add-dir <path>", "Add a directory to scan_dirs config").option("--remove-dir <path>", "Remove a directory from scan_dirs config").option("--show-dirs", "Show currently configured scan directories").action((opts) => {
|
|
3921
|
+
registryCmd.command("scan").description("Walk ~/.claude/projects/ and configured scan_dirs, upsert all projects").option("--add-dir <path>", "Add a directory to scan_dirs config").option("--remove-dir <path>", "Remove a directory from scan_dirs config").option("--show-dirs", "Show currently configured scan directories").option("--quick", "Minimal output mode (used by hooks and daemon)").action((opts) => {
|
|
3019
3922
|
if (opts.showDirs) {
|
|
3020
3923
|
const config = loadScanConfig();
|
|
3021
3924
|
if (!config.scan_dirs.length) {
|
|
@@ -3053,7 +3956,7 @@ function registerRegistryCommands(registryCmd, getDb) {
|
|
|
3053
3956
|
console.log(ok(`Removed scan directory: ${bold(display)}`));
|
|
3054
3957
|
} else console.log(warn(`Not found in config: ${display}`));
|
|
3055
3958
|
}
|
|
3056
|
-
if (!opts.addDir && !opts.removeDir) cmdScan(getDb());
|
|
3959
|
+
if (!opts.addDir && !opts.removeDir) cmdScan(getDb(), { quick: opts.quick });
|
|
3057
3960
|
});
|
|
3058
3961
|
registryCmd.command("migrate").description("Import data from ~/.claude/session-registry.json").action(() => {
|
|
3059
3962
|
cmdMigrate$1(getDb());
|
|
@@ -3794,8 +4697,8 @@ function cmdLogs(opts) {
|
|
|
3794
4697
|
}
|
|
3795
4698
|
function registerDaemonCommands(daemonCmd) {
|
|
3796
4699
|
daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
|
|
3797
|
-
const { serve } = await import("../daemon-
|
|
3798
|
-
const { loadConfig: lc, ensureConfigDir } = await import("../config-
|
|
4700
|
+
const { serve } = await import("../daemon-B-vBWU1i.mjs").then((n) => n.t);
|
|
4701
|
+
const { loadConfig: lc, ensureConfigDir } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
|
|
3799
4702
|
ensureConfigDir();
|
|
3800
4703
|
await serve(lc());
|
|
3801
4704
|
});
|
|
@@ -6416,7 +7319,7 @@ async function cmdExplore(note, opts) {
|
|
|
6416
7319
|
const depth = parseInt(opts.depth ?? "3", 10);
|
|
6417
7320
|
const direction = opts.direction ?? "both";
|
|
6418
7321
|
const mode = opts.mode ?? "all";
|
|
6419
|
-
const { zettelExplore } = await import("../zettelkasten-
|
|
7322
|
+
const { zettelExplore } = await import("../zettelkasten-B-94vghH.mjs");
|
|
6420
7323
|
const result = zettelExplore(getFedDb(), {
|
|
6421
7324
|
startNote: note,
|
|
6422
7325
|
depth,
|
|
@@ -6480,7 +7383,7 @@ async function cmdHealth(opts) {
|
|
|
6480
7383
|
const projectPath = opts.project;
|
|
6481
7384
|
const recentDays = parseInt(opts.days ?? "30", 10);
|
|
6482
7385
|
const includeTypes = opts.include ? opts.include.split(",").map((s) => s.trim()) : void 0;
|
|
6483
|
-
const { zettelHealth } = await import("../zettelkasten-
|
|
7386
|
+
const { zettelHealth } = await import("../zettelkasten-B-94vghH.mjs");
|
|
6484
7387
|
const result = zettelHealth(getFedDb(), {
|
|
6485
7388
|
scope,
|
|
6486
7389
|
projectPath,
|
|
@@ -6549,7 +7452,7 @@ async function cmdSurprise(note, opts) {
|
|
|
6549
7452
|
const limit = parseInt(opts.limit ?? "10", 10);
|
|
6550
7453
|
const minSimilarity = parseFloat(opts.minSimilarity ?? "0.3");
|
|
6551
7454
|
const minGraphDistance = parseInt(opts.minDistance ?? "3", 10);
|
|
6552
|
-
const { zettelSurprise } = await import("../zettelkasten-
|
|
7455
|
+
const { zettelSurprise } = await import("../zettelkasten-B-94vghH.mjs");
|
|
6553
7456
|
const db = getFedDb();
|
|
6554
7457
|
console.log();
|
|
6555
7458
|
console.log(header(" PAI Zettel Surprise"));
|
|
@@ -6602,7 +7505,7 @@ async function cmdSuggest(note, opts) {
|
|
|
6602
7505
|
const vaultProjectId = parseInt(opts.vaultProjectId, 10);
|
|
6603
7506
|
const limit = parseInt(opts.limit ?? "5", 10);
|
|
6604
7507
|
const excludeLinked = opts.excludeLinked !== false;
|
|
6605
|
-
const { zettelSuggest } = await import("../zettelkasten-
|
|
7508
|
+
const { zettelSuggest } = await import("../zettelkasten-B-94vghH.mjs");
|
|
6606
7509
|
const db = getFedDb();
|
|
6607
7510
|
console.log();
|
|
6608
7511
|
console.log(header(" PAI Zettel Suggest"));
|
|
@@ -6653,7 +7556,7 @@ async function cmdConverse(question, opts) {
|
|
|
6653
7556
|
const vaultProjectId = parseInt(opts.vaultProjectId, 10);
|
|
6654
7557
|
const depth = parseInt(opts.depth ?? "2", 10);
|
|
6655
7558
|
const limit = parseInt(opts.limit ?? "15", 10);
|
|
6656
|
-
const { zettelConverse } = await import("../zettelkasten-
|
|
7559
|
+
const { zettelConverse } = await import("../zettelkasten-B-94vghH.mjs");
|
|
6657
7560
|
const db = getFedDb();
|
|
6658
7561
|
console.log();
|
|
6659
7562
|
console.log(header(" PAI Zettel Converse"));
|
|
@@ -6714,7 +7617,7 @@ async function cmdThemes(opts) {
|
|
|
6714
7617
|
const minClusterSize = parseInt(opts.minSize ?? "3", 10);
|
|
6715
7618
|
const maxThemes = parseInt(opts.maxThemes ?? "10", 10);
|
|
6716
7619
|
const similarityThreshold = parseFloat(opts.threshold ?? "0.65");
|
|
6717
|
-
const { zettelThemes } = await import("../zettelkasten-
|
|
7620
|
+
const { zettelThemes } = await import("../zettelkasten-B-94vghH.mjs");
|
|
6718
7621
|
const db = getFedDb();
|
|
6719
7622
|
console.log();
|
|
6720
7623
|
console.log(header(" PAI Zettel Themes"));
|
|
@@ -8129,13 +9032,17 @@ function registerDbCommands(dbCmd) {
|
|
|
8129
9032
|
* PAI Knowledge OS — CLI entry point
|
|
8130
9033
|
*
|
|
8131
9034
|
* Command tree:
|
|
8132
|
-
* pai
|
|
8133
|
-
* pai
|
|
9035
|
+
* pai sessions list|goto|pause|info|rename|... (canonical plural namespace)
|
|
9036
|
+
* pai projects list|cd|add|info|... (canonical plural namespace)
|
|
8134
9037
|
* pai registry scan|migrate|stats|rebuild
|
|
8135
9038
|
* pai memory index|search|status
|
|
8136
9039
|
* pai search <query> (placeholder — Phase 3)
|
|
8137
9040
|
* pai update
|
|
8138
9041
|
* pai version
|
|
9042
|
+
*
|
|
9043
|
+
* Daily verb shortcuts (top-level):
|
|
9044
|
+
* pai pause / pai end / pai resume / pai cd
|
|
9045
|
+
* pai sessions / pai projects / pai notes
|
|
8139
9046
|
*/
|
|
8140
9047
|
function getVersion() {
|
|
8141
9048
|
try {
|
|
@@ -8156,11 +9063,30 @@ function getDb() {
|
|
|
8156
9063
|
return _db;
|
|
8157
9064
|
}
|
|
8158
9065
|
const program = new Command();
|
|
8159
|
-
program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit")
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
9066
|
+
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
|
|
9072
|
+
|
|
9073
|
+
Listings:
|
|
9074
|
+
pai sessions Resumable sessions catalog
|
|
9075
|
+
pai projects Known project directories
|
|
9076
|
+
pai notes Markdown session notes
|
|
9077
|
+
|
|
9078
|
+
Subcommands (power users):
|
|
9079
|
+
pai sessions ... Session management (list, goto, pause, end, ...)
|
|
9080
|
+
pai projects ... Project management (cd, list, ...)
|
|
9081
|
+
pai registry ... Registry maintenance (scan, ...)
|
|
9082
|
+
pai memory ... Memory engine (index, search, ...)
|
|
9083
|
+
|
|
9084
|
+
Shell integration:
|
|
9085
|
+
eval "$(pai shell-init)" # Add to ~/.zshrc for pai cd to work`);
|
|
9086
|
+
registerProjectsCommands(program.command("projects").description("Manage registered projects (list, cd, add, info, ...)"), getDb);
|
|
9087
|
+
const sessionsCmd = program.command("sessions").description("Session management (list, goto, pause, ...)");
|
|
9088
|
+
registerSessionsCommands(sessionsCmd, getDb);
|
|
9089
|
+
registerSessionCleanupCommand(sessionsCmd, getDb);
|
|
8164
9090
|
registerRegistryCommands(program.command("registry").description("Registry maintenance: scan, migrate, stats, rebuild"), getDb);
|
|
8165
9091
|
registerMemoryCommands(program.command("memory").description("Memory engine: index, search, and status"), getDb);
|
|
8166
9092
|
registerMcpCommands(program.command("mcp").description("MCP server management: install and status"));
|
|
@@ -8179,6 +9105,67 @@ registerObservationCommands(program.command("observation").description("Observat
|
|
|
8179
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) => {
|
|
8180
9106
|
cmdGo(getDb(), query);
|
|
8181
9107
|
});
|
|
9108
|
+
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
|
+
if (target === "all") await cmdPauseAll({
|
|
9110
|
+
exit: opts.exit,
|
|
9111
|
+
dryRun: opts.dryRun,
|
|
9112
|
+
wait: opts.wait !== void 0 ? parseInt(opts.wait, 10) : void 0
|
|
9113
|
+
});
|
|
9114
|
+
else {
|
|
9115
|
+
if (target !== void 0) {
|
|
9116
|
+
console.error(`Unknown target: ${target}. Did you mean 'pai pause all'?`);
|
|
9117
|
+
process.exitCode = 1;
|
|
9118
|
+
return;
|
|
9119
|
+
}
|
|
9120
|
+
cmdPause(getDb(), { dryRun: opts.dryRun });
|
|
9121
|
+
}
|
|
9122
|
+
});
|
|
9123
|
+
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
|
+
cmdEnd(getDb(), opts);
|
|
9125
|
+
});
|
|
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) => {
|
|
9127
|
+
cmdGoto(getDb(), name, { dryRun: opts.dryRun });
|
|
9128
|
+
});
|
|
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);
|
|
9131
|
+
if (!project) {
|
|
9132
|
+
console.error(`Project not found: ${identifier}`);
|
|
9133
|
+
process.exit(1);
|
|
9134
|
+
}
|
|
9135
|
+
process.stdout.write(project.root_path + "\n");
|
|
9136
|
+
});
|
|
9137
|
+
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
|
+
cmdList$2(getDb(), projectSlug, opts);
|
|
9139
|
+
}).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
|
+
cmdList$2(getDb(), projectSlug, opts);
|
|
9141
|
+
});
|
|
9142
|
+
program.command("shell-init").description("Emit shell integration code. Add to ~/.zshrc:\n eval \"$(pai shell-init)\"").action(() => {
|
|
9143
|
+
process.stdout.write(`# PAI shell integration — generated by: pai shell-init
|
|
9144
|
+
pai() {
|
|
9145
|
+
if [[ "$1" == "cd" ]]; then
|
|
9146
|
+
local dir=$(command pai cd "$2" 2>/dev/null)
|
|
9147
|
+
if [[ -n "$dir" && -d "$dir" ]]; then
|
|
9148
|
+
builtin cd "$dir"
|
|
9149
|
+
echo "-> $dir"
|
|
9150
|
+
else
|
|
9151
|
+
echo "Project not found: $2" >&2
|
|
9152
|
+
return 1
|
|
9153
|
+
fi
|
|
9154
|
+
elif [[ "$1" == "projects" && "$2" == "cd" ]]; then
|
|
9155
|
+
local dir=$(command pai projects cd "$3" 2>/dev/null)
|
|
9156
|
+
if [[ -n "$dir" && -d "$dir" ]]; then
|
|
9157
|
+
builtin cd "$dir"
|
|
9158
|
+
echo "-> $dir"
|
|
9159
|
+
else
|
|
9160
|
+
echo "Project not found: $3" >&2
|
|
9161
|
+
return 1
|
|
9162
|
+
fi
|
|
9163
|
+
else
|
|
9164
|
+
command pai "$@"
|
|
9165
|
+
fi
|
|
9166
|
+
}
|
|
9167
|
+
`);
|
|
9168
|
+
});
|
|
8182
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) => {
|
|
8183
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`);
|
|
8184
9171
|
});
|