@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.
- package/dist/cli/index.mjs +3 -3
- package/dist/cli/program.mjs +3 -3
- package/dist/daemon/index.mjs +3 -3
- package/dist/{daemon-mFGnPd8q.mjs → daemon-iNuNMMKd.mjs} +5 -5
- package/dist/{daemon-mFGnPd8q.mjs.map → daemon-iNuNMMKd.mjs.map} +1 -1
- package/dist/{factory-BqAdO21B.mjs → factory-BydJSrZJ.mjs} +13 -2
- package/dist/factory-BydJSrZJ.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs.map +1 -1
- package/dist/hooks/cleanup-session-files.mjs +21 -15
- package/dist/hooks/cleanup-session-files.mjs.map +2 -2
- package/dist/hooks/context-compression-hook.mjs +6 -6
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/initialize-session.mjs.map +1 -1
- package/dist/hooks/inject-observations.mjs.map +1 -1
- package/dist/hooks/load-core-context.mjs.map +1 -1
- package/dist/hooks/load-project-context.mjs +43 -25
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/observe.mjs.map +1 -1
- package/dist/hooks/stop-hook.mjs +27 -21
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +2 -2
- package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
- package/dist/{main-resolver-BfSL8zio.mjs → main-resolver-DjyUDJrv.mjs} +424 -58
- package/dist/main-resolver-DjyUDJrv.mjs.map +1 -0
- package/dist/{pick-BcmBBfOF.mjs → pick-Bmz718uo.mjs} +318 -228
- package/dist/pick-Bmz718uo.mjs.map +1 -0
- package/dist/{work-queue-worker-B8QUONlK.mjs → work-queue-worker-BAgwmMDl.mjs} +55 -14
- package/dist/work-queue-worker-BAgwmMDl.mjs.map +1 -0
- package/docs/commands/README.md +1 -0
- package/docs/commands/session.md +17 -0
- package/package.json +1 -1
- package/src/hooks/ts/lib/project-utils/index.ts +1 -1
- package/src/hooks/ts/lib/project-utils/paths.test.ts +125 -0
- package/src/hooks/ts/lib/project-utils/paths.ts +68 -14
- package/src/hooks/ts/lib/project-utils.ts +1 -1
- package/src/hooks/ts/session-start/load-project-context.ts +34 -31
- package/src/hooks/ts/stop/stop-hook.ts +5 -5
- package/src/hooks/ts/user-prompt/cleanup-session-files.ts +19 -6
- package/dist/factory-BqAdO21B.mjs.map +0 -1
- package/dist/main-resolver-BfSL8zio.mjs.map +0 -1
- package/dist/pick-BcmBBfOF.mjs.map +0 -1
- package/dist/work-queue-worker-B8QUONlK.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as openRegistry } from "./db-BtuN768f.mjs";
|
|
2
|
-
import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, i as err, l as renderTable, m as slugify, n as dim$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
|
|
2
|
+
import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, h as smartDecodeDir, i as err, l as renderTable, m as slugify, n as dim$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
|
|
3
3
|
import { a as slugify$1, i as parseSessionFilename } from "./migrate-fLD6rAdO.mjs";
|
|
4
4
|
import { n as ensurePaiMarker } from "./pai-marker-B20KqhA8.mjs";
|
|
5
5
|
import { n as openFederation } from "./db-CYmBWcjh.mjs";
|
|
@@ -11,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-
|
|
14
|
+
import { i as humanDuration, t as createStorageBackend } from "./factory-BydJSrZJ.mjs";
|
|
15
15
|
import { s as kgQuery } from "./kg-entity-r8duqhi9.mjs";
|
|
16
|
-
import { a as renderDedupedSessions, c as
|
|
16
|
+
import { _ as scanSessions, a as renderDedupedSessions, c as probeResume, d as callAiBroker, f as fetchLiveSessions, g as resolveSessionByNameOrId, h as fmtAge, i as normalizeName$2, l as restoreTopLevel, m as sendToSession, o as hasConversation, p as revealItermSession, r as buildDeduped, s as launchInDir, u as printExitDir } from "./main-resolver-DjyUDJrv.mjs";
|
|
17
17
|
import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { homedir, platform, tmpdir } from "node:os";
|
|
19
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-
|
|
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
|
|
2759
|
-
if (
|
|
2760
|
-
const mins = Math.max(1, Math.round((Date.now() - outage.since) / 6e4));
|
|
2761
|
-
const forHow = mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
|
|
2817
|
+
const health = formatStorageHealth(s);
|
|
2818
|
+
if (health.length > 0) {
|
|
2762
2819
|
console.log();
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
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-
|
|
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 ((
|
|
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-
|
|
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
|
|
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-
|
|
13584
|
+
//# sourceMappingURL=pick-Bmz718uo.mjs.map
|