@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,7 +1,7 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-95iHPtFO.mjs";
|
|
2
2
|
import { _ as warn, c as ok, h as smartDecodeDir, i as err, l as renderTable, n as dim, o as header } from "./utils-BAxjW3j8.mjs";
|
|
3
3
|
import { t as aibrokerSocketPath } from "./runtime-paths-B0P1TvUr.mjs";
|
|
4
|
-
import { createReadStream, existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { closeSync, copyFileSync, createReadStream, existsSync, linkSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { basename, join } from "node:path";
|
|
7
7
|
import chalk from "chalk";
|
|
@@ -179,6 +179,9 @@ function resolveFilter(opts) {
|
|
|
179
179
|
* Scan ~/.claude/projects/ for all Claude Code sessions.
|
|
180
180
|
*
|
|
181
181
|
* Pass 1: walk top-level <project>/<uuid>.jsonl files (the resumability source).
|
|
182
|
+
* Pass 1b: walk <project>/sessions/<uuid>.jsonl — where the stop hook moves a
|
|
183
|
+
* session's transcript once it ends. These are resumable too, and
|
|
184
|
+
* skipping them made every cleanly-stopped session unfindable by name.
|
|
182
185
|
* Pass 2: handle clc registry entries whose cached UUID was not found in Pass 1.
|
|
183
186
|
* For each such entry, scan the entry's project dir for the FRESHEST session
|
|
184
187
|
* and attach the registry name to it. This fixes the stale-UUID bug where
|
|
@@ -271,6 +274,47 @@ function scanSessions(db, opts = {}) {
|
|
|
271
274
|
if (inRegistry) attachedClcUuids.add(uuid);
|
|
272
275
|
if (passesFilter) results.push(session);
|
|
273
276
|
}
|
|
277
|
+
const sessionsDir = join(projectDir, "sessions");
|
|
278
|
+
let finished = [];
|
|
279
|
+
try {
|
|
280
|
+
finished = readdirSync(sessionsDir);
|
|
281
|
+
} catch {
|
|
282
|
+
finished = [];
|
|
283
|
+
}
|
|
284
|
+
for (const file of finished) {
|
|
285
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
286
|
+
const uuid = file.slice(0, -6);
|
|
287
|
+
if (!UUID_RE.test(uuid)) continue;
|
|
288
|
+
if (seenUuids.has(uuid)) continue;
|
|
289
|
+
const sessionJsonlPath = join(sessionsDir, file);
|
|
290
|
+
const transcript = parseTranscript(sessionJsonlPath);
|
|
291
|
+
const clcInfo = clcInfoMap.get(uuid);
|
|
292
|
+
const session = {
|
|
293
|
+
uuid,
|
|
294
|
+
shortId: uuid.slice(0, 8),
|
|
295
|
+
encodedDir,
|
|
296
|
+
decodedPath,
|
|
297
|
+
topLevelPath: "",
|
|
298
|
+
topLevelSystemLines: 0,
|
|
299
|
+
topLevelSize: 0,
|
|
300
|
+
resumable: true,
|
|
301
|
+
sessionStatus: "resumable",
|
|
302
|
+
sessionJsonlPath,
|
|
303
|
+
userLines: transcript.userLines,
|
|
304
|
+
lastUserPrompt: transcript.lastUserPrompt,
|
|
305
|
+
msgCount: transcript.msgCount,
|
|
306
|
+
aiTitle: transcript.aiTitle,
|
|
307
|
+
mtime: transcript.mtime,
|
|
308
|
+
friendlyName: clcInfo?.name ?? transcript.aiTitle ?? projectBasename ?? void 0,
|
|
309
|
+
clcDirectory: clcInfo?.directory,
|
|
310
|
+
registryRootPath
|
|
311
|
+
};
|
|
312
|
+
if (!sessionsByEncodedDir.has(encodedDir)) sessionsByEncodedDir.set(encodedDir, []);
|
|
313
|
+
sessionsByEncodedDir.get(encodedDir).push(session);
|
|
314
|
+
seenUuids.add(uuid);
|
|
315
|
+
if (clcInfo) attachedClcUuids.add(uuid);
|
|
316
|
+
results.push(session);
|
|
317
|
+
}
|
|
274
318
|
}
|
|
275
319
|
if (filterMode !== "resumable") for (const [cachedUuid, clcInfo] of clcInfoMap) {
|
|
276
320
|
if (attachedClcUuids.has(cachedUuid)) continue;
|
|
@@ -742,6 +786,296 @@ function printExitDir(dir) {
|
|
|
742
786
|
process.stdout.write(`\n\x1b[2m📂 Working directory:\x1b[0m ${dir}\n\x1b[2m cd "${dir}"\x1b[0m\n`);
|
|
743
787
|
}
|
|
744
788
|
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region src/cli/lib/launch.ts
|
|
791
|
+
/**
|
|
792
|
+
* launch.ts — Launch Claude Code in a directory, in the CURRENT terminal.
|
|
793
|
+
*
|
|
794
|
+
* Shared by the interactive picker (pick.ts). Deliberately does NOT switch
|
|
795
|
+
* iTerm tabs (aibroker_switch): switching jumps the user to a different — and
|
|
796
|
+
* sometimes wrong — terminal, which is confusing. Picking a place should start
|
|
797
|
+
* a session right here, in the chosen directory.
|
|
798
|
+
*
|
|
799
|
+
* Behaviour:
|
|
800
|
+
* resume-or-fresh → if a resumable UUID is given, probe it; on success
|
|
801
|
+
* `claude --resume`, otherwise fall back to a fresh session
|
|
802
|
+
* in the same dir. With no UUID, start fresh.
|
|
803
|
+
*
|
|
804
|
+
* The `claude` child inherits the tty (stdio: "inherit"), so the session runs
|
|
805
|
+
* in the terminal that launched `pai`. On exit we print the working directory.
|
|
806
|
+
*/
|
|
807
|
+
/**
|
|
808
|
+
* Put a transcript back where `claude --resume` looks for it.
|
|
809
|
+
*
|
|
810
|
+
* `claude --resume <uuid>` reads ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
|
|
811
|
+
* and ONLY that path. A copy under `sessions/` is invisible to it — measured
|
|
812
|
+
* 2026-08-04, both directions:
|
|
813
|
+
*
|
|
814
|
+
* b3462801 867 KB, sessions/ only → "No conversation found with session ID"
|
|
815
|
+
* a9ecdc1c top level → found
|
|
816
|
+
*
|
|
817
|
+
* Location is one of two factors, and this function addresses that one. PAI
|
|
818
|
+
* displaced these files itself, from FOUR movers — a SessionStart hook, a
|
|
819
|
+
* UserPromptSubmit hook, the stop hook, and the work-queue worker — of which
|
|
820
|
+
* the UserPromptSubmit one did most of the damage, because it ran on every
|
|
821
|
+
* prompt of every session and excluded only the caller's own transcript. All
|
|
822
|
+
* four now hardlink (project-utils/paths.ts). So this restores a file PAI
|
|
823
|
+
* displaced rather than inventing a layout Claude Code does not use.
|
|
824
|
+
*
|
|
825
|
+
* The other factor is content, and no amount of relinking helps there — see
|
|
826
|
+
* `hasConversation`.
|
|
827
|
+
*
|
|
828
|
+
* A hard link is preferred over a copy: same inode, no second megabyte on disk,
|
|
829
|
+
* and the archive under `sessions/` keeps working for everything that reads it.
|
|
830
|
+
* Returns whether the top-level path exists afterwards.
|
|
831
|
+
*/
|
|
832
|
+
function restoreTopLevel(uuid, dir) {
|
|
833
|
+
const topLevel = join(dir, `${uuid}.jsonl`);
|
|
834
|
+
if (existsSync(topLevel)) return true;
|
|
835
|
+
const archived = join(dir, "sessions", `${uuid}.jsonl`);
|
|
836
|
+
if (!existsSync(archived)) return false;
|
|
837
|
+
try {
|
|
838
|
+
linkSync(archived, topLevel);
|
|
839
|
+
return true;
|
|
840
|
+
} catch {
|
|
841
|
+
try {
|
|
842
|
+
copyFileSync(archived, topLevel);
|
|
843
|
+
return true;
|
|
844
|
+
} catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Does this transcript hold an exchange, or only session metadata?
|
|
851
|
+
*
|
|
852
|
+
* The second reason `claude --resume` says "No conversation found": the file is
|
|
853
|
+
* there and readable and still holds no conversation. Measured 2026-08-04 on
|
|
854
|
+
* 046bb712 — 537 bytes of last-prompt, custom-title, agent-name, mode and
|
|
855
|
+
* permission-mode, no user line, no assistant line. It was restored to the top
|
|
856
|
+
* level, verified same-inode, and still refused. It was never resumable, and
|
|
857
|
+
* relinking cannot make it so. 30 of PAI's own 50 displaced transcripts are
|
|
858
|
+
* this shape, one of them 745 KB — size proves nothing, because hook context
|
|
859
|
+
* attachments are large.
|
|
860
|
+
*
|
|
861
|
+
* This scans the WHOLE file, and a bounded head will not do. It is tempting —
|
|
862
|
+
* "a real session's first assistant line lands within the first few KB" — and
|
|
863
|
+
* it is false. Measured on b3462801, a session `claude --resume` accepts:
|
|
864
|
+
*
|
|
865
|
+
* file size 866953
|
|
866
|
+
* length of LINE 1 762977 <- one hook context attachment
|
|
867
|
+
* first "type":"user" 766830
|
|
868
|
+
*
|
|
869
|
+
* The first exchange sits past 766 KB because line 1 is a single enormous
|
|
870
|
+
* attachment blob. Any head shorter than that reports a 867 KB working session
|
|
871
|
+
* as an empty stub, which is the exact false negative this function exists to
|
|
872
|
+
* prevent — and it is the shape that produced today's whole incident.
|
|
873
|
+
*
|
|
874
|
+
* Chunked so that a large transcript costs a scan rather than a resident copy,
|
|
875
|
+
* with an overlap so the marker cannot hide across a chunk boundary. Reading a
|
|
876
|
+
* few MB to answer a question about resumability is cheap; being wrong is not.
|
|
877
|
+
*
|
|
878
|
+
* Unreadable counts as real. Claiming a session is empty when it cannot be
|
|
879
|
+
* inspected would talk the caller out of a resume that might have worked.
|
|
880
|
+
*/
|
|
881
|
+
const CONVERSATION_MARKERS = [
|
|
882
|
+
"\"type\":\"assistant\"",
|
|
883
|
+
"\"type\": \"assistant\"",
|
|
884
|
+
"\"type\":\"user\"",
|
|
885
|
+
"\"type\": \"user\""
|
|
886
|
+
];
|
|
887
|
+
const OVERLAP = 32;
|
|
888
|
+
function hasConversation(path, chunkBytes = 1 << 20) {
|
|
889
|
+
let fd;
|
|
890
|
+
try {
|
|
891
|
+
fd = openSync(path, "r");
|
|
892
|
+
const buf = Buffer.alloc(chunkBytes);
|
|
893
|
+
let carry = "";
|
|
894
|
+
let pos = 0;
|
|
895
|
+
for (;;) {
|
|
896
|
+
const read = readSync(fd, buf, 0, chunkBytes, pos);
|
|
897
|
+
if (read <= 0) return false;
|
|
898
|
+
pos += read;
|
|
899
|
+
const text = carry + buf.subarray(0, read).toString("utf8");
|
|
900
|
+
if (CONVERSATION_MARKERS.some((m) => text.includes(m))) return true;
|
|
901
|
+
carry = text.slice(-OVERLAP);
|
|
902
|
+
}
|
|
903
|
+
} catch {
|
|
904
|
+
return true;
|
|
905
|
+
} finally {
|
|
906
|
+
if (fd !== void 0) try {
|
|
907
|
+
closeSync(fd);
|
|
908
|
+
} catch {}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Can this session be resumed — and if the only thing standing in the way is
|
|
913
|
+
* where its transcript sits, put it back so that the answer is yes.
|
|
914
|
+
*
|
|
915
|
+
* Returns true/false when it can tell, and null when it cannot — the caller
|
|
916
|
+
* must treat null as "ask something else", never as "no". A path this function
|
|
917
|
+
* fails to recognise would otherwise silently veto a perfectly good resume.
|
|
918
|
+
*
|
|
919
|
+
* Claude Code stores transcripts at ~/.claude/projects/<encoded-cwd>/, where the
|
|
920
|
+
* encoding replaces every non-alphanumeric character with `-`.
|
|
921
|
+
*/
|
|
922
|
+
function transcriptOnDisk(uuid, cwd, home = homedir()) {
|
|
923
|
+
try {
|
|
924
|
+
const dir = join(home, ".claude", "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
|
925
|
+
if (!existsSync(dir)) return null;
|
|
926
|
+
if (!restoreTopLevel(uuid, dir)) return "missing";
|
|
927
|
+
return hasConversation(join(dir, `${uuid}.jsonl`)) ? true : "stub";
|
|
928
|
+
} catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Probe whether a session UUID is resumable from `cwd`.
|
|
934
|
+
*
|
|
935
|
+
* The filesystem is asked first, and usually answers.
|
|
936
|
+
*
|
|
937
|
+
* This used to run `claude --resume <uuid> --print --output-format=json "_"`
|
|
938
|
+
* with a 5s timeout, which is not a probe: it resumes the session AND sends a
|
|
939
|
+
* prompt to the model, then waits for a complete JSON reply. That costs a model
|
|
940
|
+
* round-trip per probe, and 5s is not enough time for one — for a LARGE session
|
|
941
|
+
* least of all, because the transcript has to be loaded first.
|
|
942
|
+
*
|
|
943
|
+
* So the check failed precisely for the sessions most worth resuming, and the
|
|
944
|
+
* caller's fallback quietly started a fresh session in their place. Observed
|
|
945
|
+
* 2026-08-04: `pai Paperfull` reported `spawn error: spawnSync claude ETIMEDOUT`
|
|
946
|
+
* for fb76a6c3 and started over, while
|
|
947
|
+
* `~/.claude/projects/…-Paperfull/sessions/fb76a6c3-….jsonl` sat on disk the
|
|
948
|
+
* whole time.
|
|
949
|
+
*
|
|
950
|
+
* A transcript on disk is what resumable MEANS, and reading a directory entry is
|
|
951
|
+
* free. The spawn remains only for the case the filesystem cannot answer, and
|
|
952
|
+
* now gets a timeout that a real answer can fit inside.
|
|
953
|
+
*
|
|
954
|
+
* "On disk" had to be tightened once. It first accepted a transcript sitting
|
|
955
|
+
* only under `sessions/`, which claude --resume does not read — so the probe
|
|
956
|
+
* swapped a 5s false negative for a confident false positive, and the caller
|
|
957
|
+
* spawned a resume that died with "No conversation found" instead of falling
|
|
958
|
+
* back to a fresh session. `restoreTopLevel` is what makes the permissive
|
|
959
|
+
* reading true rather than merely optimistic.
|
|
960
|
+
*/
|
|
961
|
+
function probeResume(uuid, cwd, home) {
|
|
962
|
+
const onDisk = transcriptOnDisk(uuid, cwd, home);
|
|
963
|
+
if (onDisk === true) return { ok: true };
|
|
964
|
+
if (onDisk === "missing") return {
|
|
965
|
+
ok: false,
|
|
966
|
+
reason: "No transcript on disk for this UUID"
|
|
967
|
+
};
|
|
968
|
+
if (onDisk === "stub") return {
|
|
969
|
+
ok: false,
|
|
970
|
+
reason: "Transcript holds only session metadata — no conversation to resume"
|
|
971
|
+
};
|
|
972
|
+
const result = spawnSync("claude", [
|
|
973
|
+
"--resume",
|
|
974
|
+
uuid,
|
|
975
|
+
"--print",
|
|
976
|
+
"--output-format=json",
|
|
977
|
+
"_"
|
|
978
|
+
], {
|
|
979
|
+
cwd,
|
|
980
|
+
timeout: 3e4,
|
|
981
|
+
env: process.env,
|
|
982
|
+
stdio: [
|
|
983
|
+
"ignore",
|
|
984
|
+
"ignore",
|
|
985
|
+
"pipe"
|
|
986
|
+
]
|
|
987
|
+
});
|
|
988
|
+
if (result.error) return {
|
|
989
|
+
ok: false,
|
|
990
|
+
reason: `spawn error: ${result.error.message}`
|
|
991
|
+
};
|
|
992
|
+
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
993
|
+
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
994
|
+
ok: false,
|
|
995
|
+
reason: "No conversation found for this UUID"
|
|
996
|
+
};
|
|
997
|
+
if (result.status !== 0) return {
|
|
998
|
+
ok: false,
|
|
999
|
+
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
1000
|
+
};
|
|
1001
|
+
return { ok: true };
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Launch `claude` in `dir` in the current terminal. `name` is used for both the
|
|
1005
|
+
* Claude session label (--name) and the /Name slash command (tab/statusline).
|
|
1006
|
+
* Never returns on the live path — it exits the process after claude exits.
|
|
1007
|
+
*/
|
|
1008
|
+
function launchInDir(dir, name, opts = {}) {
|
|
1009
|
+
let cwd;
|
|
1010
|
+
try {
|
|
1011
|
+
cwd = realpathSync(dir);
|
|
1012
|
+
} catch {
|
|
1013
|
+
console.error(err(`Directory does not exist or cannot be resolved:\n ${dir}\n The folder may have moved or been deleted.`));
|
|
1014
|
+
process.exit(1);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const promptArg = `/Name ${name}\ngo`;
|
|
1018
|
+
const wantResume = !opts.forceFresh && !!opts.resumableUuid;
|
|
1019
|
+
if (opts.dryRun) {
|
|
1020
|
+
if (wantResume) {
|
|
1021
|
+
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
1022
|
+
console.log(` cwd: ${chalk.cyan(cwd)}`);
|
|
1023
|
+
console.log(` probe: transcript on disk for ${opts.resumableUuid.slice(0, 8)}?`);
|
|
1024
|
+
console.log(` argv: claude --resume ${opts.resumableUuid} --name "${name}" "/Name ${name}\\ngo"`);
|
|
1025
|
+
console.log(` fallback: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
1026
|
+
} else {
|
|
1027
|
+
console.log("\n" + chalk.bold("Dry run — would exec (FRESH path):") + "\n");
|
|
1028
|
+
console.log(` cwd: ${chalk.cyan(cwd)}`);
|
|
1029
|
+
console.log(` argv: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
1030
|
+
}
|
|
1031
|
+
console.log();
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const fresh = () => {
|
|
1035
|
+
const result = spawnSync("claude", [
|
|
1036
|
+
"--name",
|
|
1037
|
+
name,
|
|
1038
|
+
promptArg
|
|
1039
|
+
], {
|
|
1040
|
+
cwd,
|
|
1041
|
+
stdio: "inherit",
|
|
1042
|
+
env: process.env
|
|
1043
|
+
});
|
|
1044
|
+
if (result.error) {
|
|
1045
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
1046
|
+
process.exit(1);
|
|
1047
|
+
}
|
|
1048
|
+
printExitDir(cwd);
|
|
1049
|
+
process.exit(result.status ?? 0);
|
|
1050
|
+
};
|
|
1051
|
+
if (wantResume) {
|
|
1052
|
+
const probe = probeResume(opts.resumableUuid, cwd);
|
|
1053
|
+
if (probe.ok) {
|
|
1054
|
+
const result = spawnSync("claude", [
|
|
1055
|
+
"--resume",
|
|
1056
|
+
opts.resumableUuid,
|
|
1057
|
+
"--name",
|
|
1058
|
+
name,
|
|
1059
|
+
promptArg
|
|
1060
|
+
], {
|
|
1061
|
+
cwd,
|
|
1062
|
+
stdio: "inherit",
|
|
1063
|
+
env: process.env
|
|
1064
|
+
});
|
|
1065
|
+
if (result.error) {
|
|
1066
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
1067
|
+
process.exit(1);
|
|
1068
|
+
}
|
|
1069
|
+
printExitDir(cwd);
|
|
1070
|
+
process.exit(result.status ?? 0);
|
|
1071
|
+
}
|
|
1072
|
+
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`));
|
|
1073
|
+
fresh();
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
fresh();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
745
1079
|
//#endregion
|
|
746
1080
|
//#region src/cli/lib/history-search.ts
|
|
747
1081
|
/**
|
|
@@ -848,6 +1182,57 @@ function slugToWords(slug) {
|
|
|
848
1182
|
*
|
|
849
1183
|
* @param showAll When true, include cold / zero-session / archived projects.
|
|
850
1184
|
*/
|
|
1185
|
+
/**
|
|
1186
|
+
* Which of two sessions of equal status should represent a name.
|
|
1187
|
+
*
|
|
1188
|
+
* Recency alone was the rule, and recency is exactly the wrong instinct here.
|
|
1189
|
+
* A resume that FAILS still creates a session file, and that file is newer than
|
|
1190
|
+
* the transcript it failed to open. So the tie-break reliably handed back the
|
|
1191
|
+
* artefact of a bug in preference to the work.
|
|
1192
|
+
*
|
|
1193
|
+
* Measured on Paperfull, 2026-08-04:
|
|
1194
|
+
*
|
|
1195
|
+
* b3462801 867 KB 32 lines 3 user 15:41:57 <- the actual work
|
|
1196
|
+
* 7fdbb9a8 82 KB 25 lines 2 user 15:41:59 <- empty, Ctrl-C'd, WON by 2s
|
|
1197
|
+
* a9ecdc1c 82 KB 25 lines 2 user 15:49:40 <- a second failed resume
|
|
1198
|
+
*
|
|
1199
|
+
* `pai Paperfull` therefore resolved to 7fdbb9a8 — 82 KB of hook context in
|
|
1200
|
+
* `attachment` lines, with no assistant turn anywhere in it — while 867 KB of
|
|
1201
|
+
* real work sat one second older and unchosen.
|
|
1202
|
+
*
|
|
1203
|
+
* WHAT THIS DOES NOT FIX, measured before claiming otherwise: the reported
|
|
1204
|
+
* failure was `No conversation found with session ID: 7fdbb9a8`, and that error
|
|
1205
|
+
* is about WHERE the transcript is, not what is in it. Both transcripts live
|
|
1206
|
+
* only in sessions/, and `claude --resume b3462801` — the good one, the one this
|
|
1207
|
+
* change now selects — fails with the identical message. Verified directly, and
|
|
1208
|
+
* again on a second project. So this stops dedup preferring the artefact of a
|
|
1209
|
+
* bug, which is worth doing on its own, and `pai <Name>` still cannot resume a
|
|
1210
|
+
* cleanly-stopped session until the sessions/ relocation is addressed. That part
|
|
1211
|
+
* is tracked in Notes/TODO.md and belongs to session-scan/the stop hook.
|
|
1212
|
+
*
|
|
1213
|
+
* Conversation volume comes first because an empty artefact is small BY
|
|
1214
|
+
* CONSTRUCTION — nobody spoke into it — whereas an artefact can be newer by
|
|
1215
|
+
* mere milliseconds. Recency only decides between transcripts that recorded the
|
|
1216
|
+
* same amount, where it is a reasonable guess again.
|
|
1217
|
+
*
|
|
1218
|
+
* HONEST LIMIT: the exact signal is "did the assistant ever reply", and this is
|
|
1219
|
+
* not that — it is a proxy that happens to separate the measured case by one
|
|
1220
|
+
* user line and seven total lines, which is a thin margin. The real predicate
|
|
1221
|
+
* belongs in `parseTranscript` (session-scan.ts) as an `assistantLines` count,
|
|
1222
|
+
* which is free there since it already reads every line of the file. That file
|
|
1223
|
+
* was mid-edit in another session when this landed, so this stays out of it.
|
|
1224
|
+
* Move to the exact signal when it exists; the tests below should keep passing
|
|
1225
|
+
* unchanged, since they assert the outcome and not the proxy.
|
|
1226
|
+
*/
|
|
1227
|
+
function winsTiebreak(candidate, incumbent) {
|
|
1228
|
+
const c = candidate.diskSession;
|
|
1229
|
+
const i = incumbent.diskSession;
|
|
1230
|
+
if (c && i) {
|
|
1231
|
+
if (c.userLines !== i.userLines) return c.userLines > i.userLines;
|
|
1232
|
+
if (c.msgCount !== i.msgCount) return c.msgCount > i.msgCount;
|
|
1233
|
+
}
|
|
1234
|
+
return candidate.lastActivity > incumbent.lastActivity;
|
|
1235
|
+
}
|
|
851
1236
|
function buildDeduped(liveSessions, diskSessions, registeredProjects, showAll = false) {
|
|
852
1237
|
const byName = /* @__PURE__ */ new Map();
|
|
853
1238
|
for (const s of liveSessions) {
|
|
@@ -886,7 +1271,7 @@ function buildDeduped(liveSessions, diskSessions, registeredProjects, showAll =
|
|
|
886
1271
|
else {
|
|
887
1272
|
const ep = STATUS_PRIORITY[existing.status];
|
|
888
1273
|
const np = STATUS_PRIORITY[status];
|
|
889
|
-
if (np < ep || np === ep && entry
|
|
1274
|
+
if (np < ep || np === ep && winsTiebreak(entry, existing)) byName.set(key, entry);
|
|
890
1275
|
}
|
|
891
1276
|
}
|
|
892
1277
|
const COLD_THRESHOLD_MS = 2160 * 60 * 60 * 1e3;
|
|
@@ -985,38 +1370,6 @@ function renderDedupedSessions(entries, maxRows) {
|
|
|
985
1370
|
//#endregion
|
|
986
1371
|
//#region src/cli/commands/main-resolver.ts
|
|
987
1372
|
var main_resolver_exports = /* @__PURE__ */ __exportAll({ cmdMain: () => cmdMain });
|
|
988
|
-
function probeResume(uuid, cwd) {
|
|
989
|
-
const result = spawnSync("claude", [
|
|
990
|
-
"--resume",
|
|
991
|
-
uuid,
|
|
992
|
-
"--print",
|
|
993
|
-
"--output-format=json",
|
|
994
|
-
"_"
|
|
995
|
-
], {
|
|
996
|
-
cwd,
|
|
997
|
-
timeout: 5e3,
|
|
998
|
-
env: process.env,
|
|
999
|
-
stdio: [
|
|
1000
|
-
"ignore",
|
|
1001
|
-
"ignore",
|
|
1002
|
-
"pipe"
|
|
1003
|
-
]
|
|
1004
|
-
});
|
|
1005
|
-
if (result.error) return {
|
|
1006
|
-
ok: false,
|
|
1007
|
-
reason: `spawn error: ${result.error.message}`
|
|
1008
|
-
};
|
|
1009
|
-
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
1010
|
-
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
1011
|
-
ok: false,
|
|
1012
|
-
reason: "No conversation found for this UUID"
|
|
1013
|
-
};
|
|
1014
|
-
if (result.status !== 0) return {
|
|
1015
|
-
ok: false,
|
|
1016
|
-
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
1017
|
-
};
|
|
1018
|
-
return { ok: true };
|
|
1019
|
-
}
|
|
1020
1373
|
function launchSession(session, allSessions, dryRun) {
|
|
1021
1374
|
let resumableUuid;
|
|
1022
1375
|
if (session.resumable) resumableUuid = session.uuid;
|
|
@@ -1034,13 +1387,13 @@ function launchSession(session, allSessions, dryRun) {
|
|
|
1034
1387
|
process.exit(1);
|
|
1035
1388
|
return;
|
|
1036
1389
|
}
|
|
1037
|
-
const name = session.friendlyName ?? session.shortId;
|
|
1390
|
+
const name = session.friendlyName ?? (basename(projectDir) || session.shortId);
|
|
1038
1391
|
const promptArg = `/Name ${name}\ngo`;
|
|
1039
1392
|
if (dryRun) {
|
|
1040
1393
|
if (resumableUuid) {
|
|
1041
1394
|
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
1042
1395
|
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
1043
|
-
console.log(` probe:
|
|
1396
|
+
console.log(` probe: transcript on disk for ${resumableUuid.slice(0, 8)}?`);
|
|
1044
1397
|
console.log(` argv: claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`);
|
|
1045
1398
|
console.log(` fallback: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
1046
1399
|
} else {
|
|
@@ -1172,6 +1525,34 @@ async function doSwitch(entry, dryRun) {
|
|
|
1172
1525
|
console.error(warn(`Could not switch via AIBroker: ${result.error ?? "unknown error"}`));
|
|
1173
1526
|
return false;
|
|
1174
1527
|
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Act on a matched catalog entry: switch to it, resume it, or start it.
|
|
1530
|
+
*
|
|
1531
|
+
* The third case is the one that was missing. A registered project whose
|
|
1532
|
+
* sessions have all ended matches by name perfectly well, but carries no
|
|
1533
|
+
* `diskSession`, and the old code simply ran off the end of both the exact and
|
|
1534
|
+
* the partial branch — past the picker, into a free-text search of prompt
|
|
1535
|
+
* history. So `pai Paperfull` answered a request to open a project with a list
|
|
1536
|
+
* of five old conversations that merely mentioned the word, while the project's
|
|
1537
|
+
* own directory was sitting in `entry.project` the whole time.
|
|
1538
|
+
*
|
|
1539
|
+
* Returns false only when there is genuinely nothing to open, which is the one
|
|
1540
|
+
* case where falling through to a history search is the right thing to do.
|
|
1541
|
+
*/
|
|
1542
|
+
async function openMatch(entry, allSessions, dryRun) {
|
|
1543
|
+
if (entry.status === "live") {
|
|
1544
|
+
if (await doSwitch(entry, dryRun)) return true;
|
|
1545
|
+
}
|
|
1546
|
+
if (entry.diskSession) {
|
|
1547
|
+
launchSession(entry.diskSession, allSessions, dryRun);
|
|
1548
|
+
return true;
|
|
1549
|
+
}
|
|
1550
|
+
if (entry.project && existsSync(entry.project)) {
|
|
1551
|
+
launchInDir(entry.project, entry.name, { dryRun });
|
|
1552
|
+
return true;
|
|
1553
|
+
}
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1175
1556
|
function getRegisteredProjects(db, all = false) {
|
|
1176
1557
|
try {
|
|
1177
1558
|
const statusClause = all ? "" : "WHERE p.status = 'active'";
|
|
@@ -1230,24 +1611,11 @@ async function cmdMain(db, query, pickN, opts) {
|
|
|
1230
1611
|
const nameIncludes = (e, q) => e.name.toLowerCase().includes(q) || e.slug !== void 0 && e.slug.toLowerCase().includes(qSlug);
|
|
1231
1612
|
const exactMatch = deduped.find((e) => nameMatches(e, qNorm));
|
|
1232
1613
|
if (exactMatch) {
|
|
1233
|
-
if (exactMatch.
|
|
1234
|
-
if (await doSwitch(exactMatch, opts.dryRun ?? false)) return;
|
|
1235
|
-
}
|
|
1236
|
-
if (exactMatch.diskSession) {
|
|
1237
|
-
launchSession(exactMatch.diskSession, allSessions, opts.dryRun ?? false);
|
|
1238
|
-
return;
|
|
1239
|
-
}
|
|
1614
|
+
if (await openMatch(exactMatch, allSessions, opts.dryRun ?? false)) return;
|
|
1240
1615
|
}
|
|
1241
1616
|
const partialMatches = deduped.filter((e) => nameIncludes(e, qNorm));
|
|
1242
1617
|
if (partialMatches.length === 1) {
|
|
1243
|
-
|
|
1244
|
-
if (match.status === "live") {
|
|
1245
|
-
if (await doSwitch(match, opts.dryRun ?? false)) return;
|
|
1246
|
-
}
|
|
1247
|
-
if (match.diskSession) {
|
|
1248
|
-
launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
|
|
1249
|
-
return;
|
|
1250
|
-
}
|
|
1618
|
+
if (await openMatch(partialMatches[0], allSessions, opts.dryRun ?? false)) return;
|
|
1251
1619
|
}
|
|
1252
1620
|
if (partialMatches.length > 1) {
|
|
1253
1621
|
console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
|
|
@@ -1272,11 +1640,9 @@ async function cmdMain(db, query, pickN, opts) {
|
|
|
1272
1640
|
console.log(renderTable(headers, rows));
|
|
1273
1641
|
console.log();
|
|
1274
1642
|
const pickMatch = async (match) => {
|
|
1275
|
-
if (match.
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
if (match.diskSession) launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
|
|
1643
|
+
if (await openMatch(match, allSessions, opts.dryRun ?? false)) return;
|
|
1644
|
+
console.error(err(`Nothing to open for "${match.name}" — no live session, no transcript, no directory.`));
|
|
1645
|
+
process.exitCode = 1;
|
|
1280
1646
|
};
|
|
1281
1647
|
if (pickN !== void 0) {
|
|
1282
1648
|
const idx = pickN - 1;
|
|
@@ -1365,5 +1731,5 @@ async function cmdMain(db, query, pickN, opts) {
|
|
|
1365
1731
|
}
|
|
1366
1732
|
|
|
1367
1733
|
//#endregion
|
|
1368
|
-
export { renderDedupedSessions as a,
|
|
1369
|
-
//# sourceMappingURL=main-resolver-
|
|
1734
|
+
export { scanSessions as _, renderDedupedSessions as a, probeResume as c, callAiBroker as d, fetchLiveSessions as f, resolveSessionByNameOrId as g, fmtAge as h, normalizeName as i, restoreTopLevel as l, sendToSession as m, main_resolver_exports as n, hasConversation as o, revealItermSession as p, buildDeduped as r, launchInDir as s, cmdMain as t, printExitDir as u };
|
|
1735
|
+
//# sourceMappingURL=main-resolver-DjyUDJrv.mjs.map
|