@tekmidian/pai 0.10.0 → 0.11.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/README.md CHANGED
@@ -299,34 +299,28 @@ All hooks are TypeScript compiled to `.mjs` modules. They run as separate proces
299
299
 
300
300
  PAI gives you a complete picture of every Claude Code session running on your machine — live tabs in iTerm2, paused snapshots on disk, and everything in between.
301
301
 
302
- ### The Core Idea: Think in Topics
302
+ ### The Core Idea: One Entry Point
303
303
 
304
- The user's mental model is simple: "I did something on a given subject. Where was I working on it? Let me continue."
304
+ `pai <name>` is the universal session command. It does the right thing based on session state:
305
305
 
306
- PAI v0.10.0 is built around this. You think in **topics**, not project names or session UUIDs. PAI handles the lookup.
306
+ - **Live session** switches the iTerm2 tab to front (no new Claude launched)
307
+ - **Resumable session** — probes `claude --resume`, launches with the saved UUID
308
+ - **Transcript/stub** — starts a fresh Claude in the same project directory
309
+ - **No match** — searches `~/.claude/history.jsonl`, shows a candidate picker
307
310
 
308
311
  ```bash
309
- pai mdf # Find every session where you worked on MDF
310
- pai solar panels # Free-text search across your prompt history
311
- pai 0856d40b # Resume by UUID prefix (from pai sessions)
312
- pai # Show all recent sessions (interactive picker)
312
+ pai aibroker # Switch to the live AIBroker tab (iTerm comes to front)
313
+ pai youdrill # Resume the youdrill session, or fresh if snapshot is stale
314
+ pai mdf # Free-text search across your prompt history
315
+ pai 0856d40b # Resume by UUID prefix
316
+ pai # Show all sessions (deduped, one row per name)
313
317
  ```
314
318
 
315
- When you type `pai mdf`, PAI checks:
316
- 1. Is "mdf" a named session in the catalog? → launch immediately, no picker
317
- 2. Is "mdf" a UUID prefix? → direct filesystem resume
318
- 3. Otherwise → grep `~/.claude/history.jsonl` for matching prompts, show candidate list, you pick a number
319
-
320
- The picked session resolves to the best resumable snapshot in the same project directory. PAI probes `claude --resume` automatically. If the snapshot is stale, it starts a fresh Claude in the same directory.
321
-
322
- ### Daily Verbs
323
-
324
- The commands you'll use every day:
319
+ ### Daily Commands
325
320
 
326
321
  ```bash
327
- pai # Recent sessions picker (live + disk)
328
- pai <topic> # Find + launch session by topic/keyword/UUID
329
- pai cd <name> # cd to a project directory (no Claude launch)
322
+ pai # List sessions (deduped by name, one row per name)
323
+ pai <name> # Switch / resume / fresh — universal
330
324
  pai pause # Save state checkpoint (write ## Continue to TODO.md)
331
325
  pai pause all # Pause every live Claude session at once
332
326
  pai end # Finalize: save state + mark session note Completed
@@ -339,15 +333,25 @@ And inside Claude Code, the two slash commands that matter:
339
333
  /end → same as /pause, plus marks the session note Completed
340
334
  ```
341
335
 
342
- ### Finding Sessions
336
+ ### Session Listing
337
+
338
+ `pai` (no args) shows a single deduped table — one row per session name, regardless of how many snapshots exist on disk:
343
339
 
344
- `pai` (no args) shows two sections:
340
+ ```
341
+ Sessions:
342
+
343
+ # name status age project last prompt
344
+ -- ---------- ---------- -------- ---------------------------- --------------------------
345
+ 1 AIBroker live now — —
346
+ 2 PAI resumable 2m ago /…dev/ai/PAI "refactor session listing…"
347
+ 3 MDF transcript 3d ago /…MDF/Infrastruktur/Webseiten "ok so we recently had…"
348
+ ```
345
349
 
346
- **Live Sessions** pulled from AIBroker in real time. One row per active iTerm2 pane running Claude Code. Columns: `#`, `id`, `name`, `at prompt`.
350
+ Status values: `live` (active iTerm tab), `resumable` (clean snapshot on disk), `transcript` (history available, not resumable), `stub` (empty or minimal).
347
351
 
348
- **Recent Sessions** — disk scan of `~/.claude/projects/`, sorted by last-modified.
352
+ ### Finding Sessions by Topic
349
353
 
