@tekmidian/pai 0.35.2 → 0.36.1

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 (126) hide show
  1. package/dist/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
  2. package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +25 -17
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/probe2.mjs +2 -0
  6. package/dist/cli/program.d.mts.map +1 -1
  7. package/dist/cli/program.mjs +16 -267
  8. package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
  9. package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
  10. package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
  11. package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
  12. package/dist/config-C_ErGddD.mjs +3 -0
  13. package/dist/{checkpoint-block-D3rm4dAJ.mjs → context-handover-cache-PtNvj_8D.mjs} +40 -8
  14. package/dist/context-handover-cache-PtNvj_8D.mjs.map +1 -0
  15. package/dist/daemon/index.mjs +19 -19
  16. package/dist/{daemon-B8L2f5vy.mjs → daemon-BZ93KRBo.mjs} +33 -38
  17. package/dist/daemon-BZ93KRBo.mjs.map +1 -0
  18. package/dist/daemon-DRdoA489.mjs +20 -0
  19. package/dist/daemon-mcp/index.mjs +2 -2
  20. package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
  21. package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
  22. package/dist/db-O-cyAPfS.mjs +3 -0
  23. package/dist/db-XEwJbuGO.mjs +3 -0
  24. package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
  25. package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
  26. package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
  27. package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  28. package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
  29. package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
  30. package/dist/detector-DGAk1iBR.mjs +5 -0
  31. package/dist/embeddings-CEBGrzwu.mjs +3 -0
  32. package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
  33. package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
  34. package/dist/{factory-vPTPoBR7.mjs → factory-Bsp7xOpO.mjs} +9 -12
  35. package/dist/{factory-vPTPoBR7.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
  36. package/dist/factory-CrokPMk2.mjs +3 -0
  37. package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
  38. package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
  39. package/dist/hooks/context-compression-hook.mjs +217 -70
  40. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  41. package/dist/index.mjs +10 -10
  42. package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
  43. package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
  44. package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
  45. package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
  46. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  47. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  48. package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
  49. package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
  50. package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
  51. package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
  52. package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
  53. package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
  54. package/dist/main-resolver-Dxh444GO.mjs +4 -0
  55. package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
  56. package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
  57. package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
  58. package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
  59. package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
  60. package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
  61. package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
  62. package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
  63. package/dist/{postgres-CI3FjGq0.mjs → postgres-BVme6qX0.mjs} +2 -2
  64. package/dist/{postgres-CI3FjGq0.mjs.map → postgres-BVme6qX0.mjs.map} +1 -1
  65. package/dist/{pick-DatgS3Nk.mjs → program-C-fUghPv.mjs} +1298 -223
  66. package/dist/program-C-fUghPv.mjs.map +1 -0
  67. package/dist/query-feedback-BBMBp96K.mjs +3 -0
  68. package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
  69. package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
  70. package/dist/reranker-CwTCNsgA.mjs +3 -0
  71. package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
  72. package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
  73. package/dist/router-BMkOb62X.mjs +3 -0
  74. package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
  75. package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
  76. package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
  77. package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
  78. package/dist/search-CfPpJAWQ.mjs +4 -0
  79. package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
  80. package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
  81. package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
  82. package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
  83. package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
  84. package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
  85. package/dist/state-WaXhLr6R.mjs +70 -0
  86. package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
  87. package/dist/state-qtmrBWCm.mjs +3 -0
  88. package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
  89. package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
  90. package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
  91. package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
  92. package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
  93. package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
  94. package/dist/tools-DEt6YPfc.mjs +5 -0
  95. package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
  96. package/dist/tools-ceiy7ANX.mjs.map +1 -0
  97. package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
  98. package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
  99. package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
  100. package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
  101. package/dist/utils-DhMex3Ox.mjs +3 -0
  102. package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
  103. package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
  104. package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-B8W8_3Rn.mjs} +201 -13
  105. package/dist/work-queue-worker-B8W8_3Rn.mjs.map +1 -0
  106. package/dist/work-queue-worker-HN2Ufg-L.mjs +11 -0
  107. package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
  108. package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
  109. package/package.json +1 -1
  110. package/src/hooks/ts/lib/context-fill.test.ts +628 -0
  111. package/src/hooks/ts/lib/context-fill.ts +668 -0
  112. package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
  113. package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
  114. package/src/hooks/ts/lib/transcript-text.ts +71 -0
  115. package/src/hooks/ts/pre-compact/context-compression-hook.ts +131 -30
  116. package/statusline-command.sh +16 -0
  117. package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
  118. package/dist/daemon-B8L2f5vy.mjs.map +0 -1
  119. package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
  120. package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
  121. package/dist/pick-DatgS3Nk.mjs.map +0 -1
  122. package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
  123. package/dist/state-DTvy-jRB.mjs +0 -102
  124. package/dist/tools-C1lCHerL.mjs.map +0 -1
  125. package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
  126. /package/dist/{indexer-AEcT8wHf.mjs → indexer-D7MvSQPY.mjs} +0 -0
@@ -1,23 +1,24 @@
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, 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
- import { a as slugify$1, i as parseSessionFilename } from "./migrate-fLD6rAdO.mjs";
4
- import { n as ensurePaiMarker } from "./pai-marker-B20KqhA8.mjs";
5
- import { n as openFederation } from "./db-CYmBWcjh.mjs";
6
- import { i as indexProject, n as indexAll, t as embedChunks } from "./sync--BoxBBok.mjs";
7
- import { t as STOP_WORDS } from "./stop-words-BaMEGVeY.mjs";
8
- import { n as populateSlugs, r as searchMemory } from "./search-C32zQ0V0.mjs";
9
- import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "./detect-Bf2z-oKB.mjs";
10
- import { _ as transcriptFiles, a as readBodyFile, c as loadScanConfig, d as saveScanConfig, f as upsertProject, g as scanTranscriptFolders, h as findMovedProjects, i as findNotesDir$1, m as claudeProjectsDir, n as applyContinue, o as extractAndStoreTriples, p as upsertSession, r as findLatestNote, s as cmdScan, t as appendCheckpointToNote, u as resolveHome } from "./checkpoint-block-D3rm4dAJ.mjs";
11
- import { a as schedulerLogPath, i as paiSocketPath, n as daemonLogPath, r as daemonPidPath } from "./runtime-paths-B0P1TvUr.mjs";
12
- import { t as PaiClient } from "./ipc-client-aVKVERjJ.mjs";
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-BSkVcvfq.mjs";
14
- import { i as humanDuration, t as createStorageBackend } from "./factory-vPTPoBR7.mjs";
15
- import { s as kgQuery } from "./kg-entity-r8duqhi9.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-CNSqU8wo.mjs";
1
+ import { t as openRegistry } from "./db-Ca5qfsMC.mjs";
2
+ import { _ as warn$1, a as fmtDate, c as ok$1, d as resolvePath, f as scaffoldProjectDirs, g as smartDecodeDir, h as slugify, i as err, m as slugFromPath, n as dim$1, o as header, p as shortenPath, r as encodeDir, s as now, t as bold$1, u as renderTable } from "./utils-9Err2RBW.mjs";
3
+ import { a as slugify$1, i as parseSessionFilename } from "./migrate-Cjzeefn9.mjs";
4
+ import { n as ensurePaiMarker } from "./pai-marker-CHtbJMwJ.mjs";
5
+ import { t as openFederation } from "./db-a1ixZQjr.mjs";
6
+ import { i as indexProject, n as indexAll, t as embedChunks } from "./sync-BWbe8JTg.mjs";
7
+ import { t as STOP_WORDS } from "./stop-words-Hfu8u22w.mjs";
8
+ import { a as searchMemory, i as populateSlugs } from "./search-Rpk1cSBC.mjs";
9
+ import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "./detect-CdaA48EI.mjs";
10
+ import { _ as findMovedProjects, a as findLatestNote, c as extractAndStoreTriples, f as resolveHome, g as claudeProjectsDir, h as upsertSession, i as applyContinue, l as cmdScan, m as upsertProject, o as findNotesDir$1, p as saveScanConfig, r as appendCheckpointToNote, s as readBodyFile, t as readContextHandoverCache, u as loadScanConfig, v as scanTranscriptFolders, y as transcriptFiles } from "./context-handover-cache-PtNvj_8D.mjs";
11
+ import { a as schedulerLogPath, i as paiSocketPath, n as daemonLogPath, r as daemonPidPath } from "./runtime-paths-rni52zHX.mjs";
12
+ import { t as PaiClient } from "./ipc-client-BmypMNYk.mjs";
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-B64vFg14.mjs";
14
+ import { r as humanDuration, t as createStorageBackend } from "./factory-Bsp7xOpO.mjs";
15
+ import { s as kgQuery } from "./kg-entity-DbOMPdF9.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, t as cmdMain, u as printExitDir } from "./main-resolver-BAbhKpeX.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
19
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
20
  import chalk from "chalk";
21
+ import { Command } from "commander";
21
22
  import { fileURLToPath } from "node:url";
22
23
  import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
23
24
  import { createInterface } from "node:readline";
@@ -214,10 +215,7 @@ function getProject$2(db, slug) {
214
215
  }
215
216
  function requireProject(db, slug) {
216
217
  const project = getProject$2(db, slug);
217
- if (!project) {
218
- console.error(err(`Project not found: ${slug}`));
219
- process.exit(1);
220
- }
218
+ if (!project) throw new Error(`Project not found: ${slug}`);
221
219
  return project;
222
220
  }
