@tekmidian/pai 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cli/index.mjs +3 -3
  2. package/dist/cli/program.mjs +3 -3
  3. package/dist/daemon/index.mjs +3 -3
  4. package/dist/{daemon-mFGnPd8q.mjs → daemon-iNuNMMKd.mjs} +5 -5
  5. package/dist/{daemon-mFGnPd8q.mjs.map → daemon-iNuNMMKd.mjs.map} +1 -1
  6. package/dist/{factory-BqAdO21B.mjs → factory-BydJSrZJ.mjs} +13 -2
  7. package/dist/factory-BydJSrZJ.mjs.map +1 -0
  8. package/dist/hooks/capture-all-events.mjs.map +1 -1
  9. package/dist/hooks/cleanup-session-files.mjs +21 -15
  10. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  11. package/dist/hooks/context-compression-hook.mjs +6 -6
  12. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  13. package/dist/hooks/initialize-session.mjs.map +1 -1
  14. package/dist/hooks/inject-observations.mjs.map +1 -1
  15. package/dist/hooks/load-core-context.mjs.map +1 -1
  16. package/dist/hooks/load-project-context.mjs +43 -25
  17. package/dist/hooks/load-project-context.mjs.map +3 -3
  18. package/dist/hooks/observe.mjs.map +1 -1
  19. package/dist/hooks/stop-hook.mjs +27 -21
  20. package/dist/hooks/stop-hook.mjs.map +3 -3
  21. package/dist/hooks/sync-todo-to-md.mjs +2 -2
  22. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  23. package/dist/{main-resolver-BfSL8zio.mjs → main-resolver-DjyUDJrv.mjs} +424 -58
  24. package/dist/main-resolver-DjyUDJrv.mjs.map +1 -0
  25. package/dist/{pick-BcmBBfOF.mjs → pick-Bmz718uo.mjs} +318 -228
  26. package/dist/pick-Bmz718uo.mjs.map +1 -0
  27. package/dist/{work-queue-worker-B8QUONlK.mjs → work-queue-worker-BAgwmMDl.mjs} +55 -14
  28. package/dist/work-queue-worker-BAgwmMDl.mjs.map +1 -0
  29. package/docs/commands/README.md +1 -0
  30. package/docs/commands/session.md +17 -0
  31. package/package.json +1 -1
  32. package/src/hooks/ts/lib/project-utils/index.ts +1 -1
  33. package/src/hooks/ts/lib/project-utils/paths.test.ts +125 -0
  34. package/src/hooks/ts/lib/project-utils/paths.ts +68 -14
  35. package/src/hooks/ts/lib/project-utils.ts +1 -1
  36. package/src/hooks/ts/session-start/load-project-context.ts +34 -31
  37. package/src/hooks/ts/stop/stop-hook.ts +5 -5
  38. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +19 -6
  39. package/dist/factory-BqAdO21B.mjs.map +0 -1
  40. package/dist/main-resolver-BfSL8zio.mjs.map +0 -1
  41. package/dist/pick-BcmBBfOF.mjs.map +0 -1
  42. package/dist/work-queue-worker-B8QUONlK.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { n as openRegistry } from "./db-BtuN768f.mjs";
2
- import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, i as err, l as renderTable, m as slugify, n as dim$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
2
+ import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, h as smartDecodeDir, i as err, l as renderTable, m as slugify, n as dim$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
3
3
  import { a as slugify$1, i as parseSessionFilename } from "./migrate-fLD6rAdO.mjs";
4
4
  import { n as ensurePaiMarker } from "./pai-marker-B20KqhA8.mjs";
5
5
  import { n as openFederation } from "./db-CYmBWcjh.mjs";
