@tekmidian/pai 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli/index.mjs +3 -3
  2. package/dist/cli/program.mjs +3 -3
  3. package/dist/daemon/index.mjs +3 -3
  4. package/dist/{daemon-mFGnPd8q.mjs → daemon-iNuNMMKd.mjs} +5 -5
  5. package/dist/{daemon-mFGnPd8q.mjs.map → daemon-iNuNMMKd.mjs.map} +1 -1
  6. package/dist/{factory-BqAdO21B.mjs → factory-BydJSrZJ.mjs} +13 -2
  7. package/dist/factory-BydJSrZJ.mjs.map +1 -0
  8. package/dist/hooks/capture-all-events.mjs.map +1 -1
  9. package/dist/hooks/cleanup-session-files.mjs +21 -15
  10. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  11. package/dist/hooks/context-compression-hook.mjs +6 -6
  12. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  13. package/dist/hooks/initialize-session.mjs.map +1 -1
  14. package/dist/hooks/inject-observations.mjs.map +1 -1
  15. package/dist/hooks/load-core-context.mjs.map +1 -1
  16. package/dist/hooks/load-project-context.mjs +43 -25
  17. package/dist/hooks/load-project-context.mjs.map +3 -3
  18. package/dist/hooks/observe.mjs.map +1 -1
  19. package/dist/hooks/stop-hook.mjs +27 -21
  20. package/dist/hooks/stop-hook.mjs.map +3 -3
  21. package/dist/hooks/sync-todo-to-md.mjs +2 -2
  22. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  23. package/dist/{main-resolver-BfSL8zio.mjs → main-resolver-DjyUDJrv.mjs} +424 -58
  24. package/dist/main-resolver-DjyUDJrv.mjs.map +1 -0
  25. package/dist/{pick-BcmBBfOF.mjs → pick-DlsM0ppq.mjs} +817 -247
  26. package/dist/pick-DlsM0ppq.mjs.map +1 -0
  27. package/dist/{work-queue-worker-B8QUONlK.mjs → work-queue-worker-BAgwmMDl.mjs} +55 -14
  28. package/dist/work-queue-worker-BAgwmMDl.mjs.map +1 -0
  29. package/docs/commands/README.md +5 -0
  30. package/docs/commands/project.md +38 -0
  31. package/docs/commands/projects.md +38 -0
  32. package/docs/commands/session.md +17 -0
  33. package/package.json +1 -1
  34. package/src/hooks/ts/lib/project-utils/index.ts +1 -1
  35. package/src/hooks/ts/lib/project-utils/paths.test.ts +125 -0
  36. package/src/hooks/ts/lib/project-utils/paths.ts +68 -14
  37. package/src/hooks/ts/lib/project-utils.ts +1 -1
  38. package/src/hooks/ts/session-start/load-project-context.ts +34 -31
  39. package/src/hooks/ts/stop/stop-hook.ts +5 -5
  40. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +19 -6
  41. package/dist/factory-BqAdO21B.mjs.map +0 -1
  42. package/dist/main-resolver-BfSL8zio.mjs.map +0 -1
  43. package/dist/pick-BcmBBfOF.mjs.map +0 -1
  44. package/dist/work-queue-worker-B8QUONlK.mjs.map +0 -1
@@ -1,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.lastActivity > existing.lastActivity) byName.set(key, 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: claude --resume ${resumableUuid} --print --output-format=json "_"`);
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.status === "live") {
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
- const match = partialMatches[0];
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.status === "live") {
1276
- await doSwitch(match, opts.dryRun ?? false);
1277
- return;
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, fetchLiveSessions as c, fmtAge as d, resolveSessionByNameOrId as f, normalizeName as i, revealItermSession as l, main_resolver_exports as n, printExitDir as o, scanSessions as p, buildDeduped as r, callAiBroker as s, cmdMain as t, sendToSession as u };
1369
- //# sourceMappingURL=main-resolver-BfSL8zio.mjs.map
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