223
221
  /**
@@ -420,18 +418,21 @@ function cmdAdd(db, rawPath, opts) {
420
418
  ];
421
419
  if (!validTypes.includes(type)) {
422
420
  console.error(err(`Invalid type "${type}". Valid: ${validTypes.join(", ")}`));
423
- process.exit(1);
421
+ process.exitCode = 1;
422
+ return;
424
423
  }
425
424
  const ephemeral = unregistrableReason(rootPath);
426
425
  if (ephemeral) {
427
426
  console.error(err(`Refusing to register ${shortenPath(rootPath, 60)}`));
428
427
  console.error(dim$1(` That is ${ephemeral}.`));
429
428
  console.error(dim$1(` A project needs a durable location — move it, then register.`));
430
- process.exit(1);
429
+ process.exitCode = 1;
430
+ return;
431
431
  }
432
432
  if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ?").get(slug, rootPath)) {
433
433
  console.error(err(`Project already registered (slug: ${slug} or path: ${rootPath})`));
434
- process.exit(1);
434
+ process.exitCode = 1;
435
+ return;
435
436
  }
436
437
  const dirName = basename(rootPath).toLowerCase();
437
438
  const matches = db.prepare(`SELECT slug, root_path FROM projects WHERE status = 'active' AND slug != ?`).all(slug).filter((s) => basename(s.root_path).toLowerCase() === dirName || s.slug.replace(/-\d+$/, "") === slug.replace(/-\d+$/, ""));
@@ -600,7 +601,8 @@ function cmdAlias(db, slug, alias) {
600
601
  requireProject(db, slug);
601
602
  if (db.prepare("SELECT id FROM projects WHERE slug = ?").get(alias)) {
602
603
  console.error(err(`"${alias}" is already a project slug — cannot use as alias.`));
603
- process.exit(1);
604
+ process.exitCode = 1;
605
+ return;
604
606
  }
605
607
  const project = getProject$2(db, slug);
606
608
  try {
@@ -608,7 +610,7 @@ function cmdAlias(db, slug, alias) {
608
610
  console.log(ok$1(`Alias added: ${bold$1(alias)} → ${slug}`));
609
611
  } catch {
610
612
  console.error(err(`Alias "${alias}" is already registered.`));
611
- process.exit(1);
613
+ process.exitCode = 1;
612
614
  }
613
615
  }
614
616
  function cmdEdit(db, slug, opts) {
@@ -625,7 +627,8 @@ function cmdEdit(db, slug, opts) {
625
627
  ];
626
628
  if (opts.type && !validTypes.includes(opts.type)) {
627
629
  console.error(err(`Invalid type "${opts.type}". Valid: ${validTypes.join(", ")}`));
628
- process.exit(1);
630
+ process.exitCode = 1;
631
+ return;
629
632
  }
630
633
  const ts = now();
631
634
  if (opts.displayName) {
@@ -649,7 +652,6 @@ function cmdDetect(db, pathArg, opts) {
649
652
  console.log(warn$1(`No registered project found for: ${cwd}`));
650
653
  console.log(dim$1(" Run 'pai project add .' to register this directory."));
651
654
  }
652
- process.exit(0);
653
655
  return;
654
656
  }
655
657
  if (opts.json) {
@@ -747,7 +749,8 @@ function cmdGo(db, query) {
747
749
  const all = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC").all();
748
750
  if (!all.length) {
749
751
  console.error(err("No active projects registered. Run: pai project add <path>"));
750
- process.exit(1);
752
+ process.exitCode = 1;
753
+ return;
751
754
  }
752
755
  const q = query.trim().toLowerCase();
753
756
  const exact = getProject$2(db, query);
@@ -786,7 +789,8 @@ function cmdGo(db, query) {
786
789
  });
787
790
  console.error();
788
791
  console.error(dim$1(" Use a more specific name or the exact slug."));
789
- process.exit(1);
792
+ process.exitCode = 1;
793
+ return;
790
794
  }
791
795
  const scored = all.map((p) => {
792
796
  const distSlug = levenshtein(q, p.slug.toLowerCase());
@@ -805,14 +809,15 @@ function cmdGo(db, query) {
805
809
  console.error();
806
810
  console.error(dim$1(" Run: pai project list (to see all projects)"));
807
811
  }
808
- process.exit(1);
812
+ process.exitCode = 1;
809
813
  }
810
814
  function cmdRebind(db, slug, newPath) {
811
815
  const project = requireProject(db, slug);
812
816
  const resolved = resolve(newPath.startsWith("~/") ? join(homedir(), newPath.slice(2)) : newPath);
813
817
  if (!existsSync(resolved)) {
814
818
  console.error(err(`Path does not exist: ${resolved}`));
815
- process.exit(1);
819
+ process.exitCode = 1;
820
+ return;
816
821
  }
817
822
  let isDir = false;
818
823
  try {
@@ -820,13 +825,15 @@ function cmdRebind(db, slug, newPath) {
820
825
  } catch {}
821
826
  if (!isDir) {
822
827
  console.error(err(`Path is not a directory: ${resolved}`));
823
- process.exit(1);
828
+ process.exitCode = 1;
829
+ return;
824
830
  }
825
831
  const newEncoded = encodeDir(resolved);
826
832
  const conflict = db.prepare("SELECT slug FROM projects WHERE encoded_dir = ? AND id != ?").get(newEncoded, project.id);
827
833
  if (conflict) {
828
834
  console.error(err(`Path is already registered to project: ${bold$1(conflict.slug)}\n`) + dim$1(` ${resolved}\n`) + dim$1(` Archive or move that project first, or choose a different path.`));
829
- process.exit(1);
835
+ process.exitCode = 1;
836
+ return;
830
837
  }
831
838
  const ts = now();
832
839
  db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(resolved, newEncoded, ts, project.id);
@@ -960,16 +967,19 @@ function cmdName(db, identifier, shortname, opts) {
960
967
  const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
961
968
  if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(shortname)) {
962
969
  console.error(err(`Invalid name "${shortname}". Use letters, digits, hyphens, underscores. Must start with a letter.`));
963
- process.exit(1);
970
+ process.exitCode = 1;
971
+ return;
964
972
  }
965
973
  if (db.prepare("SELECT id FROM projects WHERE slug = ? AND id != ?").get(shortname, project.id)) {
966
974
  console.error(err(`"${shortname}" is already a project slug.`));
967
- process.exit(1);
975
+ process.exitCode = 1;
976
+ return;
968
977
  }
969
978
  const conflictAlias = db.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(shortname);
970
979
  if (conflictAlias && conflictAlias.project_id !== project.id) {
971
980
  console.error(err(`"${shortname}" is already used by another project.`));
972
- process.exit(1);
981
+ process.exitCode = 1;
982
+ return;
973
983
  }
974
984
  if (!conflictAlias) db.prepare("INSERT INTO aliases (alias, project_id) VALUES (?, ?)").run(shortname, project.id);
975
985
  if (opts.permission) {
@@ -983,7 +993,8 @@ function cmdUnname(db, shortname) {
983
993
  const alias = db.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(shortname);
984
994
  if (!alias) {
985
995
  console.error(err(`No named project found: "${shortname}"`));
986
- process.exit(1);
996
+ process.exitCode = 1;
997
+ return;
987
998
  }
988
999
  db.prepare("DELETE FROM aliases WHERE alias = ?").run(shortname);
989
1000
  const remaining = db.prepare("SELECT COUNT(*) AS cnt FROM aliases WHERE project_id = ?").get(alias.project_id);
@@ -1147,7 +1158,8 @@ function cmdConfig(db, identifier, opts) {
1147
1158
  if (!identifier) {
1148
1159
  console.error(err("Specify a project: pai project config <name-or-slug>"));
1149
1160
  console.error(dim$1(" Or use --defaults for global defaults, --options for available keys."));
1150
- process.exit(1);
1161
+ process.exitCode = 1;
1162
+ return;
1151
1163
  }
1152
1164
  const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
1153
1165
  let config = getSessionConfig(project);
@@ -1692,7 +1704,8 @@ function cmdMerge(db, fromSlug, intoSlug, opts = {}) {
1692
1704
  } catch (e) {
1693
1705
  if (e instanceof MergeError) {
1694
1706
  console.error(err(` ${e.message}`));
1695
- process.exit(1);
1707
+ process.exitCode = 1;
1708
+ return;
1696
1709
  }
1697
1710
  throw e;
1698
1711
  }
@@ -1766,14 +1779,14 @@ function cmdHere(db, name, opts = {}) {
1766
1779
  target = realpathSync(opts.cwd ?? process.cwd());
1767
1780
  } catch {
1768
1781
  console.error(err(`Cannot resolve the current directory.`));
1769
- process.exit(1);
1782
+ process.exitCode = 1;
1770
1783
  return;
1771
1784
  }
1772
1785
  const encoded = encodeDir(target);
1773
1786
  const matches = findProjectsByName(db, name);
1774
1787
  if (matches.length > 1) {
1775
1788
  console.error(err(`"${name}" matches ${matches.length} projects — name one exactly:\n`) + matches.map((m) => dim$1(` ${m.slug} (${m.display_name})\n`)).join(""));
1776
- process.exit(1);
1789
+ process.exitCode = 1;
1777
1790
  return;
1778
1791
  }
1779
1792
  const owner = db.prepare("SELECT id, slug, display_name FROM projects WHERE encoded_dir = ?").get(encoded);
@@ -1781,7 +1794,7 @@ function cmdHere(db, name, opts = {}) {
1781
1794
  const p = matches[0];
1782
1795
  if (owner && owner.id !== p.id) {
1783
1796
  console.error(err(`This directory is already registered to ${bold$1(owner.slug)}.\n`) + dim$1(` ${target}\n`) + dim$1(` Merge them: pai project merge ${owner.slug} ${p.slug} --execute`));
1784
- process.exit(1);
1797
+ process.exitCode = 1;
1785
1798
  return;
1786
1799
  }
1787
1800
  let sameAlready = false;
@@ -1809,7 +1822,7 @@ function cmdHere(db, name, opts = {}) {
1809
1822
  }
1810
1823
  if (owner) {
1811
1824
  console.error(err(`This directory is already registered as ${bold$1(owner.slug)} (${owner.display_name}).\n`) + dim$1(` Rename it instead: pai project edit ${owner.slug} --display-name "${name}"`));
1812
- process.exit(1);
1825
+ process.exitCode = 1;
1813
1826
  return;
1814
1827
  }
1815
1828
  const slug = slugFromName(name);
@@ -1834,7 +1847,8 @@ function cmdUnregister(db, slug, opts = {}) {
1834
1847
  FROM projects p WHERE p.slug = ?`).get(slug);
1835
1848
  if (!row) {
1836
1849
  console.error(err(` No project with slug "${slug}".`));
1837
- process.exit(1);
1850
+ process.exitCode = 1;
1851
+ return;
1838
1852
  }
1839
1853
  console.log();
1840
1854
  console.log(bold$1(` Unregister ${row.slug}`));
@@ -1847,7 +1861,8 @@ function cmdUnregister(db, slug, opts = {}) {
1847
1861
  console.log(dim$1(` pai project merge ${row.slug} <into> --execute`));
1848
1862
  console.log(dim$1(` Or pass --force if the sessions are genuinely worthless.`));
1849
1863
  console.log();
1850
- process.exit(1);
1864
+ process.exitCode = 1;
1865
+ return;
1851
1866
  }
1852
1867
  if (!opts.execute) {
1853
1868
  console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to unregister."));
@@ -1873,7 +1888,7 @@ function cmdUnregister(db, slug, opts = {}) {
1873
1888
  //#region src/cli/commands/project/projects-index.ts
1874
1889
  function registerProjectsCommands(projectsCmd, getDb) {
1875
1890
  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) => {
1876
- const { cmdMain } = await import("./main-resolver-CNSqU8wo.mjs").then((n) => n.n);
1891
+ const { cmdMain } = await import("./main-resolver-Dxh444GO.mjs");
1877
1892
  await cmdMain(getDb(), void 0, void 0, opts);
1878
1893
  });
1879
1894
  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) => {
@@ -1884,7 +1899,7 @@ function registerProjectsCommands(projectsCmd, getDb) {
1884
1899
  const project = resolveIdentifier(db, identifier);
1885
1900
  if (!project) {
1886
1901
  console.error(`Project not found: ${identifier}`);
1887
- process.exit(1);
1902
+ process.exitCode = 1;
1888
1903
  return;
1889
1904
  }
1890
1905
  if (existsSync(project.root_path)) {
@@ -1984,7 +1999,8 @@ const SESSION_REGISTRY_PATH = join(homedir(), ".claude", "session-registry.json"
1984
1999
  function cmdMigrate$1(db) {
1985
2000
  if (!existsSync(SESSION_REGISTRY_PATH)) {
1986
2001
  console.error(err(`session-registry.json not found: ${SESSION_REGISTRY_PATH}`));
1987
- process.exit(1);
2002
+ process.exitCode = 1;
2003
+ return;
1988
2004
  }
1989
2005
  let registry;
1990
2006
  try {
@@ -1992,7 +2008,8 @@ function cmdMigrate$1(db) {
1992
2008
  registry = JSON.parse(raw);
1993
2009
  } catch (e) {
1994
2010
  console.error(err(`Failed to parse session-registry.json: ${e}`));
1995
- process.exit(1);
2011
+ process.exitCode = 1;
2012
+ return;
1996
2013
  }
1997
2014
  const projects = registry.projects ?? [];
1998
2015
  if (!projects.length) {
@@ -2326,7 +2343,8 @@ function cmdDedupe(db, opts) {
2326
2343
  if (backup) console.log(ok$1(`Registry backed up to ${bold$1(backup)}`));
2327
2344
  else {
2328
2345
  console.error(err("Could not back up the registry — refusing to merge without one."));
2329
- process.exit(1);
2346
+ process.exitCode = 1;
2347
+ return;
2330
2348
  }
2331
2349
  }
2332
2350
  const totals = {
@@ -2362,7 +2380,8 @@ function cmdDedupe(db, opts) {
2362
2380
  run();
2363
2381
  } catch (e) {
2364
2382
  console.error(err(`Merge failed and was rolled back: ${String(e)}`));
2365
- process.exit(1);
2383
+ process.exitCode = 1;
2384
+ return;
2366
2385
  }
2367
2386
  console.log();
2368
2387
  console.log(ok$1(`Merged ${totals.deletedRows} duplicate row(s).`));
@@ -2481,7 +2500,10 @@ function cmdRebuild(db) {
2481
2500
  function cmdLookup(db, fsPath) {
2482
2501
  const resolved = resolve(fsPath);
2483
2502
  const row = db.prepare("SELECT slug FROM projects WHERE root_path = ?").get(resolved);
2484
- if (!row) process.exit(1);
2503
+ if (!row) {
2504
+ process.exitCode = 1;
2505
+ return;
2506
+ }
2485
2507
  process.stdout.write(row.slug + "\n");
2486
2508
  }
2487
2509
  function registerRegistryCommands(registryCmd, getDb) {
@@ -2502,7 +2524,8 @@ function registerRegistryCommands(registryCmd, getDb) {
2502
2524
  const resolved = resolveHome(opts.addDir);
2503
2525
  if (!existsSync(resolved)) {
2504
2526
  console.error(err(`Directory not found: ${resolved}`));
2505
- process.exit(1);
2527
+ process.exitCode = 1;
2528
+ return;
2506
2529
  }
2507
2530
  const display = resolved.startsWith(homedir()) ? "~" + resolved.slice(homedir().length) : resolved;
2508
2531
  if (config.scan_dirs.includes(display) || config.scan_dirs.includes(resolved)) console.log(warn$1(`Already configured: ${display}`));
@@ -2567,13 +2590,15 @@ function registerEmbedCommand(memoryCmd, getDb) {
2567
2590
  federation = openFederation();
2568
2591
  } catch (e) {
2569
2592
  console.error(err(`Failed to open federation database: ${e}`));
2570
- process.exit(1);
2593
+ process.exitCode = 1;
2594
+ return;
2571
2595
  }
2572
2596
  if (projectSlug) {
2573
2597
  const project = registryDb.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(projectSlug);
2574
2598
  if (!project) {
2575
2599
  console.error(err(`Project not found: ${projectSlug}`));
2576
- process.exit(1);
2600
+ process.exitCode = 1;
2601
+ return;
2577
2602
  }
2578
2603
  await runEmbed(federation, project.id, project.slug, parseInt(opts.batchSize ?? "50", 10));
2579
2604
  } else await runEmbed(federation, void 0, void 0, parseInt(opts.batchSize ?? "50", 10));
@@ -2598,13 +2623,15 @@ function registerIndexCommand(memoryCmd, getDb) {
2598
2623
  federation = openFederation();
2599
2624
  } catch (e) {
2600
2625
  console.error(err(`Failed to open federation database: ${e}`));
2601
- process.exit(1);
2626
+ process.exitCode = 1;
2627
+ return;
2602
2628
  }
2603
2629
  if (projectSlug) {
2604
2630
  const project = registryDb.prepare("SELECT id, slug, display_name, root_path FROM projects WHERE slug = ? AND status = 'active'").get(projectSlug);
2605
2631
  if (!project) {
2606
2632
  console.error(err(`Project not found or not active: ${projectSlug}`));
2607
- process.exit(1);
2633
+ process.exitCode = 1;
2634
+ return;
2608
2635
  }
2609
2636
  console.log(dim$1(`Indexing ${project.display_name} (${project.slug})...`));
2610
2637
  const result = await indexProject(federation, project.id, project.root_path);
@@ -2638,7 +2665,8 @@ function registerSearchCommand(memoryCmd, getDb) {
2638
2665
  federation = openFederation();
2639
2666
  } catch (e) {
2640
2667
  console.error(err(`Failed to open federation database: ${e}`));
2641
- process.exit(1);
2668
+ process.exitCode = 1;
2669
+ return;
2642
2670
  }
2643
2671
  const config = loadConfig();
2644
2672
  const searchConfig = config.search;
@@ -2650,7 +2678,8 @@ function registerSearchCommand(memoryCmd, getDb) {
2650
2678
  "hybrid"
2651
2679
  ].includes(mode)) {
2652
2680
  console.error(err(`Invalid mode: ${mode}. Use keyword, semantic, or hybrid.`));
2653
- process.exit(1);
2681
+ process.exitCode = 1;
2682
+ return;
2654
2683
  }
2655
2684
  let projectIds;
2656
2685
  if (opts.project) {
@@ -2669,7 +2698,7 @@ function registerSearchCommand(memoryCmd, getDb) {
2669
2698
  else if (mode === "semantic" || mode === "hybrid") {
2670
2699
  const backend = await createStorageBackend(config);
2671
2700
  try {
2672
- const { generateEmbedding } = await import("./embeddings-Bn86ssxR.mjs").then((n) => n.i);
2701
+ const { generateEmbedding } = await import("./embeddings-CEBGrzwu.mjs");
2673
2702
  console.log(dim$1("Generating query embedding..."));
2674
2703
  const queryEmbedding = await generateEmbedding(query, true);
2675
2704
  if (mode === "semantic") results = await backend.searchSemantic(queryEmbedding, searchOpts);
@@ -2718,13 +2747,13 @@ function registerSearchCommand(memoryCmd, getDb) {
2718
2747
  return;
2719
2748
  }
2720
2749
  if (opts.rerank !== false) {
2721
- const { rerankResults } = await import("./reranker-CMNZcfVx.mjs").then((n) => n.r);
2750
+ const { rerankResults } = await import("./reranker-CwTCNsgA.mjs");
2722
2751
  console.log(dim$1("Reranking with cross-encoder..."));
2723
2752
  results = await rerankResults(query, results, { topK: maxResults });
2724
2753
  }
2725
2754
  const recencyDays = parseInt(opts.recency ?? String(searchConfig.recencyBoostDays), 10);
2726
2755
  if (recencyDays > 0) {
2727
- const { applyRecencyBoost } = await import("./search-C32zQ0V0.mjs").then((n) => n.o);
2756
+ const { applyRecencyBoost } = await import("./search-CfPpJAWQ.mjs");
2728
2757
  console.log(dim$1(`Applying recency boost (half-life: ${recencyDays} days)...`));
2729
2758
  results = applyRecencyBoost(results, recencyDays);
2730
2759
  }
@@ -2764,7 +2793,8 @@ function registerStatsCommands(memoryCmd, getDb) {
2764
2793
  federation = openFederation();
2765
2794
  } catch (e) {
2766
2795
  console.error(err(`Failed to open federation database: ${e}`));
2767
- process.exit(1);
2796
+ process.exitCode = 1;
2797
+ return;
2768
2798
  }
2769
2799
  try {
2770
2800
  federation.pragma("busy_timeout = 4000");
@@ -2787,7 +2817,8 @@ function registerStatsCommands(memoryCmd, getDb) {
2787
2817
  const project = registryDb.prepare("SELECT id, slug, display_name FROM projects WHERE slug = ?").get(projectSlug);
2788
2818
  if (!project) {
2789
2819
  console.error(err(`Project not found: ${projectSlug}`));
2790
- process.exit(1);
2820
+ process.exitCode = 1;
2821
+ return;
2791
2822
  }
2792
2823
  const fileStats = federation.prepare("SELECT COUNT(*) as files FROM memory_files WHERE project_id = ?").get(project.id);
2793
2824
  const chunkStats = federation.prepare("SELECT COUNT(*) as chunks FROM memory_chunks WHERE project_id = ?").get(project.id);
@@ -2863,7 +2894,8 @@ function registerStatsCommands(memoryCmd, getDb) {
2863
2894
  if (val === void 0) {
2864
2895
  console.error(err(`Unknown setting: ${key}`));
2865
2896
  console.log(dim$1(` Valid keys: mode, rerank, recencyBoostDays, defaultLimit, snippetLength`));
2866
- process.exit(1);
2897
+ process.exitCode = 1;
2898
+ return;
2867
2899
  }
2868
2900
  console.log(String(val));
2869
2901
  return;
@@ -2878,14 +2910,16 @@ function registerStatsCommands(memoryCmd, getDb) {
2878
2910
  if (!validKeys.has(key)) {
2879
2911
  console.error(err(`Unknown setting: ${key}`));
2880
2912
  console.log(dim$1(` Valid keys: ${[...validKeys].join(", ")}`));
2881
- process.exit(1);
2913
+ process.exitCode = 1;
2914
+ return;
2882
2915
  }
2883
2916
  let fileConfig = {};
2884
2917
  if (existsSync(CONFIG_FILE$2)) try {
2885
2918
  fileConfig = JSON.parse(readFileSync(CONFIG_FILE$2, "utf-8"));
2886
2919
  } catch {
2887
2920
  console.error(err(`Could not parse ${CONFIG_FILE$2}`));
2888
- process.exit(1);
2921
+ process.exitCode = 1;
2922
+ return;
2889
2923
  }
2890
2924
  if (!fileConfig.search || typeof fileConfig.search !== "object") fileConfig.search = {};
2891
2925
  let parsed;
@@ -2896,7 +2930,8 @@ function registerStatsCommands(memoryCmd, getDb) {
2896
2930
  "hybrid"
2897
2931
  ].includes(value)) {
2898
2932
  console.error(err(`Invalid mode: ${value}. Must be keyword, semantic, or hybrid.`));
2899
- process.exit(1);
2933
+ process.exitCode = 1;
2934
+ return;
2900
2935
  }
2901
2936
  parsed = value;
2902
2937
  } else if (key === "rerank") parsed = value === "true" || value === "1" || value === "on";
@@ -2904,7 +2939,8 @@ function registerStatsCommands(memoryCmd, getDb) {
2904
2939
  parsed = parseInt(value, 10);
2905
2940
  if (isNaN(parsed)) {
2906
2941
  console.error(err(`Invalid number: ${value}`));
2907
- process.exit(1);
2942
+ process.exitCode = 1;
2943
+ return;
2908
2944
  }
2909
2945
  }
2910
2946
  fileConfig.search[key] = parsed;
@@ -2915,7 +2951,7 @@ function registerStatsCommands(memoryCmd, getDb) {
2915
2951
  console.log(dim$1(` Restart daemon to apply: pai daemon restart`));
2916
2952
  } catch (e) {
2917
2953
  console.error(err(`Could not write config: ${e}`));
2918
- process.exit(1);
2954
+ process.exitCode = 1;
2919
2955
  }
2920
2956
  });
2921
2957
  }
@@ -2924,9 +2960,9 @@ function registerStatsCommands(memoryCmd, getDb) {
2924
2960
  //#region src/cli/commands/memory/sources-cmd.ts
2925
2961
  function registerSourcesCommand(memoryCmd) {
2926
2962
  memoryCmd.command("sources").description("Show what the indexer has taken in: composition by source, which roots\ncontent enters from, the heaviest single files, and how many chunks are\nrewritten per day (which distinguishes a finite backlog from a treadmill).").option("--limit <n>", "Rows per section (default 8)", "8").action(async (opts) => {
2927
- const { createStorageBackend } = await import("./factory-vPTPoBR7.mjs").then((n) => n.n);
2928
- const { loadConfig } = await import("./config-BSkVcvfq.mjs").then((n) => n.r);
2929
- const { cmdMemorySources } = await import("./sources-BDwN0B8i.mjs");
2963
+ const { createStorageBackend } = await import("./factory-CrokPMk2.mjs");
2964
+ const { loadConfig } = await import("./config-C_ErGddD.mjs");
2965
+ const { cmdMemorySources } = await import("./sources-D8ZdNfvK.mjs");
2930
2966
  let backend;
2931
2967
  try {
2932
2968
  backend = await createStorageBackend(loadConfig());
@@ -3079,7 +3115,8 @@ function registerIdentityCommands(identityCmd) {
3079
3115
  const normalized = normalizeEmail(email);
3080
3116
  if (!normalized) {
3081
3117
  console.error(err("pai identity: ") + `Not a usable email address: ${email}`);
3082
- process.exit(1);
3118
+ process.exitCode = 1;
3119
+ return;
3083
3120
  }
3084
3121
  const config = readConfig();
3085
3122
  const id = identityOf(config);
@@ -3118,7 +3155,8 @@ function registerIdentityCommands(identityCmd) {
3118
3155
  const normalized = normalizeEmail(email);
3119
3156
  if (!normalized) {
3120
3157
  console.error(err("pai identity: ") + `Not a usable email address: ${email}`);
3121
- process.exit(1);
3158
+ process.exitCode = 1;
3159
+ return;
3122
3160
  }
3123
3161
  const config = readConfig();
3124
3162
  const id = identityOf(config);
@@ -3236,7 +3274,8 @@ function cmdInstall$1() {
3236
3274
  writeClaudeJson(config);
3237
3275
  } catch (e) {
3238
3276
  console.error(err(`Failed to write ~/.claude.json: ${e}`));
3239
- process.exit(1);
3277
+ process.exitCode = 1;
3278
+ return;
3240
3279
  }
3241
3280
  console.log(ok$1("PAI MCP server registered in ~/.claude.json."));
3242
3281
  console.log(dim$1(` Binary: ${mcpBin}`));
@@ -3443,7 +3482,7 @@ async function cmdStatus$2() {
3443
3482
  console.log(dim$1(" Start with: pai daemon serve"));
3444
3483
  console.log(dim$1(" Or install as service: pai daemon install"));
3445
3484
  console.log();
3446
- process.exit(1);
3485
+ process.exitCode = 1;
3447
3486
  }
3448
3487
  }
3449
3488
  function cmdRestart() {
@@ -3470,7 +3509,7 @@ function cmdRestart() {
3470
3509
  } catch (e) {
3471
3510
  const msg = e instanceof Error ? e.message : String(e);
3472
3511
  console.error(err(`restart error: ${msg}`));
3473
- process.exit(1);
3512
+ process.exitCode = 1;
3474
3513
  }
3475
3514
  }
3476
3515
  function cmdInstall() {
@@ -3486,7 +3525,8 @@ function cmdInstall() {
3486
3525
  console.log(ok$1(` Wrote launchd plist: ${PLIST_PATH}`));
3487
3526
  } catch (e) {
3488
3527
  console.error(err(` Failed to write plist: ${e}`));
3489
- process.exit(1);
3528
+ process.exitCode = 1;
3529
+ return;
3490
3530
  }
3491
3531
  try {
3492
3532
  spawnSync("launchctl", ["unload", PLIST_PATH], { encoding: "utf8" });
@@ -3516,7 +3556,8 @@ function cmdInstall() {
3516
3556
  console.log(ok$1(" Updated ~/.claude.json to use daemon shim."));
3517
3557
  } catch (e) {
3518
3558
  console.error(err(` Failed to write ~/.claude.json: ${e}`));
3519
- process.exit(1);
3559
+ process.exitCode = 1;
3560
+ return;
3520
3561
  }
3521
3562
  }
3522
3563
  console.log();
@@ -3585,7 +3626,7 @@ async function cmdMigrate(connectionString) {
3585
3626
  });
3586
3627
  if (result.status !== 0) {
3587
3628
  console.error(err(" Migration failed. Check output above."));
3588
- process.exit(result.status ?? 1);
3629
+ process.exitCode = result.status ?? 1;
3589
3630
  }
3590
3631
  }
3591
3632
  function cmdLogs(opts) {
@@ -3603,13 +3644,13 @@ function cmdLogs(opts) {
3603
3644
  } catch (e) {
3604
3645
  const msg = e instanceof Error ? e.message : String(e);
3605
3646
  console.error(err(`Could not read log: ${msg}`));
3606
- process.exit(1);
3647
+ process.exitCode = 1;
3607
3648
  }
3608
3649
  }
3609
3650
  function registerDaemonCommands(daemonCmd) {
3610
3651
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
3611
- const { serve } = await import("./daemon-B8L2f5vy.mjs").then((n) => n.t);
3612
- const { loadConfig: lc, ensureConfigDir } = await import("./config-BSkVcvfq.mjs").then((n) => n.r);
3652
+ const { serve } = await import("./daemon-DRdoA489.mjs");
3653
+ const { loadConfig: lc, ensureConfigDir } = await import("./config-C_ErGddD.mjs");
3613
3654
  ensureConfigDir();
3614
3655
  await serve(lc());
3615
3656
  });
@@ -3824,14 +3865,16 @@ function registerRestoreCommands(program) {
3824
3865
  resolvedDir = backupPath.startsWith("~") ? backupPath.replace(/^~/, HOME) : backupPath;
3825
3866
  if (!existsSync(resolvedDir)) {
3826
3867
  console.error(err(`Backup directory not found: ${resolvedDir}`));
3827
- process.exit(1);
3868
+ process.exitCode = 1;
3869
+ return;
3828
3870
  }
3829
3871
  } else {
3830
3872
  const backups = listBackups();
3831
3873
  if (backups.length === 0) {
3832
3874
  console.error(err(`No backups found in ${BACKUPS_DIR}`));
3833
3875
  console.log(dim$1(" Run 'pai backup' to create one."));
3834
- process.exit(1);
3876
+ process.exitCode = 1;
3877
+ return;
3835
3878
  }
3836
3879
  console.log(`\n${bold$1("Available backups")} (newest first):\n`);
3837
3880
  backups.forEach((name, i) => {
@@ -3859,7 +3902,7 @@ function registerRestoreCommands(program) {
3859
3902
  if (hasSql && opts.postgres) console.log(warn$1(" Postgres database will be dropped and recreated from the backup."));
3860
3903
  if (!(opts.yes || await confirm("Proceed with restore?"))) {
3861
3904
  console.log(dim$1("Restore cancelled."));
3862
- process.exit(0);
3905
+ return;
3863
3906
  }
3864
3907
  console.log();
3865
3908
  const results = [];
@@ -3968,7 +4011,8 @@ function registerRestoreCommands(program) {
3968
4011
  console.log(` ${dim$1("Restart:")} pai daemon restart\n`);
3969
4012
  } else {
3970
4013
  console.log(`\n ${warn$1("Some items failed — check output above.")}\n`);
3971
- process.exit(1);
4014
+ process.exitCode = 1;
4015
+ return;
3972
4016
  }
3973
4017
  });
3974
4018
  }
@@ -4053,6 +4097,65 @@ function writeJsonAtomic(path, data, opts = {}) {
4053
4097
  }
4054
4098
  }
4055
4099
 
4100
+ //#endregion
4101
+ //#region src/cli/lib/exit.ts
4102
+ /**
4103
+ * Safe process termination for the CLI.
4104
+ *
4105
+ * Node's stdout/stderr are ASYNCHRONOUS when they point at a pipe (and
4106
+ * synchronous when they point at a TTY or a regular file). `process.exit()`
4107
+ * tears the process down immediately, before any still-in-flight async
4108
+ * write completes — so a command whose output is piped (`pai ... | less`,
4109
+ * `| wc -c`, a subshell capture, etc.) can have its output silently
4110
+ * truncated or dropped entirely, while the same command run against a TTY
4111
+ * or redirected to a file prints correctly. Exit code is still 0, so the
4112
+ * failure is invisible.
4113
+ *
4114
+ * The robust fix, used everywhere it is possible, is to never call
4115
+ * `process.exit()` at all: set `process.exitCode` and let the command
4116
+ * return normally. Once the event loop has nothing left to do, Node exits
4117
+ * on its own — and a natural exit always flushes pending writes first.
4118
+ *
4119
+ * `exitAfterFlush` exists for the rare call site where control flow
4120
+ * genuinely cannot return (e.g. deep inside a callback with no path back
4121
+ * to the caller). It waits for both stdout and stderr to drain before
4122
+ * calling `process.exit()`.
4123
+ */
4124
+ function streamDrained(stream) {
4125
+ if (!stream.writableLength) return Promise.resolve();
4126
+ return new Promise((resolve) => {
4127
+ stream.once("drain", () => resolve());
4128
+ const check = () => {
4129
+ if (!stream.writableLength) resolve();
4130
+ };
4131
+ setImmediate(check);
4132
+ });
4133
+ }
4134
+ /**
4135
+ * Wait for stdout and stderr to finish flushing, then exit with `code`.
4136
+ * Never returns (the return type documents that; callers should not
4137
+ * expect code after this call to run).
4138
+ */
4139
+ async function exitAfterFlush(code) {
4140
+ await drainStdio();
4141
+ process.exit(code);
4142
+ }
4143
+ /**
4144
+ * Wait for stdout and stderr to finish flushing, without exiting.
4145
+ *
4146
+ * Belt-and-suspenders backstop for cli/index.ts: every command path avoids
4147
+ * process.exit() so a natural process exit — which always flushes pending
4148
+ * writes — can do the flushing. This makes that explicit and awaited at the
4149
+ * one place every command path funnels through, instead of trusting that
4150
+ * nothing between here and process shutdown (a dependency's keep-alive
4151
+ * socket being unref'd, a timer, anything else that can make the event loop
4152
+ * look "empty") lets Node begin tearing down before a write that's still
4153
+ * asynchronously in flight to a pipe has actually reached the OS.
4154
+ */
4155
+ async function drainStdio() {
4156
+ await Promise.all([streamDrained(process.stdout), streamDrained(process.stderr)]);
4157
+ }
4158
+
4056
4159
  //#endregion