@@ -11,9 +11,9 @@ import { _ as transcriptFiles, a as readBodyFile, c as loadScanConfig, d as save
11
11
  import { a as schedulerLogPath, i as paiSocketPath, n as daemonLogPath, r as daemonPidPath } from "./runtime-paths-B0P1TvUr.mjs";
12
12
  import { t as PaiClient } from "./ipc-client-aVKVERjJ.mjs";
13
13
  import { a as expandHome, c as UNROUTED, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, s as OWNER_LABEL_PREFIX, t as CONFIG_DIR } from "./config-CcdkNSWa.mjs";
14
- import { t as createStorageBackend } from "./factory-BqAdO21B.mjs";
14
+ import { i as humanDuration, t as createStorageBackend } from "./factory-BydJSrZJ.mjs";
15
15
  import { s as kgQuery } from "./kg-entity-r8duqhi9.mjs";
16
- import { a as renderDedupedSessions, c as fetchLiveSessions, d as fmtAge, f as resolveSessionByNameOrId, i as normalizeName$2, l as revealItermSession, o as printExitDir, p as scanSessions, r as buildDeduped, s as callAiBroker, u as sendToSession } from "./main-resolver-BfSL8zio.mjs";
16
+ import { _ as scanSessions, a as renderDedupedSessions, c as probeResume, d as callAiBroker, f as fetchLiveSessions, g as resolveSessionByNameOrId, h as fmtAge, i as normalizeName$2, l as restoreTopLevel, m as sendToSession, o as hasConversation, p as revealItermSession, r as buildDeduped, s as launchInDir, u as printExitDir } from "./main-resolver-DjyUDJrv.mjs";
17
17
  import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
18
18
  import { homedir, platform, tmpdir } from "node:os";
19
19
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -1303,7 +1303,7 @@ function cmdHealth$1(db, opts) {
1303
1303
  //#region src/cli/commands/project/projects-index.ts
1304
1304
  function registerProjectsCommands(projectsCmd, getDb) {
1305
1305
  projectsCmd.command("list", { isDefault: true }).description("Show the unified deduped listing (same as `pai`).\nUse --all to include cold / zero-session / archived projects.").option("--all", "Show all entries including cold / zero-session / archived projects").option("-n, --n <count>", "Max rows to show", "20").action(async (opts) => {
1306
- const { cmdMain } = await import("./main-resolver-BfSL8zio.mjs").then((n) => n.n);
1306
+ const { cmdMain } = await import("./main-resolver-DjyUDJrv.mjs").then((n) => n.n);
1307
1307
  await cmdMain(getDb(), void 0, void 0, opts);
1308
1308
  });
1309
1309
  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) => {
@@ -2684,6 +2684,65 @@ function registerMcpCommands(mcpCmd) {
2684
2684
  });
2685
2685
  }
2686
2686
 
2687
+ //#endregion
2688
+ //#region src/cli/commands/daemon-status.ts
2689
+ /**
2690
+ * Health lines to print above the index/DB figures, worst first.
2691
+ *
2692
+ * Empty when there is nothing wrong: a healthy daemon should not grow a
2693
+ * paragraph of reassurance, or the one time it says something real gets read as
2694
+ * more of the same.
2695
+ */
2696
+ function formatStorageHealth(s, now = Date.now()) {
2697
+ const lines = [];
2698
+ const outage = s.backendOutage ?? null;
2699
+ if (outage) {
2700
+ lines.push({
2701
+ severity: "error",
2702
+ text: `BACKEND DOWN: ${outage.backend} unreachable for ${humanDuration(now - outage.since)}`
2703
+ });
2704
+ lines.push({
2705
+ severity: "warn",
2706
+ text: `${outage.attempts} attempts — ${outage.lastError}`
2707
+ });
2708
+ }
2709
+ const queue = queueLine(s.workQueue, outage !== null);
2710
+ if (queue) lines.push(queue);
2711
+ if (outage) lines.push({
2712
+ severity: "info",
2713
+ text: "Indexing, session notes and the work queue are stalled until it returns."
2714
+ });
2715
+ return lines;
2716
+ }
2717
+ /**
2718
+ * The queue depth, when it means something.
2719
+ *
2720
+ * During an outage it always means something — including a depth of zero, which
2721
+ * says the backlog is not the reason nothing is happening. Otherwise it is only
2722
+ * worth a line when work is actually sitting there: a queue that is empty
2723
+ * because everything drained is the normal case, and printing it every time
2724
+ * teaches the reader to skip the line.
2725
+ *
2726
+ * `failed` is reported whenever it is non-zero, outage or not. Items that
2727
+ * exhausted their retries are the silent-loss case this whole status block
2728
+ * exists for — they will never be retried and nothing else surfaces them.
2729
+ */
2730
+ function queueLine(q, duringOutage) {
2731
+ if (!q) return null;
2732
+ const pending = q.pending ?? 0;
2733
+ const processing = q.processing ?? 0;
2734
+ const failed = q.failed ?? 0;
2735
+ const waiting = pending + processing;
2736
+ if (!duringOutage && waiting === 0 && failed === 0) return null;
2737
+ const parts = [`${pending} pending`];
2738
+ if (processing > 0) parts.push(`${processing} processing`);
2739
+ if (failed > 0) parts.push(`${failed} failed`);
2740
+ return {
2741
+ severity: failed > 0 ? "error" : duringOutage || waiting > 0 ? "warn" : "info",
2742
+ text: `Work queue: ${parts.join(", ")}`
2743
+ };
2744
+ }
2745
+
2687
2746
  //#endregion
2688
2747
  //#region src/cli/commands/daemon.ts
2689
2748
  const HOME$2 = homedir();
@@ -2755,14 +2814,13 @@ async function cmdStatus$2() {
2755
2814
  console.log(ok$1(` Daemon running`));
2756
2815
  console.log(dim$1(` Uptime: ${s["uptime"]}s`));
2757
2816
  console.log(dim$1(` Socket: ${s["socketPath"]}`));
2758
- const outage = s["backendOutage"];
2759
- if (outage) {
2760
- const mins = Math.max(1, Math.round((Date.now() - outage.since) / 6e4));
2761
- const forHow = mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
2817
+ const health = formatStorageHealth(s);
2818
+ if (health.length > 0) {
2762
2819
  console.log();
2763
- console.log(err(` BACKEND DOWN: ${outage.backend} unreachable for ${forHow}`));
2764
- console.log(warn$1(` ${outage.attempts} attempts ${outage.lastError}`));
2765
- console.log(dim$1(` Indexing, session notes and the work queue are stalled until it returns.`));
2820
+ for (const line of health) {
2821
+ const paint = line.severity === "error" ? err : line.severity === "warn" ? warn$1 : dim$1;
2822
+ console.log(paint(` ${line.text}`));
2823
+ }
2766
2824
  console.log();
2767
2825
  }
2768
2826
  console.log(dim$1(` Index: ${s["indexInProgress"] ? "in progress" : "idle"} (interval: ${s["indexIntervalSecs"]}s)`));
@@ -2946,7 +3004,7 @@ function cmdLogs(opts) {
2946
3004
  }
2947
3005
  function registerDaemonCommands(daemonCmd) {
2948
3006
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
2949
- const { serve } = await import("./daemon-mFGnPd8q.mjs").then((n) => n.t);
3007
+ const { serve } = await import("./daemon-iNuNMMKd.mjs").then((n) => n.t);
2950
3008
  const { loadConfig: lc, ensureConfigDir } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
2951
3009
  ensureConfigDir();
2952
3010
  await serve(lc());
@@ -7983,6 +8041,33 @@ function restoreDueString(recurrence, prevDue) {
7983
8041
  return `${recurrence}${hasTime && !ruleCarriesTime ? ` at ${prevDue.slice(11, 16)}` : ""} starting ${date}`;
7984
8042
  }
7985
8043
  /**
8044
+ * When a restored slot actually falls due — the inverse of `restoreDueString`.
8045
+ *
8046
+ * Callers need this to decide whether putting the slot back is still useful.
8047
+ * Restoring one that has already passed writes a task that is overdue the
8048
+ * instant it lands, and the next tick dispatches it, which advances the date,
8049
+ * which restores it again: an unbounded loop that fires every tick for the rest
8050
+ * of the day. That is not hypothetical — it happened on 2026-08-04 with "every
8051
+ * day at 9am", which re-dispatched roughly every 30 minutes (two 900s ticks)
8052
+ * because the guard compared against the end of the day rather than 09:00.
8053
+ *
8054
+ * Both shapes `restoreDueString` can emit put the time after "at", so one match
8055
+ * covers "every day at 9am starting …" and "every week at 08:00 starting …".
8056
+ * A rule carrying no time at all is a genuine date-only occurrence, and for
8057
+ * those the slot really does run until the end of the day.
8058
+ */
8059
+ function triggeredSlotMs(keep, date) {
8060
+ const at = /\bat\s+(\d{1,2})(?::(\d{2}))?\s*([ap]m)?/i.exec(keep);
8061
+ if (!at) return (/* @__PURE__ */ new Date(`${date}T23:59:59`)).getTime();
8062
+ let hour = Number(at[1]);
8063
+ const suffix = at[3]?.toLowerCase();
8064
+ if (suffix === "pm" && hour < 12) hour += 12;
8065
+ if (suffix === "am" && hour === 12) hour = 0;
8066
+ const hh = String(hour).padStart(2, "0");
8067
+ const mm = String(Number(at[2] ?? 0)).padStart(2, "0");
8068
+ return (/* @__PURE__ */ new Date(`${date}T${hh}:${mm}:00`)).getTime();
8069
+ }
8070
+ /**
7986
8071
  * Expected runtime for a task.
7987
8072
  *
7988
8073
  * The mean rather than the max, deliberately: with a liveness probe available,
@@ -8577,7 +8662,7 @@ async function keepTriggeredSchedule(task, opts, state, now) {
8577
8662
  delete state.triggeredRestore[task.id];
8578
8663
  const date = /starting (\d{4}-\d{2}-\d{2})/.exec(keep)?.[1];
8579
8664
  if (!date || !opts.provider.setDue) return "";
8580
- if ((/* @__PURE__ */ new Date(`${date}T23:59:59`)).getTime() <= now) return ", triggered-run slot already past — schedule left as it is";
8665
+ if (triggeredSlotMs(keep, date) <= now) return ", triggered-run slot already past — schedule left as it is";
8581
8666
  try {
8582
8667
  await opts.provider.setDue(task.id, keep);
8583
8668
  state.lastSeenDue[task.id] = date;
@@ -10683,7 +10768,7 @@ function cmdActive(db, opts) {
10683
10768
  async function cmdAutoRoute(opts) {
10684
10769
  const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("./auto-route-BWGvvpcP.mjs");
10685
10770
  const { openRegistry } = await import("./db-BtuN768f.mjs").then((n) => n.t);
10686
- const { createStorageBackend } = await import("./factory-BqAdO21B.mjs").then((n) => n.n);
10771
+ const { createStorageBackend } = await import("./factory-BydJSrZJ.mjs").then((n) => n.n);
10687
10772
  const { loadConfig } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
10688
10773
  const config = loadConfig();
10689
10774
  const registryDb = openRegistry();
@@ -11453,43 +11538,6 @@ async function cmdRecent(db, opts) {
11453
11538
 
11454
11539
  //#endregion
11455
11540
  //#region src/cli/commands/session/goto.ts
11456
- /**
11457
- * Run claude --resume <uuid> --print --output-format=json "_" in the given cwd.
11458
- * Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
11459
- * in stderr). Timeout: 5 seconds.
11460
- */
11461
- function probeResume$1(uuid, cwd) {
11462
- const result = spawnSync("claude", [
11463
- "--resume",
11464
- uuid,
11465
- "--print",
11466
- "--output-format=json",
11467
- "_"
11468
- ], {
11469
- cwd,
11470
- timeout: 5e3,
11471
- env: process.env,
11472
- stdio: [
11473
- "ignore",
11474
- "ignore",
11475
- "pipe"
11476
- ]
11477
- });
11478
- if (result.error) return {
11479
- ok: false,
11480
- reason: `spawn error: ${result.error.message}`
11481
- };
11482
- const stderr = result.stderr?.toString("utf8") ?? "";
11483
- if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
11484
- ok: false,
11485
- reason: "No conversation found for this UUID"
11486
- };
11487
- if (result.status !== 0) return {
11488
- ok: false,
11489
- reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
11490
- };
11491
- return { ok: true };
11492
- }
11493
11541
  function cmdGoto(db, query, opts) {
11494
11542
  const allSessions = scanSessions(db, {
11495
11543
  limit: 500,
@@ -11553,7 +11601,7 @@ function cmdGoto(db, query, opts) {
11553
11601
  return;
11554
11602
  }
11555
11603
  if (resumableUuid) {
11556
- const probe = probeResume$1(resumableUuid, projectDir);
11604
+ const probe = probeResume(resumableUuid, projectDir);
11557
11605
  if (probe.ok) {
11558
11606
  const result = spawnSync("claude", [
11559
11607
  "--resume",
@@ -11609,12 +11657,229 @@ function cmdGoto(db, query, opts) {
11609
11657
  }
11610
11658
  }
11611
11659
 
11660
+ //#endregion
11661
+ //#region src/cli/commands/session/restore.ts
11662
+ /**
11663
+ * pai session restore — put displaced transcripts back where resume looks.
11664
+ *
11665
+ * PAI moved session transcripts out of `~/.claude/projects/<dir>/` into
11666
+ * `<dir>/sessions/`, and `claude --resume <uuid>` reads only the former. The
11667
+ * archiver no longer does that (it hardlinks), but the sessions already moved
11668
+ * stayed moved — and every checkpoint PAI ever wrote ends with
11669
+ * "Resume with: claude --resume <uuid>". For a displaced session that line is an
11670
+ * instruction that cannot work, and nothing says so.
11671
+ *
11672
+ * One project measured 1 transcript at the project root against 52 underneath.
11673
+ *
11674
+ * So this is a repair, and it is deliberately a COMMAND rather than another hook.
11675
+ * Silently relinking dozens of a user's files during an unrelated session start is
11676
+ * the same class of mistake as silently moving them was. Dry run is the default;
11677
+ * `--execute` is the only thing that touches disk.
11678
+ *
11679
+ * The link logic itself is imported, not rewritten. Today's other bug was a fix
11680
+ * that landed in one of three copies of `probeResume`, so `restoreTopLevel` stays
11681
+ * the single implementation.
11682
+ */
11683
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11684
+ /**
11685
+ * Transcripts that exist under `sessions/` with no twin at the project root.
11686
+ *
11687
+ * A twin means nothing needs doing: the archiver hardlinks now, so the normal
11688
+ * state is both names pointing at one inode. Only the ones missing upstairs were
11689
+ * displaced by the old rename.
11690
+ */
11691
+ function findDisplaced(projectsDir = claudeProjectsDir()) {
11692
+ const out = [];
11693
+ let encodedDirs;
11694
+ try {
11695
+ encodedDirs = readdirSync(projectsDir);
11696
+ } catch {
11697
+ return out;
11698
+ }
11699
+ for (const encodedDir of encodedDirs) {
11700
+ const projectDir = join(projectsDir, encodedDir);
11701
+ const sessionsDir = join(projectDir, "sessions");
11702
+ if (!existsSync(sessionsDir)) continue;
11703
+ let files;
11704
+ try {
11705
+ files = readdirSync(sessionsDir);
11706
+ } catch {
11707
+ continue;
11708
+ }
11709
+ for (const file of files) {
11710
+ if (!file.endsWith(".jsonl")) continue;
11711
+ const uuid = file.slice(0, -6);
11712
+ if (!UUID_RE.test(uuid)) continue;
11713
+ if (existsSync(join(projectDir, file))) continue;
11714
+ let bytes = 0;
11715
+ let mtime = 0;
11716
+ try {
11717
+ const st = statSync(join(sessionsDir, file));
11718
+ bytes = st.size;
11719
+ mtime = st.mtimeMs;
11720
+ } catch {
11721
+ continue;
11722
+ }
11723
+ out.push({
11724
+ uuid,
11725
+ projectDir,
11726
+ encodedDir,
11727
+ cwd: smartDecodeDir(encodedDir),
11728
+ bytes,
11729
+ mtime,
11730
+ promisedBy: [],
11731
+ hasConversation: hasConversation(join(sessionsDir, file))
11732
+ });
11733
+ }
11734
+ }
11735
+ return out.sort((a, b) => b.bytes - a.bytes);
11736
+ }
11737
+ /**
11738
+ * Notes that instruct the user to resume this id.
11739
+ *
11740
+ * Bounded on purpose — the project's own TODO.md and note files, never a search
11741
+ * from the home directory. The point is to name the specific promise each
11742
+ * restore makes true, so a count of files is not enough.
11743
+ */
11744
+ function findResumePromises(uuid, cwd) {
11745
+ if (!cwd) return [];
11746
+ const candidates = [];
11747
+ for (const notesDir of [
11748
+ cwd,
11749
+ join(cwd, "Notes"),
11750
+ join(cwd, "notes")
11751
+ ]) try {
11752
+ for (const entry of readdirSync(notesDir)) if (entry.endsWith(".md")) candidates.push(join(notesDir, entry));
11753
+ } catch {}
11754
+ const seen = /* @__PURE__ */ new Set();
11755
+ const hits = [];
11756
+ for (const file of candidates) {
11757
+ let key;
11758
+ try {
11759
+ const st = statSync(file);
11760
+ key = `${st.dev}:${st.ino}`;
11761
+ } catch {
11762
+ key = file.toLowerCase();
11763
+ }
11764
+ if (seen.has(key)) continue;
11765
+ seen.add(key);
11766
+ try {
11767
+ if (readFileSync(file, "utf8").includes(uuid)) hits.push(file);
11768
+ } catch {}
11769
+ }
11770
+ return hits;
11771
+ }
11772
+ function human(bytes) {
11773
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
11774
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
11775
+ return `${bytes} B`;
11776
+ }
11777
+ /** How many to list before the report stops being readable. */
11778
+ const LIST_LIMIT = 20;
11779
+ /**
11780
+ * Which of the displaced sessions a run is about, and which of those to link.
11781
+ *
11782
+ * Pure, and separate from the printing, for two reasons. The obvious one is that
11783
+ * it can be tested without pointing a test at the developer's real
11784
+ * `~/.claude/projects` — an earlier draft of the test did exactly that and would
11785
+ * have relinked live files. The other is that the report and the action must
11786
+ * agree: `--json` originally restored everything in scope while the formatted
11787
+ * path skipped stubs, so the two modes did different things with the same flags.
11788
+ */
11789
+ function selectTargets(everything, opts) {
11790
+ const inScope = everything.filter((d) => {
11791
+ if (opts.promised && d.promisedBy.length === 0) return false;
11792
+ if (opts.cwd && d.cwd !== opts.cwd) return false;
11793
+ return true;
11794
+ });
11795
+ return {
11796
+ inScope,
11797
+ toRestore: opts.includeStubs ? inScope : inScope.filter((d) => d.hasConversation)
11798
+ };
11799
+ }
11800
+ function cmdRestore(opts = {}) {
11801
+ const everything = findDisplaced();
11802
+ for (const d of everything) d.promisedBy = findResumePromises(d.uuid, d.cwd);
11803
+ const { inScope: displaced, toRestore } = selectTargets(everything, opts);
11804
+ if (opts.json) {
11805
+ console.log(JSON.stringify({
11806
+ displaced,
11807
+ wouldRestore: toRestore.map((d) => d.uuid),
11808
+ executed: Boolean(opts.execute)
11809
+ }, null, 2));
11810
+ if (opts.execute) for (const d of toRestore) restoreTopLevel(d.uuid, d.projectDir);
11811
+ return;
11812
+ }
11813
+ console.log();
11814
+ console.log(bold$1(" Displaced session transcripts"));
11815
+ console.log();
11816
+ if (displaced.length === 0) {
11817
+ if (everything.length > 0) {
11818
+ console.log(ok$1(` Nothing displaced in this scope.`));
11819
+ console.log(dim$1(` ${everything.length} displaced elsewhere — drop the filters to see them.`));
11820
+ } else {
11821
+ console.log(ok$1(" Nothing displaced — every archived transcript has its twin at the"));
11822
+ console.log(dim$1(" project root, which is where claude --resume looks."));
11823
+ }
11824
+ console.log();
11825
+ return;
11826
+ }
11827
+ const promised = displaced.filter((d) => d.promisedBy.length > 0);
11828
+ const totalBytes = displaced.reduce((n, d) => n + d.bytes, 0);
11829
+ console.log(warn$1(` ${displaced.length} transcript(s), ${human(totalBytes)}, currently unresumable.`));
11830
+ if (displaced.length !== everything.length) console.log(dim$1(` (filtered from ${everything.length} machine-wide)`));
11831
+ if (promised.length > 0) console.log(err(` ${promised.length} named in a checkpoint that tells you to resume them — those lines are lies today.`));
11832
+ const stubs = displaced.filter((d) => !d.hasConversation);
11833
+ if (stubs.length > 0) console.log(dim$1(` ${stubs.length} of them hold no conversation at all — restoring those changes nothing.`));
11834
+ console.log();
11835
+ const ordered = [...promised, ...displaced.filter((d) => d.promisedBy.length === 0)];
11836
+ const shown = opts.all ? ordered : ordered.slice(0, LIST_LIMIT);
11837
+ for (const d of shown) {
11838
+ const where = d.cwd ?? dim$1(`(undecodable: ${d.encodedDir})`);
11839
+ const stub = d.hasConversation ? "" : warn$1(" [stub — no conversation]");
11840
+ console.log(` ${bold$1(d.uuid.slice(0, 8))} ${human(d.bytes).padStart(8)} ${where}${stub}`);
11841
+ for (const file of d.promisedBy) console.log(d.hasConversation ? err(` promised by ${file}`) : dim$1(` promised by ${file} (promise cannot be kept: empty transcript)`));
11842
+ }
11843
+ if (shown.length < ordered.length) {
11844
+ console.log();
11845
+ console.log(dim$1(` … and ${ordered.length - shown.length} more. --all lists every one.`));
11846
+ }
11847
+ console.log();
11848
+ if (!opts.execute) {
11849
+ console.log(dim$1(" Dry run — nothing was touched."));
11850
+ console.log(dim$1(" Restore hardlinks each transcript back to the project root: same inode,"));
11851
+ console.log(dim$1(" no copy, and the sessions/ archive is left exactly where it is."));
11852
+ console.log();
11853
+ console.log(dim$1(" --promised --execute just the ones a checkpoint promises (start here)"));
11854
+ console.log(dim$1(" --cwd <path> --execute one project"));
11855
+ console.log(dim$1(" --execute everything in scope"));
11856
+ console.log();
11857
+ return;
11858
+ }
11859
+ const target = toRestore;
11860
+ const skipped = displaced.length - target.length;
11861
+ let restored = 0;
11862
+ const failed = [];
11863
+ for (const d of target) if (restoreTopLevel(d.uuid, d.projectDir)) restored++;
11864
+ else failed.push(d.uuid);
11865
+ console.log(ok$1(` Restored ${restored} of ${target.length} recoverable.`));
11866
+ if (skipped > 0) console.log(dim$1(` Skipped ${skipped} stub(s) — nothing in them to resume. --include-stubs overrides.`));
11867
+ if (failed.length > 0) {
11868
+ console.log(warn$1(` Could not restore ${failed.length}:`));
11869
+ for (const uuid of failed) console.log(dim$1(` ${uuid}`));
11870
+ }
11871
+ console.log();
11872
+ }
11873
+
11612
11874
  //#endregion
11613
11875
  //#region src/cli/commands/session/index.ts
11614
11876
  function registerSessionCommands(sessionCmd, getDb) {
11615
11877
  sessionCmd.command("list").description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions\nDefault shows all sessions you've named or that are resumable.\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--json", "Output raw JSON instead of formatted table").action(async (opts) => {
11616
11878
  await cmdRecent(getDb(), opts);
11617
11879
  });
11880
+ sessionCmd.command("restore").description("Restore transcripts PAI displaced into sessions/ so claude --resume can find them again. Reports what is unresumable, which checkpoints promise it, and which transcripts are empty. Dry run unless --execute is given.").option("--execute", "Actually restore (hardlink back to the project root)").option("--promised", "Only sessions a checkpoint tells you to resume").option("--cwd <path>", "Only sessions belonging to this working directory").option("--all", "List every displaced session, not just the largest few").option("--include-stubs", "Also restore transcripts that hold no conversation").option("--json", "Output raw JSON instead of a formatted report").action((opts) => {
11881
+ cmdRestore(opts);
11882
+ });
11618
11883
  sessionCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
11619
11884
  cmdInfo(getDb(), projectSlug, number);
11620
11885
  });
@@ -12804,181 +13069,6 @@ async function cmdFind(query, opts) {
12804
13069
  console.log();
12805
13070
  }
12806
13071
 
12807
- //#endregion
12808
- //#region src/cli/lib/launch.ts
12809
- /**
12810
- * launch.ts — Launch Claude Code in a directory, in the CURRENT terminal.
12811
- *
12812
- * Shared by the interactive picker (pick.ts). Deliberately does NOT switch
12813
- * iTerm tabs (aibroker_switch): switching jumps the user to a different — and
12814
- * sometimes wrong — terminal, which is confusing. Picking a place should start
12815
- * a session right here, in the chosen directory.
12816
- *
12817
- * Behaviour:
12818
- * resume-or-fresh → if a resumable UUID is given, probe it; on success
12819
- * `claude --resume`, otherwise fall back to a fresh session
12820
- * in the same dir. With no UUID, start fresh.
12821
- *
12822
- * The `claude` child inherits the tty (stdio: "inherit"), so the session runs
12823
- * in the terminal that launched `pai`. On exit we print the working directory.
12824
- */
12825
- /**
12826
- * Does a transcript for this session exist on disk?
12827
- *
12828
- * Returns true/false when it can tell, and null when it cannot — the caller
12829
- * must treat null as "ask something else", never as "no". A path this function
12830
- * fails to recognise would otherwise silently veto a perfectly good resume.
12831
- *
12832
- * Claude Code stores transcripts at ~/.claude/projects/<encoded-cwd>/, where the
12833
- * encoding replaces every non-alphanumeric character with `-`. Finished sessions
12834
- * are moved into a `sessions/` subdirectory by the stop hook, so both are checked.
12835
- */
12836
- function transcriptOnDisk(uuid, cwd) {
12837
- try {
12838
- const encoded = cwd.replace(/[^a-zA-Z0-9]/g, "-");
12839
- const dir = join(homedir(), ".claude", "projects", encoded);
12840
- if (!existsSync(dir)) return null;
12841
- return existsSync(join(dir, `${uuid}.jsonl`)) || existsSync(join(dir, "sessions", `${uuid}.jsonl`));
12842
- } catch {
12843
- return null;
12844
- }
12845
- }
12846
- /**
12847
- * Probe whether a session UUID is resumable from `cwd`.
12848
- *
12849
- * The filesystem is asked first, and usually answers.
12850
- *
12851
- * This used to run `claude --resume <uuid> --print --output-format=json "_"`
12852
- * with a 5s timeout, which is not a probe: it resumes the session AND sends a
12853
- * prompt to the model, then waits for a complete JSON reply. That costs a model
12854
- * round-trip per probe, and 5s is not enough time for one — for a LARGE session
12855
- * least of all, because the transcript has to be loaded first.
12856
- *
12857
- * So the check failed precisely for the sessions most worth resuming, and the
12858
- * caller's fallback quietly started a fresh session in their place. Observed
12859
- * 2026-08-04: `pai Paperfull` reported `spawn error: spawnSync claude ETIMEDOUT`
12860
- * for fb76a6c3 and started over, while
12861
- * `~/.claude/projects/…-Paperfull/sessions/fb76a6c3-….jsonl` sat on disk the
12862
- * whole time.
12863
- *
12864
- * A transcript on disk is what resumable MEANS, and reading a directory entry is
12865
- * free. The spawn remains only for the case the filesystem cannot answer, and
12866
- * now gets a timeout that a real answer can fit inside.
12867
- */
12868
- function probeResume(uuid, cwd) {
12869
- const onDisk = transcriptOnDisk(uuid, cwd);
12870
- if (onDisk === true) return { ok: true };
12871
- if (onDisk === false) return {
12872
- ok: false,
12873
- reason: "No transcript on disk for this UUID"
12874
- };
12875
- const result = spawnSync("claude", [
12876
- "--resume",
12877
- uuid,
12878
- "--print",
12879
- "--output-format=json",
12880
- "_"
12881
- ], {
12882
- cwd,
12883
- timeout: 3e4,
12884
- env: process.env,
12885
- stdio: [
12886
- "ignore",
12887
- "ignore",
12888
- "pipe"
12889
- ]
12890
- });
12891
- if (result.error) return {
12892
- ok: false,
12893
- reason: `spawn error: ${result.error.message}`
12894
- };
12895
- const stderr = result.stderr?.toString("utf8") ?? "";
12896
- if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
12897
- ok: false,
12898
- reason: "No conversation found for this UUID"
12899
- };
12900
- if (result.status !== 0) return {
12901
- ok: false,
12902
- reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
12903
- };
12904
- return { ok: true };
12905
- }
12906
- /**
12907
- * Launch `claude` in `dir` in the current terminal. `name` is used for both the
12908
- * Claude session label (--name) and the /Name slash command (tab/statusline).
12909
- * Never returns on the live path — it exits the process after claude exits.
12910
- */
12911
- function launchInDir(dir, name, opts = {}) {
12912
- let cwd;
12913
- try {
12914
- cwd = realpathSync(dir);
12915
- } catch {
12916
- console.error(err(`Directory does not exist or cannot be resolved:\n ${dir}\n The folder may have moved or been deleted.`));
12917
- process.exit(1);
12918
- return;
12919
- }
12920
- const promptArg = `/Name ${name}\ngo`;
12921
- const wantResume = !opts.forceFresh && !!opts.resumableUuid;
12922
- if (opts.dryRun) {
12923
- if (wantResume) {
12924
- console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
12925
- console.log(` cwd: ${chalk.cyan(cwd)}`);
12926
- console.log(` probe: claude --resume ${opts.resumableUuid} --print --output-format=json "_"`);
12927
- console.log(` argv: claude --resume ${opts.resumableUuid} --name "${name}" "/Name ${name}\\ngo"`);
12928
- console.log(` fallback: claude --name "${name}" "/Name ${name}\\ngo"`);
12929
- } else {
12930
- console.log("\n" + chalk.bold("Dry run — would exec (FRESH path):") + "\n");
12931
- console.log(` cwd: ${chalk.cyan(cwd)}`);
12932
- console.log(` argv: claude --name "${name}" "/Name ${name}\\ngo"`);
12933
- }
12934
- console.log();
12935
- return;
12936
- }
12937
- const fresh = () => {
12938
- const result = spawnSync("claude", [
12939
- "--name",
12940
- name,
12941
- promptArg
12942
- ], {
12943
- cwd,
12944
- stdio: "inherit",
12945
- env: process.env
12946
- });
12947
- if (result.error) {
12948
- console.error(err(`Failed to launch claude: ${result.error.message}`));
12949
- process.exit(1);
12950
- }
12951
- printExitDir(cwd);
12952
- process.exit(result.status ?? 0);
12953
- };
12954
- if (wantResume) {
12955
- const probe = probeResume(opts.resumableUuid, cwd);
12956
- if (probe.ok) {
12957
- const result = spawnSync("claude", [
12958
- "--resume",
12959
- opts.resumableUuid,
12960
- "--name",
12961
- name,
12962
- promptArg
12963
- ], {
12964
- cwd,
12965
- stdio: "inherit",
12966
- env: process.env
12967
- });
12968
- if (result.error) {
12969
- console.error(err(`Failed to launch claude: ${result.error.message}`));
12970
- process.exit(1);
12971
- }
12972
- printExitDir(cwd);
12973
- process.exit(result.status ?? 0);
12974
- }
12975
- process.stderr.write(chalk.yellow(`\n Resume failed for ${opts.resumableUuid.slice(0, 8)}: ${probe.reason ?? "unknown error"}\n Starting fresh session in same directory.\n\n`));
12976
- fresh();
12977
- return;
12978
- }
12979
- fresh();
12980
- }
12981
-
12982
13072
  //#endregion
12983
13073
  //#region src/cli/commands/pick.ts
12984
13074
  function getProjects(db, includeArchived) {
@@ -13491,4 +13581,4 @@ async function cmdPick(db, opts = {}) {
13491
13581
 
13492
13582
  //#endregion
13493
13583
  export { registerProjectsCommands as A, registerRestoreCommands as C, registerIdentityCommands as D, registerMcpCommands as E, resolveIdentifier as M, registerMemoryCommands as O, registerSetupCommand as S, registerDaemonCommands as T, registerUpdateCommand as _, cmdEnd as a, registerZettelCommands as b, cmdGoto as c, registerHelpCommand as d, registerDbCommands as f, registerNotifyCommands as g, registerTaskCommands as h, cmdPauseAll as i, findMovedPath as j, registerRegistryCommands as k, cmdPause as l, registerTopicCommands as m, cmdFind as n, registerSessionCleanupCommand as o, registerKgCommands as p, cmdClearNames as r, registerSessionCommands as s, cmdPick as t, cmdList as u, registerSkillCommands as v, registerBackupCommands as w, registerObsidianCommands as x, registerObservationCommands as y };
13494
- //# sourceMappingURL=pick-BcmBBfOF.mjs.map
13584
+ //# sourceMappingURL=pick-Bmz718uo.mjs.map