350
- `pai <topic>` searches your prompt history in `~/.claude/history.jsonl`, groups results by session, and shows a table:
354
+ `pai <topic>` first checks session names, then falls back to searching your prompt history:
351
355
 
352
356
  ```
353
357
  Sessions matching "mdf":
@@ -360,16 +364,14 @@ Sessions matching "mdf":
360
364
  Enter # to launch (1-2), or press Enter to cancel:
361
365
  ```
362
366
 
363
- Type the number and press Enter. PAI launches Claude, resumes if possible, starts fresh if not.
364
-
365
- Use `pai <topic> --auto` (or `-y`) to auto-pick #1 without the prompt. Use `pai <topic> 2` to pick #2 directly.
367
+ Use `pai <topic> --auto` (or `-y`) to auto-pick #1. Use `pai <topic> 2` to pick directly.
366
368
 
367
369
  ### Power User Access
368
370
 
369
- The full session management namespace is still available for power users:
371
+ The full session management namespace is still available:
370
372
 
371
373
  ```bash
372
- pai sessions # Live + disk listing (same as bare pai, with more columns)
374
+ pai sessions # Live + disk listing (with more columns)
373
375
  pai sessions --all # Include unnamed orphan sessions
374
376
  pai sessions --all-tabs # Include shell tabs in the live section
375
377
  pai sessions goto <name> # Named-session resolver (same as pai <name>)
@@ -2758,6 +2758,29 @@ async function sendToSession(sessionId, text) {
2758
2758
  };
2759
2759
  }
2760
2760
  }
2761
+ /**
2762
+ * Switch iTerm2 focus to the session identified by `target`.
2763
+ *
2764
+ * `target` can be a sessionId, paiName, or tab index number (as string).
2765
+ * After switching, activates the iTerm2 application itself so the window
2766
+ * comes to the foreground.
2767
+ *
2768
+ * Returns { ok: true } if AIBroker confirmed the switch, or
2769
+ * { ok: false, error } if AIBroker is not running or the session was not found.
2770
+ */
2771
+ async function switchToSession(target) {
2772
+ try {
2773
+ await callAiBroker("switch", { target });
2774
+ const { spawnSync } = await import("node:child_process");
2775
+ spawnSync("osascript", ["-e", "tell application \"iTerm\" to activate"], { stdio: "ignore" });
2776
+ return { ok: true };
2777
+ } catch (e) {
2778
+ return {
2779
+ ok: false,
2780
+ error: String(e)
2781
+ };
2782
+ }
2783
+ }
2761
2784
 
2762
2785
  //#endregion
2763
2786
  //#region src/cli/commands/session/recent.ts
@@ -2766,7 +2789,7 @@ function fmtSize(bytes) {
2766
2789
  if (bytes < 1048576) return `${Math.round(bytes / 1024)}K`;
2767
2790
  return `${(bytes / 1048576).toFixed(1)}M`;
2768
2791
  }
2769
- function fmtStatus(status) {
2792
+ function fmtStatus$1(status) {
2770
2793
  switch (status) {
2771
2794
  case "resumable": return chalk.green("resumable");
2772
2795
  case "stub": return chalk.yellow("stub");
@@ -2872,7 +2895,7 @@ async function cmdRecent(db, opts) {
2872
2895
  prompt,
2873
2896
  s.userLines > 0 ? String(s.userLines) : dim("—"),
2874
2897
  dim(fmtSize(s.topLevelSize)),
2875
- fmtStatus(s.sessionStatus)
2898
+ fmtStatus$1(s.sessionStatus)
2876
2899
  ];
2877
2900
  });
2878
2901
  console.log(renderTable(headers, rows));
@@ -3389,6 +3412,62 @@ async function cmdPauseAll(opts) {
3389
3412
  console.log();
3390
3413
  }
3391
3414
 
3415
+ //#endregion
3416
+ //#region src/cli/commands/session/clear-names.ts
3417
+ /**
3418
+ * pai sessions clear-names
3419
+ *
3420
+ * Recovery command: wipes corrupted iTerm2 session name state.
3421
+ *
3422
+ * Two-step reset:
3423
+ * 1. Clear ~/.aibroker/session-names.json (the persistent name store).
3424
+ * 2. Clear user.paiName variable from all live iTerm2 sessions.
3425
+ *
3426
+ * After this runs the user can `/Name` each tab fresh. Nothing is
3427
+ * set automatically — the user re-asserts names via the /Name skill.
3428
+ */
3429
+ async function cmdClearNames(opts) {
3430
+ if (opts.dryRun) {
3431
+ console.log(dim("Dry run — would:"));
3432
+ console.log(dim(" 1. Call AIBroker clear_session_names (wipe session-names.json)"));
3433
+ console.log(dim(" 2. Call AIBroker clear_pai_names (wipe user.paiName from all iTerm sessions)"));
3434
+ return;
3435
+ }
3436
+ let anyFailed = false;
3437
+ process.stdout.write("Clearing session-names.json … ");
3438
+ try {
3439
+ const n = (await callAiBroker("clear_session_names", {}, 1e4)).cleared ?? 0;
3440
+ console.log(ok(`cleared ${n} entr${n === 1 ? "y" : "ies"}`));
3441
+ } catch (e) {
3442
+ const msg = e instanceof Error ? e.message : String(e);
3443
+ if (msg.includes("ENOENT") || msg.includes("ECONNREFUSED")) {
3444
+ console.log(warn("AIBroker not running — session-names.json not cleared"));
3445
+ anyFailed = true;
3446
+ } else {
3447
+ console.log(err(msg));
3448
+ anyFailed = true;
3449
+ }
3450
+ }
3451
+ process.stdout.write("Clearing user.paiName from iTerm2 sessions … ");
3452
+ try {
3453
+ const n = (await callAiBroker("clear_pai_names", {}, 3e4)).cleared ?? 0;
3454
+ console.log(ok(`cleared ${n} session${n === 1 ? "" : "s"}`));
3455
+ } catch (e) {
3456
+ const msg = e instanceof Error ? e.message : String(e);
3457
+ if (msg.includes("ENOENT") || msg.includes("ECONNREFUSED")) {
3458
+ console.log(warn("AIBroker not running — iTerm2 paiName variables not cleared"));
3459
+ anyFailed = true;
3460
+ } else {
3461
+ console.log(err(msg));
3462
+ anyFailed = true;
3463
+ }
3464
+ }
3465
+ if (anyFailed) {
3466
+ console.log(warn("\nSome steps could not complete. Restart AIBroker and try again."));
3467
+ process.exitCode = 1;
3468
+ } else console.log(ok("\nDone. Use /Name to re-label each tab."));
3469
+ }
3470
+
3392
3471
  //#endregion
3393
3472
  //#region src/cli/commands/session/sessions-index.ts
3394
3473
  function registerSessionsCommands(sessionsCmd, getDb) {
@@ -3435,6 +3514,9 @@ function registerSessionsCommands(sessionsCmd, getDb) {
3435
3514
  sessionsCmd.command("active").description("Show currently active Claude Code sessions.\nDetects live sessions by checking which JSONL transcript files\nwere recently modified in ~/.claude/projects/.").option("--minutes <n>", "Consider sessions active if modified within N minutes (default: 60)", "60").option("--json", "Output raw JSON instead of formatted display").action((opts) => {
3436
3515
  cmdActive(getDb(), opts);
3437
3516
  });
3517
+ sessionsCmd.command("clear-names").description("Recovery: wipe corrupted iTerm2 session name state.\nClears ~/.aibroker/session-names.json AND user.paiName from all live iTerm2 sessions.\nAfter running this, use /Name to re-label each tab.").option("--dry-run", "Preview what would be cleared without making changes").action(async (opts) => {
3518
+ await cmdClearNames(opts);
3519
+ });
3438
3520
  sessionsCmd.command("auto-route").description("Auto-detect which project this session belongs to.\nTries: (1) path match in registry, (2) Notes/PAI.md marker walk, (3) topic detection.").option("--cwd <path>", "Working directory to detect from (default: process.cwd())").option("--context <text>", "Conversation context for topic-based fallback routing").option("--json", "Output raw JSON instead of formatted display").action(async (opts) => {
3439
3521
  await cmdAutoRoute(opts);
3440
3522
  });
@@ -9535,6 +9617,64 @@ async function searchHistory(query, maxResults) {
9535
9617
 
9536
9618
  //#endregion
9537
9619
  //#region src/cli/commands/main-resolver.ts
9620
+ const STATUS_PRIORITY = {
9621
+ live: 0,
9622
+ resumable: 1,
9623
+ "transcript-only": 2,
9624
+ stub: 3,
9625
+ orphan: 4
9626
+ };
9627
+ function buildDeduped(liveSessions, diskSessions) {
9628
+ const byName = /* @__PURE__ */ new Map();
9629
+ for (const s of liveSessions) {
9630
+ if (s.kind === "shell") continue;
9631
+ const rawName = s.paiName ?? s.name ?? s.sessionId.slice(0, 8);
9632
+ const key = rawName.toLowerCase();
9633
+ const entry = {
9634
+ name: rawName,
9635
+ status: "live",
9636
+ liveSessionId: s.sessionId,
9637
+ lastActivity: Date.now(),
9638
+ project: "",
9639
+ lastPrompt: ""
9640
+ };
9641
+ const existing = byName.get(key);
9642
+ if (!existing || STATUS_PRIORITY["live"] < STATUS_PRIORITY[existing.status]) byName.set(key, entry);
9643
+ }
9644
+ for (const s of diskSessions) {
9645
+ const rawName = s.friendlyName ?? s.shortId;
9646
+ const key = rawName.toLowerCase();
9647
+ let status;
9648
+ if (s.resumable) status = "resumable";
9649
+ else if (s.sessionStatus === "transcript-only") status = "transcript-only";
9650
+ else if (s.sessionStatus === "stub") status = "stub";
9651
+ else status = "orphan";
9652
+ const entry = {
9653
+ name: rawName,
9654
+ status,
9655
+ diskSession: s,
9656
+ lastActivity: s.mtime,
9657
+ project: s.decodedPath ?? "",
9658
+ lastPrompt: s.lastUserPrompt ?? ""
9659
+ };
9660
+ const existing = byName.get(key);
9661
+ if (!existing) byName.set(key, entry);
9662
+ else {
9663
+ const existingPriority = STATUS_PRIORITY[existing.status];
9664
+ const newPriority = STATUS_PRIORITY[status];
9665
+ if (newPriority < existingPriority) byName.set(key, entry);
9666
+ else if (newPriority === existingPriority && entry.lastActivity > existing.lastActivity) byName.set(key, entry);
9667
+ }
9668
+ }
9669
+ const entries = Array.from(byName.values());
9670
+ entries.sort((a, b) => {
9671
+ const pa = STATUS_PRIORITY[a.status];
9672
+ const pb = STATUS_PRIORITY[b.status];
9673
+ if (pa !== pb) return pa - pb;
9674
+ return b.lastActivity - a.lastActivity;
9675
+ });
9676
+ return entries;
9677
+ }
9538
9678
  function probeResume(uuid, cwd) {
9539
9679
  const result = spawnSync("claude", [
9540
9680
  "--resume",
@@ -9567,23 +9707,13 @@ function probeResume(uuid, cwd) {
9567
9707
  };
9568
9708
  return { ok: true };
9569
9709
  }
9570
- /**
9571
- * Launch Claude for the given session. Handles resume probe + fallback to fresh.
9572
- * Never returns on success (process.exit inside spawnSync block).
9573
- */
9574
9710
  function launchSession(session, allSessions, dryRun) {
9575
9711
  let resumableUuid;
9576
- let resumableSession;
9577
- if (session.resumable) {
9578
- resumableUuid = session.uuid;
9579
- resumableSession = session;
9580
- } else if (session.encodedDir) {
9712
+ if (session.resumable) resumableUuid = session.uuid;
9713
+ else if (session.encodedDir) {
9581
9714
  const sameProject = allSessions.filter((s) => s.encodedDir === session.encodedDir && s.resumable);
9582
9715
  sameProject.sort((a, b) => b.mtime - a.mtime);
9583
- if (sameProject.length > 0) {
9584
- resumableSession = sameProject[0];
9585
- resumableUuid = resumableSession.uuid;
9586
- }
9716
+ if (sameProject.length > 0) resumableUuid = sameProject[0].uuid;
9587
9717
  }
9588
9718
  const rawDir = session.clcDirectory ?? session.registryRootPath ?? session.decodedPath;
9589
9719
  let projectDir;
@@ -9664,11 +9794,6 @@ function launchSession(session, allSessions, dryRun) {
9664
9794
  process.exit(result.status ?? 0);
9665
9795
  }
9666
9796
  }
9667
- /**
9668
- * Given a SessionMatch from history search, find the corresponding ScannedSession
9669
- * (for launch). Falls back to a minimal synthetic session using the decodedPath
9670
- * from the history entry's project field.
9671
- */
9672
9797
  function matchToSession(match, allSessions) {
9673
9798
  if (!match.sessionId) return null;
9674
9799
  const catalogMatch = allSessions.find((s) => s.uuid === match.sessionId);
@@ -9703,6 +9828,15 @@ function shortenProject(p, maxLen = 44) {
9703
9828
  if (!p || p.length <= maxLen) return p || dim("—");
9704
9829
  return "…" + p.slice(-(maxLen - 1));
9705
9830
  }
9831
+ function fmtStatus(s) {
9832
+ switch (s) {
9833
+ case "live": return chalk.green("live");
9834
+ case "resumable": return chalk.cyan("resumable");
9835
+ case "transcript-only": return chalk.dim("transcript");
9836
+ case "stub": return chalk.dim("stub");
9837
+ case "orphan": return chalk.dim("orphan");
9838
+ }
9839
+ }
9706
9840
  async function askForChoice(max) {
9707
9841
  return new Promise((resolve) => {
9708
9842
  const rl = createInterface({
@@ -9728,51 +9862,36 @@ async function cmdMain(db, query, pickN, opts) {
9728
9862
  try {
9729
9863
  liveSessions = await fetchLiveSessions();
9730
9864
  } catch {}
9731
- const claudeLive = liveSessions.filter((s) => s.kind !== "shell");
9732
- const recentDisk = allSessions.slice(0, maxResults);
9733
- if (claudeLive.length === 0 && recentDisk.length === 0) {
9865
+ const deduped = buildDeduped(liveSessions, allSessions);
9866
+ if (deduped.length === 0) {
9734
9867
  console.log(warn("No sessions found. Start Claude Code in a project directory first."));
9735
9868
  return;
9736
9869
  }
9737
- if (claudeLive.length > 0) {
9738
- console.log("\n" + header("Live Sessions") + "\n");
9739
- const liveHeaders = [
9740
- "#",
9741
- "id",
9742
- "name",
9743
- "at prompt"
9744
- ];
9745
- const liveRows = claudeLive.map((s, i) => [
9870
+ console.log("\n" + header("Sessions") + "\n");
9871
+ const tableHeaders = [
9872
+ "#",
9873
+ "name",
9874
+ "status",
9875
+ "age",
9876
+ "project",
9877
+ "last prompt"
9878
+ ];
9879
+ const tableRows = deduped.slice(0, maxResults).map((entry, i) => {
9880
+ const age = entry.status === "live" ? chalk.green("now") : dim(fmtAge(entry.lastActivity));
9881
+ const snippet = entry.lastPrompt.replace(/\n+/g, " ").trim().slice(0, 36);
9882
+ const project = entry.diskSession ? dim(shortenProject(entry.diskSession.friendlyName ?? entry.diskSession.decodedPath, 28)) : dim("—");
9883
+ return [
9746
9884
  dim(String(i + 1)),
9747
- chalk.cyan(s.sessionId.slice(0, 8)),
9748
- s.paiName ?? s.name ?? dim("—"),
9749
- s.atPrompt ? chalk.green("yes") : chalk.dim("no")
9750
- ]);
9751
- console.log(renderTable(liveHeaders, liveRows));
9752
- }
9753
- if (recentDisk.length > 0) {
9754
- console.log("\n" + header("Recent Sessions") + "\n");
9755
- const diskHeaders = [
9756
- "#",
9757
- "id",
9758
- "age",
9759
- "project",
9760
- "last prompt"
9885
+ chalk.white(entry.name),
9886
+ fmtStatus(entry.status),
9887
+ age,
9888
+ project,
9889
+ chalk.dim(snippet ? `"${snippet}"` : "—")
9761
9890
  ];
9762
- const diskRows = recentDisk.map((s, i) => {
9763
- const snippet = s.lastUserPrompt.replace(/\n+/g, " ").trim().slice(0, 40);
9764
- return [
9765
- dim(String(i + 1)),
9766
- chalk.cyan(s.shortId),
9767
- dim(fmtAge(s.mtime)),
9768
- dim(shortenProject(s.friendlyName ?? s.decodedPath, 30)),
9769
- chalk.dim(snippet ? `"${snippet}"` : "—")
9770
- ];
9771
- });
9772
- console.log(renderTable(diskHeaders, diskRows));
9773
- }
9891
+ });
9892
+ console.log(renderTable(tableHeaders, tableRows));
9774
9893
  console.log();
9775
- console.log(dim(" Resume a session: ") + chalk.white("pai <topic>") + dim(" or ") + chalk.white("pai <id>"));
9894
+ console.log(dim(" Switch/resume/start: ") + chalk.white("pai <name>") + dim(" or ") + chalk.white("pai <uuid-prefix>"));
9776
9895
  console.log();
9777
9896
  return;
9778
9897
  }
@@ -9789,39 +9908,94 @@ async function cmdMain(db, query, pickN, opts) {
9789
9908
  }
9790
9909
  }
9791
9910
  {
9911
+ let liveSessions = [];
9912
+ try {
9913
+ liveSessions = await fetchLiveSessions();
9914
+ } catch {}
9915
+ const deduped = buildDeduped(liveSessions, allSessions);
9792
9916
  const qLower = query.toLowerCase();
9793
- const byExact = allSessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase() === qLower);
9794
- if (byExact.length >= 1) {
9795
- launchSession(byExact[0], allSessions, opts.dryRun ?? false);
9796
- return;
9917
+ const exactMatch = deduped.find((e) => e.name.toLowerCase() === qLower);
9918
+ if (exactMatch) {
9919
+ if (exactMatch.status === "live" && exactMatch.liveSessionId) {
9920
+ if (opts.dryRun) {
9921
+ console.log("\n" + chalk.bold("Dry run — would switch iTerm tab:") + "\n");
9922
+ console.log(` target: ${exactMatch.name} (${exactMatch.liveSessionId.slice(0, 8)})`);
9923
+ console.log(` action: aibroker_switch + osascript iTerm activate`);
9924
+ console.log();
9925
+ return;
9926
+ }
9927
+ const result = await switchToSession(exactMatch.liveSessionId);
9928
+ if (result.ok) console.log(ok(`Switched to live session: ${chalk.white(exactMatch.name)}`));
9929
+ else {
9930
+ console.error(warn(`Could not switch via AIBroker: ${result.error ?? "unknown error"}`));
9931
+ console.error(dim(" Falling back to disk launch..."));
9932
+ if (exactMatch.diskSession) launchSession(exactMatch.diskSession, allSessions, opts.dryRun ?? false);
9933
+ }
9934
+ return;
9935
+ }
9936
+ if (exactMatch.diskSession) {
9937
+ launchSession(exactMatch.diskSession, allSessions, opts.dryRun ?? false);
9938
+ return;
9939
+ }
9797
9940
  }
9798
- const byPartial = allSessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase().includes(qLower));
9799
- if (byPartial.length === 1) {
9800
- launchSession(byPartial[0], allSessions, opts.dryRun ?? false);
9801
- return;
9941
+ const partialMatches = deduped.filter((e) => e.name.toLowerCase().includes(qLower));
9942
+ if (partialMatches.length === 1) {
9943
+ const match = partialMatches[0];
9944
+ if (match.status === "live" && match.liveSessionId) {
9945
+ if (opts.dryRun) {
9946
+ console.log("\n" + chalk.bold("Dry run — would switch iTerm tab:") + "\n");
9947
+ console.log(` target: ${match.name} (${match.liveSessionId.slice(0, 8)})`);
9948
+ console.log(` action: aibroker_switch + osascript iTerm activate`);
9949
+ console.log();
9950
+ return;
9951
+ }
9952
+ const result = await switchToSession(match.liveSessionId);
9953
+ if (result.ok) console.log(ok(`Switched to live session: ${chalk.white(match.name)}`));
9954
+ else {
9955
+ console.error(warn(`Could not switch via AIBroker: ${result.error ?? "unknown error"}`));
9956
+ if (match.diskSession) launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
9957
+ }
9958
+ return;
9959
+ }
9960
+ if (match.diskSession) {
9961
+ launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
9962
+ return;
9963
+ }
9802
9964
  }
9803
- if (byPartial.length > 1) {
9965
+ if (partialMatches.length > 1) {
9804
9966
  console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
9805
9967
  const headers = [
9806
9968
  "#",
9807
- "id",
9808
- "age",
9809
9969
  "name",
9970
+ "status",
9971
+ "age",
9810
9972
  "project"
9811
9973
  ];
9812
- const rows = byPartial.slice(0, maxResults).map((s, i) => [
9813
- dim(String(i + 1)),
9814
- chalk.cyan(s.shortId),
9815
- dim(fmtAge(s.mtime)),
9816
- s.friendlyName ?? dim("—"),
9817
- dim(shortenProject(s.decodedPath, 36))
9818
- ]);
9974
+ const rows = partialMatches.slice(0, maxResults).map((entry, i) => {
9975
+ const age = entry.status === "live" ? chalk.green("now") : dim(fmtAge(entry.lastActivity));
9976
+ const project = entry.diskSession ? dim(shortenProject(entry.diskSession.decodedPath, 36)) : dim("—");
9977
+ return [
9978
+ dim(String(i + 1)),
9979
+ chalk.white(entry.name),
9980
+ fmtStatus(entry.status),
9981
+ age,
9982
+ project
9983
+ ];
9984
+ });
9819
9985
  console.log(renderTable(headers, rows));
9820
9986
  console.log();
9821
9987
  if (pickN !== void 0) {
9822
9988
  const idx = pickN - 1;
9823
- if (idx >= 0 && idx < byPartial.length) {
9824
- launchSession(byPartial[idx], allSessions, opts.dryRun ?? false);
9989
+ if (idx >= 0 && idx < partialMatches.length) {
9990
+ const match = partialMatches[idx];
9991
+ if (match.status === "live" && match.liveSessionId) {
9992
+ if (!opts.dryRun) {
9993
+ await switchToSession(match.liveSessionId);
9994
+ console.log(ok(`Switched to live session: ${chalk.white(match.name)}`));
9995
+ }
9996
+ return;
9997
+ }
9998
+ if (match.diskSession) launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
9825
9999
  return;
9826
10000
  }
9827
10001
  console.error(err(`Invalid choice: ${pickN}`));
@@ -9829,17 +10003,34 @@ async function cmdMain(db, query, pickN, opts) {
9829
10003
  return;
9830
10004
  }
9831
10005
  if (opts.auto) {
9832
- launchSession(byPartial[0], allSessions, opts.dryRun ?? false);
10006
+ const match = partialMatches[0];
10007
+ if (match.status === "live" && match.liveSessionId) {
10008
+ if (!opts.dryRun) {
10009
+ await switchToSession(match.liveSessionId);
10010
+ console.log(ok(`Switched to live session: ${chalk.white(match.name)}`));
10011
+ }
10012
+ return;
10013
+ }
10014
+ if (match.diskSession) launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
9833
10015
  return;
9834
10016
  }
9835
- const choice = await askForChoice(Math.min(byPartial.length, maxResults));
9836
- if (choice !== null) launchSession(byPartial[choice - 1], allSessions, opts.dryRun ?? false);
10017
+ const choice = await askForChoice(Math.min(partialMatches.length, maxResults));
10018
+ if (choice !== null) {
10019
+ const match = partialMatches[choice - 1];
10020
+ if (match.status === "live" && match.liveSessionId) {
10021
+ await switchToSession(match.liveSessionId);
10022
+ console.log(ok(`Switched to live session: ${chalk.white(match.name)}`));
10023
+ return;
10024
+ }
10025
+ if (match.diskSession) launchSession(match.diskSession, allSessions, opts.dryRun ?? false);
10026
+ }
9837
10027
  return;
9838
10028
  }
9839
10029
  }
9840
10030
  if (!existsSync(HISTORY_FILE)) {
9841
10031
  console.error(err("~/.claude/history.jsonl not found."));
9842
10032
  console.error(dim(" No prompt history available for search."));
10033
+ console.error(dim(` Try: pai (no args) to see all sessions.`));
9843
10034
  process.exitCode = 1;
9844
10035
  return;
9845
10036
  }
@@ -9848,7 +10039,7 @@ async function cmdMain(db, query, pickN, opts) {
9848
10039
  if (matches.length === 0) {
9849
10040
  console.log(warn(`No sessions found matching "${query}".`));
9850
10041
  console.log(dim(` Try a shorter or different search term.`));
9851
- console.log(dim(` Or run: `) + chalk.white("pai") + dim(" (no args) to see all recent sessions."));
10042
+ console.log(dim(` Or run: `) + chalk.white("pai") + dim(" (no args) to see all sessions."));
9852
10043
  return;
9853
10044
  }
9854
10045
  console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
@@ -9917,13 +10108,12 @@ async function cmdMain(db, query, pickN, opts) {
9917
10108
  //#endregion
9918
10109
  //#region src/cli/index.ts
9919
10110
  /**
9920
- * PAI Knowledge OS — CLI entry point (v0.10.0 topic-first redesign)
10111
+ * PAI Knowledge OS — CLI entry point (v0.11.0 deduped sessions + universal pai <name>)
9921
10112
  *
9922
- * Top-level surface:
9923
- * pai → recent sessions picker
9924
- * pai <topic> history search + candidate picker + launch
10113
+ * Daily surface:
10114
+ * pai → deduped session listing (one row per name)
10115
+ * pai <name> universal: switch live tab / resume / fresh
9925
10116
  * pai <uuid-prefix> → direct session resume via filesystem scan
9926
- * pai cd <name> → cd to project directory (no Claude launch)
9927
10117
  * pai pause [all] → save state (or mass-pause every live session)
9928
10118
  * pai end → finalize session
9929
10119
  *
@@ -9953,28 +10143,25 @@ function getDb() {
9953
10143
  }
9954
10144
  const program = new Command();
9955
10145
  program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit").addHelpText("after", `