4057
4160
  //#region src/cli/commands/setup/utils.ts
4058
4161
  /**
@@ -4088,7 +4191,7 @@ function createRl() {
4088
4191
  line$1();
4089
4192
  line$1(c.dim(" Setup cancelled. Run `pai setup` again to restart."));
4090
4193
  line$1();
4091
- process.exit(0);
4194
+ exitAfterFlush(0);
4092
4195
  });
4093
4196
  return rl;
4094
4197
  }
@@ -6948,7 +7051,8 @@ function cmdOpen(opts) {
6948
7051
  if (!existsSync(vaultPath)) {
6949
7052
  console.error(err(`Vault not found at: ${vaultPath}`));
6950
7053
  console.error(dim$1(" Run: pai obsidian sync"));
6951
- process.exit(1);
7054
+ process.exitCode = 1;
7055
+ return;
6952
7056
  }
6953
7057
  const url = `obsidian://open?vault=${encodeURIComponent(vaultName)}`;
6954
7058
  console.log(dim$1(` Opening: ${url}`));
@@ -6958,7 +7062,8 @@ function cmdOpen(opts) {
6958
7062
  } catch (e) {
6959
7063
  console.error(err(` Failed to open Obsidian: ${e}`));
6960
7064
  console.error(dim$1(" Ensure Obsidian is installed and the vault is registered in Obsidian."));
6961
- process.exit(1);
7065
+ process.exitCode = 1;
7066
+ return;
6962
7067
  }
6963
7068
  }
6964
7069
  function registerObsidianCommands(obsidianCmd, getDb) {
@@ -6997,7 +7102,7 @@ async function cmdExplore(note, opts) {
6997
7102
  const depth = parseInt(opts.depth ?? "3", 10);
6998
7103
  const direction = opts.direction ?? "both";
6999
7104
  const mode = opts.mode ?? "all";
7000
- const { zettelExplore } = await import("./zettelkasten-W-h8G2is.mjs");
7105
+ const { zettelExplore } = await import("./zettelkasten-CvjmMghT.mjs");
7001
7106
  const result = zettelExplore(getFedDb(), {
7002
7107
  startNote: note,
7003
7108
  depth,
@@ -7049,7 +7154,8 @@ function registerExploreCommand(parent) {
7049
7154
  await cmdExplore(note, opts);
7050
7155
  } catch (e) {
7051
7156
  console.error(err(` Error: ${e}`));
7052
- process.exit(1);
7157
+ process.exitCode = 1;
7158
+ return;
7053
7159
  }
7054
7160
  });
7055
7161
  }
@@ -7061,7 +7167,7 @@ async function cmdHealth(opts) {
7061
7167
  const projectPath = opts.project;
7062
7168
  const recentDays = parseInt(opts.days ?? "30", 10);
7063
7169
  const includeTypes = opts.include ? opts.include.split(",").map((s) => s.trim()) : void 0;
7064
- const { zettelHealth } = await import("./zettelkasten-W-h8G2is.mjs");
7170
+ const { zettelHealth } = await import("./zettelkasten-CvjmMghT.mjs");
7065
7171
  const result = zettelHealth(getFedDb(), {
7066
7172
  scope,
7067
7173
  projectPath,
@@ -7114,7 +7220,8 @@ function registerHealthCommand(parent) {
7114
7220
  await cmdHealth(opts);
7115
7221
  } catch (e) {
7116
7222
  console.error(err(` Error: ${e}`));
7117
- process.exit(1);
7223
+ process.exitCode = 1;
7224
+ return;
7118
7225
  }
7119
7226
  });
7120
7227
  }
@@ -7124,13 +7231,14 @@ function registerHealthCommand(parent) {
7124
7231
  async function cmdSurprise(note, opts) {
7125
7232
  if (!opts.vaultProjectId) {
7126
7233
  console.error(err(" --vault-project-id is required"));
7127
- process.exit(1);
7234
+ process.exitCode = 1;
7235
+ return;
7128
7236
  }
7129
7237
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
7130
7238
  const limit = parseInt(opts.limit ?? "10", 10);
7131
7239
  const minSimilarity = parseFloat(opts.minSimilarity ?? "0.3");
7132
7240
  const minGraphDistance = parseInt(opts.minDistance ?? "3", 10);
7133
- const { zettelSurprise } = await import("./zettelkasten-W-h8G2is.mjs");
7241
+ const { zettelSurprise } = await import("./zettelkasten-CvjmMghT.mjs");
7134
7242
  const db = getFedDb();
7135
7243
  console.log();
7136
7244
  console.log(header(" PAI Zettel Surprise"));
@@ -7168,7 +7276,8 @@ function registerSurpriseCommand(parent) {
7168
7276
  await cmdSurprise(note, opts);
7169
7277
  } catch (e) {
7170
7278
  console.error(err(` Error: ${e}`));
7171
- process.exit(1);
7279
+ process.exitCode = 1;
7280
+ return;
7172
7281
  }
7173
7282
  });
7174
7283
  }
@@ -7178,12 +7287,13 @@ function registerSurpriseCommand(parent) {
7178
7287
  async function cmdSuggest(note, opts) {
7179
7288
  if (!opts.vaultProjectId) {
7180
7289
  console.error(err(" --vault-project-id is required"));
7181
- process.exit(1);
7290
+ process.exitCode = 1;
7291
+ return;
7182
7292
  }
7183
7293
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
7184
7294
  const limit = parseInt(opts.limit ?? "5", 10);
7185
7295
  const excludeLinked = opts.excludeLinked !== false;
7186
- const { zettelSuggest } = await import("./zettelkasten-W-h8G2is.mjs");
7296
+ const { zettelSuggest } = await import("./zettelkasten-CvjmMghT.mjs");
7187
7297
  const db = getFedDb();
7188
7298
  console.log();
7189
7299
  console.log(header(" PAI Zettel Suggest"));
@@ -7219,7 +7329,8 @@ function registerSuggestCommand(parent) {
7219
7329
  await cmdSuggest(note, opts);
7220
7330
  } catch (e) {
7221
7331
  console.error(err(` Error: ${e}`));
7222
- process.exit(1);
7332
+ process.exitCode = 1;
7333
+ return;
7223
7334
  }
7224
7335
  });
7225
7336
  }
@@ -7229,12 +7340,13 @@ function registerSuggestCommand(parent) {
7229
7340
  async function cmdConverse(question, opts) {
7230
7341
  if (!opts.vaultProjectId) {
7231
7342
  console.error(err(" --vault-project-id is required"));
7232
- process.exit(1);
7343
+ process.exitCode = 1;
7344
+ return;
7233
7345
  }
7234
7346
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
7235
7347
  const depth = parseInt(opts.depth ?? "2", 10);
7236
7348
  const limit = parseInt(opts.limit ?? "15", 10);
7237
- const { zettelConverse } = await import("./zettelkasten-W-h8G2is.mjs");
7349
+ const { zettelConverse } = await import("./zettelkasten-CvjmMghT.mjs");
7238
7350
  const db = getFedDb();
7239
7351
  console.log();
7240
7352
  console.log(header(" PAI Zettel Converse"));
@@ -7278,7 +7390,8 @@ function registerConverseCommand(parent) {
7278
7390
  await cmdConverse(question, opts);
7279
7391
  } catch (e) {
7280
7392
  console.error(err(` Error: ${e}`));
7281
- process.exit(1);
7393
+ process.exitCode = 1;
7394
+ return;
7282
7395
  }
7283
7396
  });
7284
7397
  }
@@ -7288,14 +7401,15 @@ function registerConverseCommand(parent) {
7288
7401
  async function cmdThemes(opts) {
7289
7402
  if (!opts.vaultProjectId) {
7290
7403
  console.error(err(" --vault-project-id is required"));
7291
- process.exit(1);
7404
+ process.exitCode = 1;
7405
+ return;
7292
7406
  }
7293
7407
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
7294
7408
  const lookbackDays = parseInt(opts.days ?? "30", 10);
7295
7409
  const minClusterSize = parseInt(opts.minSize ?? "3", 10);
7296
7410
  const maxThemes = parseInt(opts.maxThemes ?? "10", 10);
7297
7411
  const similarityThreshold = parseFloat(opts.threshold ?? "0.65");
7298
- const { zettelThemes } = await import("./zettelkasten-W-h8G2is.mjs");
7412
+ const { zettelThemes } = await import("./zettelkasten-CvjmMghT.mjs");
7299
7413
  const db = getFedDb();
7300
7414
  console.log();
7301
7415
  console.log(header(" PAI Zettel Themes"));
@@ -7339,7 +7453,8 @@ function registerThemesCommand(parent) {
7339
7453
  await cmdThemes(opts);
7340
7454
  } catch (e) {
7341
7455
  console.error(err(` Error: ${e}`));
7342
- process.exit(1);
7456
+ process.exitCode = 1;
7457
+ return;
7343
7458
  }
7344
7459
  });
7345
7460
  }
@@ -7436,7 +7551,8 @@ async function cmdList$2(opts) {
7436
7551
  } catch (e) {
7437
7552
  console.error(err(` Failed to reach PAI daemon: ${e}`));
7438
7553
  console.error(dim$1(" Is the daemon running? Try: pai daemon status"));
7439
- process.exit(1);
7554
+ process.exitCode = 1;
7555
+ return;
7440
7556
  }
7441
7557
  console.log();
7442
7558
  console.log(header(" PAI Observations"));
@@ -7489,7 +7605,8 @@ async function cmdSearch(query, opts) {
7489
7605
  } catch (e) {
7490
7606
  console.error(err(` Failed to reach PAI daemon: ${e}`));
7491
7607
  console.error(dim$1(" Is the daemon running? Try: pai daemon status"));
7492
- process.exit(1);
7608
+ process.exitCode = 1;
7609
+ return;
7493
7610
  }
7494
7611
  const q = query.toLowerCase();
7495
7612
  const observations = allObservations.filter((o) => (o.title ?? "").toLowerCase().includes(q) || (o.narrative ?? "").toLowerCase().includes(q) || (o.tool_input_summary ?? "").toLowerCase().includes(q)).slice(0, limit);
@@ -7528,7 +7645,8 @@ async function cmdStats$1() {
7528
7645
  } catch (e) {
7529
7646
  console.error(err(` Failed to reach PAI daemon: ${e}`));
7530
7647
  console.error(dim$1(" Is the daemon running? Try: pai daemon status"));
7531
- process.exit(1);
7648
+ process.exitCode = 1;
7649
+ return;
7532
7650
  }
7533
7651
  console.log();
7534
7652
  console.log(header(" PAI Observation Statistics"));
@@ -7573,7 +7691,8 @@ function registerObservationCommands(parent) {
7573
7691
  await cmdList$2(opts);
7574
7692
  } catch (e) {
7575
7693
  console.error(err(` Error: ${e}`));
7576
- process.exit(1);
7694
+ process.exitCode = 1;
7695
+ return;
7577
7696
  }
7578
7697
  });
7579
7698
  parent.command("search <query>").description("Search observations by title or narrative text").option("--project <slug>", "Filter by project slug").option("--type <type>", "Filter by type").option("--limit <n>", "Maximum results", "20").action(async (query, opts) => {
@@ -7581,7 +7700,8 @@ function registerObservationCommands(parent) {
7581
7700
  await cmdSearch(query, opts);
7582
7701
  } catch (e) {
7583
7702
  console.error(err(` Error: ${e}`));
7584
- process.exit(1);
7703
+ process.exitCode = 1;
7704
+ return;
7585
7705
  }
7586
7706
  });
7587
7707
  parent.command("stats").description("Show observation statistics: totals, by type, by project").action(async () => {
@@ -7589,7 +7709,8 @@ function registerObservationCommands(parent) {
7589
7709
  await cmdStats$1();
7590
7710
  } catch (e) {
7591
7711
  console.error(err(` Error: ${e}`));
7592
- process.exit(1);
7712
+ process.exitCode = 1;
7713
+ return;
7593
7714
  }
7594
7715
  });
7595
7716
  }
@@ -7671,7 +7792,8 @@ async function cmdTelemetry(opts) {
7671
7792
  } catch (e) {
7672
7793
  console.error(err(` Failed to reach PAI daemon: ${e}`));
7673
7794
  console.error(dim$1(" Is the daemon running? Try: pai daemon status"));
7674
- process.exit(1);
7795
+ process.exitCode = 1;
7796
+ return;
7675
7797
  }
7676
7798
  if (opts.json) {
7677
7799
  console.log(JSON.stringify(rows ?? [], null, 2));
@@ -7714,7 +7836,8 @@ function registerSkillCommands(parent) {
7714
7836
  await cmdTelemetry(opts);
7715
7837
  } catch (e) {
7716
7838
  console.error(err(` Error: ${e}`));
7717
- process.exit(1);
7839
+ process.exitCode = 1;
7840
+ return;
7718
7841
  }
7719
7842
  });
7720
7843
  }
@@ -7993,7 +8116,8 @@ async function runUpdate() {
7993
8116
  const repoDir = stepVerifyRepo();
7994
8117
  if (!repoDir) {
7995
8118
  line();
7996
- process.exit(1);
8119
+ process.exitCode = 1;
8120
+ return;
7997
8121
  }
7998
8122
  const didStash = stepStash(repoDir);
7999
8123
  const newCommitsLog = stepPull(repoDir);
@@ -8002,7 +8126,8 @@ async function runUpdate() {
8002
8126
  line();
8003
8127
  error("Update failed at build step. Check errors above.");
8004
8128
  line();
8005
- process.exit(1);
8129
+ process.exitCode = 1;
8130
+ return;
8006
8131
  }
8007
8132
  stepRestartDaemon(repoDir);
8008
8133
  stepRefreshClaudeMd(repoDir, newCommitsLog);
@@ -8070,7 +8195,8 @@ async function cmdStatus() {
8070
8195
  console.log(dim$1(` ${msg}`));
8071
8196
  console.log(dim$1(" Start it with: pai daemon serve"));
8072
8197
  console.log();
8073
- process.exit(1);
8198
+ process.exitCode = 1;
8199
+ return;
8074
8200
  }
8075
8201
  }
8076
8202
  async function cmdSet(opts) {
@@ -8088,7 +8214,8 @@ async function cmdSet(opts) {
8088
8214
  ];
8089
8215
  if (!validModes.includes(opts.mode)) {
8090
8216
  console.error(err(`Invalid mode: ${opts.mode}. Valid: ${validModes.join(", ")}`));
8091
- process.exit(1);
8217
+ process.exitCode = 1;
8218
+ return;
8092
8219
  }
8093
8220
  patch.mode = opts.mode;
8094
8221
  }
@@ -8112,7 +8239,8 @@ async function cmdSet(opts) {
8112
8239
  console.error(err("No changes specified. Use --mode, --enable, --disable, etc."));
8113
8240
  console.log(dim$1(" Example: pai notify set --mode voice"));
8114
8241
  console.log(dim$1(" Example: pai notify set --mode auto --enable macos --disable ntfy"));
8115
- process.exit(1);
8242
+ process.exitCode = 1;
8243
+ return;
8116
8244
  }
8117
8245
  try {
8118
8246
  const { config } = await client.setNotificationConfig(patch);
@@ -8123,7 +8251,8 @@ async function cmdSet(opts) {
8123
8251
  } catch (e) {
8124
8252
  const msg = e instanceof Error ? e.message : String(e);
8125
8253
  console.error(err(` Failed: ${msg}`));
8126
- process.exit(1);
8254
+ process.exitCode = 1;
8255
+ return;
8127
8256
  }
8128
8257
  }
8129
8258
  async function cmdTest(opts) {
@@ -8146,7 +8275,8 @@ async function cmdTest(opts) {
8146
8275
  } catch (e) {
8147
8276
  const msg = e instanceof Error ? e.message : String(e);
8148
8277
  console.error(err(` Failed: ${msg}`));
8149
- process.exit(1);
8278
+ process.exitCode = 1;
8279
+ return;
8150
8280
  }
8151
8281
  }
8152
8282
  async function cmdSend(event, message, opts) {
@@ -8160,7 +8290,8 @@ async function cmdSend(event, message, opts) {
8160
8290
  ];
8161
8291
  if (!validEvents.includes(event)) {
8162
8292
  console.error(err(`Invalid event: ${event}. Valid: ${validEvents.join(", ")}`));
8163
- process.exit(1);
8293
+ process.exitCode = 1;
8294
+ return;
8164
8295
  }
8165
8296
  try {
8166
8297
  const result = await client.sendNotification({
@@ -8174,7 +8305,8 @@ async function cmdSend(event, message, opts) {
8174
8305
  } catch (e) {
8175
8306
  const msg = e instanceof Error ? e.message : String(e);
8176
8307
  console.error(err(` Failed: ${msg}`));
8177
- process.exit(1);
8308
+ process.exitCode = 1;
8309
+ return;
8178
8310
  }
8179
8311
  }
8180
8312
  function registerNotifyCommands(notifyCmd) {
@@ -8852,8 +8984,8 @@ async function escalate(task, headline, detail, state, now, opts) {
8852
8984
  state.alarmedAt[task.id] = now;
8853
8985
  let delivered = false;
8854
8986
  try {
8855
- const { routeNotification } = await import("./router-i9S19Usg.mjs").then((n) => n.n);
8856
- const { loadConfig } = await import("./config-BSkVcvfq.mjs").then((n) => n.r);
8987
+ const { routeNotification } = await import("./router-BMkOb62X.mjs");
8988
+ const { loadConfig } = await import("./config-C_ErGddD.mjs");
8857
8989
  delivered = (await routeNotification({
8858
8990
  event: "error",
8859
8991
  title: `PAI: ${headline}`,
@@ -8885,6 +9017,51 @@ function saveState$1(state, file) {
8885
9017
  writeJsonAtomic(file, state, { backup: false });
8886
9018
  }
8887
9019
  /**
9020
+ * Fold in any completion a concurrent `pai task done` wrote while this tick
9021
+ * was busy with the rest of the list.
9022
+ *
9023
+ * A tick holds one in-memory `state` for its whole run and saves it exactly
9024
+ * once, at the end. For a morning routine with several due tasks that run is
9025
+ * not instantaneous — each dispatch waits out AIBroker's spawn-readiness
9026
+ * window in turn, so the tick can still be working through task 4 minutes
9027
+ * after task 1 was launched. `pai task done` has no such window: it loads,
9028
+ * releases the claim, stamps `lastSeenDue` to the next occurrence, and saves,
9029
+ * all in well under a second. If the session for task 1 finishes and closes
9030
+ * it while the tick is still on task 4, the CLI's write lands on disk first —
9031
+ * and the tick's blind save at the end, built from the snapshot it took
9032
+ * before any of this happened, overwrites it: `lastSeenDue` reverts to the
9033
+ * occurrence that was just completed, and `startedAt`/`claimSeenAt` come back
9034
+ * because this tick never learned they had been released. The next tick then
9035
+ * reads that reverted date as "due advanced by one period, unclaimed" — which
9036
+ * is `wasTicked`'s exact definition of a hand-tick — and redispatches a task
9037
+ * that already finished. Reproduced against a live poller run; see the
9038
+ * negative control referenced in Notes/TODO.md.
9039
+ *
9040
+ * `lastSeenDue` only ever moves forward in normal operation, so a disk value
9041
+ * strictly ahead of what this tick computed can only mean a concurrent writer
9042
+ * — there is exactly one, `pai task done` — saw a later completion than this
9043
+ * tick's stale snapshot knew about. Whenever that happens, this tick's own
9044
+ * value is the stale one: defer to disk for that task's date, and carry the
9045
+ * claim release forward too, since a more-advanced date with no such release
9046
+ * is not a state `pai task done` ever produces.
9047
+ *
9048
+ * Scoped to exactly the fields `pai task done` touches (`releaseClaim` plus
9049
+ * the `lastSeenDue` stamp) — the two writers race over nothing else, so a
9050
+ * broader merge would only add risk without removing any.
9051
+ */
9052
+ function reconcileWithDisk(state, stateFile) {
9053
+ const onDisk = loadState$1(stateFile);
9054
+ for (const [id, oursDue] of Object.entries(state.lastSeenDue)) {
9055
+ const diskDue = onDisk.lastSeenDue[id];
9056
+ if (diskDue !== void 0 && diskDue > oursDue) {
9057
+ state.lastSeenDue[id] = diskDue;
9058
+ delete state.startedAt[id];
9059
+ delete state.claimSeenAt[id];
9060
+ delete state.failedProbes[id];
9061
+ }
9062
+ }
9063
+ }
9064
+ /**
8888
9065
  * Multiple of the expected duration past which a task is flagged even though it
8889
9066
  * keeps reporting `busy`. A session can be alive and still wrong — stuck in a
8890
9067
  * loop, waiting on a prompt that will never come. Liveness is not progress.
@@ -9018,11 +9195,7 @@ async function tick(opts) {
9018
9195
  case "abandoned":
9019
9196
  note = `claimed ${d.elapsedMinutes}m ago, past the ${d.thresholdMinutes}m limit — releasing the claim`;
9020
9197
  if (!opts.dryRun) {
9021
- await opts.provider.setLabels(task.id, task.labels.filter((l) => l.toLowerCase() !== RUNNING_LABEL));
9022
- delete state.startedAt[task.id];
9023
- delete state.failedProbes[task.id];
9024
- delete state.claimSeenAt[task.id];
9025
- await clearRunningMark(task, opts);
9198
+ await releaseClaim(task, opts.provider, state);
9026
9199
  report.stuck++;
9027
9200
  }
9028
9201
  break;
@@ -9042,7 +9215,10 @@ async function tick(opts) {
9042
9215
  note
9043
9216
  });
9044
9217
  }
9045
- if (!opts.dryRun) saveState$1(state, stateFile);
9218
+ if (!opts.dryRun) {
9219
+ reconcileWithDisk(state, stateFile);
9220
+ saveState$1(state, stateFile);
9221
+ }
9046
9222
  return report;
9047
9223
  }
9048
9224
  /**
@@ -9127,7 +9303,7 @@ async function handleDispatch(task, overdue, opts, state, now) {
9127
9303
  };
9128
9304
  }
9129
9305
  await opts.provider.setLabels(task.id, task.labels);
9130
- await clearRunningMark(task, opts);
9306
+ await clearRunningMark(task, opts.provider);
9131
9307
  const fails = (state.failedDispatches[task.id] ?? 0) + 1;
9132
9308
  state.failedDispatches[task.id] = fails;
9133
9309
  const detail = `${result.outcome}${result.reason ? " — " + result.reason : ""}`;
@@ -9205,15 +9381,44 @@ async function markRunning(task, opts, startedAt) {
9205
9381
  * exactly the stale-state problem this whole subsystem keeps producing, so the
9206
9382
  * cleanup is attached to the release rather than to the happy path.
9207
9383
  */
