@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.
Files changed (44) hide show
  1. package/dist/cli/index.mjs +3 -3
  2. package/dist/cli/program.mjs +3 -3
  3. package/dist/daemon/index.mjs +3 -3
  4. package/dist/{daemon-mFGnPd8q.mjs → daemon-iNuNMMKd.mjs} +5 -5
  5. package/dist/{daemon-mFGnPd8q.mjs.map → daemon-iNuNMMKd.mjs.map} +1 -1
  6. package/dist/{factory-BqAdO21B.mjs → factory-BydJSrZJ.mjs} +13 -2
  7. package/dist/factory-BydJSrZJ.mjs.map +1 -0
  8. package/dist/hooks/capture-all-events.mjs.map +1 -1
  9. package/dist/hooks/cleanup-session-files.mjs +21 -15
  10. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  11. package/dist/hooks/context-compression-hook.mjs +6 -6
  12. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  13. package/dist/hooks/initialize-session.mjs.map +1 -1
  14. package/dist/hooks/inject-observations.mjs.map +1 -1
  15. package/dist/hooks/load-core-context.mjs.map +1 -1
  16. package/dist/hooks/load-project-context.mjs +43 -25
  17. package/dist/hooks/load-project-context.mjs.map +3 -3
  18. package/dist/hooks/observe.mjs.map +1 -1
  19. package/dist/hooks/stop-hook.mjs +27 -21
  20. package/dist/hooks/stop-hook.mjs.map +3 -3
  21. package/dist/hooks/sync-todo-to-md.mjs +2 -2
  22. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  23. package/dist/{main-resolver-BfSL8zio.mjs → main-resolver-DjyUDJrv.mjs} +424 -58
  24. package/dist/main-resolver-DjyUDJrv.mjs.map +1 -0
  25. package/dist/{pick-BcmBBfOF.mjs → pick-DlsM0ppq.mjs} +817 -247
  26. package/dist/pick-DlsM0ppq.mjs.map +1 -0
  27. package/dist/{work-queue-worker-B8QUONlK.mjs → work-queue-worker-BAgwmMDl.mjs} +55 -14
  28. package/dist/work-queue-worker-BAgwmMDl.mjs.map +1 -0
  29. package/docs/commands/README.md +5 -0
  30. package/docs/commands/project.md +38 -0
  31. package/docs/commands/projects.md +38 -0
  32. package/docs/commands/session.md +17 -0
  33. package/package.json +1 -1
  34. package/src/hooks/ts/lib/project-utils/index.ts +1 -1
  35. package/src/hooks/ts/lib/project-utils/paths.test.ts +125 -0
  36. package/src/hooks/ts/lib/project-utils/paths.ts +68 -14
  37. package/src/hooks/ts/lib/project-utils.ts +1 -1
  38. package/src/hooks/ts/session-start/load-project-context.ts +34 -31
  39. package/src/hooks/ts/stop/stop-hook.ts +5 -5
  40. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +19 -6
  41. package/dist/factory-BqAdO21B.mjs.map +0 -1
  42. package/dist/main-resolver-BfSL8zio.mjs.map +0 -1
  43. package/dist/pick-BcmBBfOF.mjs.map +0 -1
  44. 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-BqAdO21B.mjs";
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 fetchLiveSessions, d as fmtAge, f as resolveSessionByNameOrId, i as normalizeName$2, l as revealItermSession, o as printExitDir, p as scanSessions, r as buildDeduped, s as callAiBroker, u as sendToSession } from "./main-resolver-BfSL8zio.mjs";
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
- function suggestMovedPath(project) {
1197
- const name = basename(project.root_path);
1198
- const candidates = [
1199
- join(homedir(), "dev", name),
1200
- join(homedir(), "dev", "ai", name),
1201
- join(homedir(), "Desktop", name),
1202
- join(homedir(), "Projects", name)
1203
- ];
1204
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
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(" Dead projects (path missing, no match found):"));
1603
+ console.log(err(" Unreachable projects (path missing):"));
1285
1604
  for (const r of dead) {
1286
- console.log(` ${bold$1(r.project.slug)} ${dim$1(r.project.root_path)}`);
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(` Fix: pai project archive ${r.project.slug} (or pai project move ...)`));
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
- console.log(dim$1(` ${rows.length} total: ${active.length} active, ${stale.length} stale, ${dead.length} dead`));
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-BfSL8zio.mjs").then((n) => n.n);
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 outage = s["backendOutage"];
2759
- if (outage) {
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
- console.log(err(` BACKEND DOWN: ${outage.backend} unreachable for ${forHow}`));
2764
- console.log(warn$1(` ${outage.attempts} attempts ${outage.lastError}`));
2765
- console.log(dim$1(` Indexing, session notes and the work queue are stalled until it returns.`));
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-mFGnPd8q.mjs").then((n) => n.t);
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 ((/* @__PURE__ */ new Date(`${date}T23:59:59`)).getTime() <= now) return ", triggered-run slot already past — schedule left as it is";
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-BqAdO21B.mjs").then((n) => n.n);
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$1(resumableUuid, projectDir);
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-BcmBBfOF.mjs.map
14064
+ //# sourceMappingURL=pick-DlsM0ppq.mjs.map