9956
- Usage:
9957
- pai Show recent sessions (interactive picker)
9958
- pai <topic> Find sessions by topic and launch the chosen one
9959
- pai <uuid-prefix> Resume a specific session by UUID
9960
- pai cd <name> cd to a project directory (no Claude launch)
9961
- pai pause [all] Save state (or mass-pause every live session)
9962
- pai end Finalize session: save state + mark note Completed
10146
+ Daily commands:
10147
+ pai List your sessions (deduped by name)
10148
+ pai <name> Switch / resume / fresh universal
10149
+ pai pause Save state checkpoint for the current session
10150
+ pai pause all Pause every live Claude session at once
10151
+ pai end Finalize current session: save state + mark Completed
10152
+
10153
+ Subcommands (advanced):
10154
+ pai sessions ... Full session management
10155
+ pai projects ... Project management
10156
+ pai registry ... Registry maintenance
10157
+ pai memory ... Memory engine
10158
+ pai shell-init Shell integration (eval "$(pai shell-init)")
9963
10159
 
9964
10160
  Examples:
9965
- pai mdf Find all sessions where you worked on MDF
9966
- pai solar panels Free-text search across your prompt history
10161
+ pai aibroker Switch to the live AIBroker tab in iTerm
10162
+ pai mdf Find sessions where you worked on MDF
9967
10163
  pai 81c5c3dc Resume session by UUID prefix