9208
- async function clearRunningMark(task, opts) {
9209
- const { listComments, deleteComment } = opts.provider;
9384
+ async function clearRunningMark(task, provider) {
9385
+ const { listComments, deleteComment } = provider;
9210
9386
  if (!listComments || !deleteComment) return;
9211
9387
  try {
9212
- const comments = await listComments.call(opts.provider, task.id);
9213
- for (const c of comments) if (c.content.includes(RUNNING_COMMENT_MARK)) await deleteComment.call(opts.provider, c.id);
9388
+ const comments = await listComments.call(provider, task.id);
9389
+ for (const c of comments) if (c.content.includes(RUNNING_COMMENT_MARK)) await deleteComment.call(provider, c.id);
9214
9390
  } catch {}
9215
9391
  }
9216
9392
  /**
9393
+ * Release a claim on a task: strip the interlock label, remove the progress
9394
+ * marker, and forget the transient run state that made this poller believe
9395
+ * something was still working on it.
9396
+ *
9397
+ * This is every effect of ending a run except the tracker-side completion
9398
+ * itself — factored out so `handleComplete` and the "abandoned" release below
9399
+ * cannot drift apart, and so anything else that ends a claim (`pai task done`
9400
+ * included) gets the identical cleanup rather than a hand-rolled subset of it.
9401
+ * A partial version of this — closing the task but skipping this call — is
9402
+ * exactly what left `pai-running` and the transient state behind on every
9403
+ * `pai task done` before this existed: the next occurrence of a recurring task
9404
+ * inherited a live-looking claim it never made, and the poller spent the
9405
+ * morning asking a session whether it was still working on a task it had
9406
+ * already finished.
9407
+ *
9408
+ * Only ever removes `RUNNING_LABEL` from the label array — never replaces it —
9409
+ * so any other label the user put on the task survives. Only ever deletes this
9410
+ * task's own entries from `startedAt` / `claimSeenAt` / `failedProbes` —
9411
+ * `lastSeenDue` and `history` are untouched, exactly as `handleComplete` always
9412
+ * left them.
9413
+ */
9414
+ async function releaseClaim(task, provider, state) {
9415
+ await provider.setLabels(task.id, task.labels.filter((l) => l.toLowerCase() !== RUNNING_LABEL));
9416
+ await clearRunningMark(task, provider);
9417
+ delete state.startedAt[task.id];
9418
+ delete state.failedProbes[task.id];
9419
+ delete state.claimSeenAt[task.id];
9420
+ }
9421
+ /**
9217
9422
  * Put a hand-ticked task's schedule back.
9218
9423
  *
9219
9424
  * Deliberately after the dispatch and never in its place: the run is the point,
@@ -9235,12 +9440,8 @@ async function restoreSchedule(task, restoreTo, opts, state) {
9235
9440
  }
9236
9441
  async function handleComplete(task, durationMinutes, opts, state, now) {
9237
9442
  if (opts.dryRun) return `would clear ${RUNNING_LABEL}, ${durationMinutes ?? "?"}m`;
9238
- await opts.provider.setLabels(task.id, task.labels.filter((l) => l.toLowerCase() !== RUNNING_LABEL));
9239
- await clearRunningMark(task, opts);
9240
9443
  const wasStuck = (state.failedProbes[task.id] ?? 0) > 0;
9241
- delete state.startedAt[task.id];
9242
- delete state.failedProbes[task.id];
9243
- delete state.claimSeenAt[task.id];
9444
+ await releaseClaim(task, opts.provider, state);
9244
9445
  if (durationMinutes !== null && !wasStuck) {
9245
9446
  const hist = state.history[task.id] ?? [];
9246
9447
  hist.push(durationMinutes);
@@ -10005,6 +10206,15 @@ function registerTaskCommands(taskCmd) {
10005
10206
  if (opts.archive !== false) archived = await archiveTaskThread(provider, id);
10006
10207
  await provider.complete(id);
10007
10208
  console.log(chalk.green(` Completed ${id}.`));
10209
+ try {
10210
+ const task = await provider.getTask(id);
10211
+ if (task) {
10212
+ const state = loadState$1(STATE_FILE$1);
10213
+ await releaseClaim(task, provider, state);
10214
+ if (task.due) state.lastSeenDue[id] = task.due;
10215
+ saveState$1(state, STATE_FILE$1);
10216
+ }
10217
+ } catch {}
10008
10218
  if (archived) {
10009
10219
  const what = `${archived.commentCount} comment${archived.commentCount === 1 ? "" : "s"}`;
10010
10220
  console.log(archived.written ? dim(` Discussion saved (${what}) → ${archived.path}`) : dim(` Discussion already saved (${what}), unchanged.`));
@@ -10166,7 +10376,8 @@ async function cmdCheck(context, opts) {
10166
10376
  const threshold = opts.threshold ? parseFloat(opts.threshold) : void 0;
10167
10377
  if (threshold !== void 0 && (isNaN(threshold) || threshold < 0 || threshold > 1)) {
10168
10378
  console.error(err(" --threshold must be a number between 0 and 1"));
10169
- process.exit(1);
10379
+ process.exitCode = 1;
10380
+ return;
10170
10381
  }
10171
10382
  try {
10172
10383
  const result = await client.topicCheck({
@@ -10179,7 +10390,10 @@ async function cmdCheck(context, opts) {
10179
10390
  return;
10180
10391
  }
10181
10392
  printTopicResult(result);
10182
- if (result.shifted) process.exit(1);
10393
+ if (result.shifted) {
10394
+ process.exitCode = 1;
10395
+ return;
10396
+ }
10183
10397
  } catch (e) {
10184
10398
  const msg = e instanceof Error ? e.message : String(e);
10185
10399
  console.log();
@@ -10187,7 +10401,8 @@ async function cmdCheck(context, opts) {
10187
10401
  console.log(dim$1(` ${msg}`));
10188
10402
  console.log(dim$1(" Start it with: pai daemon serve"));
10189
10403
  console.log();
10190
- process.exit(1);
10404
+ process.exitCode = 1;
10405
+ return;
10191
10406
  }
10192
10407
  }
10193
10408
  function registerTopicCommands(topicCmd) {
@@ -10378,7 +10593,8 @@ async function cmdBackfill(opts) {
10378
10593
  const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
10379
10594
  if (limit !== void 0 && (isNaN(limit) || limit < 1)) {
10380
10595
  console.error(err(" --limit must be a positive integer"));
10381
- process.exit(1);
10596
+ process.exitCode = 1;
10597
+ return;
10382
10598
  }
10383
10599
  console.log();
10384
10600
  console.log(header(" PAI KG Backfill"));
@@ -10408,7 +10624,8 @@ async function cmdBackfill(opts) {
10408
10624
  } catch (e) {
10409
10625
  const msg = e instanceof Error ? e.message : String(e);
10410
10626
  console.error(err(` ${msg}`));
10411
- process.exit(1);
10627
+ process.exitCode = 1;
10628
+ return;
10412
10629
  }
10413
10630
  }
10414
10631
  async function cmdQuery$1(opts) {
@@ -10423,7 +10640,8 @@ async function cmdQuery$1(opts) {
10423
10640
  const asOf = opts.asOf ? new Date(opts.asOf) : void 0;
10424
10641
  if (asOf && isNaN(asOf.getTime())) {
10425
10642
  console.error(err(` Invalid --as-of date: ${opts.asOf}`));
10426
- process.exit(1);
10643
+ process.exitCode = 1;
10644
+ return;
10427
10645
  }
10428
10646
  const triples = await kgQuery(pool, {
10429
10647
  subject: opts.subject,
@@ -10685,7 +10903,8 @@ async function cmdQuery(sql, opts) {
10685
10903
  } catch (e) {
10686
10904
  const msg = e instanceof Error ? e.message : String(e);
10687
10905
  console.error(err(` ${msg}`));
10688
- process.exit(1);
10906
+ process.exitCode = 1;
10907
+ return;
10689
10908
  }
10690
10909
  }
10691
10910
  async function cmdTables(opts) {
@@ -10709,7 +10928,8 @@ async function cmdTables(opts) {
10709
10928
  } catch (e) {
10710
10929
  const msg = e instanceof Error ? e.message : String(e);
10711
10930
  console.error(err(` ${msg}`));
10712
- process.exit(1);
10931
+ process.exitCode = 1;
10932
+ return;
10713
10933
  }
10714
10934
  }
10715
10935
  async function cmdSchema(table, opts) {
@@ -10730,7 +10950,8 @@ async function cmdSchema(table, opts) {
10730
10950
  } catch (e) {
10731
10951
  const msg = e instanceof Error ? e.message : String(e);
10732
10952
  console.error(err(` ${msg}`));
10733
- process.exit(1);
10953
+ process.exitCode = 1;
10954
+ return;
10734
10955
  }
10735
10956
  }
10736
10957
  function registerDbCommands(dbCmd) {
@@ -11052,7 +11273,8 @@ function cmdList(db, projectSlug, opts) {
11052
11273
  const project = getProject$1(db, projectSlug);
11053
11274
  if (!project) {
11054
11275
  console.error(err(`Project not found: ${projectSlug}`));
11055
- process.exit(1);
11276
+ process.exitCode = 1;
11277
+ return;
11056
11278
  }
11057
11279
  where.push("s.project_id = ?");
11058
11280
  params.push(project.id);
@@ -11117,7 +11339,8 @@ function cmdInfo(db, projectSlug, sessionNumber) {
11117
11339
  const project = getProject$1(db, projectSlug);
11118
11340
  if (!project) {
11119
11341
  console.error(err(`Project not found: ${projectSlug}`));
11120
- process.exit(1);
11342
+ process.exitCode = 1;
11343
+ return;
11121
11344
  }
11122
11345
  const session = resolveSession(db, project, sessionNumber);
11123
11346
  console.log();
@@ -11138,13 +11361,15 @@ function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
11138
11361
  const project = getProject$1(db, projectSlug);
11139
11362
  if (!project) {
11140
11363
  console.error(err(`Project not found: ${projectSlug}`));
11141
- process.exit(1);
11364
+ process.exitCode = 1;
11365
+ return;
11142
11366
  }
11143
11367
  const session = resolveSession(db, project, numberOrLatest);
11144
11368
  const notesDir = getNotesDir(project);
11145
11369
  if (!existsSync(notesDir)) {
11146
11370
  console.error(err(`Notes directory not found: ${notesDir}`));
11147
- process.exit(1);
11371
+ process.exitCode = 1;
11372
+ return;
11148
11373
  }
11149
11374
  const titleSlug = toTitleCase$1(newSlug);
11150
11375
  const newFilename = formatFilename(session.number, session.date, titleSlug);
@@ -11155,7 +11380,8 @@ function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
11155
11380
  renameSync(oldPath, newPath);
11156
11381
  } catch (e) {
11157
11382
  console.error(err(`Failed to rename file: ${e}`));
11158
- process.exit(1);
11383
+ process.exitCode = 1;
11384
+ return;
11159
11385
  }
11160
11386
  } else {
11161
11387
  console.log(warn$1(` Note: file not found at expected path: ${session.filename}`));
@@ -11190,7 +11416,8 @@ function cmdSlug(db, projectSlug, numberOrLatest, opts) {
11190
11416
  const project = getProject$1(db, projectSlug);
11191
11417
  if (!project) {
11192
11418
  console.error(err(`Project not found: ${projectSlug}`));
11193
- process.exit(1);
11419
+ process.exitCode = 1;
11420
+ return;
11194
11421
  }
11195
11422
  const session = resolveSession(db, project, numberOrLatest);
11196
11423
  const transcriptPath = findLatestTranscript(project.encoded_dir);
@@ -11217,7 +11444,8 @@ function cmdTag(db, projectSlug, sessionNumber, rawTags) {
11217
11444
  const project = getProject$1(db, projectSlug);
11218
11445
  if (!project) {
11219
11446
  console.error(err(`Project not found: ${projectSlug}`));
11220
- process.exit(1);
11447
+ process.exitCode = 1;
11448
+ return;
11221
11449
  }
11222
11450
  const session = resolveSession(db, project, sessionNumber);
11223
11451
  if (rawTags.length === 0) {
@@ -11254,13 +11482,15 @@ function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
11254
11482
  const project = getProject$1(db, projectSlug);
11255
11483
  if (!project) {
11256
11484
  console.error(err(`Project not found: ${projectSlug}`));
11257
- process.exit(1);
11485
+ process.exitCode = 1;
11486
+ return;
11258
11487
  }
11259
11488
  const session = resolveSession(db, project, sessionNumber);
11260
11489
  const targetProject = db.prepare("SELECT id, slug, display_name FROM projects WHERE slug = ?").get(targetProjectSlug);
11261
11490
  if (!targetProject) {
11262
11491
  console.error(err(`Target project not found: ${targetProjectSlug}`));
11263
- process.exit(1);
11492
+ process.exitCode = 1;
11493
+ return;
11264
11494
  }
11265
11495
  const validTypes = [
11266
11496
  "related",
@@ -11270,7 +11500,8 @@ function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
11270
11500
  const linkType = opts.type ?? "related";
11271
11501
  if (!validTypes.includes(linkType)) {
11272
11502
  console.error(err(`Invalid link type "${linkType}". Valid: ${validTypes.join(", ")}`));
11273
- process.exit(1);
11503
+ process.exitCode = 1;
11504
+ return;
11274
11505
  }
11275
11506
  try {
11276
11507
  db.prepare(`INSERT INTO links (session_id, target_project_id, link_type, created_at)
@@ -11370,10 +11601,10 @@ function cmdActive(db, opts) {
11370
11601
  ], rows));
11371
11602
  }
11372
11603
  async function cmdAutoRoute(opts) {
11373
- const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("./auto-route-DVM3U2ZY.mjs");
11374
- const { openRegistry } = await import("./db-BtuN768f.mjs").then((n) => n.t);
11375
- const { createStorageBackend } = await import("./factory-vPTPoBR7.mjs").then((n) => n.n);
11376
- const { loadConfig } = await import("./config-BSkVcvfq.mjs").then((n) => n.r);
11604
+ const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("./auto-route-Byf8ENXj.mjs");
11605
+ const { openRegistry } = await import("./db-XEwJbuGO.mjs");
11606
+ const { createStorageBackend } = await import("./factory-CrokPMk2.mjs");
11607
+ const { loadConfig } = await import("./config-C_ErGddD.mjs");
11377
11608
  const config = loadConfig();
11378
11609
  const registryDb = openRegistry();
11379
11610
  const federation = await createStorageBackend(config);
@@ -11484,10 +11715,10 @@ function touchCheckpointSentinel(notesDir) {
11484
11715
  function cmdCheckpoint(message, opts) {
11485
11716
  const minGapSeconds = parseInt(opts.minGap ?? "300", 10);
11486
11717
  const notesDir = findNotesDirForCwd();
11487
- if (!notesDir) process.exit(0);
11488
- if (checkpointTooRecent(notesDir, minGapSeconds)) process.exit(0);
11718
+ if (!notesDir) return;
11719
+ if (checkpointTooRecent(notesDir, minGapSeconds)) return;
11489
11720
  const notePath = findLatestNoteFile(notesDir);
11490
- if (!notePath) process.exit(0);
11721
+ if (!notePath) return;
11491
11722
  const block = `\n## Checkpoint — ${(/* @__PURE__ */ new Date()).toISOString()}\n${message}\n`;
11492
11723
  const tmpPath = `${notePath}.checkpoint.tmp`;
11493
11724
  try {
@@ -11497,10 +11728,9 @@ function cmdCheckpoint(message, opts) {
11497
11728
  try {
11498
11729
  if (existsSync(tmpPath)) renameSync(tmpPath, tmpPath + ".dead");
11499
11730
  } catch {}
11500
- process.exit(0);
11731
+ return;
11501
11732
  }
11502
11733
  touchCheckpointSentinel(notesDir);
11503
- process.exit(0);
11504
11734
  }
11505
11735
 
11506
11736
  //#endregion
@@ -11573,22 +11803,26 @@ function cmdPause(db, opts) {
11573
11803
  body = readBodyFile(opts.bodyFile).trim();
11574
11804
  } catch (err) {
11575
11805
  console.error(chalk.red("pai pause: ") + `Could not read checkpoint body from ${opts.bodyFile}: ${String(err)}`);
11576
- process.exit(1);
11806
+ process.exitCode = 1;
11807
+ return;
11577
11808
  }
11578
11809
  if (!body) {
11579
11810
  console.error(chalk.red("pai pause: ") + `Checkpoint body at ${opts.bodyFile} is empty.`);
11580
- process.exit(1);
11811
+ process.exitCode = 1;
11812
+ return;
11581
11813
  }
11582
11814
  } else if (!opts.noBody) {
11583
11815
  printMissingBodyError();
11584
- process.exit(1);
11816
+ process.exitCode = 1;
11817
+ return;
11585
11818
  }
11586
11819
  const cwd = process.cwd();
11587
11820
  const project = resolveProjectByCwd(db, cwd);
11588
11821
  if (!project) {
11589
11822
  console.error(chalk.red("pai pause: ") + `Current directory is not within a registered PAI project.
