@tekmidian/pai 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +3 -3
- package/dist/cli/program.mjs +3 -3
- package/dist/daemon/index.mjs +3 -3
- package/dist/{daemon-mFGnPd8q.mjs → daemon-iNuNMMKd.mjs} +5 -5
- package/dist/{daemon-mFGnPd8q.mjs.map → daemon-iNuNMMKd.mjs.map} +1 -1
- package/dist/{factory-BqAdO21B.mjs → factory-BydJSrZJ.mjs} +13 -2
- package/dist/factory-BydJSrZJ.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs.map +1 -1
- package/dist/hooks/cleanup-session-files.mjs +21 -15
- package/dist/hooks/cleanup-session-files.mjs.map +2 -2
- package/dist/hooks/context-compression-hook.mjs +6 -6
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/initialize-session.mjs.map +1 -1
- package/dist/hooks/inject-observations.mjs.map +1 -1
- package/dist/hooks/load-core-context.mjs.map +1 -1
- package/dist/hooks/load-project-context.mjs +43 -25
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/observe.mjs.map +1 -1
- package/dist/hooks/stop-hook.mjs +27 -21
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +2 -2
- package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
- package/dist/{main-resolver-BfSL8zio.mjs → main-resolver-DjyUDJrv.mjs} +424 -58
- package/dist/main-resolver-DjyUDJrv.mjs.map +1 -0
- package/dist/{pick-BcmBBfOF.mjs → pick-DlsM0ppq.mjs} +817 -247
- package/dist/pick-DlsM0ppq.mjs.map +1 -0
- package/dist/{work-queue-worker-B8QUONlK.mjs → work-queue-worker-BAgwmMDl.mjs} +55 -14
- package/dist/work-queue-worker-BAgwmMDl.mjs.map +1 -0
- package/docs/commands/README.md +5 -0
- package/docs/commands/project.md +38 -0
- package/docs/commands/projects.md +38 -0
- package/docs/commands/session.md +17 -0
- package/package.json +1 -1
- package/src/hooks/ts/lib/project-utils/index.ts +1 -1
- package/src/hooks/ts/lib/project-utils/paths.test.ts +125 -0
- package/src/hooks/ts/lib/project-utils/paths.ts +68 -14
- package/src/hooks/ts/lib/project-utils.ts +1 -1
- package/src/hooks/ts/session-start/load-project-context.ts +34 -31
- package/src/hooks/ts/stop/stop-hook.ts +5 -5
- package/src/hooks/ts/user-prompt/cleanup-session-files.ts +19 -6
- package/dist/factory-BqAdO21B.mjs.map +0 -1
- package/dist/main-resolver-BfSL8zio.mjs.map +0 -1
- package/dist/pick-BcmBBfOF.mjs.map +0 -1
- package/dist/work-queue-worker-B8QUONlK.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as openRegistry } from "./db-BtuN768f.mjs";
|
|
2
|
-
import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, i as err, l as renderTable, m as slugify, n as dim$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
|
|
2
|
+
import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, h as smartDecodeDir, i as err, l as renderTable, m as slugify, n as dim$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
|
|
3
3
|
import { a as slugify$1, i as parseSessionFilename } from "./migrate-fLD6rAdO.mjs";
|
|
4
4
|
import { n as ensurePaiMarker } from "./pai-marker-B20KqhA8.mjs";
|
|
5
5
|
import { n as openFederation } from "./db-CYmBWcjh.mjs";
|
|
@@ -11,12 +11,12 @@ import { _ as transcriptFiles, a as readBodyFile, c as loadScanConfig, d as save
|
|
|
11
11
|
import { a as schedulerLogPath, i as paiSocketPath, n as daemonLogPath, r as daemonPidPath } from "./runtime-paths-B0P1TvUr.mjs";
|
|
12
12
|
import { t as PaiClient } from "./ipc-client-aVKVERjJ.mjs";
|
|
13
13
|
import { a as expandHome, c as UNROUTED, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, s as OWNER_LABEL_PREFIX, t as CONFIG_DIR } from "./config-CcdkNSWa.mjs";
|
|
14
|
-
import { t as createStorageBackend } from "./factory-
|
|
14
|
+
import { i as humanDuration, t as createStorageBackend } from "./factory-BydJSrZJ.mjs";
|
|
15
15
|
import { s as kgQuery } from "./kg-entity-r8duqhi9.mjs";
|
|
16
|
-
import { a as renderDedupedSessions, c as
|
|
16
|
+
import { _ as scanSessions, a as renderDedupedSessions, c as probeResume, d as callAiBroker, f as fetchLiveSessions, g as resolveSessionByNameOrId, h as fmtAge, i as normalizeName$2, l as restoreTopLevel, m as sendToSession, o as hasConversation, p as revealItermSession, r as buildDeduped, s as launchInDir, u as printExitDir } from "./main-resolver-DjyUDJrv.mjs";
|
|
17
17
|
import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { homedir, platform, tmpdir } from "node:os";
|
|
19
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
19
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import chalk from "chalk";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -24,6 +24,61 @@ import { createInterface } from "node:readline";
|
|
|
24
24
|
import { createConnection } from "net";
|
|
25
25
|
import { promisify } from "node:util";
|
|
26
26
|
|
|
27
|
+
//#region src/registry/registrable.ts
|
|
28
|
+
/**
|
|
29
|
+
* registrable.ts — directories that must never become registered projects.
|
|
30
|
+
*
|
|
31
|
+
* A project is a durable thing. The registry, however, has been accepting
|
|
32
|
+
* whatever directory a session happened to start in, and some of those
|
|
33
|
+
* directories are disposable by construction. Measured on the real registry,
|
|
34
|
+
* 2026-08-04:
|
|
35
|
+
*
|
|
36
|
+
* 08 - Others/MDF/Infrastruktur/.claude/worktrees/cool-haibt 1 session
|
|
37
|
+
* 08 - Others/MDF/Infrastruktur/.claude/worktrees/strange-haibt 7 sessions
|
|
38
|
+
* /private/tmp/ops-webui dead
|
|
39
|
+
* /private/tmp/claude-501/-Users-…-AIBroker/aae854c6-… dead
|
|
40
|
+
*
|
|
41
|
+
* The worktrees are agent isolation directories, created to be removed. The temp
|
|
42
|
+
* paths are exactly what their name says. Registering them attributes session
|
|
43
|
+
* history to a location with no future, and lets `pai <name>` route someone into
|
|
44
|
+
* a directory a cleanup can delete underneath them.
|
|
45
|
+
*
|
|
46
|
+
* So this refuses at the point of registration rather than reclassifying
|
|
47
|
+
* afterwards. It is a guard, not a policy about what health should call things —
|
|
48
|
+
* it stops the set growing while the vocabulary question (dead vs duplicate vs
|
|
49
|
+
* misnamed vs ephemeral) is decided separately.
|
|
50
|
+
*
|
|
51
|
+
* Found by the AIBroker session while we were dividing up the dead-path work.
|
|
52
|
+
*/
|
|
53
|
+
/** Path fragments that mark a location as disposable, with why. */
|
|
54
|
+
const EPHEMERAL = [
|
|
55
|
+
{
|
|
56
|
+
fragment: `${sep}.claude${sep}worktrees${sep}`,
|
|
57
|
+
because: "a git worktree created for agent isolation — it is meant to be removed"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
fragment: `${sep}private${sep}tmp${sep}`,
|
|
61
|
+
because: "a system temp directory"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
fragment: `${sep}var${sep}folders${sep}`,
|
|
65
|
+
because: "a macOS per-user temp directory"
|
|
66
|
+
}
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Why this path cannot be a project, or undefined if it can.
|
|
70
|
+
*
|
|
71
|
+
* Returns the reason rather than a boolean so the caller can tell the user which
|
|
72
|
+
* rule caught them — "refused" without a reason invites someone to work around it
|
|
73
|
+
* rather than move their project somewhere durable.
|
|
74
|
+
*/
|
|
75
|
+
function unregistrableReason(rootPath) {
|
|
76
|
+
const padded = rootPath.endsWith(sep) ? rootPath : rootPath + sep;
|
|
77
|
+
for (const { fragment, because } of EPHEMERAL) if (padded.includes(fragment)) return because;
|
|
78
|
+
if (padded.startsWith(`${sep}tmp${sep}`)) return "a system temp directory";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
27
82
|
//#region src/session/promote.ts
|
|
28
83
|
/**
|
|
29
84
|
* Derive a human-readable project name from a session note filename.
|
|
@@ -74,6 +129,13 @@ function cmdPromote(db, opts) {
|
|
|
74
129
|
console.error(err(`Could not derive a valid slug from name: "${displayName}"`));
|
|
75
130
|
process.exit(1);
|
|
76
131
|
}
|
|
132
|
+
const ephemeral = unregistrableReason(targetPath);
|
|
133
|
+
if (ephemeral) {
|
|
134
|
+
console.error(err(`Refusing to promote into ${targetPath}`));
|
|
135
|
+
console.error(dim$1(` That is ${ephemeral}.`));
|
|
136
|
+
console.error(dim$1(` Pick a durable location for the new project.`));
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
77
139
|
const encodedDir = encodeDir(targetPath);
|
|
78
140
|
if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ? OR encoded_dir = ?").get(slug, targetPath, encodedDir)) {
|
|
79
141
|
console.error(err(`A project with slug "${slug}" or path "${targetPath}" is already registered.`));
|
|
@@ -360,6 +422,13 @@ function cmdAdd(db, rawPath, opts) {
|
|
|
360
422
|
console.error(err(`Invalid type "${type}". Valid: ${validTypes.join(", ")}`));
|
|
361
423
|
process.exit(1);
|
|
362
424
|
}
|
|
425
|
+
const ephemeral = unregistrableReason(rootPath);
|
|
426
|
+
if (ephemeral) {
|
|
427
|
+
console.error(err(`Refusing to register ${shortenPath(rootPath, 60)}`));
|
|
428
|
+
console.error(dim$1(` That is ${ephemeral}.`));
|
|
429
|
+
console.error(dim$1(` A project needs a durable location — move it, then register.`));
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
363
432
|
if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ?").get(slug, rootPath)) {
|
|
364
433
|
console.error(err(`Project already registered (slug: ${slug} or path: ${rootPath})`));
|
|
365
434
|
process.exit(1);
|
|
@@ -1170,6 +1239,197 @@ function cmdConfig(db, identifier, opts) {
|
|
|
1170
1239
|
console.log();
|
|
1171
1240
|
}
|
|
1172
1241
|
|
|
1242
|
+
//#endregion
|
|
1243
|
+
//#region src/cli/commands/project/relocate.ts
|
|
1244
|
+
/**
|
|
1245
|
+
* relocate.ts — find a registered project whose ancestor directory was renamed.
|
|
1246
|
+
*
|
|
1247
|
+
* The registry stores absolute root paths. Rename any directory ABOVE a project
|
|
1248
|
+
* and every project underneath it goes missing at once — `existsSync` fails, and
|
|
1249
|
+
* the health check reported them as dead and offered to archive them. Renaming
|
|
1250
|
+
* `Ideaverse` to `🧠 Ideaverse` orphaned a whole subtree that way: 32 entries, all
|
|
1251
|
+
* still on disk, none of them findable.
|
|
1252
|
+
*
|
|
1253
|
+
* The old suggestion logic could not see this. It took the project's BASENAME and
|
|
1254
|
+
* looked for it in four hardcoded directories (`~/dev`, `~/dev/ai`, `~/Desktop`,
|
|
1255
|
+
* `~/Projects`), so it could only recognise "the leaf moved into a place I already
|
|
1256
|
+
* know about". A renamed ancestor leaves the leaf exactly where it was.
|
|
1257
|
+
*
|
|
1258
|
+
* So walk down from the root while the path still exists — that lands on the
|
|
1259
|
+
* deepest surviving ancestor, which is precisely where the rename happened — then
|
|
1260
|
+
* match the remaining segments by NORMALISED name. Normalising strips the emoji,
|
|
1261
|
+
* the space and the case, so `🧠 Ideaverse` and `Ideaverse` both reduce to
|
|
1262
|
+
* "ideaverse". The general case is "an ancestor was renamed decoratively", which
|
|
1263
|
+
* is the whole class of failure rather than this one instance of it.
|
|
1264
|
+
*
|
|
1265
|
+
* Algorithm and the measurements behind it come from the AIBroker session, which
|
|
1266
|
+
* probed it and handed it over rather than implementing it in a file that was not
|
|
1267
|
+
* theirs.
|
|
1268
|
+
*/
|
|
1269
|
+
/**
|
|
1270
|
+
* A directory name reduced to what a rename is unlikely to have changed.
|
|
1271
|
+
*
|
|
1272
|
+
* NFC first because macOS hands back decomposed Unicode: an "ä" typed in one
|
|
1273
|
+
* place and read from a directory listing in another are different byte
|
|
1274
|
+
* sequences, and comparing them raw fails for reasons no one can see. Then case
|
|
1275
|
+
* and every non-alphanumeric are dropped, which is what makes `🧠 Ideaverse`
|
|
1276
|
+
* match `Ideaverse` — and, unavoidably, what makes `my-project` match
|
|
1277
|
+
* `my project`. That looseness is the point, and the uniqueness rule below is
|
|
1278
|
+
* what keeps it safe.
|
|
1279
|
+
*/
|
|
1280
|
+
function norm(s) {
|
|
1281
|
+
return s.normalize("NFC").toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, "");
|
|
1282
|
+
}
|
|
1283
|
+
/** Hidden by Unix convention — tool state rather than a project directory. */
|
|
1284
|
+
function isHidden(name) {
|
|
1285
|
+
return name.startsWith(".");
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Drop a leading ordering prefix: `20 - Webseiten` -> `Webseiten`.
|
|
1289
|
+
*
|
|
1290
|
+
* This vault numbers directories to force sort order — `04 - Ablage`,
|
|
1291
|
+
* `08 - Others`, `01 - Base Setup`, `20 - Webseiten` — and adding or removing that
|
|
1292
|
+
* prefix is a rename of exactly the same kind as adding an emoji. `norm()` alone
|
|
1293
|
+
* cannot see it, because the digits survive normalisation: "webseiten" against
|
|
1294
|
+
* "20webseiten".
|
|
1295
|
+
*
|
|
1296
|
+
* Found because `MDF.md` links to `Infrastruktur/20 - Webseiten` while the
|
|
1297
|
+
* registry has a dead entry for plain `Infrastruktur/Webseiten`. The note files
|
|
1298
|
+
* knew where it went.
|
|
1299
|
+
*
|
|
1300
|
+
* Deliberately NOT a general suffix match, and the reason is worth spelling out
|
|
1301
|
+
* because "why not just match on the suffix" is the obvious next question.
|
|
1302
|
+
*
|
|
1303
|
+
* `MDF/Infrastruktur/` is wall-to-wall numbered: `00 - Migration Analysis`,
|
|
1304
|
+
* `01 - Base Setup`, `02 - Storage Setup`, `03 - Network Setup`, and so on. A
|
|
1305
|
+
* suffix rule on a wanted `Setup` hits eight of them at once — so the uniqueness
|
|
1306
|
+
* rule below would veto it, and the danger would never show itself. The one that
|
|
1307
|
+
* bites is a wanted `Analysis` against `00 - Migration Analysis`: a single hit,
|
|
1308
|
+
* therefore silently relocated, therefore wrong, and uniqueness cannot save you
|
|
1309
|
+
* because there is nothing ambiguous about it.
|
|
1310
|
+
*
|
|
1311
|
+
* That is a failure mode this file's own safety rule would have HIDDEN rather than
|
|
1312
|
+
* caught. Only a leading run of digits with optional separator comes off.
|
|
1313
|
+
*/
|
|
1314
|
+
function stripOrderingPrefix(name) {
|
|
1315
|
+
return name.replace(/^\d+\s*[-._)]?\s*/, "");
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* The names under which a directory can be recognised.
|
|
1319
|
+
*
|
|
1320
|
+
* Both spellings are offered from both sides, so the prefix can have been added
|
|
1321
|
+
* OR removed since the path was registered.
|
|
1322
|
+
*/
|
|
1323
|
+
function matchKeys(name) {
|
|
1324
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1325
|
+
const bare = norm(name);
|
|
1326
|
+
if (bare) keys.add(bare);
|
|
1327
|
+
const stripped = norm(stripOrderingPrefix(name));
|
|
1328
|
+
if (stripped) keys.add(stripped);
|
|
1329
|
+
return keys;
|
|
1330
|
+
}
|
|
1331
|
+
function shareAKey(a, b) {
|
|
1332
|
+
for (const k of a) if (b.has(k)) return true;
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Resolve symlinks so two spellings of one directory compare equal.
|
|
1337
|
+
*
|
|
1338
|
+
* Falls back to the input when it cannot resolve — a path that does not exist has
|
|
1339
|
+
* no real path, and for the duplicate check below "unresolvable" must not
|
|
1340
|
+
* accidentally equal some other unresolvable path.
|
|
1341
|
+
*/
|
|
1342
|
+
function realOrSelf(path) {
|
|
1343
|
+
try {
|
|
1344
|
+
return realpathSync(path);
|
|
1345
|
+
} catch {
|
|
1346
|
+
return path;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
function isDir(path) {
|
|
1350
|
+
try {
|
|
1351
|
+
return statSync(path).isDirectory();
|
|
1352
|
+
} catch {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Where a project went when a directory above it was renamed.
|
|
1358
|
+
*
|
|
1359
|
+
* Returns undefined rather than a guess whenever it cannot be certain — see the
|
|
1360
|
+
* uniqueness rule inside. undefined means "still dead as far as this can tell",
|
|
1361
|
+
* which leaves the entry exactly as it was.
|
|
1362
|
+
*/
|
|
1363
|
+
function relocateRenamedAncestor(rootPath) {
|
|
1364
|
+
if (existsSync(rootPath)) return void 0;
|
|
1365
|
+
const segments = rootPath.split(sep).filter((s) => s.length > 0);
|
|
1366
|
+
if (segments.length === 0) return void 0;
|
|
1367
|
+
let current = rootPath.startsWith(sep) ? sep : "";
|
|
1368
|
+
let i = 0;
|
|
1369
|
+
for (; i < segments.length; i++) {
|
|
1370
|
+
const next = join(current, segments[i]);
|
|
1371
|
+
if (!existsSync(next)) break;
|
|
1372
|
+
current = next;
|
|
1373
|
+
}
|
|
1374
|
+
if (i === segments.length) return void 0;
|
|
1375
|
+
for (; i < segments.length; i++) {
|
|
1376
|
+
const wanted = matchKeys(segments[i]);
|
|
1377
|
+
if (wanted.size === 0) return void 0;
|
|
1378
|
+
let children;
|
|
1379
|
+
try {
|
|
1380
|
+
children = readdirSync(current);
|
|
1381
|
+
} catch {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const hits = children.filter((c) => isDir(join(current, c)) && shareAKey(matchKeys(c), wanted) && isHidden(c) === isHidden(segments[i]));
|
|
1385
|
+
if (hits.length !== 1) return void 0;
|
|
1386
|
+
current = join(current, hits[0]);
|
|
1387
|
+
}
|
|
1388
|
+
return current;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* The pre-existing guess: the project's leaf name in one of a few usual places.
|
|
1392
|
+
*
|
|
1393
|
+
* Kept, and kept SECOND, because it answers a different question — "the project
|
|
1394
|
+
* itself was moved somewhere I know about" rather than "an ancestor was renamed".
|
|
1395
|
+
* It is the weaker of the two: matching on basename alone can point at an
|
|
1396
|
+
* unrelated directory that happens to share a name, so it only gets to answer
|
|
1397
|
+
* when the ancestor walk has declined.
|
|
1398
|
+
*/
|
|
1399
|
+
function suggestByBasename(rootPath) {
|
|
1400
|
+
const name = basename(rootPath);
|
|
1401
|
+
const candidates = [
|
|
1402
|
+
join(homedir(), "dev", name),
|
|
1403
|
+
join(homedir(), "dev", "ai", name),
|
|
1404
|
+
join(homedir(), "Desktop", name),
|
|
1405
|
+
join(homedir(), "Projects", name)
|
|
1406
|
+
];
|
|
1407
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Where a missing project probably is, or undefined.
|
|
1411
|
+
*
|
|
1412
|
+
* Feeds the health check's `suggestedPath ? "stale" : "dead"` classification, so
|
|
1413
|
+
* an answer here moves an entry out of the dead list and into the one `--fix`
|
|
1414
|
+
* repairs.
|
|
1415
|
+
*
|
|
1416
|
+
* `otherRoots` is every OTHER registered project's root path, and it is what
|
|
1417
|
+
* stops a repair from becoming a duplicate. Caught by the AIBroker session on the
|
|
1418
|
+
* first version of this: `~/PAI` was "recovered" to `~/dev/ai/PAI`, which is a
|
|
1419
|
+
* second spelling of `~/Daten/Cloud/Development/ai/PAI` — a path an ACTIVE project
|
|
1420
|
+
* already owns. Two registry entries for one directory is the exact mess that was
|
|
1421
|
+
* merged out of this registry earlier the same day.
|
|
1422
|
+
*
|
|
1423
|
+
* The comparison must be by realpath, not string. String equality is precisely
|
|
1424
|
+
* what missed it: the two paths share not one character after `/Users/i052341/`.
|
|
1425
|
+
*/
|
|
1426
|
+
function suggestMovedPath(rootPath, otherRoots = []) {
|
|
1427
|
+
const candidate = relocateRenamedAncestor(rootPath) ?? suggestByBasename(rootPath);
|
|
1428
|
+
if (!candidate) return void 0;
|
|
1429
|
+
if (new Set(otherRoots.map(realOrSelf)).has(realOrSelf(candidate))) return void 0;
|
|
1430
|
+
return candidate;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1173
1433
|
//#endregion
|
|
1174
1434
|
//#region src/cli/commands/project/health.ts
|
|
1175
1435
|
function findOrphanedNotesDirs(project) {
|
|
@@ -1193,15 +1453,66 @@ function findOrphanedNotesDirs(project) {
|
|
|
1193
1453
|
} catch {}
|
|
1194
1454
|
return results;
|
|
1195
1455
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1456
|
+
/**
|
|
1457
|
+
* Why a row is unhealthy, and what to do about it.
|
|
1458
|
+
*
|
|
1459
|
+
* `category` said only dead / stale / active, and "archive" was offered as the
|
|
1460
|
+
* remedy for everything. Four different situations were collapsed into that, and
|
|
1461
|
+
* archiving is right for exactly one of them:
|
|
1462
|
+
*
|
|
1463
|
+
* EPHEMERAL the path is a worktree or a temp dir — it should never have been
|
|
1464
|
+
* registered. Checked FIRST, deliberately: a temp path whose
|
|
1465
|
+
* directory has also vanished is both ephemeral and dead, and
|
|
1466
|
+
* "should never have been registered" is the stronger and more
|
|
1467
|
+
* actionable statement. Without a stated precedence the same row
|
|
1468
|
+
* gets different labels depending on evaluation order.
|
|
1469
|
+
* DUPLICATE the path is gone and another project owns where it went. Its
|
|
1470
|
+
* sessions are the only thing of value on it, so the remedy is
|
|
1471
|
+
* merge — archiving strands them.
|
|
1472
|
+
* MISNAMED the path EXISTS and another project owns a subtree of it, under a
|
|
1473
|
+
* slug that has nothing to do with it. `pferde` on `08 - Others/MDF`.
|
|
1474
|
+
* health never reported this at all, because existsSync says yes.
|
|
1475
|
+
* DEAD the path is gone and nothing claims it. Archive is correct here.
|
|
1476
|
+
*
|
|
1477
|
+
* The action must never name a command that destroys a row holding sessions —
|
|
1478
|
+
* a wrong command in an action field reads as vetted.
|
|
1479
|
+
*/
|
|
1480
|
+
function diagnose(project, pathExists, others) {
|
|
1481
|
+
const ephemeral = unregistrableReason(project.root_path);
|
|
1482
|
+
if (ephemeral) return {
|
|
1483
|
+
reason: "ephemeral",
|
|
1484
|
+
action: project.session_count > 0 ? `holds ${project.session_count} session(s) — pai project merge ${project.slug} <durable-project> --execute, then unregister` : `pai project unregister ${project.slug} --execute (${ephemeral})`
|
|
1485
|
+
};
|
|
1486
|
+
if (pathExists) {
|
|
1487
|
+
const nested = others.find((o) => o.status === "active" && o.root_path.startsWith(project.root_path + "/") && existsSync(o.root_path));
|
|
1488
|
+
if (nested && project.status !== "active") return {
|
|
1489
|
+
reason: "misnamed",
|
|
1490
|
+
owner: nested.slug,
|
|
1491
|
+
action: `live directory, but ${nested.slug} owns a subtree of it — rename, or pai project merge ${project.slug} ${nested.slug}`
|
|
1492
|
+
};
|
|
1493
|
+
return {};
|
|
1494
|
+
}
|
|
1495
|
+
const suggestion = suggestMovedPath(project.root_path, []);
|
|
1496
|
+
if (suggestion) {
|
|
1497
|
+
const owner = others.find((o) => o.root_path === suggestion || realpathEq(o.root_path, suggestion));
|
|
1498
|
+
if (owner) return {
|
|
1499
|
+
reason: "duplicate",
|
|
1500
|
+
owner: owner.slug,
|
|
1501
|
+
action: project.session_count > 0 ? `pai project merge ${project.slug} ${owner.slug} (moves ${project.session_count} session(s))` : `pai project merge ${project.slug} ${owner.slug} (no sessions — a plain drop)`
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
reason: "dead",
|
|
1506
|
+
action: project.session_count > 0 ? `holds ${project.session_count} session(s) — merge before archiving, or they become unreachable` : `pai project archive ${project.slug}`
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
/** Same directory under two spellings — symlinks, not string equality. */
|
|
1510
|
+
function realpathEq(a, b) {
|
|
1511
|
+
try {
|
|
1512
|
+
return realpathSync(a) === realpathSync(b);
|
|
1513
|
+
} catch {
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1205
1516
|
}
|
|
1206
1517
|
function cmdHealth$1(db, opts) {
|
|
1207
1518
|
const rows = db.prepare(`SELECT p.*,
|
|
@@ -1211,19 +1522,24 @@ function cmdHealth$1(db, opts) {
|
|
|
1211
1522
|
const results = rows.map((project) => {
|
|
1212
1523
|
const pathExists = existsSync(project.root_path);
|
|
1213
1524
|
const orphaned = findOrphanedNotesDirs(project);
|
|
1525
|
+
const others = rows.filter((r) => r.id !== project.id);
|
|
1214
1526
|
let category;
|
|
1215
1527
|
let suggestedPath;
|
|
1216
1528
|
if (pathExists) category = "active";
|
|
1217
1529
|
else {
|
|
1218
|
-
suggestedPath = suggestMovedPath(project);
|
|
1530
|
+
suggestedPath = suggestMovedPath(project.root_path, others.map((r) => r.root_path));
|
|
1219
1531
|
category = suggestedPath ? "stale" : "dead";
|
|
1220
1532
|
}
|
|
1533
|
+
const { reason, owner, action } = diagnose(project, pathExists, others);
|
|
1221
1534
|
return {
|
|
1222
1535
|
project,
|
|
1223
1536
|
category,
|
|
1224
1537
|
suggestedPath,
|
|
1225
1538
|
claudeNotesExists: orphaned.length > 0,
|
|
1226
|
-
orphanedNotesDirs: orphaned
|
|
1539
|
+
orphanedNotesDirs: orphaned,
|
|
1540
|
+
reason,
|
|
1541
|
+
owner,
|
|
1542
|
+
action
|
|
1227
1543
|
};
|
|
1228
1544
|
});
|
|
1229
1545
|
const filtered = opts.status ? results.filter((r) => r.category === opts.status) : results;
|
|
@@ -1236,7 +1552,10 @@ function cmdHealth$1(db, opts) {
|
|
|
1236
1552
|
session_count: r.project.session_count,
|
|
1237
1553
|
suggested_path: r.suggestedPath ?? null,
|
|
1238
1554
|
claude_notes_exists: r.claudeNotesExists,
|
|
1239
|
-
orphaned_notes_dirs: r.orphanedNotesDirs
|
|
1555
|
+
orphaned_notes_dirs: r.orphanedNotesDirs,
|
|
1556
|
+
reason: r.reason ?? null,
|
|
1557
|
+
owner: r.owner ?? null,
|
|
1558
|
+
action: r.action ?? null
|
|
1240
1559
|
})), null, 2));
|
|
1241
1560
|
return;
|
|
1242
1561
|
}
|
|
@@ -1281,29 +1600,184 @@ function cmdHealth$1(db, opts) {
|
|
|
1281
1600
|
console.log();
|
|
1282
1601
|
}
|
|
1283
1602
|
if (dead.length) {
|
|
1284
|
-
console.log(err("
|
|
1603
|
+
console.log(err(" Unreachable projects (path missing):"));
|
|
1285
1604
|
for (const r of dead) {
|
|
1286
|
-
|
|
1605
|
+
const label = r.reason && r.reason !== "dead" ? warn$1(` [${r.reason}]`) : "";
|
|
1606
|
+
console.log(` ${bold$1(r.project.slug)} ${dim$1(r.project.root_path)}${label}`);
|
|
1287
1607
|
if (r.claudeNotesExists) console.log(chalk.yellow(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
|
|
1288
|
-
if (r.project.session_count === 0 && opts.fix) {
|
|
1608
|
+
if (r.project.session_count === 0 && opts.fix && r.reason === "dead") {
|
|
1289
1609
|
db.prepare("UPDATE projects SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), r.project.id);
|
|
1290
|
-
console.log(ok$1(" Auto-fixed: archived (0 sessions, path gone)"));
|
|
1291
|
-
} else console.log(dim$1(`
|
|
1610
|
+
console.log(ok$1(" Auto-fixed: archived (0 sessions, path gone, nothing claims it)"));
|
|
1611
|
+
} else if (r.action) console.log(dim$1(` Do: ${r.action}`));
|
|
1292
1612
|
}
|
|
1293
1613
|
console.log();
|
|
1294
1614
|
}
|
|
1295
|
-
|
|
1615
|
+
const archivedButPresent = results.filter((r) => r.category === "active" && r.project.status !== "active").length;
|
|
1616
|
+
console.log(dim$1(` ${rows.length} total: ${active.length} with the path present, ${stale.length} stale, ${dead.length} dead`));
|
|
1617
|
+
if (archivedButPresent > 0) console.log(dim$1(` ${archivedButPresent} of those ${active.length} are archived in the registry — "present" here is about the directory, not the registry status.`));
|
|
1296
1618
|
if (!opts.fix && (stale.length > 0 || dead.length > 0)) {
|
|
1297
1619
|
console.log();
|
|
1298
1620
|
console.log(warn$1(" Run with --fix to auto-remediate where possible."));
|
|
1299
1621
|
}
|
|
1300
1622
|
}
|
|
1301
1623
|
|
|
1624
|
+
//#endregion
|
|
1625
|
+
//#region src/registry/merge.ts
|
|
1626
|
+
var MergeError = class extends Error {};
|
|
1627
|
+
/**
|
|
1628
|
+
* What a merge would do, without doing it.
|
|
1629
|
+
*
|
|
1630
|
+
* Built as a plan first because the session renumbering is the part a reader will
|
|
1631
|
+
* not predict, and printing "0001 -> 0016" is the difference between a command
|
|
1632
|
+
* that can be trusted and one that has to be taken on faith.
|
|
1633
|
+
*/
|
|
1634
|
+
function planMerge(db, fromSlug, intoSlug) {
|
|
1635
|
+
if (fromSlug === intoSlug) throw new MergeError(`Cannot merge ${fromSlug} into itself.`);
|
|
1636
|
+
const from = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(fromSlug);
|
|
1637
|
+
const into = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(intoSlug);
|
|
1638
|
+
if (!from) throw new MergeError(`No project with slug "${fromSlug}".`);
|
|
1639
|
+
if (!into) throw new MergeError(`No project with slug "${intoSlug}".`);
|
|
1640
|
+
let next = db.prepare("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = ?").get(into.id).n;
|
|
1641
|
+
const sessions = db.prepare("SELECT id, number FROM sessions WHERE project_id = ? ORDER BY number ASC").all(from.id).map((s) => ({
|
|
1642
|
+
id: s.id,
|
|
1643
|
+
from: s.number,
|
|
1644
|
+
to: ++next
|
|
1645
|
+
}));
|
|
1646
|
+
const count = (sql, ...args) => db.prepare(sql).get(...args).n;
|
|
1647
|
+
const aliasTaken = count("SELECT COUNT(*) AS n FROM aliases WHERE alias = ?", from.slug) > 0 || count("SELECT COUNT(*) AS n FROM projects WHERE slug = ?", from.slug) > 1;
|
|
1648
|
+
return {
|
|
1649
|
+
fromId: from.id,
|
|
1650
|
+
fromSlug: from.slug,
|
|
1651
|
+
intoId: into.id,
|
|
1652
|
+
intoSlug: into.slug,
|
|
1653
|
+
sessions,
|
|
1654
|
+
tags: count("SELECT COUNT(*) AS n FROM project_tags WHERE project_id = ?", from.id),
|
|
1655
|
+
aliases: count("SELECT COUNT(*) AS n FROM aliases WHERE project_id = ?", from.id),
|
|
1656
|
+
compactions: count("SELECT COUNT(*) AS n FROM compaction_log WHERE project_id = ?", from.id),
|
|
1657
|
+
links: count("SELECT COUNT(*) AS n FROM links WHERE target_project_id = ?", from.id),
|
|
1658
|
+
aliasToAdd: aliasTaken ? void 0 : from.slug
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Apply a plan. One transaction: either the whole row is folded in or nothing is.
|
|
1663
|
+
*
|
|
1664
|
+
* A half-merged project is the worst outcome available here — sessions moved but
|
|
1665
|
+
* the row still present, or the row gone and its tags orphaned — and with foreign
|
|
1666
|
+
* keys off nothing would complain.
|
|
1667
|
+
*/
|
|
1668
|
+
function applyMerge(db, plan) {
|
|
1669
|
+
db.transaction(() => {
|
|
1670
|
+
const move = db.prepare("UPDATE sessions SET project_id = ?, number = ? WHERE id = ?");
|
|
1671
|
+
for (const s of plan.sessions) move.run(plan.intoId, s.to, s.id);
|
|
1672
|
+
db.prepare(`INSERT OR IGNORE INTO project_tags (project_id, tag_id)
|
|
1673
|
+
SELECT ?, tag_id FROM project_tags WHERE project_id = ?`).run(plan.intoId, plan.fromId);
|
|
1674
|
+
db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(plan.fromId);
|
|
1675
|
+
db.prepare("UPDATE aliases SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
|
|
1676
|
+
db.prepare("UPDATE compaction_log SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
|
|
1677
|
+
db.prepare(`UPDATE OR IGNORE links SET target_project_id = ? WHERE target_project_id = ?`).run(plan.intoId, plan.fromId);
|
|
1678
|
+
db.prepare("DELETE FROM links WHERE target_project_id = ?").run(plan.fromId);
|
|
1679
|
+
db.prepare(`DELETE FROM links WHERE target_project_id = ?
|
|
1680
|
+
AND session_id IN (SELECT id FROM sessions WHERE project_id = ?)`).run(plan.intoId, plan.intoId);
|
|
1681
|
+
if (plan.aliasToAdd) db.prepare("INSERT OR IGNORE INTO aliases (alias, project_id) VALUES (?, ?)").run(plan.aliasToAdd, plan.intoId);
|
|
1682
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(plan.fromId);
|
|
1683
|
+
})();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
//#endregion
|
|
1687
|
+
//#region src/cli/commands/project/merge.ts
|
|
1688
|
+
function cmdMerge(db, fromSlug, intoSlug, opts = {}) {
|
|
1689
|
+
let plan;
|
|
1690
|
+
try {
|
|
1691
|
+
plan = planMerge(db, fromSlug, intoSlug);
|
|
1692
|
+
} catch (e) {
|
|
1693
|
+
if (e instanceof MergeError) {
|
|
1694
|
+
console.error(err(` ${e.message}`));
|
|
1695
|
+
process.exit(1);
|
|
1696
|
+
}
|
|
1697
|
+
throw e;
|
|
1698
|
+
}
|
|
1699
|
+
const path = (id) => {
|
|
1700
|
+
const row = db.prepare("SELECT root_path FROM projects WHERE id = ?").get(id);
|
|
1701
|
+
return row ? shortenPath(row.root_path, 54) : "(unknown)";
|
|
1702
|
+
};
|
|
1703
|
+
console.log();
|
|
1704
|
+
console.log(bold$1(` Merge ${plan.fromSlug} into ${plan.intoSlug}`));
|
|
1705
|
+
console.log();
|
|
1706
|
+
console.log(dim$1(` from ${plan.fromSlug.padEnd(22)} ${path(plan.fromId)}`));
|
|
1707
|
+
console.log(dim$1(` into ${plan.intoSlug.padEnd(22)} ${path(plan.intoId)}`));
|
|
1708
|
+
console.log();
|
|
1709
|
+
if (plan.sessions.length > 0) {
|
|
1710
|
+
console.log(` ${plan.sessions.length} session(s) move and are renumbered:`);
|
|
1711
|
+
for (const s of plan.sessions) console.log(dim$1(` ${String(s.from).padStart(4)} -> ${String(s.to).padStart(4)}`));
|
|
1712
|
+
} else console.log(dim$1(` No sessions to move.`));
|
|
1713
|
+
const extras = [];
|
|
1714
|
+
if (plan.tags) extras.push(`${plan.tags} tag(s)`);
|
|
1715
|
+
if (plan.aliases) extras.push(`${plan.aliases} existing alias(es)`);
|
|
1716
|
+
if (plan.compactions) extras.push(`${plan.compactions} compaction record(s)`);
|
|
1717
|
+
if (plan.links) extras.push(`${plan.links} inbound link(s)`);
|
|
1718
|
+
if (extras.length > 0) console.log(dim$1(` Also repointed: ${extras.join(", ")}.`));
|
|
1719
|
+
if (plan.aliasToAdd) console.log(dim$1(` "${plan.aliasToAdd}" is kept as an alias, so the old name still resolves.`));
|
|
1720
|
+
else console.log(warn$1(` "${plan.fromSlug}" cannot be kept as an alias — the name is already taken.`));
|
|
1721
|
+
console.log(dim$1(` Then the ${plan.fromSlug} row is deleted.`));
|
|
1722
|
+
console.log();
|
|
1723
|
+
if (!opts.execute) {
|
|
1724
|
+
console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to merge."));
|
|
1725
|
+
console.log();
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
applyMerge(db, plan);
|
|
1729
|
+
console.log(ok$1(` Merged. ${plan.sessions.length} session(s) now belong to ${plan.intoSlug}.`));
|
|
1730
|
+
console.log();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
//#endregion
|
|
1734
|
+
//#region src/cli/commands/project/unregister.ts
|
|
1735
|
+
function cmdUnregister(db, slug, opts = {}) {
|
|
1736
|
+
const row = db.prepare(`SELECT p.id, p.slug, p.root_path, p.status,
|
|
1737
|
+
(SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
|
|
1738
|
+
FROM projects p WHERE p.slug = ?`).get(slug);
|
|
1739
|
+
if (!row) {
|
|
1740
|
+
console.error(err(` No project with slug "${slug}".`));
|
|
1741
|
+
process.exit(1);
|
|
1742
|
+
}
|
|
1743
|
+
console.log();
|
|
1744
|
+
console.log(bold$1(` Unregister ${row.slug}`));
|
|
1745
|
+
console.log(dim$1(` ${row.root_path}`));
|
|
1746
|
+
console.log(dim$1(` status ${row.status}, ${row.session_count} session(s)`));
|
|
1747
|
+
console.log();
|
|
1748
|
+
if (row.session_count > 0 && !opts.force) {
|
|
1749
|
+
console.log(warn$1(` Refusing: ${row.session_count} session(s) would be stranded.`));
|
|
1750
|
+
console.log(dim$1(` Those sessions are the only thing of value on this row. Move them first:`));
|
|
1751
|
+
console.log(dim$1(` pai project merge ${row.slug} <into> --execute`));
|
|
1752
|
+
console.log(dim$1(` Or pass --force if the sessions are genuinely worthless.`));
|
|
1753
|
+
console.log();
|
|
1754
|
+
process.exit(1);
|
|
1755
|
+
}
|
|
1756
|
+
if (!opts.execute) {
|
|
1757
|
+
console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to unregister."));
|
|
1758
|
+
if (row.session_count > 0) console.log(warn$1(` --force is set: ${row.session_count} session(s) WILL be deleted.`));
|
|
1759
|
+
console.log();
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
db.transaction(() => {
|
|
1763
|
+
db.prepare("DELETE FROM links WHERE target_project_id = ?").run(row.id);
|
|
1764
|
+
db.prepare("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = ?)").run(row.id);
|
|
1765
|
+
db.prepare("DELETE FROM compaction_log WHERE project_id = ?").run(row.id);
|
|
1766
|
+
db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(row.id);
|
|
1767
|
+
db.prepare("DELETE FROM aliases WHERE project_id = ?").run(row.id);
|
|
1768
|
+
db.prepare("DELETE FROM sessions WHERE project_id = ?").run(row.id);
|
|
1769
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(row.id);
|
|
1770
|
+
})();
|
|
1771
|
+
console.log(ok$1(` Unregistered ${row.slug}.`));
|
|
1772
|
+
console.log(dim$1(` The directory itself was not touched.`));
|
|
1773
|
+
console.log();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1302
1776
|
//#endregion
|
|
1303
1777
|
//#region src/cli/commands/project/projects-index.ts
|
|
1304
1778
|
function registerProjectsCommands(projectsCmd, getDb) {
|
|
1305
1779
|
projectsCmd.command("list", { isDefault: true }).description("Show the unified deduped listing (same as `pai`).\nUse --all to include cold / zero-session / archived projects.").option("--all", "Show all entries including cold / zero-session / archived projects").option("-n, --n <count>", "Max rows to show", "20").action(async (opts) => {
|
|
1306
|
-
const { cmdMain } = await import("./main-resolver-
|
|
1780
|
+
const { cmdMain } = await import("./main-resolver-DjyUDJrv.mjs").then((n) => n.n);
|
|
1307
1781
|
await cmdMain(getDb(), void 0, void 0, opts);
|
|
1308
1782
|
});
|
|
1309
1783
|
projectsCmd.command("list-raw").description("Raw registry listing with slug, path, status, last_active columns").option("--all", "Include archived projects (default: active only)").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
|
|
@@ -1351,6 +1825,12 @@ function registerProjectsCommands(projectsCmd, getDb) {
|
|
|
1351
1825
|
projectsCmd.command("archive <slug>").description("Archive a project").action((slug) => {
|
|
1352
1826
|
cmdArchive(getDb(), slug);
|
|
1353
1827
|
});
|
|
1828
|
+
projectsCmd.command("merge <from> <into>").description("Fold a duplicate project into another: move its sessions (renumbered), repoint its tags, aliases, compaction records and links, keep the old slug as an alias, then delete the row. Preview unless --execute is given.").option("--execute", "Actually perform the merge").action((from, into, opts) => {
|
|
1829
|
+
cmdMerge(getDb(), from, into, opts);
|
|
1830
|
+
});
|
|
1831
|
+
projectsCmd.command("unregister <slug>").description("Remove a project row entirely, for paths that should never have been registered (worktrees, temp dirs). Refuses when the row holds sessions — merge those first. Preview unless --execute is given. The directory itself is never touched.").option("--execute", "Actually remove the row").option("--force", "Remove even though sessions would be deleted with it").action((slug, opts) => {
|
|
1832
|
+
cmdUnregister(getDb(), slug, opts);
|
|
1833
|
+
});
|
|
1354
1834
|
projectsCmd.command("unarchive <slug>").description("Restore an archived project to active status").action((slug) => {
|
|
1355
1835
|
cmdUnarchive(getDb(), slug);
|
|
1356
1836
|
});
|
|
@@ -2684,6 +3164,65 @@ function registerMcpCommands(mcpCmd) {
|
|
|
2684
3164
|
});
|
|
2685
3165
|
}
|
|
2686
3166
|
|
|
3167
|
+
//#endregion
|
|
3168
|
+
//#region src/cli/commands/daemon-status.ts
|
|
3169
|
+
/**
|
|
3170
|
+
* Health lines to print above the index/DB figures, worst first.
|
|
3171
|
+
*
|
|
3172
|
+
* Empty when there is nothing wrong: a healthy daemon should not grow a
|
|
3173
|
+
* paragraph of reassurance, or the one time it says something real gets read as
|
|
3174
|
+
* more of the same.
|
|
3175
|
+
*/
|
|
3176
|
+
function formatStorageHealth(s, now = Date.now()) {
|
|
3177
|
+
const lines = [];
|
|
3178
|
+
const outage = s.backendOutage ?? null;
|
|
3179
|
+
if (outage) {
|
|
3180
|
+
lines.push({
|
|
3181
|
+
severity: "error",
|
|
3182
|
+
text: `BACKEND DOWN: ${outage.backend} unreachable for ${humanDuration(now - outage.since)}`
|
|
3183
|
+
});
|
|
3184
|
+
lines.push({
|
|
3185
|
+
severity: "warn",
|
|
3186
|
+
text: `${outage.attempts} attempts — ${outage.lastError}`
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
const queue = queueLine(s.workQueue, outage !== null);
|
|
3190
|
+
if (queue) lines.push(queue);
|
|
3191
|
+
if (outage) lines.push({
|
|
3192
|
+
severity: "info",
|
|
3193
|
+
text: "Indexing, session notes and the work queue are stalled until it returns."
|
|
3194
|
+
});
|
|
3195
|
+
return lines;
|
|
3196
|
+
}
|
|
3197
|
+
/**
|
|
3198
|
+
* The queue depth, when it means something.
|
|
3199
|
+
*
|
|
3200
|
+
* During an outage it always means something — including a depth of zero, which
|
|
3201
|
+
* says the backlog is not the reason nothing is happening. Otherwise it is only
|
|
3202
|
+
* worth a line when work is actually sitting there: a queue that is empty
|
|
3203
|
+
* because everything drained is the normal case, and printing it every time
|
|
3204
|
+
* teaches the reader to skip the line.
|
|
3205
|
+
*
|
|
3206
|
+
* `failed` is reported whenever it is non-zero, outage or not. Items that
|
|
3207
|
+
* exhausted their retries are the silent-loss case this whole status block
|
|
3208
|
+
* exists for — they will never be retried and nothing else surfaces them.
|
|
3209
|
+
*/
|
|
3210
|
+
function queueLine(q, duringOutage) {
|
|
3211
|
+
if (!q) return null;
|
|
3212
|
+
const pending = q.pending ?? 0;
|
|
3213
|
+
const processing = q.processing ?? 0;
|
|
3214
|
+
const failed = q.failed ?? 0;
|
|
3215
|
+
const waiting = pending + processing;
|
|
3216
|
+
if (!duringOutage && waiting === 0 && failed === 0) return null;
|
|
3217
|
+
const parts = [`${pending} pending`];
|
|
3218
|
+
if (processing > 0) parts.push(`${processing} processing`);
|
|
3219
|
+
if (failed > 0) parts.push(`${failed} failed`);
|
|
3220
|
+
return {
|
|
3221
|
+
severity: failed > 0 ? "error" : duringOutage || waiting > 0 ? "warn" : "info",
|
|
3222
|
+
text: `Work queue: ${parts.join(", ")}`
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
|
|
2687
3226
|
//#endregion
|
|
2688
3227
|
//#region src/cli/commands/daemon.ts
|
|
2689
3228
|
const HOME$2 = homedir();
|
|
@@ -2755,14 +3294,13 @@ async function cmdStatus$2() {
|
|
|
2755
3294
|
console.log(ok$1(` Daemon running`));
|
|
2756
3295
|
console.log(dim$1(` Uptime: ${s["uptime"]}s`));
|
|
2757
3296
|
console.log(dim$1(` Socket: ${s["socketPath"]}`));
|
|
2758
|
-
const
|
|
2759
|
-
if (
|
|
2760
|
-
const mins = Math.max(1, Math.round((Date.now() - outage.since) / 6e4));
|
|
2761
|
-
const forHow = mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
|
|
3297
|
+
const health = formatStorageHealth(s);
|
|
3298
|
+
if (health.length > 0) {
|
|
2762
3299
|
console.log();
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
3300
|
+
for (const line of health) {
|
|
3301
|
+
const paint = line.severity === "error" ? err : line.severity === "warn" ? warn$1 : dim$1;
|
|
3302
|
+
console.log(paint(` ${line.text}`));
|
|
3303
|
+
}
|
|
2766
3304
|
console.log();
|
|
2767
3305
|
}
|
|
2768
3306
|
console.log(dim$1(` Index: ${s["indexInProgress"] ? "in progress" : "idle"} (interval: ${s["indexIntervalSecs"]}s)`));
|
|
@@ -2946,7 +3484,7 @@ function cmdLogs(opts) {
|
|
|
2946
3484
|
}
|
|
2947
3485
|
function registerDaemonCommands(daemonCmd) {
|
|
2948
3486
|
daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
|
|
2949
|
-
const { serve } = await import("./daemon-
|
|
3487
|
+
const { serve } = await import("./daemon-iNuNMMKd.mjs").then((n) => n.t);
|
|
2950
3488
|
const { loadConfig: lc, ensureConfigDir } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
|
|
2951
3489
|
ensureConfigDir();
|
|
2952
3490
|
await serve(lc());
|
|
@@ -7983,6 +8521,33 @@ function restoreDueString(recurrence, prevDue) {
|
|
|
7983
8521
|
return `${recurrence}${hasTime && !ruleCarriesTime ? ` at ${prevDue.slice(11, 16)}` : ""} starting ${date}`;
|
|
7984
8522
|
}
|
|
7985
8523
|
/**
|
|
8524
|
+
* When a restored slot actually falls due — the inverse of `restoreDueString`.
|
|
8525
|
+
*
|
|
8526
|
+
* Callers need this to decide whether putting the slot back is still useful.
|
|
8527
|
+
* Restoring one that has already passed writes a task that is overdue the
|
|
8528
|
+
* instant it lands, and the next tick dispatches it, which advances the date,
|
|
8529
|
+
* which restores it again: an unbounded loop that fires every tick for the rest
|
|
8530
|
+
* of the day. That is not hypothetical — it happened on 2026-08-04 with "every
|
|
8531
|
+
* day at 9am", which re-dispatched roughly every 30 minutes (two 900s ticks)
|
|
8532
|
+
* because the guard compared against the end of the day rather than 09:00.
|
|
8533
|
+
*
|
|
8534
|
+
* Both shapes `restoreDueString` can emit put the time after "at", so one match
|
|
8535
|
+
* covers "every day at 9am starting …" and "every week at 08:00 starting …".
|
|
8536
|
+
* A rule carrying no time at all is a genuine date-only occurrence, and for
|
|
8537
|
+
* those the slot really does run until the end of the day.
|
|
8538
|
+
*/
|
|
8539
|
+
function triggeredSlotMs(keep, date) {
|
|
8540
|
+
const at = /\bat\s+(\d{1,2})(?::(\d{2}))?\s*([ap]m)?/i.exec(keep);
|
|
8541
|
+
if (!at) return (/* @__PURE__ */ new Date(`${date}T23:59:59`)).getTime();
|
|
8542
|
+
let hour = Number(at[1]);
|
|
8543
|
+
const suffix = at[3]?.toLowerCase();
|
|
8544
|
+
if (suffix === "pm" && hour < 12) hour += 12;
|
|
8545
|
+
if (suffix === "am" && hour === 12) hour = 0;
|
|
8546
|
+
const hh = String(hour).padStart(2, "0");
|
|
8547
|
+
const mm = String(Number(at[2] ?? 0)).padStart(2, "0");
|
|
8548
|
+
return (/* @__PURE__ */ new Date(`${date}T${hh}:${mm}:00`)).getTime();
|
|
8549
|
+
}
|
|
8550
|
+
/**
|
|
7986
8551
|
* Expected runtime for a task.
|
|
7987
8552
|
*
|
|
7988
8553
|
* The mean rather than the max, deliberately: with a liveness probe available,
|
|
@@ -8577,7 +9142,7 @@ async function keepTriggeredSchedule(task, opts, state, now) {
|
|
|
8577
9142
|
delete state.triggeredRestore[task.id];
|
|
8578
9143
|
const date = /starting (\d{4}-\d{2}-\d{2})/.exec(keep)?.[1];
|
|
8579
9144
|
if (!date || !opts.provider.setDue) return "";
|
|
8580
|
-
if ((
|
|
9145
|
+
if (triggeredSlotMs(keep, date) <= now) return ", triggered-run slot already past — schedule left as it is";
|
|
8581
9146
|
try {
|
|
8582
9147
|
await opts.provider.setDue(task.id, keep);
|
|
8583
9148
|
state.lastSeenDue[task.id] = date;
|
|
@@ -10683,7 +11248,7 @@ function cmdActive(db, opts) {
|
|
|
10683
11248
|
async function cmdAutoRoute(opts) {
|
|
10684
11249
|
const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("./auto-route-BWGvvpcP.mjs");
|
|
10685
11250
|
const { openRegistry } = await import("./db-BtuN768f.mjs").then((n) => n.t);
|
|
10686
|
-
const { createStorageBackend } = await import("./factory-
|
|
11251
|
+
const { createStorageBackend } = await import("./factory-BydJSrZJ.mjs").then((n) => n.n);
|
|
10687
11252
|
const { loadConfig } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
|
|
10688
11253
|
const config = loadConfig();
|
|
10689
11254
|
const registryDb = openRegistry();
|
|
@@ -11453,43 +12018,6 @@ async function cmdRecent(db, opts) {
|
|
|
11453
12018
|
|
|
11454
12019
|
//#endregion
|
|
11455
12020
|
//#region src/cli/commands/session/goto.ts
|
|
11456
|
-
/**
|
|
11457
|
-
* Run claude --resume <uuid> --print --output-format=json "_" in the given cwd.
|
|
11458
|
-
* Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
|
|
11459
|
-
* in stderr). Timeout: 5 seconds.
|
|
11460
|
-
*/
|
|
11461
|
-
function probeResume$1(uuid, cwd) {
|
|
11462
|
-
const result = spawnSync("claude", [
|
|
11463
|
-
"--resume",
|
|
11464
|
-
uuid,
|
|
11465
|
-
"--print",
|
|
11466
|
-
"--output-format=json",
|
|
11467
|
-
"_"
|
|
11468
|
-
], {
|
|
11469
|
-
cwd,
|
|
11470
|
-
timeout: 5e3,
|
|
11471
|
-
env: process.env,
|
|
11472
|
-
stdio: [
|
|
11473
|
-
"ignore",
|
|
11474
|
-
"ignore",
|
|
11475
|
-
"pipe"
|
|
11476
|
-
]
|
|
11477
|
-
});
|
|
11478
|
-
if (result.error) return {
|
|
11479
|
-
ok: false,
|
|
11480
|
-
reason: `spawn error: ${result.error.message}`
|
|
11481
|
-
};
|
|
11482
|
-
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
11483
|
-
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
11484
|
-
ok: false,
|
|
11485
|
-
reason: "No conversation found for this UUID"
|
|
11486
|
-
};
|
|
11487
|
-
if (result.status !== 0) return {
|
|
11488
|
-
ok: false,
|
|
11489
|
-
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
11490
|
-
};
|
|
11491
|
-
return { ok: true };
|
|
11492
|
-
}
|
|
11493
12021
|
function cmdGoto(db, query, opts) {
|
|
11494
12022
|
const allSessions = scanSessions(db, {
|
|
11495
12023
|
limit: 500,
|
|
@@ -11553,7 +12081,7 @@ function cmdGoto(db, query, opts) {
|
|
|
11553
12081
|
return;
|
|
11554
12082
|
}
|
|
11555
12083
|
if (resumableUuid) {
|
|
11556
|
-
const probe = probeResume
|
|
12084
|
+
const probe = probeResume(resumableUuid, projectDir);
|
|
11557
12085
|
if (probe.ok) {
|
|
11558
12086
|
const result = spawnSync("claude", [
|
|
11559
12087
|
"--resume",
|
|
@@ -11609,12 +12137,229 @@ function cmdGoto(db, query, opts) {
|
|
|
11609
12137
|
}
|
|
11610
12138
|
}
|
|
11611
12139
|
|
|
12140
|
+
//#endregion
|
|
12141
|
+
//#region src/cli/commands/session/restore.ts
|
|
12142
|
+
/**
|
|
12143
|
+
* pai session restore — put displaced transcripts back where resume looks.
|
|
12144
|
+
*
|
|
12145
|
+
* PAI moved session transcripts out of `~/.claude/projects/<dir>/` into
|
|
12146
|
+
* `<dir>/sessions/`, and `claude --resume <uuid>` reads only the former. The
|
|
12147
|
+
* archiver no longer does that (it hardlinks), but the sessions already moved
|
|
12148
|
+
* stayed moved — and every checkpoint PAI ever wrote ends with
|
|
12149
|
+
* "Resume with: claude --resume <uuid>". For a displaced session that line is an
|
|
12150
|
+
* instruction that cannot work, and nothing says so.
|
|
12151
|
+
*
|
|
12152
|
+
* One project measured 1 transcript at the project root against 52 underneath.
|
|
12153
|
+
*
|
|
12154
|
+
* So this is a repair, and it is deliberately a COMMAND rather than another hook.
|
|
12155
|
+
* Silently relinking dozens of a user's files during an unrelated session start is
|
|
12156
|
+
* the same class of mistake as silently moving them was. Dry run is the default;
|
|
12157
|
+
* `--execute` is the only thing that touches disk.
|
|
12158
|
+
*
|
|
12159
|
+
* The link logic itself is imported, not rewritten. Today's other bug was a fix
|
|
12160
|
+
* that landed in one of three copies of `probeResume`, so `restoreTopLevel` stays
|
|
12161
|
+
* the single implementation.
|
|
12162
|
+
*/
|
|
12163
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12164
|
+
/**
|
|
12165
|
+
* Transcripts that exist under `sessions/` with no twin at the project root.
|
|
12166
|
+
*
|
|
12167
|
+
* A twin means nothing needs doing: the archiver hardlinks now, so the normal
|
|
12168
|
+
* state is both names pointing at one inode. Only the ones missing upstairs were
|
|
12169
|
+
* displaced by the old rename.
|
|
12170
|
+
*/
|
|
12171
|
+
function findDisplaced(projectsDir = claudeProjectsDir()) {
|
|
12172
|
+
const out = [];
|
|
12173
|
+
let encodedDirs;
|
|
12174
|
+
try {
|
|
12175
|
+
encodedDirs = readdirSync(projectsDir);
|
|
12176
|
+
} catch {
|
|
12177
|
+
return out;
|
|
12178
|
+
}
|
|
12179
|
+
for (const encodedDir of encodedDirs) {
|
|
12180
|
+
const projectDir = join(projectsDir, encodedDir);
|
|
12181
|
+
const sessionsDir = join(projectDir, "sessions");
|
|
12182
|
+
if (!existsSync(sessionsDir)) continue;
|
|
12183
|
+
let files;
|
|
12184
|
+
try {
|
|
12185
|
+
files = readdirSync(sessionsDir);
|
|
12186
|
+
} catch {
|
|
12187
|
+
continue;
|
|
12188
|
+
}
|
|
12189
|
+
for (const file of files) {
|
|
12190
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
12191
|
+
const uuid = file.slice(0, -6);
|
|
12192
|
+
if (!UUID_RE.test(uuid)) continue;
|
|
12193
|
+
if (existsSync(join(projectDir, file))) continue;
|
|
12194
|
+
let bytes = 0;
|
|
12195
|
+
let mtime = 0;
|
|
12196
|
+
try {
|
|
12197
|
+
const st = statSync(join(sessionsDir, file));
|
|
12198
|
+
bytes = st.size;
|
|
12199
|
+
mtime = st.mtimeMs;
|
|
12200
|
+
} catch {
|
|
12201
|
+
continue;
|
|
12202
|
+
}
|
|
12203
|
+
out.push({
|
|
12204
|
+
uuid,
|
|
12205
|
+
projectDir,
|
|
12206
|
+
encodedDir,
|
|
12207
|
+
cwd: smartDecodeDir(encodedDir),
|
|
12208
|
+
bytes,
|
|
12209
|
+
mtime,
|
|
12210
|
+
promisedBy: [],
|
|
12211
|
+
hasConversation: hasConversation(join(sessionsDir, file))
|
|
12212
|
+
});
|
|
12213
|
+
}
|
|
12214
|
+
}
|
|
12215
|
+
return out.sort((a, b) => b.bytes - a.bytes);
|
|
12216
|
+
}
|
|
12217
|
+
/**
|
|
12218
|
+
* Notes that instruct the user to resume this id.
|
|
12219
|
+
*
|
|
12220
|
+
* Bounded on purpose — the project's own TODO.md and note files, never a search
|
|
12221
|
+
* from the home directory. The point is to name the specific promise each
|
|
12222
|
+
* restore makes true, so a count of files is not enough.
|
|
12223
|
+
*/
|
|
12224
|
+
function findResumePromises(uuid, cwd) {
|
|
12225
|
+
if (!cwd) return [];
|
|
12226
|
+
const candidates = [];
|
|
12227
|
+
for (const notesDir of [
|
|
12228
|
+
cwd,
|
|
12229
|
+
join(cwd, "Notes"),
|
|
12230
|
+
join(cwd, "notes")
|
|
12231
|
+
]) try {
|
|
12232
|
+
for (const entry of readdirSync(notesDir)) if (entry.endsWith(".md")) candidates.push(join(notesDir, entry));
|
|
12233
|
+
} catch {}
|
|
12234
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12235
|
+
const hits = [];
|
|
12236
|
+
for (const file of candidates) {
|
|
12237
|
+
let key;
|
|
12238
|
+
try {
|
|
12239
|
+
const st = statSync(file);
|
|
12240
|
+
key = `${st.dev}:${st.ino}`;
|
|
12241
|
+
} catch {
|
|
12242
|
+
key = file.toLowerCase();
|
|
12243
|
+
}
|
|
12244
|
+
if (seen.has(key)) continue;
|
|
12245
|
+
seen.add(key);
|
|
12246
|
+
try {
|
|
12247
|
+
if (readFileSync(file, "utf8").includes(uuid)) hits.push(file);
|
|
12248
|
+
} catch {}
|
|
12249
|
+
}
|
|
12250
|
+
return hits;
|
|
12251
|
+
}
|
|
12252
|
+
function human(bytes) {
|
|
12253
|
+
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
12254
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
12255
|
+
return `${bytes} B`;
|
|
12256
|
+
}
|
|
12257
|
+
/** How many to list before the report stops being readable. */
|
|
12258
|
+
const LIST_LIMIT = 20;
|
|
12259
|
+
/**
|
|
12260
|
+
* Which of the displaced sessions a run is about, and which of those to link.
|
|
12261
|
+
*
|
|
12262
|
+
* Pure, and separate from the printing, for two reasons. The obvious one is that
|
|
12263
|
+
* it can be tested without pointing a test at the developer's real
|
|
12264
|
+
* `~/.claude/projects` — an earlier draft of the test did exactly that and would
|
|
12265
|
+
* have relinked live files. The other is that the report and the action must
|
|
12266
|
+
* agree: `--json` originally restored everything in scope while the formatted
|
|
12267
|
+
* path skipped stubs, so the two modes did different things with the same flags.
|
|
12268
|
+
*/
|
|
12269
|
+
function selectTargets(everything, opts) {
|
|
12270
|
+
const inScope = everything.filter((d) => {
|
|
12271
|
+
if (opts.promised && d.promisedBy.length === 0) return false;
|
|
12272
|
+
if (opts.cwd && d.cwd !== opts.cwd) return false;
|
|
12273
|
+
return true;
|
|
12274
|
+
});
|
|
12275
|
+
return {
|
|
12276
|
+
inScope,
|
|
12277
|
+
toRestore: opts.includeStubs ? inScope : inScope.filter((d) => d.hasConversation)
|
|
12278
|
+
};
|
|
12279
|
+
}
|
|
12280
|
+
function cmdRestore(opts = {}) {
|
|
12281
|
+
const everything = findDisplaced();
|
|
12282
|
+
for (const d of everything) d.promisedBy = findResumePromises(d.uuid, d.cwd);
|
|
12283
|
+
const { inScope: displaced, toRestore } = selectTargets(everything, opts);
|
|
12284
|
+
if (opts.json) {
|
|
12285
|
+
console.log(JSON.stringify({
|
|
12286
|
+
displaced,
|
|
12287
|
+
wouldRestore: toRestore.map((d) => d.uuid),
|
|
12288
|
+
executed: Boolean(opts.execute)
|
|
12289
|
+
}, null, 2));
|
|
12290
|
+
if (opts.execute) for (const d of toRestore) restoreTopLevel(d.uuid, d.projectDir);
|
|
12291
|
+
return;
|
|
12292
|
+
}
|
|
12293
|
+
console.log();
|
|
12294
|
+
console.log(bold$1(" Displaced session transcripts"));
|
|
12295
|
+
console.log();
|
|
12296
|
+
if (displaced.length === 0) {
|
|
12297
|
+
if (everything.length > 0) {
|
|
12298
|
+
console.log(ok$1(` Nothing displaced in this scope.`));
|
|
12299
|
+
console.log(dim$1(` ${everything.length} displaced elsewhere — drop the filters to see them.`));
|
|
12300
|
+
} else {
|
|
12301
|
+
console.log(ok$1(" Nothing displaced — every archived transcript has its twin at the"));
|
|
12302
|
+
console.log(dim$1(" project root, which is where claude --resume looks."));
|
|
12303
|
+
}
|
|
12304
|
+
console.log();
|
|
12305
|
+
return;
|
|
12306
|
+
}
|
|
12307
|
+
const promised = displaced.filter((d) => d.promisedBy.length > 0);
|
|
12308
|
+
const totalBytes = displaced.reduce((n, d) => n + d.bytes, 0);
|
|
12309
|
+
console.log(warn$1(` ${displaced.length} transcript(s), ${human(totalBytes)}, currently unresumable.`));
|
|
12310
|
+
if (displaced.length !== everything.length) console.log(dim$1(` (filtered from ${everything.length} machine-wide)`));
|
|
12311
|
+
if (promised.length > 0) console.log(err(` ${promised.length} named in a checkpoint that tells you to resume them — those lines are lies today.`));
|
|
12312
|
+
const stubs = displaced.filter((d) => !d.hasConversation);
|
|
12313
|
+
if (stubs.length > 0) console.log(dim$1(` ${stubs.length} of them hold no conversation at all — restoring those changes nothing.`));
|
|
12314
|
+
console.log();
|
|
12315
|
+
const ordered = [...promised, ...displaced.filter((d) => d.promisedBy.length === 0)];
|
|
12316
|
+
const shown = opts.all ? ordered : ordered.slice(0, LIST_LIMIT);
|
|
12317
|
+
for (const d of shown) {
|
|
12318
|
+
const where = d.cwd ?? dim$1(`(undecodable: ${d.encodedDir})`);
|
|
12319
|
+
const stub = d.hasConversation ? "" : warn$1(" [stub — no conversation]");
|
|
12320
|
+
console.log(` ${bold$1(d.uuid.slice(0, 8))} ${human(d.bytes).padStart(8)} ${where}${stub}`);
|
|
12321
|
+
for (const file of d.promisedBy) console.log(d.hasConversation ? err(` promised by ${file}`) : dim$1(` promised by ${file} (promise cannot be kept: empty transcript)`));
|
|
12322
|
+
}
|
|
12323
|
+
if (shown.length < ordered.length) {
|
|
12324
|
+
console.log();
|
|
12325
|
+
console.log(dim$1(` … and ${ordered.length - shown.length} more. --all lists every one.`));
|
|
12326
|
+
}
|
|
12327
|
+
console.log();
|
|
12328
|
+
if (!opts.execute) {
|
|
12329
|
+
console.log(dim$1(" Dry run — nothing was touched."));
|
|
12330
|
+
console.log(dim$1(" Restore hardlinks each transcript back to the project root: same inode,"));
|
|
12331
|
+
console.log(dim$1(" no copy, and the sessions/ archive is left exactly where it is."));
|
|
12332
|
+
console.log();
|
|
12333
|
+
console.log(dim$1(" --promised --execute just the ones a checkpoint promises (start here)"));
|
|
12334
|
+
console.log(dim$1(" --cwd <path> --execute one project"));
|
|
12335
|
+
console.log(dim$1(" --execute everything in scope"));
|
|
12336
|
+
console.log();
|
|
12337
|
+
return;
|
|
12338
|
+
}
|
|
12339
|
+
const target = toRestore;
|
|
12340
|
+
const skipped = displaced.length - target.length;
|
|
12341
|
+
let restored = 0;
|
|
12342
|
+
const failed = [];
|
|
12343
|
+
for (const d of target) if (restoreTopLevel(d.uuid, d.projectDir)) restored++;
|
|
12344
|
+
else failed.push(d.uuid);
|
|
12345
|
+
console.log(ok$1(` Restored ${restored} of ${target.length} recoverable.`));
|
|
12346
|
+
if (skipped > 0) console.log(dim$1(` Skipped ${skipped} stub(s) — nothing in them to resume. --include-stubs overrides.`));
|
|
12347
|
+
if (failed.length > 0) {
|
|
12348
|
+
console.log(warn$1(` Could not restore ${failed.length}:`));
|
|
12349
|
+
for (const uuid of failed) console.log(dim$1(` ${uuid}`));
|
|
12350
|
+
}
|
|
12351
|
+
console.log();
|
|
12352
|
+
}
|
|
12353
|
+
|
|
11612
12354
|
//#endregion
|
|
11613
12355
|
//#region src/cli/commands/session/index.ts
|
|
11614
12356
|
function registerSessionCommands(sessionCmd, getDb) {
|
|
11615
12357
|
sessionCmd.command("list").description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions\nDefault shows all sessions you've named or that are resumable.\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) => {
|
|
11616
12358
|
await cmdRecent(getDb(), opts);
|
|
11617
12359
|
});
|
|
12360
|
+
sessionCmd.command("restore").description("Restore transcripts PAI displaced into sessions/ so claude --resume can find them again. Reports what is unresumable, which checkpoints promise it, and which transcripts are empty. Dry run unless --execute is given.").option("--execute", "Actually restore (hardlink back to the project root)").option("--promised", "Only sessions a checkpoint tells you to resume").option("--cwd <path>", "Only sessions belonging to this working directory").option("--all", "List every displaced session, not just the largest few").option("--include-stubs", "Also restore transcripts that hold no conversation").option("--json", "Output raw JSON instead of a formatted report").action((opts) => {
|
|
12361
|
+
cmdRestore(opts);
|
|
12362
|
+
});
|
|
11618
12363
|
sessionCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
|
|
11619
12364
|
cmdInfo(getDb(), projectSlug, number);
|
|
11620
12365
|
});
|
|
@@ -12804,181 +13549,6 @@ async function cmdFind(query, opts) {
|
|
|
12804
13549
|
console.log();
|
|
12805
13550
|
}
|
|
12806
13551
|
|
|
12807
|
-
//#endregion
|
|
12808
|
-
//#region src/cli/lib/launch.ts
|
|
12809
|
-
/**
|
|
12810
|
-
* launch.ts — Launch Claude Code in a directory, in the CURRENT terminal.
|
|
12811
|
-
*
|
|
12812
|
-
* Shared by the interactive picker (pick.ts). Deliberately does NOT switch
|
|
12813
|
-
* iTerm tabs (aibroker_switch): switching jumps the user to a different — and
|
|
12814
|
-
* sometimes wrong — terminal, which is confusing. Picking a place should start
|
|
12815
|
-
* a session right here, in the chosen directory.
|
|
12816
|
-
*
|
|
12817
|
-
* Behaviour:
|
|
12818
|
-
* resume-or-fresh → if a resumable UUID is given, probe it; on success
|
|
12819
|
-
* `claude --resume`, otherwise fall back to a fresh session
|
|
12820
|
-
* in the same dir. With no UUID, start fresh.
|
|
12821
|
-
*
|
|
12822
|
-
* The `claude` child inherits the tty (stdio: "inherit"), so the session runs
|
|
12823
|
-
* in the terminal that launched `pai`. On exit we print the working directory.
|
|
12824
|
-
*/
|
|
12825
|
-
/**
|
|
12826
|
-
* Does a transcript for this session exist on disk?
|
|
12827
|
-
*
|
|
12828
|
-
* Returns true/false when it can tell, and null when it cannot — the caller
|
|
12829
|
-
* must treat null as "ask something else", never as "no". A path this function
|
|
12830
|
-
* fails to recognise would otherwise silently veto a perfectly good resume.
|
|
12831
|
-
*
|
|
12832
|
-
* Claude Code stores transcripts at ~/.claude/projects/<encoded-cwd>/, where the
|
|
12833
|
-
* encoding replaces every non-alphanumeric character with `-`. Finished sessions
|
|
12834
|
-
* are moved into a `sessions/` subdirectory by the stop hook, so both are checked.
|
|
12835
|
-
*/
|
|
12836
|
-
function transcriptOnDisk(uuid, cwd) {
|
|
12837
|
-
try {
|
|
12838
|
-
const encoded = cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
12839
|
-
const dir = join(homedir(), ".claude", "projects", encoded);
|
|
12840
|
-
if (!existsSync(dir)) return null;
|
|
12841
|
-
return existsSync(join(dir, `${uuid}.jsonl`)) || existsSync(join(dir, "sessions", `${uuid}.jsonl`));
|
|
12842
|
-
} catch {
|
|
12843
|
-
return null;
|
|
12844
|
-
}
|
|
12845
|
-
}
|
|
12846
|
-
/**
|
|
12847
|
-
* Probe whether a session UUID is resumable from `cwd`.
|
|
12848
|
-
*
|
|
12849
|
-
* The filesystem is asked first, and usually answers.
|
|
12850
|
-
*
|
|
12851
|
-
* This used to run `claude --resume <uuid> --print --output-format=json "_"`
|
|
12852
|
-
* with a 5s timeout, which is not a probe: it resumes the session AND sends a
|
|
12853
|
-
* prompt to the model, then waits for a complete JSON reply. That costs a model
|
|
12854
|
-
* round-trip per probe, and 5s is not enough time for one — for a LARGE session
|
|
12855
|
-
* least of all, because the transcript has to be loaded first.
|
|
12856
|
-
*
|
|
12857
|
-
* So the check failed precisely for the sessions most worth resuming, and the
|
|
12858
|
-
* caller's fallback quietly started a fresh session in their place. Observed
|
|
12859
|
-
* 2026-08-04: `pai Paperfull` reported `spawn error: spawnSync claude ETIMEDOUT`
|
|
12860
|
-
* for fb76a6c3 and started over, while
|
|
12861
|
-
* `~/.claude/projects/…-Paperfull/sessions/fb76a6c3-….jsonl` sat on disk the
|
|
12862
|
-
* whole time.
|
|
12863
|
-
*
|
|
12864
|
-
* A transcript on disk is what resumable MEANS, and reading a directory entry is
|
|
12865
|
-
* free. The spawn remains only for the case the filesystem cannot answer, and
|
|
12866
|
-
* now gets a timeout that a real answer can fit inside.
|
|
12867
|
-
*/
|
|
12868
|
-
function probeResume(uuid, cwd) {
|
|
12869
|
-
const onDisk = transcriptOnDisk(uuid, cwd);
|
|
12870
|
-
if (onDisk === true) return { ok: true };
|
|
12871
|
-
if (onDisk === false) return {
|
|
12872
|
-
ok: false,
|
|
12873
|
-
reason: "No transcript on disk for this UUID"
|
|
12874
|
-
};
|
|
12875
|
-
const result = spawnSync("claude", [
|
|
12876
|
-
"--resume",
|
|
12877
|
-
uuid,
|
|
12878
|
-
"--print",
|
|
12879
|
-
"--output-format=json",
|
|
12880
|
-
"_"
|
|
12881
|
-
], {
|
|
12882
|
-
cwd,
|
|
12883
|
-
timeout: 3e4,
|
|
12884
|
-
env: process.env,
|
|
12885
|
-
stdio: [
|
|
12886
|
-
"ignore",
|
|
12887
|
-
"ignore",
|
|
12888
|
-
"pipe"
|
|
12889
|
-
]
|
|
12890
|
-
});
|
|
12891
|
-
if (result.error) return {
|
|
12892
|
-
ok: false,
|
|
12893
|
-
reason: `spawn error: ${result.error.message}`
|
|
12894
|
-
};
|
|
12895
|
-
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
12896
|
-
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
12897
|
-
ok: false,
|
|
12898
|
-
reason: "No conversation found for this UUID"
|
|
12899
|
-
};
|
|
12900
|
-
if (result.status !== 0) return {
|
|
12901
|
-
ok: false,
|
|
12902
|
-
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
12903
|
-
};
|
|
12904
|
-
return { ok: true };
|
|
12905
|
-
}
|
|
12906
|
-
/**
|
|
12907
|
-
* Launch `claude` in `dir` in the current terminal. `name` is used for both the
|
|
12908
|
-
* Claude session label (--name) and the /Name slash command (tab/statusline).
|
|
12909
|
-
* Never returns on the live path — it exits the process after claude exits.
|
|
12910
|
-
*/
|
|
12911
|
-
function launchInDir(dir, name, opts = {}) {
|
|
12912
|
-
let cwd;
|
|
12913
|
-
try {
|
|
12914
|
-
cwd = realpathSync(dir);
|
|
12915
|
-
} catch {
|
|
12916
|
-
console.error(err(`Directory does not exist or cannot be resolved:\n ${dir}\n The folder may have moved or been deleted.`));
|
|
12917
|
-
process.exit(1);
|
|
12918
|
-
return;
|
|
12919
|
-
}
|
|
12920
|
-
const promptArg = `/Name ${name}\ngo`;
|
|
12921
|
-
const wantResume = !opts.forceFresh && !!opts.resumableUuid;
|
|
12922
|
-
if (opts.dryRun) {
|
|
12923
|
-
if (wantResume) {
|
|
12924
|
-
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
12925
|
-
console.log(` cwd: ${chalk.cyan(cwd)}`);
|
|
12926
|
-
console.log(` probe: claude --resume ${opts.resumableUuid} --print --output-format=json "_"`);
|
|
12927
|
-
console.log(` argv: claude --resume ${opts.resumableUuid} --name "${name}" "/Name ${name}\\ngo"`);
|
|
12928
|
-
console.log(` fallback: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
12929
|
-
} else {
|
|
12930
|
-
console.log("\n" + chalk.bold("Dry run — would exec (FRESH path):") + "\n");
|
|
12931
|
-
console.log(` cwd: ${chalk.cyan(cwd)}`);
|
|
12932
|
-
console.log(` argv: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
12933
|
-
}
|
|
12934
|
-
console.log();
|
|
12935
|
-
return;
|
|
12936
|
-
}
|
|
12937
|
-
const fresh = () => {
|
|
12938
|
-
const result = spawnSync("claude", [
|
|
12939
|
-
"--name",
|
|
12940
|
-
name,
|
|
12941
|
-
promptArg
|
|
12942
|
-
], {
|
|
12943
|
-
cwd,
|
|
12944
|
-
stdio: "inherit",
|
|
12945
|
-
env: process.env
|
|
12946
|
-
});
|
|
12947
|
-
if (result.error) {
|
|
12948
|
-
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
12949
|
-
process.exit(1);
|
|
12950
|
-
}
|
|
12951
|
-
printExitDir(cwd);
|
|
12952
|
-
process.exit(result.status ?? 0);
|
|
12953
|
-
};
|
|
12954
|
-
if (wantResume) {
|
|
12955
|
-
const probe = probeResume(opts.resumableUuid, cwd);
|
|
12956
|
-
if (probe.ok) {
|
|
12957
|
-
const result = spawnSync("claude", [
|
|
12958
|
-
"--resume",
|
|
12959
|
-
opts.resumableUuid,
|
|
12960
|
-
"--name",
|
|
12961
|
-
name,
|
|
12962
|
-
promptArg
|
|
12963
|
-
], {
|
|
12964
|
-
cwd,
|
|
12965
|
-
stdio: "inherit",
|
|
12966
|
-
env: process.env
|
|
12967
|
-
});
|
|
12968
|
-
if (result.error) {
|
|
12969
|
-
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
12970
|
-
process.exit(1);
|
|
12971
|
-
}
|
|
12972
|
-
printExitDir(cwd);
|
|
12973
|
-
process.exit(result.status ?? 0);
|
|
12974
|
-
}
|
|
12975
|
-
process.stderr.write(chalk.yellow(`\n Resume failed for ${opts.resumableUuid.slice(0, 8)}: ${probe.reason ?? "unknown error"}\n Starting fresh session in same directory.\n\n`));
|
|
12976
|
-
fresh();
|
|
12977
|
-
return;
|
|
12978
|
-
}
|
|
12979
|
-
fresh();
|
|
12980
|
-
}
|
|
12981
|
-
|
|
12982
13552
|
//#endregion
|
|
12983
13553
|
//#region src/cli/commands/pick.ts
|
|
12984
13554
|
function getProjects(db, includeArchived) {
|
|
@@ -13491,4 +14061,4 @@ async function cmdPick(db, opts = {}) {
|
|
|
13491
14061
|
|
|
13492
14062
|
//#endregion
|
|
13493
14063
|
export { registerProjectsCommands as A, registerRestoreCommands as C, registerIdentityCommands as D, registerMcpCommands as E, resolveIdentifier as M, registerMemoryCommands as O, registerSetupCommand as S, registerDaemonCommands as T, registerUpdateCommand as _, cmdEnd as a, registerZettelCommands as b, cmdGoto as c, registerHelpCommand as d, registerDbCommands as f, registerNotifyCommands as g, registerTaskCommands as h, cmdPauseAll as i, findMovedPath as j, registerRegistryCommands as k, cmdPause as l, registerTopicCommands as m, cmdFind as n, registerSessionCleanupCommand as o, registerKgCommands as p, cmdClearNames as r, registerSessionCommands as s, cmdPick as t, cmdList as u, registerSkillCommands as v, registerBackupCommands as w, registerObsidianCommands as x, registerObservationCommands as y };
|
|
13494
|
-
//# sourceMappingURL=pick-
|
|
14064
|
+
//# sourceMappingURL=pick-DlsM0ppq.mjs.map
|