9968
- pai See the 20 most recent sessions
9969
-
9970
- Power-user namespaces (run "pai sessions --help" etc. for details):
9971
- pai sessions ... Full session management (list, goto, info, ...)
9972
- pai projects ... Project management (cd, rebind, list, ...)
9973
- pai registry ... Registry maintenance (scan, ...)
9974
- pai memory ... Memory engine (index, search, ...)
9975
-
9976
- Shell integration:
9977
- eval "$(pai shell-init)" # Add to ~/.zshrc for pai cd to work`);
10164
+ pai See all sessions (deduped, one row per name)`);
9978
10165
  registerProjectsCommands(program.command("projects").description("Manage registered projects (list, cd, add, info, ...)"), getDb);
9979
10166
  const sessionsCmd = program.command("sessions").description("Session management (list, goto, pause, ...)");
9980
10167
  registerSessionsCommands(sessionsCmd, getDb);
@@ -10012,10 +10199,10 @@ program.command("pause [target]").description("Save state and display safe-exit
10012
10199
  program.command("end").description("Finalize a session: save state, mark note Completed, display safe-exit instructions.\nLong form: pai sessions end").option("--dry-run", "Preview all changes without writing them").action((opts) => {
10013
10200
  cmdEnd(getDb(), opts);
10014
10201
  });
10015
- program.command("resume <name>").description("Go to a session by name or UUID: resume if resumable, start fresh otherwise.\nAlso: pai <name> (shorter) | pai sessions goto <name> (long form)").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
10202
+ program.command("resume <name>", { hidden: true }).description("Go to a session by name or UUID: resume if resumable, start fresh otherwise.\nPrefer: pai <name> | Long form: pai sessions goto <name>").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
10016
10203
  cmdGoto(getDb(), name, { dryRun: opts.dryRun });
10017
10204
  });
10018
- program.command("cd <identifier>").description("cd to a project directory (shell wrapper handles the actual cd).\nLong form: pai projects cd <identifier>\nThe shell function installed by pai shell-init intercepts this command\nand calls builtin cd with the resolved path.\nAuto-detects moved projects when the registered path no longer exists.").action((identifier) => {
10205
+ program.command("cd <identifier>", { hidden: true }).description("cd to a project directory (shell wrapper handles the actual cd).\nLong form: pai projects cd <identifier>\nThe shell function installed by pai shell-init intercepts this command\nand calls builtin cd with the resolved path.\nAuto-detects moved projects when the registered path no longer exists.").action((identifier) => {
10019
10206
  const db = getDb();
10020
10207
  const project = resolveIdentifier(db, identifier);
10021
10208
  if (!project) {