11590
11823
  cwd: ${cwd}`);
11591
- process.exit(1);
11824
+ process.exitCode = 1;
11825
+ return;
11592
11826
  }
11593
11827
  const session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
11594
11828
  const notePath = resolveNotePath(project);
@@ -11615,7 +11849,8 @@ function cmdPause(db, opts) {
11615
11849
  });
11616
11850
  if (result.action === "failed") {
11617
11851
  console.error(chalk.red("pai pause: ") + `Failed to write TODO.md: ${result.error}`);
11618
- process.exit(1);
11852
+ process.exitCode = 1;
11853
+ return;
11619
11854
  }
11620
11855
  if (opts.dryRun) {
11621
11856
  console.log("\n" + chalk.bold("Dry run — would write to:") + " " + chalk.cyan(result.path ?? "?"));
@@ -11884,31 +12119,71 @@ function readWorkingTree(cwd) {
11884
12119
  * nothing asked". The handover survives in the session note either way, but
11885
12120
  * `## Continue` is what the next session is shown, so what sits there matters.
11886
12121
  */
12122
+ /** Cap on `@n=` file-declaration lines, so the whole message stays ≤25 lines. */
12123
+ const MAX_AT_LINES = 17;
12124
+ /** Flatten a prompt (or any field value) to a single trimmed line. */
12125
+ function flattenField(text) {
12126
+ return text.replace(/\s*\n\s*/g, " ").trim();
12127
+ }
12128
+ /**
12129
+ * Strip a git-porcelain status prefix off a `changes` entry, leaving the path.
12130
+ *
12131
+ * Porcelain lines are a 1-2 char status field, a space, then the path (e.g.
12132
+ * `"M src/foo.ts"`, `" M src/foo.ts"`, `"?? x.ts"`). The stripped result falls
12133
+ * back to the trimmed original whenever the pattern does not match, so a
12134
+ * surprising entry still produces a usable `@n=` line instead of an empty one.
12135
+ */
12136
+ function pathOf(entry) {
12137
+ return entry.replace(/^\s*\S{1,2}\s+/, "").trim() || entry.trim();
12138
+ }
12139
+ /**
12140
+ * Build the auto-checkpoint body as a single AG2 (Agentish v2) message of kind
12141
+ * `T` (task/handover). Pure function of its inputs so it can be unit-tested
12142
+ * without touching the filesystem or git.
12143
+ *
12144
+ * Field layout — see the AG2 validator for the authoritative rules:
12145
+ * i = ckpt-<sanitised timestamp>
12146
+ * g = the most recent prompt (goal)
12147
+ * d = the earlier prompts, joined by " | " ("?" when there are none)
12148
+ * t = "?" always — a mechanical handover runs no tests
12149
+ * @n = one declaration per changed file, capped at MAX_AT_LINES so the
12150
+ * whole message stays within AG2's 25-line limit
12151
+ * z = a note summarising the working tree, capped at 200 chars
12152
+ */
12153
+ function ag2Checkpoint(prompts, tree, ts) {
12154
+ const id = `ckpt-${ts.replace(/[^A-Za-z0-9._-]/g, "-")}`;
12155
+ const g = flattenField(prompts[0]);
12156
+ const d = prompts.length > 1 ? prompts.slice(1).map(flattenField).join(" | ") : "?";
12157
+ const lines = [
12158
+ "T",
12159
+ `i=${id}`,
12160
+ `g=${g}`,
12161
+ `d=${d}`,
12162
+ "t=?"
12163
+ ];
12164
+ let extra = 0;
12165
+ if (tree) {
12166
+ const shown = tree.changes.slice(0, MAX_AT_LINES);
12167
+ extra = Math.max(0, tree.changes.length - MAX_AT_LINES) + tree.overflow;
12168
+ shown.forEach((entry, i) => lines.push(`@${i + 1}=${pathOf(entry)}`));
12169
+ }
12170
+ let z;
12171
+ if (!tree) z = "auto ckpt (no model)";
12172
+ else {
12173
+ const dirty = tree.changes.length;
12174
+ const head = tree.head ?? "nohead";
12175
+ z = `auto ckpt (no model); ${tree.branch}@${head}; ${dirty} dirty`;
12176
+ if (extra > 0) z += `; +${extra} paths`;
12177
+ z += "; git status for detail";
12178
+ }
12179
+ lines.push(`z=${z.length > 200 ? z.slice(0, 200) : z}`);
12180
+ return lines.join("\n");
12181
+ }
11887
12182
  function buildAutosaveBody(input) {
11888
12183
  const ts = input.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
11889
12184
  const prompts = recentPrompts(input.transcriptPaths ?? []);
11890
12185
  if (prompts.length === 0) return "";
11891
- const tree = readWorkingTree(input.cwd);
11892
- const dirty = tree?.changes.length ?? 0;
11893
- const out = [`_Automatic checkpoint — ${ts}. Written without the model, from the transcript and the working tree. A model-authored checkpoint replaces this; it is here so an interrupted session still leaves something._`];
11894
- if (prompts.length > 0) {
11895
- out.push("", "### What was being asked", "");
11896
- for (const p of prompts) out.push(`- ${p.replace(/\s*\n\s*/g, " ")}`);
11897
- }
11898
- if (tree) {
11899
- out.push("", "### Working tree", "");
11900
- out.push(`- Branch: \`${tree.branch}\``);
11901
- if (tree.head) out.push(`- HEAD: ${tree.head}`);
11902
- if (dirty === 0) out.push("- Clean — nothing uncommitted.");
11903
- else {
11904
- out.push(`- ${dirty} uncommitted path(s)${tree.overflow > 0 ? ` (+${tree.overflow} more)` : ""}:`);
11905
- out.push("");
11906
- out.push("```");
11907
- out.push(...tree.changes);
11908
- out.push("```");
11909
- }
11910
- }
11911
- return out.join("\n");
12186
+ return ag2Checkpoint(prompts, readWorkingTree(input.cwd), ts);
11912
12187
  }
11913
12188
 
11914
12189
  //#endregion
