@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.
package/dist/cli/index.mjs
CHANGED
|
@@ -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
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
2170
|
-
if (
|
|
2193
|
+
if (filterMode !== "resumable") for (const [cachedUuid, clcInfo] of clcInfoMap) {
|
|
2194
|
+
if (attachedClcUuids.has(cachedUuid)) continue;
|
|
2171
2195
|
let foundEncodedDir;
|
|
2172
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
2184
|
-
if (!foundEncodedDir)
|
|
2185
|
-
const
|
|
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
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
-
|
|
2203
|
-
seenUuids.add(uuid);
|
|
2264
|
+
seenUuids.add(cachedUuid);
|
|
2204
2265
|
results.push({
|
|
2205
|
-
uuid,
|
|
2206
|
-
shortId:
|
|
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
|
|
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
|
|
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 =
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
|
|
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
|
|
2401
|
-
const
|
|
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
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
console.log(
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
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("--
|
|
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("
|
|
2584
|
-
|
|
2585
|
-
|
|
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-
|
|
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
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
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);
|