@tekmidian/pai 0.9.14 → 0.9.15

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.
@@ -20,8 +20,10 @@ import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSy
20
20
  import { homedir, platform, tmpdir } from "node:os";
21
21
  import { basename, dirname, join, relative, resolve } from "node:path";
22
22
  import chalk from "chalk";
23
+ import { randomUUID } from "node:crypto";
23
24
  import { Command } from "commander";
24
25
  import { fileURLToPath } from "node:url";
26
+ import { connect } from "node:net";
25
27
  import { execSync, spawnSync } from "node:child_process";
26
28
  import { createInterface } from "node:readline";
27
29
  import { createConnection } from "net";
@@ -1942,13 +1944,26 @@ function cmdHandover(db, projectSlug, numberOrLatest) {
1942
1944
  * line of type "system". Sessions that only have a sessions/ counterpart cannot be
1943
1945
  * resumed by Claude Code regardless of how much transcript content they have.
1944
1946
  *
1945
- * Scan strategy:
1946
- * 1. Walk top-level <project>/<uuid>.jsonl files (fast: ~20 files total).
1947
- * 2. For each, check system line count to set the resumable flag.
1948
- * 3. Join with sessions/<uuid>.jsonl (if present) for user lines, last prompt, ai-title.
1949
- * 4. Enrich with clc name registry and PAI registry root path.
1947
+ * Stale-UUID problem (fixed in this version):
1948
+ * The clc session.json registry stores ONE uuid per named session the uuid Claude Code
1949
+ * had when the user last named the session. If the user resumes and the session gets a
1950
+ * new uuid (or they Ctrl+C and start fresh), the registry entry still points to the OLD
1951
+ * uuid. The scanner now resolves names to the MOST RECENT top-level jsonl in the project
1952
+ * directory, not the clc-cached uuid.
1950
1953
  *
1951
- * Used by: pai session recent, pai session resume
1954
+ * Resolution strategy:
1955
+ * 1. Walk top-level <project>/<uuid>.jsonl files (Pass 1).
1956
+ * 2. For each, attach the clc name if the uuid matches (exact hit).
1957
+ * 3. After Pass 1, for every clc registry entry:
1958
+ * a. If the cached uuid was found → already handled.
1959
+ * b. Find the encodedDir for this entry's directory.
1960
+ * c. Check ALL sessions in that encodedDir (already in our Pass-1 results).
1961
+ * d. Pick the MOST RECENT resumable session in that dir and attach the name to it.
1962
+ * If no resumable session, fall through to transcript-only pass.
1963
+ * 4. This means the displayed uuid for "Jobs Matthias" is always today's active session,
1964
+ * not the stale cached one.
1965
+ *
1966
+ * Used by: pai sessions, pai resume
1952
1967
  */
1953
1968
  const CLC_SESSIONS_FILE = join(homedir(), ".claude", "session.json");
1954
1969
  /** Load clc's session registry → uuid → ClcInfo map. Verbatim, never slugified. */
@@ -2082,7 +2097,11 @@ function resolveFilter(opts) {
2082
2097
  * Scan ~/.claude/projects/ for all Claude Code sessions.
2083
2098
  *
2084
2099
  * Pass 1: walk top-level <project>/<uuid>.jsonl files (the resumability source).
2085
- * Pass 2: add clc registry entries that have no top-level jsonl (transcript-only).
2100
+ * Pass 2: handle clc registry entries whose cached UUID was not found in Pass 1.
2101
+ * For each such entry, scan the entry's project dir for the FRESHEST session
2102
+ * and attach the registry name to it. This fixes the stale-UUID bug where
2103
+ * clc's session.json points to an old uuid after a fresh start.
2104
+ * Pass 3: any remaining clc entries with truly no top-level jsonl → transcript-only.
2086
2105
  *
2087
2106
  * Filter modes:
2088
2107
  * "named" — resumable + registry-known stubs + transcript-only (default)
@@ -2099,12 +2118,14 @@ function scanSessions(db, opts = {}) {
2099
2118
  const rootPathMap = buildRegistryRootPathMap(db);
2100
2119
  const results = [];
2101
2120
  const seenUuids = /* @__PURE__ */ new Set();
2121
+ const attachedClcUuids = /* @__PURE__ */ new Set();
2102
2122
  let encodedDirs;
2103
2123
  try {
2104
2124
  encodedDirs = readdirSync(CLAUDE_PROJECTS_DIR);
2105
2125
  } catch {
2106
2126
  return [];
2107
2127
  }
2128
+ const sessionsByEncodedDir = /* @__PURE__ */ new Map();
2108
2129
  for (const encodedDir of encodedDirs) {
2109
2130
  const projectDir = join(CLAUDE_PROJECTS_DIR, encodedDir);
2110
2131
  try {
@@ -2131,8 +2152,7 @@ function scanSessions(db, opts = {}) {
2131
2152
  const clcInfo = clcInfoMap.get(uuid);
2132
2153
  const inRegistry = !!clcInfo;
2133
2154
  const sessionStatus = resumable ? "resumable" : inRegistry ? "stub" : "orphan";
2134
- if (filterMode === "resumable" && !resumable) continue;
2135
- if (filterMode === "named" && !resumable && !inRegistry) continue;
2155
+ const passesFilter = filterMode === "resumable" ? resumable : filterMode === "named" ? resumable || inRegistry : true;
2136
2156
  const sessionJsonlPath = join(projectDir, "sessions", `${uuid}.jsonl`);
2137
2157
  const hasTranscript = existsSync(sessionJsonlPath);
2138
2158
  const transcript = hasTranscript ? parseTranscript(sessionJsonlPath) : {
@@ -2143,8 +2163,7 @@ function scanSessions(db, opts = {}) {
2143
2163
  };
2144
2164
  const mtime = topInfo.mtime || transcript.mtime;
2145
2165
  const friendlyName = clcInfo?.name ?? transcript.aiTitle ?? projectBasename ?? void 0;
2146
- seenUuids.add(uuid);
2147
- results.push({
2166
+ const session = {
2148
2167
  uuid,
2149
2168
  shortId: uuid.slice(0, 8),
2150
2169
  encodedDir,
@@ -2163,48 +2182,90 @@ function scanSessions(db, opts = {}) {
2163
2182
  friendlyName,
2164
2183
  clcDirectory: clcInfo?.directory,
2165
2184
  registryRootPath
2166
- });
2185
+ };
2186
+ if (!sessionsByEncodedDir.has(encodedDir)) sessionsByEncodedDir.set(encodedDir, []);
2187
+ sessionsByEncodedDir.get(encodedDir).push(session);
2188
+ seenUuids.add(uuid);
2189
+ if (inRegistry) attachedClcUuids.add(uuid);
2190
+ if (passesFilter) results.push(session);
2167
2191
  }
2168
2192
  }
2169
- if (filterMode !== "resumable") for (const [uuid, clcInfo] of clcInfoMap) {
2170
- if (seenUuids.has(uuid)) continue;
2193
+ if (filterMode !== "resumable") for (const [cachedUuid, clcInfo] of clcInfoMap) {
2194
+ if (attachedClcUuids.has(cachedUuid)) continue;
2171
2195
  let foundEncodedDir;
2172
- let foundTranscriptPath;
2173
- if (clcInfo.directory) try {
2196
+ if (clcInfo.directory) {
2174
2197
  const real = realpathSyncSafe(clcInfo.directory);
2175
2198
  if (real) {
2176
2199
  const encoded = encodeProjectDir(real);
2177
- const candidateTranscript = join(CLAUDE_PROJECTS_DIR, encoded, "sessions", `${uuid}.jsonl`);
2178
- if (existsSync(candidateTranscript)) {
2179
- foundEncodedDir = encoded;
2180
- foundTranscriptPath = candidateTranscript;
2181
- } else if (existsSync(join(CLAUDE_PROJECTS_DIR, encoded))) foundEncodedDir = encoded;
2200
+ if (existsSync(join(CLAUDE_PROJECTS_DIR, encoded))) foundEncodedDir = encoded;
2182
2201
  }
2183
- } catch {}
2184
- if (!foundEncodedDir) for (const encodedDir of encodedDirs) {
2185
- const candidateTranscript = join(CLAUDE_PROJECTS_DIR, encodedDir, "sessions", `${uuid}.jsonl`);
2186
- if (existsSync(candidateTranscript)) {
2202
+ }
2203
+ if (!foundEncodedDir) {
2204
+ for (const encodedDir of encodedDirs) if (existsSync(join(CLAUDE_PROJECTS_DIR, encodedDir, "sessions", `${cachedUuid}.jsonl`))) {
2187
2205
  foundEncodedDir = encodedDir;
2188
- foundTranscriptPath = candidateTranscript;
2189
2206
  break;
2190
2207
  }
2191
2208
  }
2192
- const encodedDir = foundEncodedDir ?? "";
2193
- const decodedPath = clcInfo.directory ?? (foundEncodedDir ? smartDecodeDir(foundEncodedDir) ?? foundEncodedDir.replace(/-/g, "/") : uuid);
2194
- const registryRootPath = foundEncodedDir ? rootPathMap.get(foundEncodedDir) : void 0;
2195
- const topLevelPath = foundEncodedDir ? join(CLAUDE_PROJECTS_DIR, foundEncodedDir, `${uuid}.jsonl`) : "";
2209
+ if (foundEncodedDir) {
2210
+ const freshestResumable = (sessionsByEncodedDir.get(foundEncodedDir) ?? []).filter((s) => s.resumable && !s.friendlyName).sort((a, b) => b.mtime - a.mtime)[0];
2211
+ if (freshestResumable) {
2212
+ freshestResumable.friendlyName = clcInfo.name;
2213
+ freshestResumable.clcDirectory = freshestResumable.clcDirectory ?? clcInfo.directory;
2214
+ freshestResumable.sessionStatus = "resumable";
2215
+ attachedClcUuids.add(freshestResumable.uuid);
2216
+ if (!seenUuids.has(freshestResumable.uuid) || !results.includes(freshestResumable)) {
2217
+ if (!results.includes(freshestResumable)) results.push(freshestResumable);
2218
+ }
2219
+ continue;
2220
+ }
2221
+ }
2222
+ if (!foundEncodedDir) {
2223
+ const encodedDir = "";
2224
+ const decodedPath = clcInfo.directory ?? cachedUuid;
2225
+ const registryRootPath = void 0;
2226
+ const mtime = {
2227
+ userLines: 0,
2228
+ lastUserPrompt: "",
2229
+ msgCount: 0,
2230
+ mtime: 0
2231
+ }.mtime;
2232
+ seenUuids.add(cachedUuid);
2233
+ results.push({
2234
+ uuid: cachedUuid,
2235
+ shortId: cachedUuid.slice(0, 8),
2236
+ encodedDir,
2237
+ decodedPath,
2238
+ topLevelPath: "",
2239
+ topLevelSystemLines: 0,
2240
+ topLevelSize: 0,
2241
+ resumable: false,
2242
+ sessionStatus: "transcript-only",
2243
+ sessionJsonlPath: void 0,
2244
+ userLines: 0,
2245
+ lastUserPrompt: "",
2246
+ msgCount: 0,
2247
+ mtime,
2248
+ friendlyName: clcInfo.name,
2249
+ clcDirectory: clcInfo.directory,
2250
+ registryRootPath
2251
+ });
2252
+ continue;
2253
+ }
2254
+ const foundTranscriptPath = existsSync(join(CLAUDE_PROJECTS_DIR, foundEncodedDir, "sessions", `${cachedUuid}.jsonl`)) ? join(CLAUDE_PROJECTS_DIR, foundEncodedDir, "sessions", `${cachedUuid}.jsonl`) : void 0;
2255
+ const decodedPath = clcInfo.directory ?? smartDecodeDir(foundEncodedDir) ?? foundEncodedDir.replace(/-/g, "/");
2256
+ const registryRootPath = rootPathMap.get(foundEncodedDir);
2257
+ const topLevelPath = join(CLAUDE_PROJECTS_DIR, foundEncodedDir, `${cachedUuid}.jsonl`);
2196
2258
  const transcript = foundTranscriptPath ? parseTranscript(foundTranscriptPath) : {
2197
2259
  userLines: 0,
2198
2260
  lastUserPrompt: "",
2199
2261
  msgCount: 0,
2200
2262
  mtime: 0
2201
2263
  };
2202
- const mtime = transcript.mtime;
2203
- seenUuids.add(uuid);
2264
+ seenUuids.add(cachedUuid);
2204
2265
  results.push({
2205
- uuid,
2206
- shortId: uuid.slice(0, 8),
2207
- encodedDir,
2266
+ uuid: cachedUuid,
2267
+ shortId: cachedUuid.slice(0, 8),
2268
+ encodedDir: foundEncodedDir,
2208
2269
  decodedPath,
2209
2270
  topLevelPath,
2210
2271
  topLevelSystemLines: 0,
@@ -2216,7 +2277,7 @@ function scanSessions(db, opts = {}) {
2216
2277
  lastUserPrompt: transcript.lastUserPrompt,
2217
2278
  msgCount: transcript.msgCount,
2218
2279
  aiTitle: transcript.aiTitle,
2219
- mtime,
2280
+ mtime: transcript.mtime,
2220
2281
  friendlyName: clcInfo.name,
2221
2282
  clcDirectory: clcInfo.directory,
2222
2283
  registryRootPath
@@ -2282,7 +2343,116 @@ function resolveSessionByNameOrId(sessions, query) {
2282
2343
  const candidates = byUuid.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
2283
2344
  throw new Error(`UUID prefix "${query}" is ambiguous — ${byUuid.length} matches:\n${candidates}\n\nProvide more characters.`);
2284
2345
  }
2285
- throw new Error(`No resumable session found matching "${query}".\n\nRun: pai session recent to list resumable sessions.\nRun: pai session recent --all to include transcript-only sessions.`);
2346
+ throw new Error(`No session found matching "${query}".\n\nRun: pai sessions to list sessions.\nRun: pai sessions --all to include transcript-only sessions.`);
2347
+ }
2348
+
2349
+ //#endregion
2350
+ //#region src/cli/lib/aibroker-client.ts
2351
+ /**
2352
+ * aibroker-client.ts — Lightweight IPC client for AIBroker daemon.
2353
+ *
2354
+ * Connects to the AIBroker Unix Domain Socket, sends a JSON-RPC request,
2355
+ * and reads a single newline-terminated JSON response. No class needed —
2356
+ * just a thin async function matching the WatcherClient protocol.
2357
+ *
2358
+ * Socket path: /tmp/aibroker.sock (default; override via AIBROKER_SOCKET env).
2359
+ */
2360
+ const DEFAULT_SOCKET = process.env.AIBROKER_SOCKET ?? "/tmp/aibroker.sock";
2361
+ /**
2362
+ * Call an AIBroker IPC method and return the result.
2363
+ *
2364
+ * Resolves with the `result` field of a successful response.
2365
+ * Rejects if the socket is not available, the call times out, or the
2366
+ * daemon returns an error.
2367
+ *
2368
+ * @param method IPC method name (e.g. "session_content", "send_to_session")
2369
+ * @param params Method parameters object
2370
+ * @param timeoutMs Connection + response timeout in milliseconds (default: 8 000)
2371
+ */
2372
+ function callAiBroker(method, params = {}, timeoutMs = 8e3) {
2373
+ return new Promise((resolve, reject) => {
2374
+ const socketPath = DEFAULT_SOCKET;
2375
+ let done = false;
2376
+ let buffer = "";
2377
+ let timer = null;
2378
+ function finish(err, value) {
2379
+ if (done) return;
2380
+ done = true;
2381
+ if (timer !== null) {
2382
+ clearTimeout(timer);
2383
+ timer = null;
2384
+ }
2385
+ try {
2386
+ socket.destroy();
2387
+ } catch {}
2388
+ if (err) reject(err);
2389
+ else resolve(value);
2390
+ }
2391
+ const socket = connect(socketPath, () => {
2392
+ const request = {
2393
+ id: randomUUID(),
2394
+ sessionId: process.env.TERM_SESSION_ID ?? "pai-cli",
2395
+ method,
2396
+ params
2397
+ };
2398
+ const itermId = process.env.ITERM_SESSION_ID;
2399
+ if (itermId) Object.assign(request, { itermSessionId: itermId });
2400
+ socket.write(JSON.stringify(request) + "\n");
2401
+ });
2402
+ socket.on("data", (chunk) => {
2403
+ buffer += chunk.toString("utf8");
2404
+ const nl = buffer.indexOf("\n");
2405
+ if (nl === -1) return;
2406
+ const line = buffer.slice(0, nl);
2407
+ let response;
2408
+ try {
2409
+ response = JSON.parse(line);
2410
+ } catch {
2411
+ finish(/* @__PURE__ */ new Error(`AIBroker IPC parse error: ${line.slice(0, 120)}`));
2412
+ return;
2413
+ }
2414
+ if (!response.ok) finish(new Error(response.error ?? "AIBroker IPC call failed"));
2415
+ else finish(null, response.result ?? {});
2416
+ });
2417
+ socket.on("error", (e) => {
2418
+ if (e.code === "ENOENT" || e.code === "ECONNREFUSED") finish(/* @__PURE__ */ new Error("AIBroker not running (socket not found)."));
2419
+ else finish(e);
2420
+ });
2421
+ socket.on("end", () => {
2422
+ if (!done) finish(/* @__PURE__ */ new Error("AIBroker IPC connection closed before response."));
2423
+ });
2424
+ timer = setTimeout(() => finish(/* @__PURE__ */ new Error("AIBroker IPC call timed out.")), timeoutMs);
2425
+ });
2426
+ }
2427
+ /**
2428
+ * Fetch all live iTerm2 sessions from AIBroker.
2429
+ * Returns an empty array if AIBroker is not running.
2430
+ */
2431
+ async function fetchLiveSessions() {
2432
+ try {
2433
+ const sessions = (await callAiBroker("session_content", { lineCount: 0 })).sessions;
2434
+ if (!Array.isArray(sessions)) return [];
2435
+ return sessions;
2436
+ } catch {
2437
+ return [];
2438
+ }
2439
+ }
2440
+ /**
2441
+ * Send text to a specific AIBroker session by its iTerm2 sessionId.
2442
+ */
2443
+ async function sendToSession(sessionId, text) {
2444
+ try {
2445
+ await callAiBroker("send_to_session", {
2446
+ sessionId,
2447
+ text
2448
+ });
2449
+ return { ok: true };
2450
+ } catch (e) {
2451
+ return {
2452
+ ok: false,
2453
+ error: String(e)
2454
+ };
2455
+ }
2286
2456
  }
2287
2457
 
2288
2458
  //#endregion
@@ -2300,38 +2470,73 @@ function fmtStatus(status) {
2300
2470
  case "orphan": return chalk.dim("orphan");
2301
2471
  }
2302
2472
  }
2303
- function cmdRecent(db, opts) {
2473
+ function renderLiveSessions(liveSessions) {
2474
+ if (liveSessions.length === 0) return;
2475
+ console.log("\n" + header("Live Sessions") + "\n");
2476
+ const liveHeaders = [
2477
+ "#",
2478
+ "iTerm2 id",
2479
+ "name",
2480
+ "at prompt",
2481
+ "paiName"
2482
+ ];
2483
+ const liveRows = liveSessions.map((s, idx) => {
2484
+ const shortId = s.sessionId.slice(0, 8);
2485
+ const name = s.name.length > 32 ? s.name.slice(0, 31) + "…" : s.name;
2486
+ const paiName = s.paiName ? s.paiName.length > 20 ? s.paiName.slice(0, 19) + "…" : s.paiName : dim("—");
2487
+ const atPrompt = s.atPrompt ? chalk.green("yes") : chalk.yellow("busy");
2488
+ return [
2489
+ chalk.dim(String(idx + 1)),
2490
+ chalk.cyan(shortId),
2491
+ name,
2492
+ atPrompt,
2493
+ paiName
2494
+ ];
2495
+ });
2496
+ console.log(renderTable(liveHeaders, liveRows));
2497
+ }
2498
+ async function cmdRecent(db, opts) {
2304
2499
  const limit = parseInt(opts.n ?? "20", 10);
2305
2500
  const includeAll = opts.all === true;
2501
+ const liveSessions = await fetchLiveSessions();
2306
2502
  const sessions = scanSessions(db, {
2307
2503
  limit,
2308
2504
  filter: includeAll ? "all" : "named"
2309
2505
  });
2310
- if (sessions.length === 0) {
2311
- if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
2312
- else console.log(err("No named sessions found.\n\n Named sessions appear when you have entries in ~/.claude/session.json\n (set via /Name inside Claude Code) or resumable top-level jsonl files.\n Run: pai session recent --all to list all sessions including unnamed orphans."));
2313
- return;
2314
- }
2315
2506
  if (opts.json) {
2316
- const output = sessions.map((s, idx) => ({
2317
- idx: idx + 1,
2318
- uuid: s.uuid,
2319
- shortId: s.shortId,
2320
- resumable: s.resumable,
2321
- sessionStatus: s.sessionStatus,
2322
- age: fmtAge(s.mtime),
2323
- mtime: s.mtime,
2324
- name: s.friendlyName,
2325
- lastUserPrompt: s.lastUserPrompt,
2326
- userLines: s.userLines,
2327
- msgCount: s.msgCount,
2328
- topLevelSize: s.topLevelSize,
2329
- decodedPath: s.decodedPath
2330
- }));
2507
+ const output = {
2508
+ live: liveSessions.map((s) => ({
2509
+ sessionId: s.sessionId,
2510
+ name: s.name,
2511
+ paiName: s.paiName ?? null,
2512
+ atPrompt: s.atPrompt
2513
+ })),
2514
+ paused: sessions.map((s, idx) => ({
2515
+ idx: idx + 1,
2516
+ uuid: s.uuid,
2517
+ shortId: s.shortId,
2518
+ resumable: s.resumable,
2519
+ sessionStatus: s.sessionStatus,
2520
+ age: fmtAge(s.mtime),
2521
+ mtime: s.mtime,
2522
+ name: s.friendlyName,
2523
+ lastUserPrompt: s.lastUserPrompt,
2524
+ userLines: s.userLines,
2525
+ msgCount: s.msgCount,
2526
+ topLevelSize: s.topLevelSize,
2527
+ decodedPath: s.decodedPath
2528
+ }))
2529
+ };
2331
2530
  console.log(JSON.stringify(output, null, 2));
2332
2531
  return;
2333
2532
  }
2334
- const title = includeAll ? "All Sessions (named + orphans)" : "Named Sessions";
2533
+ if (liveSessions.length > 0) renderLiveSessions(liveSessions);
2534
+ if (sessions.length === 0) {
2535
+ if (liveSessions.length === 0) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
2536
+ else console.log(err("No named sessions found.\n\n Named sessions appear when you have entries in ~/.claude/session.json\n (set via /Name inside Claude Code) or resumable top-level jsonl files.\n Run: pai session recent --all to list all sessions including unnamed orphans."));
2537
+ return;
2538
+ }
2539
+ const title = includeAll ? "Paused / All Sessions (named + orphans)" : "Paused / Resumable Sessions";
2335
2540
  console.log("\n" + header(title) + "\n");
2336
2541
  const headers = [
2337
2542
  "#",
@@ -2358,11 +2563,48 @@ function cmdRecent(db, opts) {
2358
2563
  ];
2359
2564
  });
2360
2565
  console.log(renderTable(headers, rows));
2361
- console.log("\n" + dim("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (includeAll ? "" : dim("Show all sessions: ") + chalk.white("pai sessions --all") + "\n"));
2566
+ console.log("\n" + dim("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (liveSessions.length > 0 ? dim("Pause all live: ") + chalk.white("pai pause all") + "\n" : "") + (includeAll ? "" : dim("Show all sessions: ") + chalk.white("pai sessions --all") + "\n"));
2362
2567
  }
2363
2568
 
2364
2569
  //#endregion
2365
2570
  //#region src/cli/commands/session/goto.ts
2571
+ /**
2572
+ * Run claude --resume <uuid> --print --output-format=json "_" in the given cwd.
2573
+ * Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
2574
+ * in stderr). Timeout: 5 seconds.
2575
+ */
2576
+ function probeResume(uuid, cwd) {
2577
+ const result = spawnSync("claude", [
2578
+ "--resume",
2579
+ uuid,
2580
+ "--print",
2581
+ "--output-format=json",
2582
+ "_"
2583
+ ], {
2584
+ cwd,
2585
+ timeout: 5e3,
2586
+ env: process.env,
2587
+ stdio: [
2588
+ "ignore",
2589
+ "ignore",
2590
+ "pipe"
2591
+ ]
2592
+ });
2593
+ if (result.error) return {
2594
+ ok: false,
2595
+ reason: `spawn error: ${result.error.message}`
2596
+ };
2597
+ const stderr = result.stderr?.toString("utf8") ?? "";
2598
+ if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
2599
+ ok: false,
2600
+ reason: "No conversation found for this UUID"
2601
+ };
2602
+ if (result.status !== 0) return {
2603
+ ok: false,
2604
+ reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
2605
+ };
2606
+ return { ok: true };
2607
+ }
2366
2608
  function cmdGoto(db, query, opts) {
2367
2609
  const allSessions = scanSessions(db, {
2368
2610
  limit: 500,
@@ -2397,44 +2639,84 @@ function cmdGoto(db, query, opts) {
2397
2639
  console.error(err(`session "${query}": directory does not exist or cannot be resolved.\n Registry says: ${rawDir}\n The directory may have moved or been deleted.`));
2398
2640
  process.exit(1);
2399
2641
  }
2400
- const useName = !opts.noName && !!friendlyName;
2401
- const useGo = !opts.noGo;
2402
- let promptArg = null;
2403
- if (useName && friendlyName) promptArg = useGo ? `/Name ${friendlyName}\ngo` : `/Name ${friendlyName}`;
2404
- else if (useGo) promptArg = "go";
2405
- const fullArgv = resumableUuid ? [
2406
- "claude",
2407
- "--resume",
2408
- resumableUuid,
2409
- ...promptArg ? [promptArg] : []
2410
- ] : ["claude", ...promptArg ? [promptArg] : []];
2642
+ const name = friendlyName ?? query;
2643
+ const promptArg = `/Name ${name}\ngo`;
2411
2644
  if (opts.dryRun) {
2412
- const mode = resumableUuid ? "RESUME" : "FRESH";
2413
- console.log("\n" + chalk.bold(`Dry run would execute (${mode}):`) + "\n");
2414
- console.log(` cwd: ${chalk.cyan(projectDir)}`);
2415
- const argDisplay = fullArgv.map((a) => a.includes("\n") ? `"${a.replace(/\n/g, "\\n")}"` : a.startsWith("/Name") ? `"${a}"` : a).join(" ");
2416
- console.log(` argv: ${chalk.white(argDisplay)}`);
2417
- if (useName && friendlyName) console.log(` name: ${chalk.green(friendlyName)}`);
2418
- if (resumableSession) {
2419
- console.log(`\n session: ${resumableSession.uuid}`);
2420
- console.log(` age: ${fmtAge(resumableSession.mtime)}`);
2421
- console.log(` system lines: ${resumableSession.topLevelSystemLines}`);
2422
- console.log(` user msgs: ${resumableSession.userLines > 0 ? resumableSession.userLines : "—"}`);
2423
- } else console.log(`\n mode: ${chalk.yellow("fresh session (no resumable snapshot)")}`);
2424
- console.log(` project path: ${matchedSession.decodedPath}`);
2645
+ if (resumableUuid) {
2646
+ const argvResume = `claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`;
2647
+ const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
2648
+ console.log("\n" + chalk.bold("Dry run would probe then exec (RESUME path):") + "\n");
2649
+ console.log(` cwd: ${chalk.cyan(projectDir)}`);
2650
+ console.log(` probe: claude --resume ${resumableUuid} --print --output-format=json "_"`);
2651
+ console.log(` argv: ${chalk.white(argvResume)}`);
2652
+ console.log(` fallback: ${chalk.yellow(argvFresh)}`);
2653
+ if (resumableSession) {
2654
+ console.log(`\n uuid: ${resumableSession.uuid}`);
2655
+ console.log(` age: ${fmtAge(resumableSession.mtime)}`);
2656
+ console.log(` status: ${resumableSession.sessionStatus}`);
2657
+ console.log(` sys: ${resumableSession.topLevelSystemLines} system lines`);
2658
+ }
2659
+ } else {
2660
+ const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
2661
+ console.log("\n" + chalk.bold("Dry run — would exec (FRESH path, no resumable UUID):") + "\n");
2662
+ console.log(` cwd: ${chalk.cyan(projectDir)}`);
2663
+ console.log(` argv: ${chalk.white(argvFresh)}`);
2664
+ }
2425
2665
  console.log();
2426
2666
  return;
2427
2667
  }
2428
- const result = spawnSync(fullArgv[0], fullArgv.slice(1), {
2429
- cwd: projectDir,
2430
- stdio: "inherit",
2431
- env: process.env
2432
- });
2433
- if (result.error) {
2434
- console.error(err(`Failed to launch claude: ${result.error.message}`));
2435
- process.exit(1);
2668
+ if (resumableUuid) {
2669
+ const probe = probeResume(resumableUuid, projectDir);
2670
+ if (probe.ok) {
2671
+ const result = spawnSync("claude", [
2672
+ "--resume",
2673
+ resumableUuid,
2674
+ "--name",
2675
+ name,
2676
+ promptArg
2677
+ ], {
2678
+ cwd: projectDir,
2679
+ stdio: "inherit",
2680
+ env: process.env
2681
+ });
2682
+ if (result.error) {
2683
+ console.error(err(`Failed to launch claude: ${result.error.message}`));
2684
+ process.exit(1);
2685
+ }
2686
+ process.exit(result.status ?? 0);
2687
+ } else {
2688
+ process.stderr.write(chalk.yellow(`\n Resume failed for ${resumableUuid.slice(0, 8)}: ${probe.reason ?? "unknown error"}\n Starting fresh session in same directory.\n\n`));
2689
+ const result = spawnSync("claude", [
2690
+ "--name",
2691
+ name,
2692
+ promptArg
2693
+ ], {
2694
+ cwd: projectDir,
2695
+ stdio: "inherit",
2696
+ env: process.env
2697
+ });
2698
+ if (result.error) {
2699
+ console.error(err(`Failed to launch claude: ${result.error.message}`));
2700
+ process.exit(1);
2701
+ }
2702
+ process.exit(result.status ?? 0);
2703
+ }
2704
+ } else {
2705
+ const result = spawnSync("claude", [
2706
+ "--name",
2707
+ name,
2708
+ promptArg
2709
+ ], {
2710
+ cwd: projectDir,
2711
+ stdio: "inherit",
2712
+ env: process.env
2713
+ });
2714
+ if (result.error) {
2715
+ console.error(err(`Failed to launch claude: ${result.error.message}`));
2716
+ process.exit(1);
2717
+ }
2718
+ process.exit(result.status ?? 0);
2436
2719
  }
2437
- process.exit(result.status ?? 0);
2438
2720
  }
2439
2721
 
2440
2722
  //#endregion
@@ -2564,25 +2846,255 @@ function cmdPause(db, opts) {
2564
2846
  console.log(box);
2565
2847
  }
2566
2848
 
2849
+ //#endregion
2850
+ //#region src/cli/commands/session/end.ts
2851
+ /** PAI_DIR — mirrors pai-paths.ts resolution. */
2852
+ function getPaiDir() {
2853
+ const envDir = process.env.PAI_DIR;
2854
+ if (envDir) try {
2855
+ return realpathSync(envDir);
2856
+ } catch {
2857
+ return envDir;
2858
+ }
2859
+ return join(homedir(), ".claude");
2860
+ }
2861
+ /**
2862
+ * Find the notes directory for a project — checks local first, then central.
2863
+ * Returns null if neither exists (dry-run safe: don't create).
2864
+ */
2865
+ function findNotesDir$1(rootPath, encodedDir) {
2866
+ for (const rel of [
2867
+ "Notes",
2868
+ "notes",
2869
+ ".claude/Notes"
2870
+ ]) {
2871
+ const p = join(rootPath, rel);
2872
+ if (existsSync(p)) return p;
2873
+ }
2874
+ const central = join(getPaiDir(), "projects", encodedDir, "Notes");
2875
+ if (existsSync(central)) return central;
2876
+ return null;
2877
+ }
2878
+ /**
2879
+ * Find the current (latest) session note inside notesDir.
2880
+ * Searches YYYY/MM subdirectory (current month, then previous month),
2881
+ * then flat notesDir as legacy fallback.
2882
+ */
2883
+ function findLatestNote(notesDir) {
2884
+ const findIn = (dir) => {
2885
+ if (!existsSync(dir)) return null;
2886
+ const files = readdirSync(dir).filter((f) => /^\d{3,4}[\s_-].*\.md$/.test(f)).sort((a, b) => {
2887
+ return parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
2888
+ });
2889
+ return files.length > 0 ? join(dir, files[files.length - 1]) : null;
2890
+ };
2891
+ const now = /* @__PURE__ */ new Date();
2892
+ const current = findIn(join(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
2893
+ if (current) return current;
2894
+ const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
2895
+ const prevFound = findIn(join(notesDir, String(prev.getFullYear()), String(prev.getMonth() + 1).padStart(2, "0")));
2896
+ if (prevFound) return prevFound;
2897
+ return findIn(notesDir);
2898
+ }
2899
+ function finalizeNote(notePath) {
2900
+ const content = readFileSync(notePath, "utf-8");
2901
+ if (content.includes("**Status:** Completed")) return {
2902
+ finalized: false,
2903
+ path: notePath
2904
+ };
2905
+ let updated = content.replace("**Status:** In Progress", "**Status:** Completed");
2906
+ if (!updated.includes("**Completed:**")) {
2907
+ const completionTime = (/* @__PURE__ */ new Date()).toISOString();
2908
+ updated = updated.replace("---\n\n## Work Done", `**Completed:** ${completionTime}\n\n---\n\n## Work Done`);
2909
+ }
2910
+ const tmp = `${notePath}.end.tmp`;
2911
+ writeFileSync(tmp, updated, "utf-8");
2912
+ renameSync(tmp, notePath);
2913
+ return {
2914
+ finalized: true,
2915
+ path: notePath
2916
+ };
2917
+ }
2918
+ function cmdEnd(db, opts) {
2919
+ cmdPause(db, opts);
2920
+ const cwd = process.cwd();
2921
+ const project = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir
2922
+ FROM projects
2923
+ WHERE ? LIKE root_path || '%'
2924
+ ORDER BY length(root_path) DESC
2925
+ LIMIT 1`).get(cwd);
2926
+ if (!project) return;
2927
+ const notesDir = findNotesDir$1(project.root_path, project.encoded_dir ?? "");
2928
+ if (!notesDir) {
2929
+ console.log(chalk.dim(" (No session note found — notes directory does not exist yet.)"));
2930
+ printEndBox(opts.dryRun);
2931
+ return;
2932
+ }
2933
+ const notePath = findLatestNote(notesDir);
2934
+ if (!notePath) {
2935
+ console.log(chalk.dim(" (No session note found in notes directory.)"));
2936
+ printEndBox(opts.dryRun);
2937
+ return;
2938
+ }
2939
+ if (opts.dryRun) {
2940
+ console.log("\n" + chalk.bold("Dry run — would finalize session note:") + " " + chalk.cyan(notePath));
2941
+ console.log(chalk.dim(" Would replace: **Status:** In Progress\n with: **Status:** Completed"));
2942
+ console.log(chalk.dim(" Would add: **Completed:** <timestamp>"));
2943
+ } else {
2944
+ const { finalized, path } = finalizeNote(notePath);
2945
+ if (finalized) console.log(chalk.green(" Session note finalized: ") + chalk.cyan(basename(path)));
2946
+ else console.log(chalk.dim(` Session note already marked Completed: ${basename(notePath)}`));
2947
+ }
2948
+ printEndBox(opts.dryRun);
2949
+ }
2950
+ function printEndBox(dryRun) {
2951
+ const label = dryRun ? " (dry-run)" : "";
2952
+ const box = [
2953
+ "",
2954
+ chalk.bgRed.white.bold(` SESSION ENDING${label}: How to exit safely `),
2955
+ "",
2956
+ chalk.yellow(" Inside the Claude Code session, type: " + chalk.white.bold("/exit") + chalk.yellow(" (then press Enter)")),
2957
+ "",
2958
+ chalk.red.bold(" DO NOT press Ctrl+C."),
2959
+ "",
2960
+ chalk.dim(" Ctrl+C bypasses PAI's stop-hook, which means:"),
2961
+ chalk.dim(" - The session note is NOT written to by the stop-hook"),
2962
+ chalk.dim(" - The session becomes orphaned (cannot --resume next time)"),
2963
+ chalk.dim(" - The final session summary is never generated"),
2964
+ "",
2965
+ chalk.dim(" ## Continue checkpoint and session note status are already saved."),
2966
+ chalk.dim(" Use /exit to let PAI's stop-hook finalize the session fully."),
2967
+ ""
2968
+ ].join("\n");
2969
+ console.log(box);
2970
+ }
2971
+
2972
+ //#endregion
2973
+ //#region src/cli/commands/session/pause-all.ts
2974
+ /**
2975
+ * pai pause all [--exit] [--dry-run]
2976
+ *
2977
+ * Pause every live Claude Code session that AIBroker knows about.
2978
+ *
2979
+ * For each live session returned by AIBroker's session_content IPC method:
2980
+ * 1. Send "pause session" to the iTerm2 pane via send_to_session.
2981
+ * 2. Optionally send "\n/exit\n" after a short delay (--exit flag).
2982
+ * 3. Print a summary of what was sent and to which sessions.
2983
+ *
2984
+ * If AIBroker is not running, prints a clear error and exits.
2985
+ * If no live sessions are found, reports "nothing to pause".
2986
+ */
2987
+ function sleep(ms) {
2988
+ return new Promise((resolve) => setTimeout(resolve, ms));
2989
+ }
2990
+ function sessionLabel(s) {
2991
+ const name = s.paiName ?? s.name;
2992
+ return `${chalk.cyan(s.sessionId.slice(0, 8))} ${chalk.bold(name)}`;
2993
+ }
2994
+ async function cmdPauseAll(opts) {
2995
+ const waitMs = opts.wait ?? 5e3;
2996
+ let liveSessions;
2997
+ try {
2998
+ liveSessions = await fetchLiveSessions();
2999
+ } catch (e) {
3000
+ console.error(err("AIBroker is not running. Cannot list live sessions."));
3001
+ console.error(dim(" Start AIBroker or run `pai pause` from each session manually."));
3002
+ process.exitCode = 1;
3003
+ return;
3004
+ }
3005
+ const claudeSessions = liveSessions.filter((s) => s.paiName !== null && s.paiName !== void 0 && s.paiName !== "");
3006
+ const skipped = liveSessions.length - claudeSessions.length;
3007
+ if (skipped > 0) process.stderr.write(`Skipping ${skipped} non-Claude tab${skipped === 1 ? "" : "s"} (bare shells).\n`);
3008
+ if (claudeSessions.length === 0) {
3009
+ console.log(warn("No live Claude sessions found via AIBroker. Nothing to pause."));
3010
+ return;
3011
+ }
3012
+ if (opts.dryRun) {
3013
+ console.log("\n" + header("Dry Run — Would Pause These Sessions") + "\n");
3014
+ for (const s of claudeSessions) {
3015
+ console.log(" " + sessionLabel(s));
3016
+ console.log(dim(" → send: \"pause session\""));
3017
+ if (opts.exit) console.log(dim(` → wait ${waitMs}ms then send: "/exit"`));
3018
+ }
3019
+ console.log();
3020
+ return;
3021
+ }
3022
+ console.log("\n" + header("Pausing All Live Sessions") + "\n" + dim(` ${claudeSessions.length} Claude session(s) via AIBroker`) + "\n");
3023
+ const results = [];
3024
+ for (const s of claudeSessions) {
3025
+ process.stdout.write(" " + sessionLabel(s) + " … ");
3026
+ const pauseResult = await sendToSession(s.sessionId, "pause session\n");
3027
+ if (!pauseResult.ok) {
3028
+ console.log(err("FAILED: " + (pauseResult.error ?? "unknown error")));
3029
+ results.push({
3030
+ session: s,
3031
+ pauseOk: false,
3032
+ error: pauseResult.error
3033
+ });
3034
+ continue;
3035
+ }
3036
+ console.log(ok("paused"));
3037
+ results.push({
3038
+ session: s,
3039
+ pauseOk: true
3040
+ });
3041
+ }
3042
+ if (opts.exit) {
3043
+ const pausedSessions = results.filter((r) => r.pauseOk).map((r) => r.session);
3044
+ if (pausedSessions.length > 0) {
3045
+ console.log("\n" + dim(` Waiting ${waitMs / 1e3}s for sessions to save state…`));
3046
+ await sleep(waitMs);
3047
+ console.log();
3048
+ for (const s of pausedSessions) {
3049
+ process.stdout.write(" " + sessionLabel(s) + " exiting … ");
3050
+ const exitResult = await sendToSession(s.sessionId, "/exit\n");
3051
+ const rec = results.find((r) => r.session.sessionId === s.sessionId);
3052
+ rec.exitOk = exitResult.ok;
3053
+ if (!exitResult.ok) console.log(warn("exit failed: " + (exitResult.error ?? "unknown")));
3054
+ else console.log(ok("exited"));
3055
+ }
3056
+ }
3057
+ }
3058
+ const total = results.length;
3059
+ const succeeded = results.filter((r) => r.pauseOk).length;
3060
+ const failed = total - succeeded;
3061
+ console.log();
3062
+ if (failed === 0) console.log(ok(`All ${total} session(s) paused successfully.`));
3063
+ else {
3064
+ console.log(warn(`${succeeded}/${total} session(s) paused. `) + err(`${failed} failed.`));
3065
+ for (const r of results.filter((r) => !r.pauseOk)) {
3066
+ const label = r.session.paiName ?? r.session.name;
3067
+ console.log(err(` ${label}: ${r.error ?? "unknown error"}`));
3068
+ }
3069
+ }
3070
+ if (opts.exit) {
3071
+ const exitOk = results.filter((r) => r.exitOk).length;
3072
+ console.log(dim(` /exit sent to ${exitOk} session(s).`));
3073
+ }
3074
+ console.log();
3075
+ }
3076
+
2567
3077
  //#endregion
2568
3078
  //#region src/cli/commands/session/sessions-index.ts
2569
3079
  function registerSessionsCommands(sessionsCmd, getDb) {
2570
- sessionsCmd.command("list", { isDefault: true }).description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions (bare, no subcommand)\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--json", "Output raw JSON instead of formatted table").action((opts) => {
2571
- cmdRecent(getDb(), opts);
3080
+ sessionsCmd.command("list", { isDefault: true }).description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions (bare, no subcommand)\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--json", "Output raw JSON instead of formatted table").action(async (opts) => {
3081
+ await cmdRecent(getDb(), opts);
2572
3082
  });
2573
- sessionsCmd.command("goto <name-or-id>").description("Go to a session: resume if a resumable snapshot exists, start fresh otherwise.\nRecommended short form: pai resume <name>\nResolves by clc/registry name (case-insensitive) or UUID prefix.").option("--skip-name", "Do not prepend /Name to restore the session name").option("--skip-go", "Do not append \\ngo to trigger PAI auto-resume").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((nameOrId, opts) => {
2574
- cmdGoto(getDb(), nameOrId, {
2575
- noName: opts.skipName,
2576
- noGo: opts.skipGo,
2577
- dryRun: opts.dryRun
2578
- });
3083
+ sessionsCmd.command("goto <name-or-id>").description("Go to a session: resume if a resumable snapshot exists, start fresh otherwise.\nRecommended short form: pai resume <name>\nResolves by clc/registry name (case-insensitive) or UUID prefix.").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((nameOrId, opts) => {
3084
+ cmdGoto(getDb(), nameOrId, { dryRun: opts.dryRun });
2579
3085
  });
2580
3086
  sessionsCmd.command("pause").description("Write a ## Continue checkpoint to the project's TODO.md.\nRecommended short form: pai pause\nUse /exit inside Claude Code to preserve full session resumability.").option("--dry-run", "Preview the ## Continue block without writing it").action((opts) => {
2581
3087
  cmdPause(getDb(), opts);
2582
3088
  });
2583
- sessionsCmd.command("recent").description("[Deprecated] Use `pai sessions list` instead.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--json", "Output raw JSON instead of formatted table").action((opts) => {
2584
- process.stderr.write("Deprecated: `pai sessions recent` — use `pai sessions list` instead.\n");
2585
- cmdRecent(getDb(), opts);
3089
+ sessionsCmd.command("pause-all").description("Pause every live Claude Code session via AIBroker.\nRequires AIBroker to be running. Sends 'pause session' to each live iTerm2 pane.\nTop-level short form: pai pause all").option("--exit", "Also send /exit to each session after it has saved state").option("--wait <ms>", "Milliseconds to wait before /exit (default: 5000)", "5000").option("--dry-run", "Show what would be sent without actually sending").action(async (opts) => {
3090
+ await cmdPauseAll({
3091
+ exit: opts.exit,
3092
+ dryRun: opts.dryRun,
3093
+ wait: opts.wait !== void 0 ? parseInt(opts.wait, 10) : void 0
3094
+ });
3095
+ });
3096
+ sessionsCmd.command("end").description("Finalize a session: write ## Continue checkpoint + mark session note Completed.\nRecommended short form: pai end\nUse /exit inside Claude Code after running this command.").option("--dry-run", "Preview all changes without writing them").action((opts) => {
3097
+ cmdEnd(getDb(), opts);
2586
3098
  });
2587
3099
  sessionsCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
2588
3100
  cmdInfo(getDb(), projectSlug, number);
@@ -4185,7 +4697,7 @@ function cmdLogs(opts) {
4185
4697
  }
4186
4698
  function registerDaemonCommands(daemonCmd) {
4187
4699
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
4188
- const { serve } = await import("../daemon-BryEC3as.mjs").then((n) => n.t);
4700
+ const { serve } = await import("../daemon-B-vBWU1i.mjs").then((n) => n.t);
4189
4701
  const { loadConfig: lc, ensureConfigDir } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
4190
4702
  ensureConfigDir();
4191
4703
  await serve(lc());
@@ -8529,7 +9041,7 @@ function registerDbCommands(dbCmd) {
8529
9041
  * pai version
8530
9042
  *
8531
9043
  * Daily verb shortcuts (top-level):
8532
- * pai pause / pai resume / pai cd
9044
+ * pai pause / pai end / pai resume / pai cd
8533
9045
  * pai sessions / pai projects / pai notes
8534
9046
  */
8535
9047
  function getVersion() {
@@ -8554,6 +9066,7 @@ const program = new Command();
8554
9066
  program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit").addHelpText("after", `
8555
9067
  Daily verbs (short forms):
8556
9068
  pai pause Save state + display safe-exit reminder
9069
+ pai end Finalize session: pause + mark note Completed
8557
9070
  pai resume <name> Go to a session (resume or start fresh)
8558
9071
  pai cd <name> cd to a project directory
8559
9072
 
@@ -8563,7 +9076,7 @@ Listings:
8563
9076
  pai notes Markdown session notes
8564
9077
 
8565
9078
  Subcommands (power users):
8566
- pai sessions ... Session management (list, goto, pause, ...)
9079
+ pai sessions ... Session management (list, goto, pause, end, ...)
8567
9080
  pai projects ... Project management (cd, list, ...)
8568
9081
  pai registry ... Registry maintenance (scan, ...)
8569
9082
  pai memory ... Memory engine (index, search, ...)
@@ -8592,15 +9105,26 @@ registerObservationCommands(program.command("observation").description("Observat
8592
9105
  program.command("go <query>").description("Jump to a project directory by slug or partial name.\nPrints the root path to stdout — use with: cd $(pai go <query>)\nExample shell function in ~/.zshrc:\n pcd() { cd \"$(pai go \"$@\")\" }").action((query) => {
8593
9106
  cmdGo(getDb(), query);
8594
9107
  });
8595
- program.command("pause").description("Save state and display safe-exit instructions for the current session.\nWrites a ## Continue checkpoint to the project's TODO.md.\nLong form: pai sessions pause").option("--dry-run", "Preview the ## Continue block without writing it").action((opts) => {
8596
- cmdPause(getDb(), opts);
8597
- });
8598
- program.command("resume <name>").description("Go to a session by name: resume if resumable, start fresh otherwise.\nLong form: pai sessions goto <name>").option("--skip-name", "Do not prepend /Name to restore the session name").option("--skip-go", "Do not append \\ngo to trigger PAI auto-resume").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
8599
- cmdGoto(getDb(), name, {
8600
- noName: opts.skipName,
8601
- noGo: opts.skipGo,
8602
- dryRun: opts.dryRun
9108
+ program.command("pause [target]").description("Save state and display safe-exit instructions for the current session.\nWrites a ## Continue checkpoint to the project's TODO.md.\nUse `pai pause all` to pause every live session via AIBroker.\nLong form: pai sessions pause").option("--dry-run", "Preview changes without writing them").option("--exit", "(pause all only) Also send /exit to each session after pausing").option("--wait <ms>", "(pause all only) Milliseconds to wait before /exit (default: 5000)", "5000").action(async (target, opts) => {
9109
+ if (target === "all") await cmdPauseAll({
9110
+ exit: opts.exit,
9111
+ dryRun: opts.dryRun,
9112
+ wait: opts.wait !== void 0 ? parseInt(opts.wait, 10) : void 0
8603
9113
  });
9114
+ else {
9115
+ if (target !== void 0) {
9116
+ console.error(`Unknown target: ${target}. Did you mean 'pai pause all'?`);
9117
+ process.exitCode = 1;
9118
+ return;
9119
+ }
9120
+ cmdPause(getDb(), { dryRun: opts.dryRun });
9121
+ }
9122
+ });
9123
+ 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) => {
9124
+ cmdEnd(getDb(), opts);
9125
+ });
9126
+ program.command("resume <name>").description("Go to a session by name: resume if resumable, start fresh otherwise.\nLong form: pai sessions goto <name>").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
9127
+ cmdGoto(getDb(), name, { dryRun: opts.dryRun });
8604
9128
  });
8605
9129
  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.").action((identifier) => {
8606
9130
  const project = resolveIdentifier(getDb(), identifier);