@@ -11936,10 +12211,10 @@ function cmdHandover(db, projectSlug, numberOrLatest, sessionId) {
11936
12211
  let project;
11937
12212
  if (projectSlug) {
11938
12213
  project = db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(projectSlug);
11939
- if (!project) process.exit(0);
12214
+ if (!project) return;
11940
12215
  } else {
11941
12216
  const row = resolveProjectByCwd(db, process.cwd());
11942
- if (!row) process.exit(0);
12217
+ if (!row) return;
11943
12218
  project = row;
11944
12219
  }
11945
12220
  let session;
@@ -11957,7 +12232,549 @@ function cmdHandover(db, projectSlug, numberOrLatest, sessionId) {
11957
12232
  cwd: process.cwd(),
11958
12233
  body: buildStopBody(project, sessionId)
11959
12234
  });
11960
- process.exit(0);
12235
+ }
12236
+
12237
+ //#endregion
12238
+ //#region src/hooks/ts/lib/context-fill.ts
12239
+ /**
12240
+ * context-fill.ts — "how full is this session's context window, right now?"
12241
+ *
12242
+ * No hook payload carries this number directly (verified: PreCompact,
12243
+ * UserPromptSubmit and PostToolUse stdin never include `context_window`).
12244
+ * Two sources can reconstruct it, in order of preference:
12245
+ *
12246
+ * 1. STATUSLINE STATE FILE — statusline-command.sh receives the exact
12247
+ * figure from Claude Code (`.context_window.used_percentage` /
12248
+ * `.context_window.context_window_size`) on every render and persists
12249
+ * it to `${TMPDIR}/pai-context-<session_id>.json`. This is authoritative
12250
+ * but depends on the status line having rendered recently — a session
12251
+ * whose terminal isn't drawing a status line (headless, backgrounded)
12252
+ * leaves this file missing or stale.
12253
+ *
12254
+ * 2. TRANSCRIPT USAGE — every `message.usage` entry in the session's own
12255
+ * .jsonl transcript already reports the token accounting for that one
12256
+ * API call: `input_tokens + cache_read_input_tokens +
12257
+ * cache_creation_input_tokens` on the MOST RECENT such entry *is* the
12258
+ * current context fill, because each request resends the full context.
12259
+ * This is why it must be the last entry's fields summed, never a sum
12260
+ * across entries — summing across entries is a running token-spend
12261
+ * total, not a fill reading (see calculateSessionTokens, a different
12262
+ * metric answering a different question).
12263
+ *
12264
+ * When neither source is available, the answer is UNKNOWN, not zero. A zero
12265
+ * reads as "plenty of room" and would silently suppress every downstream
12266
+ * decision that depends on this number (the compaction handover chief among
12267
+ * them) for the entire session.
12268
+ */
12269
+ /** Fallback window size when nothing on hand reports one. Matches the
12270
+ * default statusline-command.sh already falls back to. */
12271
+ const DEFAULT_CONTEXT_WINDOW = 2e5;
12272
+ /** How many trailing lines of the transcript to scan for a usage entry.
12273
+ * A single turn is rarely more than a handful of JSONL lines (assistant
12274
+ * text + tool_use/tool_result pairs), so 40 comfortably covers one turn
12275
+ * even a busy one, without reading the whole file on every check. */
12276
+ const TRANSCRIPT_TAIL_LINES = 40;
12277
+ /** A statusline reading older than this is treated as not there at all —
12278
+ * a stale number is worse than none, because it looks confident. */
12279
+ const STATUSLINE_STALE_MS = 300 * 1e3;
12280
+ function unknownReading(windowSize) {
12281
+ return {
12282
+ status: "unknown",
12283
+ usedTokens: null,
12284
+ windowSize,
12285
+ fraction: null,
12286
+ source: "unknown"
12287
+ };
12288
+ }
12289
+ function statuslineStateFilePath(sessionId) {
12290
+ return join(tmpdir(), `pai-context-${sessionId}.json`);
12291
+ }
12292
+ /**
12293
+ * Read the state file statusline-command.sh writes on every render.
12294
+ * Returns null when the file is missing, unparsable, or older than
12295
+ * STATUSLINE_STALE_MS — all three mean "not a source right now".
12296
+ */
12297
+ function readStatuslineFill(sessionId, now = Date.now()) {
12298
+ if (!sessionId) return null;
12299
+ const path = statuslineStateFilePath(sessionId);
12300
+ if (!existsSync(path)) return null;
12301
+ let raw;
12302
+ try {
12303
+ raw = JSON.parse(readFileSync(path, "utf-8"));
12304
+ } catch {
12305
+ return null;
12306
+ }
12307
+ if (typeof raw.timestamp !== "number" || now - raw.timestamp > STATUSLINE_STALE_MS) return null;
12308
+ if (typeof raw.used_percentage !== "number" || typeof raw.context_window_size !== "number") return null;
12309
+ const windowSize = raw.context_window_size;
12310
+ return {
12311
+ status: "ok",
12312
+ usedTokens: Math.round(raw.used_percentage / 100 * windowSize),
12313
+ windowSize,
12314
+ fraction: raw.used_percentage / 100,
12315
+ source: "statusline"
12316
+ };
12317
+ }
12318
+ /**
12319
+ * Sum the three context-carrying fields of ONE usage object — never across
12320
+ * usage objects. A single `message.usage` already reports what that one API
12321
+ * call sent as context; adding another entry's numbers to it produces a
12322
+ * cumulative spend figure, not a fill reading, and can exceed the window
12323
+ * size many times over on a long session (the exact defect this helper
12324
+ * exists to not repeat — see the PreCompact header fix in the same change).
12325
+ */
12326
+ function usageTotal(usage) {
12327
+ return (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
12328
+ }
12329
+ /**
12330
+ * Derive context fill from the tail of a .jsonl transcript: the most recent
12331
+ * `message.usage` entry, read from the end backwards so a trailing line with
12332
+ * no usage field (a plain text turn, a tool_result) doesn't hide one just
12333
+ * before it.
12334
+ */
12335
+ function contextFillFromTranscript(transcriptPath, windowSize = DEFAULT_CONTEXT_WINDOW) {
12336
+ if (!transcriptPath || !existsSync(transcriptPath)) return unknownReading(windowSize);
12337
+ let raw;
12338
+ try {
12339
+ raw = readFileSync(transcriptPath, "utf-8");
12340
+ } catch {
12341
+ return unknownReading(windowSize);
12342
+ }
12343
+ const tail = raw.trim().split("\n").filter((l) => l.trim()).slice(-TRANSCRIPT_TAIL_LINES);
12344
+ for (let i = tail.length - 1; i >= 0; i--) {
12345
+ let entry;
12346
+ try {
12347
+ entry = JSON.parse(tail[i]);
12348
+ } catch {
12349
+ continue;
12350
+ }
12351
+ const usage = entry?.message?.usage;
12352
+ if (usage && typeof usage === "object") {
12353
+ const usedTokens = usageTotal(usage);
12354
+ return {
12355
+ status: "ok",
12356
+ usedTokens,
12357
+ windowSize,
12358
+ fraction: usedTokens / windowSize,
12359
+ source: "transcript"
12360
+ };
12361
+ }
12362
+ }
12363
+ return unknownReading(windowSize);
12364
+ }
12365
+ function getContextFill(input, now = Date.now()) {
12366
+ const windowSize = input.windowSize ?? DEFAULT_CONTEXT_WINDOW;
12367
+ if (input.sessionId) {
12368
+ const fromStatusline = readStatuslineFill(input.sessionId, now);
12369
+ if (fromStatusline) return fromStatusline;
12370
+ }
12371
+ if (input.transcriptPath) return contextFillFromTranscript(input.transcriptPath, windowSize);
12372
+ return unknownReading(windowSize);
12373
+ }
12374
+ const DEFAULT_AUTOCOMPACT_PCT = 80;
12375
+ /** How many of the most recent compact_boundary events to consider, and to
12376
+ * take the minimum of. */
12377
+ const MEASURED_TRIGGER_SAMPLE_SIZE = 3;
12378
+ const THRESHOLD_MARGIN_TOKENS = {
12379
+ warmup: 1e5,
12380
+ refresh: 4e4,
12381
+ immediate: 15e3
12382
+ };
12383
+ /** Real default — overridable per-call so tests never touch the user's
12384
+ * actual ~/.claude/projects/ (a live directory this very session writes to). */
12385
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
12386
+ function encodeProjectPath(cwd) {
12387
+ return cwd.replace(/[/\s.-]/g, "-");
12388
+ }
12389
+ /** Every `.jsonl` transcript belonging to a project — top-level (the live
12390
+ * file) and `sessions/` (Claude Code's archive, which mirrors it). No file
12391
+ * is excluded and no ordering is applied here: ordering by EVENT
12392
+ * timestamp, not by file mtime, is the whole point (see
12393
+ * readCompactBoundarySamples) — a file's mtime does not reliably track
12394
+ * which events inside it are recent, and pre-filtering by mtime is exactly
12395
+ * what caused this function to return a stale, pre-regime-change trigger
12396
+ * on real data. */
12397
+ function listProjectTranscripts(cwd, projectsDir) {
12398
+ const projectDir = join(projectsDir, encodeProjectPath(cwd));
12399
+ if (!existsSync(projectDir)) return [];
12400
+ const paths = [];
12401
+ const collect = (dir) => {
12402
+ if (!existsSync(dir)) return;
12403
+ let entries;
12404
+ try {
12405
+ entries = readdirSync(dir);
12406
+ } catch {
12407
+ return;
12408
+ }
12409
+ for (const entry of entries) if (entry.endsWith(".jsonl")) paths.push(join(dir, entry));
12410
+ };
12411
+ collect(projectDir);
12412
+ collect(join(projectDir, "sessions"));
12413
+ return paths;
12414
+ }
12415
+ /**
12416
+ * Every DISTINCT compact_boundary sample found across ALL of a project's
12417
+ * transcripts (live + archived), newest first by the event's OWN timestamp
12418
+ * — never by which file it came from or that file's mtime. Deduplicated by
12419
+ * the event's uuid (falling back to a timestamp+preTokens key for the rare
12420
+ * line with no uuid) so an event mirrored into `sessions/` is counted once.
12421
+ */
12422
+ function readCompactBoundarySamples(cwd, projectsDir) {
12423
+ const byKey = /* @__PURE__ */ new Map();
12424
+ for (const path of listProjectTranscripts(cwd, projectsDir)) {
12425
+ let raw;
12426
+ try {
12427
+ raw = readFileSync(path, "utf-8");
12428
+ } catch {
12429
+ continue;
12430
+ }
12431
+ for (const line of raw.split("\n")) {
12432
+ if (!line.trim()) continue;
12433
+ let entry;
12434
+ try {
12435
+ entry = JSON.parse(line);
12436
+ } catch {
12437
+ continue;
12438
+ }
12439
+ if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
12440
+ const preTokens = entry.compactMetadata?.preTokens;
12441
+ if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
12442
+ const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
12443
+ if (!Number.isFinite(timestampMs)) continue;
12444
+ const key = entry.uuid ?? `${timestampMs}:${preTokens}`;
12445
+ if (!byKey.has(key)) byKey.set(key, {
12446
+ preTokens,
12447
+ timestampMs,
12448
+ timestamp: entry.timestamp,
12449
+ uuid: entry.uuid
12450
+ });
12451
+ }
12452
+ }
12453
+ return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);
12454
+ }
12455
+ /**
12456
+ * The most recent MEASURED_TRIGGER_SAMPLE_SIZE distinct compact_boundary
12457
+ * events for a project, newest first — exposed on its own (not just the
12458
+ * derived minimum) so the number `measureCompactionTrigger` returns is
12459
+ * checkable: print these and the timestamps prove which three events
12460
+ * produced it, rather than asking for trust.
12461
+ */
12462
+ function selectedCompactionSamples(cwd, projectsDir = CLAUDE_PROJECTS_DIR) {
12463
+ if (!cwd) return [];
12464
+ return readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
12465
+ }
12466
+ /**
12467
+ * The measured compaction trigger for a project, or null when it has no
12468
+ * compaction history yet (a brand-new project, or one whose transcripts
12469
+ * this process cannot read). Minimum of the most recent
12470
+ * MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by
12471
+ * the events' own timestamps across every transcript the project has
12472
+ * (live and archived) — see the module comment above for why minimum, not
12473
+ * mean, and readCompactBoundarySamples for why "distinct" and "own
12474
+ * timestamp" both matter (a file-mtime-ordered, non-deduplicated version of
12475
+ * this returned a stale pre-regime-change trigger on real project data).
12476
+ */
12477
+ function measureCompactionTrigger(cwd, projectsDir = CLAUDE_PROJECTS_DIR) {
12478
+ const samples = selectedCompactionSamples(cwd, projectsDir);
12479
+ if (samples.length === 0) return null;
12480
+ const trigger = Math.min(...samples.map((s) => s.preTokens));
12481
+ console.error(`[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` + samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")");
12482
+ return trigger;
12483
+ }
12484
+ /**
12485
+ * Read CLAUDE_AUTOCOMPACT_PCT_OVERRIDE from the environment. Absent →
12486
+ * DEFAULT_AUTOCOMPACT_PCT. Present but not a finite number in (0, 100] →
12487
+ * also DEFAULT_AUTOCOMPACT_PCT, logged, so a typo in the override degrades
12488
+ * to the documented default instead of silently producing nonsense
12489
+ * thresholds (0, negative, or a fraction so large no session ever reaches
12490
+ * it).
12491
+ */
12492
+ function resolveAutocompactPct(env = process.env) {
12493
+ const raw = env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
12494
+ if (raw === void 0 || raw === "") return DEFAULT_AUTOCOMPACT_PCT;
12495
+ const parsed = Number(raw);
12496
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {
12497
+ console.error(`[context-fill] CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="${raw}" is not a usable percentage — falling back to the default ${DEFAULT_AUTOCOMPACT_PCT}.`);
12498
+ return DEFAULT_AUTOCOMPACT_PCT;
12499
+ }
12500
+ return parsed;
12501
+ }
12502
+ /**
12503
+ * Derive warmup/refresh/immediate thresholds from a fill reading.
12504
+ *
12505
+ * effectiveTrigger = min(measured, configuredChainValue) when a measured
12506
+ * value exists — NOT the measured value outright. A project whose newest
12507
+ * compaction predates a regime change measures a stale-HIGH trigger: a real
12508
+ * case on this machine measured 998,267 for a project whose ACTUAL current
12509
+ * boundary (from a different project's fresher history, cross-checked
12510
+ * independently) is ~784,000 — using 998,267 directly would compute a
12511
+ * warm-up of 898,267, above the real boundary, so the handover would never
12512
+ * fire there. The measurement is honest; it is just old.
12513
+ *
12514
+ * The minimum is correct in both directions: the measured value is the
12515
+ * better estimate when it is LOWER than configured (it reflects reality the
12516
+ * configured percentage cannot know — see the module comment above, the
12517
+ * 100%-to-78% regime change this project itself lived through); it is
12518
+ * unsafe when it is HIGHER (it reflects a regime that no longer applies).
12519
+ * Taking the minimum costs nothing in the safe direction — one wasted
12520
+ * summary if the project's regime actually did move up — and prevents the
12521
+ * unsafe direction, where a stale-high measurement suppresses the handover
12522
+ * past the real boundary. That asymmetry is the same one the 80-not-100
12523
+ * default was chosen for.
12524
+ *
12525
+ * Margins below effectiveTrigger are clamped at 0 (and logged) for a window
12526
+ * small enough that a margin would otherwise go negative — a pathological
12527
+ * input should degrade to "fire immediately", never to a threshold below
12528
+ * zero.
12529
+ */
12530
+ function contextFillThresholds(reading, env = process.env, opts = {}) {
12531
+ const windowConfirmed = reading.source === "statusline";
12532
+ const autocompactPct = resolveAutocompactPct(env);
12533
+ const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
12534
+ const measuredTriggerTokens = opts.measuredTrigger !== void 0 ? opts.measuredTrigger : opts.cwd ? measureCompactionTrigger(opts.cwd) : null;
12535
+ let effectiveTriggerTokens;
12536
+ let triggerSource;
12537
+ if (measuredTriggerTokens === null) {
12538
+ effectiveTriggerTokens = configuredTriggerTokens;
12539
+ triggerSource = "configured";
12540
+ console.error(`[context-fill] trigger source: CONFIGURED — no compaction history for this project yet. measured=none, configured=${configuredTriggerTokens} (${autocompactPct}% of a ${reading.windowSize}-token window) -> using ${effectiveTriggerTokens}.`);
12541
+ } else if (measuredTriggerTokens <= configuredTriggerTokens) {
12542
+ effectiveTriggerTokens = measuredTriggerTokens;
12543
+ triggerSource = "measured";
12544
+ console.error(`[context-fill] trigger source: MEASURED — measured=${measuredTriggerTokens} (minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events), configured=${configuredTriggerTokens} -> using ${effectiveTriggerTokens} (measured, ≤ configured).`);
12545
+ } else {
12546
+ effectiveTriggerTokens = configuredTriggerTokens;
12547
+ triggerSource = "measured-clamped";
12548
+ console.error(`[context-fill] trigger source: MEASURED-CLAMPED — measured=${measuredTriggerTokens} is HIGHER than configured=${configuredTriggerTokens} (a stale regime this project's history predates) -> using ${effectiveTriggerTokens} (configured, the safer bound).`);
12549
+ }
12550
+ const clamp = (name, value) => {
12551
+ if (value >= 0) return value;
12552
+ console.error(`[context-fill] ${name} threshold went negative (${value}) for a ${reading.windowSize}-token window — clamping to 0.`);
12553
+ return 0;
12554
+ };
12555
+ return {
12556
+ warmupTokens: clamp("warmup", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.warmup),
12557
+ refreshTokens: clamp("refresh", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),
12558
+ immediateTokens: clamp("immediate", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),
12559
+ effectiveTriggerTokens,
12560
+ measuredTriggerTokens,
12561
+ configuredTriggerTokens,
12562
+ autocompactPct,
12563
+ triggerSource,
12564
+ windowConfirmed
12565
+ };
12566
+ }
12567
+ /**
12568
+ * Which named thresholds `usedTokens` has newly crossed, given the ones that
12569
+ * have already fired this session. Ascending order (warmup before refresh).
12570
+ *
12571
+ * A single check can return more than one name — e.g. a session first
12572
+ * observed at 99% of its window crosses warmup and refresh in the same tick,
12573
+ * because there was no earlier clean crossing to catch it at. That is the
12574
+ * "fire immediately" case: the caller enqueues one handover and marks every
12575
+ * newly-crossed name fired, rather than waiting for a crossing that already
12576
+ * happened.
12577
+ */
12578
+ function crossedThresholds(usedTokens, thresholds, alreadyFired) {
12579
+ const fired = new Set(alreadyFired);
12580
+ return [["warmup", thresholds.warmupTokens], ["refresh", thresholds.refreshTokens]].filter(([name, tokens]) => !fired.has(name) && usedTokens >= tokens).map(([name]) => name);
12581
+ }
12582
+ /**
12583
+ * True once a session is at or above the "no time left for a clean crossing"
12584
+ * floor (0.985 of the window). Purely informational for callers — it never
12585
+ * gates whether `crossedThresholds` fires (a poll-based check already fires
12586
+ * any unfired threshold the moment `usedTokens` reaches it, on whatever tick
12587
+ * observes it) — but it distinguishes "this crossed on schedule" from "this
12588
+ * was first observed already almost out of room", which is worth a different
12589
+ * log line and, for a caller that queues work, a higher priority.
12590
+ */
12591
+ function isImmediate(usedTokens, thresholds) {
12592
+ return usedTokens >= thresholds.immediateTokens;
12593
+ }
12594
+
12595
+ //#endregion
12596
+ //#region src/session/context-handover-trigger.ts
12597
+ /**
12598
+ * context-handover-trigger.ts — decide whether THIS check is the moment a
12599
+ * session should get its threshold-triggered pre-compaction handover, and
12600
+ * enqueue it with the daemon if so.
12601
+ *
12602
+ * Called from the same live hooks that already drive the AG2 rolling
12603
+ * autosave (UserPromptSubmit, PostToolUse) — see
12604
+ * cli/commands/session/autosave.ts. Reuses the context-fill reading and the
12605
+ * derived thresholds from hooks/ts/lib/context-fill.ts; this module only
12606
+ * adds the "have we already fired this one" bookkeeping and the enqueue.
12607
+ *
12608
+ * BUG FIXED (found in live use, session 77084e72-...): a threshold used to
12609
+ * be marked fired the moment the ENQUEUE call succeeded — recording intent,
12610
+ * not outcome. When the daemon worker then failed or was restarted mid-spawn
12611
+ * (observed: a daemon restart 90s after the enqueue), no handover cache was
12612
+ * ever written, but the marker already said "done" — so the session
12613
+ * compacted with the mechanical scrape only, and would never have retried,
12614
+ * ever, for that session. A threshold is now marked CONFIRMED only after a
12615
+ * handover cache file actually appears; an enqueue with no cache to show for
12616
+ * it within PENDING_TIMEOUT_MS is treated as failed and retried.
12617
+ *
12618
+ * Never throws. A daemon that isn't running, a stale reading, an unknown
12619
+ * fill — every one of those is a reason to do nothing this check and try
12620
+ * again next time, not a reason to interrupt the hook that called this.
12621
+ */
12622
+ /** The enqueue only needs to hand the item to the daemon's queue file — the
12623
+ * actual LLM call happens later, asynchronously, in the worker loop. A
12624
+ * short bound keeps a rare (at most twice per session) daemon hiccup from
12625
+ * making the calling hook wait anywhere near as long as a normal IPC call
12626
+ * is allowed to. */
12627
+ const ENQUEUE_TIMEOUT_MS = 2e3;
12628
+ /**
12629
+ * How long to wait for a handover cache to appear after an enqueue before
12630
+ * treating it as failed and retrying. Generous over the worker's own sonnet
12631
+ * timeout (120s, see session-summary-worker.ts's CLAUDE_TIMEOUT_MS) plus
12632
+ * queue latency, so a slow-but-working run isn't retried out from under
12633
+ * itself — and short enough that a genuinely dead attempt (a daemon restart
12634
+ * mid-spawn, exactly what happened in the live failure this fixes) is
12635
+ * retried well before the next compaction, not "never".
12636
+ */
12637
+ const PENDING_TIMEOUT_MS = 300 * 1e3;
12638
+ /** Both thresholds confirmed — nothing left to check for the rest of the session. */
12639
+ const ALL_THRESHOLDS = ["warmup", "refresh"];
12640
+ function triggerStatePath(sessionId) {
12641
+ return join(tmpdir(), `pai-context-handover-fired-${sessionId}.json`);
12642
+ }
12643
+ function loadTriggerState(sessionId) {
12644
+ const path = triggerStatePath(sessionId);
12645
+ if (!existsSync(path)) return {
12646
+ confirmed: [],
12647
+ pending: null
12648
+ };
12649
+ try {
12650
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
12651
+ return {
12652
+ confirmed: Array.isArray(raw.confirmed) ? raw.confirmed : [],
12653
+ pending: raw.pending && Array.isArray(raw.pending.thresholds) && typeof raw.pending.enqueuedAt === "string" ? raw.pending : null
12654
+ };
12655
+ } catch {
12656
+ return {
12657
+ confirmed: [],
12658
+ pending: null
12659
+ };
12660
+ }
12661
+ }
12662
+ function saveTriggerState(sessionId, state) {
12663
+ try {
12664
+ writeFileSync(triggerStatePath(sessionId), JSON.stringify(state), "utf-8");
12665
+ } catch {}
12666
+ }
12667
+ async function enqueueContextHandover(payload) {
12668
+ await new PaiClient().call("work_queue_enqueue", {
12669
+ type: "context-handover",
12670
+ priority: 3,
12671
+ payload: {
12672
+ sessionId: payload.sessionId,
12673
+ cwd: payload.cwd,
12674
+ transcriptPath: payload.transcriptPath,
12675
+ threshold: payload.threshold,
12676
+ urgent: payload.urgent
12677
+ }
12678
+ }, ENQUEUE_TIMEOUT_MS);
12679
+ }
12680
+ const defaultDeps = {
12681
+ getReading: (input) => getContextFill({
12682
+ sessionId: input.sessionId,
12683
+ transcriptPath: input.transcriptPath
12684
+ }),
12685
+ loadState: loadTriggerState,
12686
+ saveState: saveTriggerState,
12687
+ readCache: readContextHandoverCache,
12688
+ enqueue: enqueueContextHandover,
12689
+ now: () => Date.now()
12690
+ };
12691
+ const NOTHING = {
12692
+ attempted: [],
12693
+ confirmed: []
12694
+ };
12695
+ /**
12696
+ * Check this session's current context fill against the derived thresholds,
12697
+ * confirm or retry any in-flight attempt, and enqueue a context-handover job
12698
+ * for any newly-crossed threshold. Never marks a threshold done on the
12699
+ * strength of the enqueue call alone — only a verified cache file does that.
12700
+ *
12701
+ * A single check that crosses both warmup and refresh in one jump (a
12702
+ * session first observed already close to compaction) enqueues ONE job,
12703
+ * tagged with the more urgent of the two, and — once confirmed — marks both
12704
+ * done together, since one model-written handover already covers what
12705
+ * either alone would have.
12706
+ */
12707
+ async function checkAndEnqueueContextHandover(input, deps = defaultDeps) {
12708
+ let state = deps.loadState(input.sessionId);
12709
+ if (ALL_THRESHOLDS.every((t) => state.confirmed.includes(t))) return NOTHING;
12710
+ let confirmedThisCheck = [];
12711
+ if (state.pending) {
12712
+ const cache = deps.readCache(input.sessionId);
12713
+ if (cache !== null && Date.parse(cache.generatedAt) > Date.parse(state.pending.enqueuedAt)) {
12714
+ const newlyConfirmed = state.pending.thresholds.filter((t) => !state.confirmed.includes(t));
12715
+ state = {
12716
+ confirmed: [...state.confirmed, ...newlyConfirmed],
12717
+ pending: null
12718
+ };
12719
+ deps.saveState(input.sessionId, state);
12720
+ confirmedThisCheck = newlyConfirmed;
12721
+ console.error(`[context-handover-trigger] session ${input.sessionId}: confirmed [${newlyConfirmed.join(", ")}] — handover cache verified (generated ${cache.generatedAt}).`);
12722
+ if (ALL_THRESHOLDS.every((t) => state.confirmed.includes(t))) return {
12723
+ attempted: [],
12724
+ confirmed: confirmedThisCheck
12725
+ };
12726
+ } else if (deps.now() - Date.parse(state.pending.enqueuedAt) < PENDING_TIMEOUT_MS) return NOTHING;
12727
+ else {
12728
+ console.error(`[context-handover-trigger] session ${input.sessionId}: pending [${state.pending.thresholds.join(", ")}] timed out after ${PENDING_TIMEOUT_MS}ms with no handover cache written — treating as FAILED and retrying (daemon worker likely died or was restarted mid-run).`);
12729
+ state = {
12730
+ ...state,
12731
+ pending: null
12732
+ };
12733
+ deps.saveState(input.sessionId, state);
12734
+ }
12735
+ }
12736
+ const reading = deps.getReading(input);
12737
+ if (reading.status !== "ok" || reading.usedTokens === null) return {
12738
+ attempted: [],
12739
+ confirmed: confirmedThisCheck
12740
+ };
12741
+ const thresholds = contextFillThresholds(reading, process.env, { cwd: input.cwd });
12742
+ if (!thresholds.windowConfirmed) console.error(`[context-handover-trigger] session ${input.sessionId}: window size not confirmed — using the assumed default (${reading.windowSize} tokens) rather than a reading Claude Code reported.`);
12743
+ const newlyCrossed = crossedThresholds(reading.usedTokens, thresholds, state.confirmed);
12744
+ if (newlyCrossed.length === 0) return {
12745
+ attempted: [],
12746
+ confirmed: confirmedThisCheck
12747
+ };
12748
+ const urgent = isImmediate(reading.usedTokens, thresholds);
12749
+ const mostUrgent = newlyCrossed[newlyCrossed.length - 1];
12750
+ try {
12751
+ await deps.enqueue({
12752
+ sessionId: input.sessionId,
12753
+ cwd: input.cwd,
12754
+ transcriptPath: input.transcriptPath,
12755
+ threshold: mostUrgent,
12756
+ urgent
12757
+ });
12758
+ } catch (err) {
12759
+ console.error(`[context-handover-trigger] session ${input.sessionId}: enqueue FAILED — will retry next check: ${err}`);
12760
+ return {
12761
+ attempted: [],
12762
+ confirmed: confirmedThisCheck
12763
+ };
12764
+ }
12765
+ state = {
12766
+ ...state,
12767
+ pending: {
12768
+ thresholds: newlyCrossed,
12769
+ enqueuedAt: new Date(deps.now()).toISOString()
12770
+ }
12771
+ };
12772
+ deps.saveState(input.sessionId, state);
12773
+ console.error(`[context-handover-trigger] session ${input.sessionId}: enqueued [${newlyCrossed.join(", ")}] at ${reading.usedTokens} tokens (trigger=${thresholds.effectiveTriggerTokens}, triggerSource=${thresholds.triggerSource}, autocompactPct=${thresholds.autocompactPct}, urgent=${urgent}) — awaiting outcome confirmation before marking done.`);
12774
+ return {
12775
+ attempted: newlyCrossed,
12776
+ confirmed: confirmedThisCheck
12777
+ };
11961
12778
  }
11962
12779
 
11963
12780
  //#endregion
@@ -11990,17 +12807,28 @@ function touchSentinel(rootPath) {
11990
12807
  writeFileSync(sentinelPath(rootPath), String(Date.now()), "utf8");
11991
12808
  } catch {}
11992
12809
  }
11993
- function cmdAutosave(db, opts) {
12810
+ async function cmdAutosave(db, opts) {
11994
12811
  const minGap = parseInt(opts.minGap ?? String(DEFAULT_MIN_GAP), 10);
11995
12812
  const cwd = process.cwd();
11996
12813
  const project = resolveProjectByCwd(db, cwd);
11997
- if (!project) process.exit(0);
11998
- if (!opts.dryRun && tooRecent(project.root_path, minGap)) process.exit(0);
12814
+ if (!project) return;
12815
+ if (!opts.dryRun && tooRecent(project.root_path, minGap)) return;
12816
+ const transcriptPaths = findTranscripts(resolveTranscriptDir(project), opts.sessionId);
12817
+ if (opts.sessionId && !opts.dryRun) {
12818
+ const liveTranscript = transcriptPaths[transcriptPaths.length - 1];
12819
+ try {
12820
+ await checkAndEnqueueContextHandover({
12821
+ sessionId: opts.sessionId,
12822
+ cwd,
12823
+ transcriptPath: liveTranscript
12824
+ });
12825
+ } catch {}
12826
+ }
11999
12827
  const body = buildAutosaveBody({
12000
12828
  cwd,
12001
- transcriptPaths: findTranscripts(resolveTranscriptDir(project), opts.sessionId)
12829
+ transcriptPaths
12002
12830
  });
12003
- if (!body) process.exit(0);
12831
+ if (!body) return;
12004
12832
  const session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
12005
12833
  const result = applyContinue({
12006
12834
  rootPath: project.root_path,
@@ -12014,10 +12842,9 @@ function cmdAutosave(db, opts) {
12014
12842
  if (opts.dryRun) {
12015
12843
  console.log(result.block);
12016
12844
  console.log(`\n[dry-run] action would be: ${result.action}`);
12017
- process.exit(0);
12845
+ return;
12018
12846
  }
12019
12847
  touchSentinel(project.root_path);
12020
- process.exit(0);
12021
12848
  }
12022
12849
 
12023
12850
  //#endregion
@@ -12154,7 +12981,8 @@ function cmdGoto(db, query, opts) {
12154
12981
  let msg = String(resolveErr).replace(/^Error: /, "");
12155
12982
  if (msg.includes("No session found matching")) msg += `\n\nTip: pai find "${query}" — search prompt history by keywords`;
12156
12983
  console.error(err(msg));
12157
- process.exit(1);
12984
+ process.exitCode = 1;
12985
+ return;
12158
12986
  }
12159
12987
  const { session: matchedSession, friendlyName } = resolved;
12160
12988
  let resumableUuid;
@@ -12176,7 +13004,8 @@ function cmdGoto(db, query, opts) {
12176
13004
  projectDir = realpathSync(rawDir);
12177
13005
  } catch {
12178
13006
  console.error(err(`session "${query}": directory does not exist or cannot be resolved.\n Registry says: ${rawDir}\n The directory may have moved or been deleted.`));
12179
- process.exit(1);
13007
+ process.exitCode = 1;
13008
+ return;
12180
13009
  }
12181
13010
  const name = friendlyName ?? query;
12182
13011
  const promptArg = `/Name ${name}\ngo`;
@@ -12220,10 +13049,12 @@ function cmdGoto(db, query, opts) {
12220
13049
  });
12221
13050
  if (result.error) {
12222
13051
  console.error(err(`Failed to launch claude: ${result.error.message}`));
12223
- process.exit(1);
13052
+ process.exitCode = 1;
13053
+ return;
12224
13054
  }
12225
13055
  printExitDir(projectDir);
12226
- process.exit(result.status ?? 0);
13056
+ process.exitCode = result.status ?? 0;
13057
+ return;
12227
13058
  } else {
12228
13059
  process.stderr.write(chalk.yellow(`\n Resume failed for ${resumableUuid.slice(0, 8)}: ${probe.reason ?? "unknown error"}\n Starting fresh session in same directory.\n\n`));
12229
13060
  const result = spawnSync("claude", [
@@ -12237,10 +13068,12 @@ function cmdGoto(db, query, opts) {
12237
13068
  });
12238
13069
  if (result.error) {
12239
13070
  console.error(err(`Failed to launch claude: ${result.error.message}`));
12240
- process.exit(1);
13071
+ process.exitCode = 1;
13072
+ return;
12241
13073
  }
12242
13074
  printExitDir(projectDir);
12243
- process.exit(result.status ?? 0);
13075
+ process.exitCode = result.status ?? 0;
13076
+ return;
12244
13077
  }
12245
13078
  } else {
12246
13079
  const result = spawnSync("claude", [
@@ -12254,10 +13087,11 @@ function cmdGoto(db, query, opts) {
12254
13087
  });
12255
13088
  if (result.error) {
12256
13089
  console.error(err(`Failed to launch claude: ${result.error.message}`));
12257
- process.exit(1);
13090
+ process.exitCode = 1;
13091
+ return;
12258
13092
  }
12259
13093
  printExitDir(projectDir);
12260
- process.exit(result.status ?? 0);
13094
+ process.exitCode = result.status ?? 0;
12261
13095
  }
12262
13096
  }
12263
13097
 
@@ -12505,8 +13339,8 @@ function registerSessionCommands(sessionCmd, getDb) {
12505
13339
  sessionCmd.command("checkpoint <message>").description("Append a timestamped checkpoint to the active session note.\nDesigned for hooks (PostToolUse, UserPromptSubmit) — fast and silent.\nRate-limited: skips silently if last checkpoint was < --min-gap seconds ago.").option("--min-gap <seconds>", "Minimum seconds between checkpoints (default: 300 = 5 minutes)", "300").action((message, opts) => {
12506
13340
  cmdCheckpoint(message, opts);
12507
13341
  });
12508
- sessionCmd.command("autosave").description("Refresh the ## Continue checkpoint from the transcript and working tree.\nRuns unattended from live hooks so an interrupted session still leaves\na usable handover. Writes in auto mode, so a model-authored checkpoint\nfor the same session is preserved untouched. Silent; always exits 0.").option("--session-id <uuid>", "Claude session UUID — the key that decides whether an existing authored\ncheckpoint belongs to this session and must be left alone.").option("--min-gap <seconds>", "Minimum seconds between autosaves, shared across all triggers (default: 240)", "240").option("--dry-run", "Print the block that would be written and exit").action((opts) => {
12509
- cmdAutosave(getDb(), opts);
13342
+ sessionCmd.command("autosave").description("Refresh the ## Continue checkpoint from the transcript and working tree.\nRuns unattended from live hooks so an interrupted session still leaves\na usable handover. Writes in auto mode, so a model-authored checkpoint\nfor the same session is preserved untouched. Silent; always exits 0.").option("--session-id <uuid>", "Claude session UUID — the key that decides whether an existing authored\ncheckpoint belongs to this session and must be left alone.").option("--min-gap <seconds>", "Minimum seconds between autosaves, shared across all triggers (default: 240)", "240").option("--dry-run", "Print the block that would be written and exit").action(async (opts) => {
13343
+ await cmdAutosave(getDb(), opts);
12510
13344
  });
12511
13345
  sessionCmd.command("active").description("Show currently active Claude Code sessions.\nDetects live sessions by checking which JSONL transcript files\nwere recently modified in ~/.claude/projects/.").option("--minutes <n>", "Consider sessions active if modified within N minutes (default: 60)", "60").option("--json", "Output raw JSON instead of formatted display").action((opts) => {
12512
13346
  cmdActive(getDb(), opts);
@@ -12900,8 +13734,8 @@ function analyzeProject(db, project) {
12900
13734
  async function countVectorDbPaths(oldPaths) {
12901
13735
  if (oldPaths.length === 0) return 0;
12902
13736
  try {
12903
- const { loadConfig } = await import("./config-BSkVcvfq.mjs").then((n) => n.r);
12904
- const { PostgresBackend } = await import("./postgres-CI3FjGq0.mjs");
13737
+ const { loadConfig } = await import("./config-C_ErGddD.mjs");
13738
+ const { PostgresBackend } = await import("./postgres-BVme6qX0.mjs");
12905
13739
  const config = loadConfig();
12906
13740
  if (config.storageBackend !== "postgres") return 0;
12907
13741
  const pgBackend = new PostgresBackend(config.postgres ?? {});
@@ -12921,8 +13755,8 @@ async function countVectorDbPaths(oldPaths) {
12921
13755
  async function updateVectorDbPaths(moves) {
12922
13756
  if (moves.length === 0) return 0;
12923
13757
  try {
12924
- const { loadConfig } = await import("./config-BSkVcvfq.mjs").then((n) => n.r);
12925
- const { PostgresBackend } = await import("./postgres-CI3FjGq0.mjs");
13758
+ const { loadConfig } = await import("./config-C_ErGddD.mjs");
13759
+ const { PostgresBackend } = await import("./postgres-BVme6qX0.mjs");
12926
13760
  const config = loadConfig();
12927
13761
  if (config.storageBackend !== "postgres") return 0;
12928
13762
  const pgBackend = new PostgresBackend(config.postgres ?? {});
@@ -13228,7 +14062,8 @@ function registerSessionCleanupCommand(sessionCmd, getDb) {
13228
14062
  const p = getProject(db, projectSlug);
13229
14063
  if (!p) {
13230
14064
  console.error(err(`Project not found: ${projectSlug}`));
13231
- process.exit(1);
14065
+ process.exitCode = 1;
14066
+ return;
13232
14067
  }
13233
14068
  projects = [p];
13234
14069
  } else projects = getAllProjects(db);
@@ -14204,5 +15039,245 @@ async function cmdPick(db, opts = {}) {
14204
15039
  }
14205
15040
 
14206
15041
  //#endregion
14207
- 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 };
14208
- //# sourceMappingURL=pick-DatgS3Nk.mjs.map
15042
+ //#region src/cli/program.ts
15043
+ /**
15044
+ * PAI CLI — Commander program builder.
15045
+ *
15046
+ * Constructs the full `pai` command tree WITHOUT parsing argv. This separation
15047
+ * lets the documentation generator (scripts/build-docs.mjs) import and introspect
15048
+ * the live program object — the single source of truth for `--help`, the
15049
+ * generated man pages, and `pai help <area>`.
15050
+ *
15051
+ * The thin entry point (cli/index.ts) imports buildProgram() and calls parse().
15052
+ */
15053
+ function getVersion() {
15054
+ try {
15055
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json");
15056
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
15057
+ } catch {
15058
+ return "0.0.0";
15059
+ }
15060
+ }
15061
+ let _db = null;
15062
+ function getDb() {
15063
+ if (!_db) try {
15064
+ _db = openRegistry();
15065
+ } catch (e) {
15066
+ throw new Error(`Failed to open PAI registry: ${e}`);
15067
+ }
15068
+ return _db;
15069
+ }
15070
+ /**
15071
+ * Build and return the fully configured `pai` Commander program.
15072
+ * Does NOT call parse — the caller (cli/index.ts) does that.
15073
+ */
15074
+ function buildProgram() {
15075
+ const program = new Command();
15076
+ program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit").addHelpText("after", `
15077
+ Daily commands:
15078
+ pai Show your work: live tabs + recent sessions + recent projects
15079
+ pai <name> Switch / resume / fresh — universal
15080
+ pai pause [all] Pause current (or all live) sessions
15081
+ pai end Finalize current session
15082
+
15083
+ Subcommands (rarely needed):
15084
+ pai projects ... Project registry management (cd, rebind, info, ...)
15085
+ pai registry ... Registry maintenance (scan, ...)
15086
+ pai memory ... Memory engine (index, search, ...)
15087
+ pai shell-init Shell integration (eval "$(pai shell-init)")
15088
+
15089
+ Recovery:
15090
+ pai clear-names Wipe corrupt iTerm tab-name store
15091
+
15092
+ Examples:
15093
+ pai aibroker Switch to the live AIBroker tab in iTerm
15094
+ pai mdf Find sessions where you worked on MDF
15095
+ pai 81c5c3dc Resume session by UUID prefix
15096
+ pai --all Show everything including cold and zero-session projects`);
15097
+ registerProjectsCommands(program.command("projects").description("Manage registered projects (list, cd, add, info, ...)"), getDb);
15098
+ registerProjectsCommands(program.command("project").description("Alias for `pai projects`"), getDb);
15099
+ const sessionCmd = program.command("session").description("Session management: list, info, checkpoint, handover, cleanup, slug, tag, route.\nShort forms: `pai pause`, `pai end`, `pai resume <name>`.");
15100
+ registerSessionCommands(sessionCmd, getDb);
15101
+ registerSessionCleanupCommand(sessionCmd, getDb);
15102
+ program.command("sessions").description("Alias for `pai` — show the unified deduped listing.").option("--all", "Show all entries including cold / zero-session / archived projects").option("-n, --n <count>", "Max candidates for history search", "20").action(async (opts) => {
15103
+ await cmdMain(getDb(), void 0, void 0, opts);
15104
+ });
15105
+ registerRegistryCommands(program.command("registry").description("Registry maintenance: scan, migrate, stats, rebuild"), getDb);
15106
+ registerMemoryCommands(program.command("memory").description("Memory engine: index, search, and status"), getDb);
15107
+ registerIdentityCommands(program.command("identity").description("Declare who you are: self addresses and where mail is delivered"));
15108
+ registerMcpCommands(program.command("mcp").description("MCP server management: install and status"));
15109
+ registerDaemonCommands(program.command("daemon").description("PAI daemon management: serve, status, restart, install, uninstall, logs"));
15110
+ registerBackupCommands(program);
15111
+ registerRestoreCommands(program);
15112
+ registerSetupCommand(program);
15113
+ registerUpdateCommand(program);
15114
+ registerNotifyCommands(program.command("notify").description("Notification config: status, get, set, test, send"));
15115
+ registerTaskCommands(program.command("task").alias("tasks").description("Task bus: list, add, dispatch, and complete cross-session work"));
15116
+ registerTopicCommands(program.command("topic").description("Topic shift detection: check whether context has drifted to a different project"));
15117
+ registerKgCommands(program.command("kg").description("Temporal knowledge graph: backfill, query, list, stats"));
15118
+ registerDbCommands(program.command("db").description("Database inspection: query, tables, schema (sqlite or postgres)"));
15119
+ registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
15120
+ registerZettelCommands(program.command("zettel").description("Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest"), getDb);
15121
+ registerObservationCommands(program.command("observation").description("Observation capture: list, search, and stats"));
15122
+ registerSkillCommands(program.command("skill").description("Skill telemetry and (future) discovery for the self-educating skill system"));
15123
+ registerHelpCommand(program);
15124
+ program.command("pause [target]").description("Save state and display safe-exit instructions for the current session.\nWrites a ## Continue checkpoint to the project's TODO.md.\nUse `pai pause all` to pause every live session via AIBroker.").option("--dry-run", "Preview changes without writing them").option("--body-file <path>", "File holding the checkpoint markdown to persist ('-' reads stdin). Required unless --no-body.").option("--session-id <uuid>", "Claude Code session UUID — recorded as the `claude --resume` handle").option("--no-body", "Deliberately write a metadata-only checkpoint (no content)").option("--exit", "(pause all only) Also send /exit to each session after pausing").option("--only <name>", "(pause all only) Pause just the sessions whose name or id contains <name>.\nFor rehearsing the real thing against one session before trusting it with\nevery open tab — a dry run exercises enumeration but never sends anything,\nwhich is exactly how a broken send survived until it was needed.").option("--wait <ms>", "(pause all only) Max milliseconds to wait for a session to finish its\ncheckpoint before /exit. Not a fixed delay — sessions are exited as soon\nas they return to the prompt, and any still busy at the deadline are left\nopen rather than killed mid-write. (default: 180000)", "180000").action(async (target, opts) => {
15125
+ if (target === "all") await cmdPauseAll({
15126
+ exit: opts.exit,
15127
+ dryRun: opts.dryRun,
15128
+ only: opts.only,
15129
+ wait: opts.wait !== void 0 ? parseInt(opts.wait, 10) : void 0
15130
+ });
15131
+ else {
15132
+ if (target !== void 0) {
15133
+ console.error(err(`Unknown target: ${target}. Did you mean 'pai pause all'?`));
15134
+ process.exitCode = 1;
15135
+ return;
15136
+ }
15137
+ cmdPause(getDb(), {
15138
+ dryRun: opts.dryRun,
15139
+ bodyFile: opts.bodyFile,
15140
+ sessionId: opts.sessionId,
15141
+ noBody: opts.body === false
15142
+ });
15143
+ }
15144
+ });
15145
+ program.command("end").description("Finalize a session: save state, mark note Completed, display safe-exit instructions.").option("--dry-run", "Preview all changes without writing them").option("--body-file <path>", "File holding the checkpoint markdown to persist ('-' reads stdin). Required unless --no-body.").option("--session-id <uuid>", "Claude Code session UUID — recorded as the `claude --resume` handle").option("--no-body", "Deliberately write a metadata-only checkpoint (no content)").action((opts) => {
15146
+ cmdEnd(getDb(), {
15147
+ dryRun: opts.dryRun,
15148
+ bodyFile: opts.bodyFile,
15149
+ sessionId: opts.sessionId,
15150
+ noBody: opts.body === false
15151
+ });
15152
+ });
15153
+ program.command("clear-names").description("Recovery: wipe corrupted iTerm2 session name state.\nClears ~/.aibroker/session-names.json AND user.paiName from all live iTerm2 sessions.\nAfter running this, use /Name to re-label each tab.").option("--dry-run", "Preview what would be cleared without making changes").action(async (opts) => {
15154
+ await cmdClearNames(opts);
15155
+ });
15156
+ program.command("resume <name>", { hidden: true }).description("Go to a session by name or UUID (prefer: pai <name>)").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
15157
+ cmdGoto(getDb(), name, { dryRun: opts.dryRun });
15158
+ });
15159
+ program.command("cd <identifier>", { hidden: true }).description("cd to a project directory (shell wrapper handles the actual cd)").action((identifier) => {
15160
+ const db = getDb();
15161
+ const project = resolveIdentifier(db, identifier);
15162
+ if (!project) {
15163
+ console.error(`Project not found: ${identifier}`);
15164
+ process.exitCode = 1;
15165
+ return;
15166
+ }
15167
+ if (existsSync(project.root_path)) {
15168
+ process.stdout.write(project.root_path + "\n");
15169
+ return;
15170
+ }
15171
+ process.stderr.write(warn$1(`Path not found: ${project.root_path}\n`) + dim$1(" Searching for moved location...\n"));
15172
+ const result = findMovedPath(project.root_path);
15173
+ if (result.found) {
15174
+ const newPath = result.found;
15175
+ const newEncoded = encodeDir(newPath);
15176
+ const ts = now();
15177
+ db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
15178
+ process.stderr.write(ok$1(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim$1(` → ${newPath}\n`) + ok$1("Registry updated.\n"));
15179
+ process.stdout.write(newPath + "\n");
15180
+ return;
15181
+ }
15182
+ if (result.ambiguous) {
15183
+ process.stderr.write(warn$1(`Multiple directories named "${basename(project.root_path)}" found:\n`));
15184
+ for (const candidate of result.ambiguous) process.stderr.write(dim$1(` ${candidate}\n`));
15185
+ process.stderr.write(dim$1(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
15186
+ process.exitCode = 1;
15187
+ return;
15188
+ }
15189
+ process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim$1(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
15190
+ process.exitCode = 1;
15191
+ });
15192
+ program.command("notes [project-slug]", { hidden: true }).description("Markdown session notes — list notes, optionally filtered to a project.").option("--limit <n>", "Maximum number of notes to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
15193
+ cmdList(getDb(), projectSlug, opts);
15194
+ }).command("list [project-slug]").description("List markdown session notes (same as: pai notes)").option("--limit <n>", "Maximum number of notes to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
15195
+ cmdList(getDb(), projectSlug, opts);
15196
+ });
15197
+ program.command("find <query>", { hidden: true }).description("Search prompt history for matching sessions.").option("-n, --n <count>", "Maximum number of sessions to show", "20").option("--json", "Output as JSON array").action(async (query, opts) => {
15198
+ await cmdFind(query, opts);
15199
+ });
15200
+ program.command("shell-init").description("Emit shell integration code. Add to ~/.zshrc: eval \"$(pai shell-init)\"").action(() => {
15201
+ process.stdout.write(`# PAI shell integration — generated by: pai shell-init
15202
+ pai() {
15203
+ if [[ "$1" == "cd" ]]; then
15204
+ local dir=$(command pai cd "$2" 2>/dev/null)
15205
+ if [[ -n "$dir" && -d "$dir" ]]; then
15206
+ builtin cd "$dir"
15207
+ echo "-> $dir"
15208
+ else
15209
+ echo "Project not found: $2" >&2
15210
+ return 1
15211
+ fi
15212
+ elif [[ "$1" == "projects" && "$2" == "cd" ]]; then
15213
+ local dir=$(command pai projects cd "$3" 2>/dev/null)
15214
+ if [[ -n "$dir" && -d "$dir" ]]; then
15215
+ builtin cd "$dir"
15216
+ echo "-> $dir"
15217
+ else
15218
+ echo "Project not found: $3" >&2
15219
+ return 1
15220
+ fi
15221
+ elif [[ $# -eq 0 ]]; then
15222
+ # Bare \`pai\` → interactive picker. fzf draws on /dev/tty; the picker writes
15223
+ # a chosen directory to PAI_PICK_OUT when you press Ctrl-G (cd only), which we
15224
+ # then cd into here (a child process can't change this shell's cwd itself).
15225
+ local __pai_f
15226
+ __pai_f=$(mktemp -t pai-pick 2>/dev/null) || { command pai; return $?; }
15227
+ PAI_PICK_OUT="$__pai_f" command pai
15228
+ local __pai_rc=$?
15229
+ local __pai_dir=""
15230
+ [[ -s "$__pai_f" ]] && __pai_dir=$(cat "$__pai_f")
15231
+ command rm -f "$__pai_f"
15232
+ if [[ -n "$__pai_dir" && -d "$__pai_dir" ]]; then
15233
+ builtin cd "$__pai_dir"
15234
+ echo "-> $__pai_dir"
15235
+ fi
15236
+ return $__pai_rc
15237
+ else
15238
+ command pai "$@"
15239
+ fi
15240
+ }
15241
+
15242
+ # Print the working directory after an interactive Claude Code session exits.
15243
+ # A SessionEnd hook can't do this reliably: Claude Code does not fire SessionEnd
15244
+ # on /exit (anthropics/claude-code#17885) and may reap the hook mid-run (#41577).
15245
+ # Running it from the shell, after \`claude\` returns, sidesteps both.
15246
+ claude() {
15247
+ local __pai_skip=0 __pai_arg
15248
+ for __pai_arg in "$@"; do
15249
+ case "$__pai_arg" in
15250
+ -p|--print) __pai_skip=1 ;;
15251
+ esac
15252
+ done
15253
+ command claude "$@"
15254
+ local __pai_rc=$?
15255
+ if [[ -t 1 && $__pai_skip -eq 0 ]]; then
15256
+ printf '\\n\\033[2m📂 Working directory:\\033[0m %s\\n\\033[2m cd "%s"\\033[0m\\n' "$PWD" "$PWD"
15257
+ fi
15258
+ return $__pai_rc
15259
+ }
15260
+ `);
15261
+ });
15262
+ program.command("query [query] [pick]", {
15263
+ isDefault: true,
15264
+ hidden: true
15265
+ }).description("Find and launch a session by topic, name, or UUID.\nNo arg → deduped session listing\nName → switch live tab, or resume, or start fresh\nUUID → direct resume via filesystem scan\nTopic → history search → candidate list → pick #").option("-y, --auto", "Auto-pick #1 without prompting").option("--dry-run", "Print what would happen without launching").option("-n, --n <count>", "Max candidates for history search", "20").option("--all", "Show all entries including cold / zero-session / archived projects").option("--list", "Skip the interactive picker; print the static session listing").action(async (query, pick, opts) => {
15266
+ if (query === void 0 && !opts.list) {
15267
+ await cmdPick(getDb(), {
15268
+ all: opts.all,
15269
+ dryRun: opts.dryRun
15270
+ });
15271
+ return;
15272
+ }
15273
+ const pickN = pick !== void 0 ? parseInt(pick, 10) : void 0;
15274
+ await cmdMain(getDb(), query, pickN, opts);
15275
+ });
15276
+ program.configureOutput({ writeErr: (str) => process.stderr.write(err(str)) });
15277
+ program.exitOverride();
15278
+ return program;
15279
+ }
15280
+
15281
+ //#endregion
15282
+ export { drainStdio as n, buildProgram as t };
15283
+ //# sourceMappingURL=program-C-fUghPv.mjs.map