ccakashic 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,6 +53,8 @@ A local HTTP server starts and your browser opens automatically.
53
53
  - **Filter search** — Incremental filtering on list pages
54
54
  - **Keyboard navigation** — `j` / `k` to move between messages, `p` / `n` to move between your own prompts
55
55
  - **One-click resume in cmux** — `▶ Resume` spawns a [cmux](https://github.com/manaflow-ai/cmux) workspace that runs `cd <session cwd> && claude --resume <id>`; `📋 Copy` copies the same command for any terminal
56
+ - **Reopen what you had open** — After a reboot or a hung cmux, `npx ccakashic restore` (or the dashboard banner) reopens the whole set of sessions you were working in, each in its own cmux workspace. See [below](#reopen-the-sessions-you-had-open)
57
+ - **Read-only JSON feed** — `GET /api/sessions?limit=40&waiting=1` returns what the dashboard shows (title, project, branch, model, `status`, `waiting`, `detailUrl`, `resumeCommand`) so other local tools can reuse the waiting signal. `waiting=1` returns only the sessions asking for you, and is the cheap path — it looks those up by id instead of parsing a whole window of session files
56
58
  - **Zero dependencies** — Node.js built-in modules only
57
59
 
58
60
  ## cmux integration
@@ -73,6 +75,27 @@ Notes:
73
75
  - The `cmux` binary is found via `$PATH`, then the common Homebrew locations. If it lives elsewhere, point `CCAKASHIC_CMUX` at it (e.g. `CCAKASHIC_CMUX=/path/to/cmux npx ccakashic`)
74
76
  - Disable the integration with `--no-cmux` or `CCAKASHIC_NO_CMUX=1`
75
77
 
78
+ ## Reopen the sessions you had open
79
+
80
+ After a reboot (memory pressure, an update) or a hung cmux you had to kill, every `claude` you had open is gone — cmux brings back its workspace tabs, but not the sessions inside them. One command reopens the last set you were working in.
81
+
82
+ ```bash
83
+ # From a terminal inside cmux
84
+ npx ccakashic restore # lists the sessions you had open, then reopens them
85
+ npx ccakashic restore --dry-run # just list them
86
+
87
+ # Optional: record open sessions every minute (launchd, macOS) for an exact list
88
+ npx ccakashic install-agent
89
+ ```
90
+
91
+ **No setup needed.** Without the agent, the list is estimated from the conversation logs: the most recent group of sessions that stopped together. When they were closed normally — including by a shutdown or reboot — each wrote an exit record at the same moment, so the group is precise. When cmux was force-quit nothing gets written, so it falls back to "active within 15 minutes of the last one", which misses sessions that sat idle. Install the agent if that case matters to you.
92
+
93
+ Sessions are only offered when **none of the group is still running** alongside something older: if you closed one session while others kept going, nothing is offered. Sessions you have already reopened drop off the list, and once a group is fully reopened, older ones are not dug up.
94
+
95
+ The dashboard shows the same list as a banner (`↺ N sessions you had open`) with checkboxes, `Reopen selected` and `Dismiss`. Each session opens in its own background cmux workspace, a moment apart so a dozen `claude` processes don't start at once. Without cmux, the command prints the `cd … && claude --resume …` lines to paste instead.
96
+
97
+ With the agent, the list comes from Claude Code's live-session registry (`~/.claude/sessions/`, interactive sessions only), copied every minute into `~/.config/ccakashic/live-sessions.json`. The agent runs its own copy of the recorder from `~/.config/ccakashic/agent/`, not the npx cache, so clearing the cache or upgrading doesn't silently stop it; any later `npx ccakashic` run refreshes that copy. Remove it with `npx ccakashic uninstall-agent`.
98
+
76
99
  ## Options
77
100
 
78
101
  ```bash
package/dist/agent.js ADDED
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AGENT_DIR = exports.AGENT_PLIST = exports.AGENT_LABEL = void 0;
37
+ exports.buildPlist = buildPlist;
38
+ exports.refreshInstalledAgent = refreshInstalledAgent;
39
+ exports.installAgent = installAgent;
40
+ exports.uninstallAgent = uninstallAgent;
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
44
+ const child_process_1 = require("child_process");
45
+ const snapshot_1 = require("./snapshot");
46
+ // launchd agent that records live sessions every minute. It has to live
47
+ // outside both cmux and the ccakashic server: a hung cmux takes the server
48
+ // (usually started inside it) down with it, and the whole point is to have
49
+ // recorded what was running before that happened.
50
+ //
51
+ // ccakashic is mostly run through npx, whose cache directory npm may clear or
52
+ // replace on any version bump. Pointing launchd at it would make the agent stop
53
+ // silently — noticed only after the next crash. So the agent runs its own copy
54
+ // of the compiled snapshot module (Node built-ins only) under ~/.config.
55
+ exports.AGENT_LABEL = 'com.ccakashic.snapshot';
56
+ exports.AGENT_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${exports.AGENT_LABEL}.plist`);
57
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
58
+ const LOG_FILE = path.join(CONFIG_DIR, 'snapshot.log');
59
+ exports.AGENT_DIR = path.join(CONFIG_DIR, 'agent');
60
+ const AGENT_MODULE = path.join(exports.AGENT_DIR, 'snapshot.js');
61
+ const AGENT_RUNNER = path.join(exports.AGENT_DIR, 'run.js');
62
+ const RUNNER_SOURCE = `// Written by \`ccakashic install-agent\`; run by launchd every minute.
63
+ require('./snapshot.js').snapshotNow();
64
+ `;
65
+ const INTERVAL_SEC = 60;
66
+ function xmlEscape(s) {
67
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
68
+ }
69
+ function buildPlist(nodePath, runnerPath) {
70
+ const args = [nodePath, runnerPath].map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
71
+ return `<?xml version="1.0" encoding="UTF-8"?>
72
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
73
+ <plist version="1.0">
74
+ <dict>
75
+ <key>Label</key>
76
+ <string>${exports.AGENT_LABEL}</string>
77
+ <key>ProgramArguments</key>
78
+ <array>
79
+ ${args}
80
+ </array>
81
+ <key>StartInterval</key>
82
+ <integer>${INTERVAL_SEC}</integer>
83
+ <key>RunAtLoad</key>
84
+ <true/>
85
+ <key>ProcessType</key>
86
+ <string>Background</string>
87
+ <key>StandardErrorPath</key>
88
+ <string>${xmlEscape(LOG_FILE)}</string>
89
+ </dict>
90
+ </plist>
91
+ `;
92
+ }
93
+ function launchctl(args) {
94
+ return new Promise((resolve) => {
95
+ (0, child_process_1.execFile)('launchctl', args, { timeout: 10_000 }, (err, stdout, stderr) => {
96
+ resolve({ ok: !err, out: `${stdout}${stderr}`.trim() });
97
+ });
98
+ });
99
+ }
100
+ // process.execPath is often version-pinned (e.g. ~/.volta/tools/image/node/24.x
101
+ // or an nvm version dir) and vanishes on the next upgrade, silently stopping the
102
+ // agent. A `node` elsewhere on PATH is usually a stable shim or symlink
103
+ // (~/.volta/bin, /opt/homebrew/bin). Version managers put the pinned dir itself
104
+ // first on PATH while running node, so that one is skipped.
105
+ function stableNodePath() {
106
+ const pinnedDir = path.dirname(process.execPath);
107
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
108
+ if (!dir || path.resolve(dir) === pinnedDir)
109
+ continue;
110
+ const candidate = path.join(dir, 'node');
111
+ try {
112
+ fs.accessSync(candidate, fs.constants.X_OK);
113
+ return candidate;
114
+ }
115
+ catch {
116
+ // not here
117
+ }
118
+ }
119
+ return process.execPath;
120
+ }
121
+ const domain = () => `gui/${process.getuid ? process.getuid() : 501}`;
122
+ // The compiled snapshot module that shipped with this ccakashic.
123
+ const bundledModule = () => path.join(__dirname, 'snapshot.js');
124
+ // Write the agent's copy; returns true when anything changed. Atomic per file
125
+ // so a tick firing mid-update never loads a truncated module.
126
+ function writeAgentFiles() {
127
+ let changed = false;
128
+ fs.mkdirSync(exports.AGENT_DIR, { recursive: true });
129
+ for (const [dest, content] of [
130
+ [AGENT_MODULE, fs.readFileSync(bundledModule(), 'utf-8')],
131
+ [AGENT_RUNNER, RUNNER_SOURCE],
132
+ ]) {
133
+ try {
134
+ if (fs.readFileSync(dest, 'utf-8') === content)
135
+ continue;
136
+ }
137
+ catch {
138
+ // not written yet
139
+ }
140
+ const tmp = `${dest}.${process.pid}.tmp`;
141
+ fs.writeFileSync(tmp, content);
142
+ fs.renameSync(tmp, dest);
143
+ changed = true;
144
+ }
145
+ return changed;
146
+ }
147
+ // Keep an installed agent's copy in step with the ccakashic being run, so a
148
+ // newer version's snapshot format reaches the agent without a reinstall. The
149
+ // plist points at a fixed path, so launchd needs no reload.
150
+ function refreshInstalledAgent() {
151
+ if (!fs.existsSync(exports.AGENT_PLIST))
152
+ return;
153
+ try {
154
+ writeAgentFiles();
155
+ }
156
+ catch {
157
+ // the previous copy keeps running
158
+ }
159
+ }
160
+ async function installAgent() {
161
+ if (process.platform !== 'darwin') {
162
+ throw new Error('The snapshot agent uses launchd and is macOS-only. Run `ccakashic snapshot` from cron instead.');
163
+ }
164
+ writeAgentFiles();
165
+ fs.mkdirSync(path.dirname(exports.AGENT_PLIST), { recursive: true });
166
+ const nodePath = stableNodePath();
167
+ fs.writeFileSync(exports.AGENT_PLIST, buildPlist(nodePath, AGENT_RUNNER));
168
+ // Re-install cleanly: bootout fails harmlessly when not loaded yet.
169
+ await launchctl(['bootout', `${domain()}/${exports.AGENT_LABEL}`]);
170
+ const res = await launchctl(['bootstrap', domain(), exports.AGENT_PLIST]);
171
+ if (!res.ok)
172
+ throw new Error(`launchctl bootstrap failed: ${res.out}`);
173
+ console.log(`Installed ${exports.AGENT_PLIST} (node: ${nodePath})`);
174
+ console.log(`Recording live sessions every ${INTERVAL_SEC}s → ${snapshot_1.SNAPSHOT_FILE}`);
175
+ console.log(`(runs its own copy in ${exports.AGENT_DIR}, so clearing the npx cache won't stop it)`);
176
+ }
177
+ async function uninstallAgent() {
178
+ await launchctl(['bootout', `${domain()}/${exports.AGENT_LABEL}`]);
179
+ fs.rmSync(exports.AGENT_DIR, { recursive: true, force: true });
180
+ try {
181
+ fs.unlinkSync(exports.AGENT_PLIST);
182
+ console.log(`Removed ${exports.AGENT_PLIST}`);
183
+ }
184
+ catch {
185
+ console.log('Agent was not installed');
186
+ }
187
+ }
package/dist/api.js ADDED
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_SESSION_LIMIT = exports.DEFAULT_SESSION_LIMIT = void 0;
4
+ exports.toSessionRow = toSessionRow;
5
+ exports.parseSessionLimit = parseSessionLimit;
6
+ exports.orderSessionRows = orderSessionRows;
7
+ const dashboard_1 = require("./dashboard");
8
+ const cmux_1 = require("./cmux");
9
+ // Read-only JSON view of what the dashboard already computes, so other local
10
+ // tools can treat ccakashic as the source of truth for "which session is
11
+ // waiting for me" instead of re-deriving it from ~/.claude. The waiting signal
12
+ // in particular cannot be rebuilt elsewhere: it maps cmux's unread
13
+ // notifications through ccakashic's own resume map.
14
+ exports.DEFAULT_SESSION_LIMIT = 40;
15
+ // Each row costs a full session-file read, so cap what one request can ask for.
16
+ exports.MAX_SESSION_LIMIT = 100;
17
+ const PREVIEW_MAX = 200;
18
+ function toSessionRow(session, waiting) {
19
+ return {
20
+ id: session.id,
21
+ projectRawName: session.projectRawName,
22
+ projectName: session.projectName,
23
+ cwd: session.cwd,
24
+ title: (0, dashboard_1.paneTitle)(session),
25
+ preview: (session.preview || '').slice(0, PREVIEW_MAX),
26
+ gitBranch: session.gitBranch,
27
+ model: session.model,
28
+ lastModified: session.lastModified,
29
+ status: (0, dashboard_1.paneStatus)(session.lastModified),
30
+ waiting,
31
+ detailUrl: `/project/${encodeURIComponent(session.projectRawName)}/session/${encodeURIComponent(session.id)}`,
32
+ // /api/resume is POST-only and requires the CSRF token, so there is no GET
33
+ // URL to hand out — and publishing that token from an unauthenticated
34
+ // endpoint would defeat it. Callers that want to act on a session either
35
+ // open detailUrl and press Resume, or run resumeCommand themselves.
36
+ resumeUrl: null,
37
+ resumeCommand: session.cwd ? (0, cmux_1.buildResumeCommand)(session.cwd, session.id) : null,
38
+ };
39
+ }
40
+ function parseSessionLimit(raw) {
41
+ const n = parseInt(raw || '', 10);
42
+ if (!Number.isFinite(n) || n <= 0)
43
+ return exports.DEFAULT_SESSION_LIMIT;
44
+ return Math.min(n, exports.MAX_SESSION_LIMIT);
45
+ }
46
+ function orderSessionRows(rows, limit) {
47
+ return rows.slice().sort((a, b) => b.lastModified - a.lastModified).slice(0, limit);
48
+ }
@@ -38,13 +38,18 @@ const http = __importStar(require("http"));
38
38
  const fs = __importStar(require("fs"));
39
39
  const os = __importStar(require("os"));
40
40
  const path = __importStar(require("path"));
41
+ const readline = __importStar(require("readline"));
41
42
  const child_process_1 = require("child_process");
42
43
  const util_1 = require("../util");
43
44
  const discover_1 = require("../discover");
45
+ const api_1 = require("../api");
44
46
  const parser_1 = require("../parser");
45
47
  const html_generator_1 = require("../html-generator");
46
48
  const pages_1 = require("../pages");
47
49
  const dashboard_1 = require("../dashboard");
50
+ const snapshot_1 = require("../snapshot");
51
+ const restore_1 = require("../restore");
52
+ const agent_1 = require("../agent");
48
53
  const cmux_1 = require("../cmux");
49
54
  // Published at dist/bin/ccakashic.js, so ../../package.json resolves from dist/
50
55
  const pkg = __importStar(require("../../package.json"));
@@ -100,16 +105,25 @@ async function buildResumeContext() {
100
105
  }
101
106
  return { token: RESUME_TOKEN, cmuxAvailable, openSessionIds };
102
107
  }
103
- // sessionId → wait reason, from cmux's unread notifications mapped through the
104
- // resume map. Only covers ccakashic-resumed sessions; others fall back to the
105
- // jsonl-derived activity below. Empty when cmux is unavailable/disabled.
108
+ // sessionId → wait reason, from cmux's unread notifications resolved through
109
+ // two independent workspace→session sources. Empty when cmux is
110
+ // unavailable/disabled.
106
111
  async function buildCmuxWaitMap() {
107
112
  const result = new Map();
108
113
  if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
109
114
  return result;
110
115
  try {
111
116
  const waiting = await (0, cmux_1.listWaitingWorkspacesCached)();
117
+ // The resume map only covers sessions ccakashic resumed, which left every
118
+ // hand-started session permanently unbadged. The live map reads
119
+ // CMUX_WORKSPACE_ID from each running session's own process and covers
120
+ // those. They complement each other — the resume map still resolves
121
+ // sessions that have since exited — so the live one is layered on top,
122
+ // winning conflicts because it reflects the process attached right now.
112
123
  const wsToSession = (0, cmux_1.loadWorkspaceToSession)();
124
+ for (const [wsId, sessionId] of await (0, cmux_1.liveWorkspaceToSessionCached)()) {
125
+ wsToSession.set(wsId, sessionId);
126
+ }
113
127
  for (const [wsId, reason] of waiting) {
114
128
  const sessionId = wsToSession.get(wsId);
115
129
  if (sessionId)
@@ -214,6 +228,58 @@ async function handleResume(req, res) {
214
228
  });
215
229
  }
216
230
  }
231
+ // The stopped group the dashboard offers to bring back, unless dismissed.
232
+ async function buildRestoreBanner() {
233
+ try {
234
+ const { state, stop } = (0, restore_1.detectLastStop)();
235
+ if (!stop || !stop.sessions.length || stop.stoppedAt === state.dismissedStopAt)
236
+ return undefined;
237
+ const cmuxAvailable = !NO_CMUX && await (0, cmux_1.isCmuxAvailable)();
238
+ return {
239
+ token: RESUME_TOKEN,
240
+ stoppedAt: stop.stoppedAt,
241
+ estimated: !!stop.estimated,
242
+ cmuxAvailable,
243
+ items: await (0, restore_1.describeStopped)(stop.sessions),
244
+ };
245
+ }
246
+ catch {
247
+ return undefined; // the banner is an extra; never break the dashboard over it
248
+ }
249
+ }
250
+ async function handleRestore(req, res, action) {
251
+ const respond = (status, body) => {
252
+ res.writeHead(status, { 'Content-Type': 'application/json' });
253
+ res.end(JSON.stringify(body));
254
+ };
255
+ if (req.method !== 'POST')
256
+ return respond(405, { error: 'POST only' });
257
+ if (req.headers['x-ccakashic-token'] !== RESUME_TOKEN)
258
+ return respond(403, { error: 'Invalid token' });
259
+ let body;
260
+ try {
261
+ body = await readJsonBody(req);
262
+ }
263
+ catch (err) {
264
+ return respond(400, { error: err?.message || 'Bad request' });
265
+ }
266
+ // Recompute rather than trust the page: sessions may have been resumed in the
267
+ // meantime, and resuming a live one again would fork it.
268
+ const { state, stop } = (0, restore_1.detectLastStop)();
269
+ if (!stop || stop.stoppedAt !== body?.stoppedAt) {
270
+ return respond(409, { error: 'The list is out of date — reload the dashboard' });
271
+ }
272
+ if (action === 'dismiss') {
273
+ (0, snapshot_1.saveSnapshot)({ ...state, dismissedStopAt: stop.stoppedAt });
274
+ return respond(200, { ok: true });
275
+ }
276
+ if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
277
+ return respond(503, { error: 'cmux is not reachable' });
278
+ const wanted = Array.isArray(body.sessions) ? new Set(body.sessions) : null;
279
+ const chosen = stop.sessions.filter((s) => !wanted || wanted.has(s.sessionId));
280
+ const outcomes = await (0, restore_1.restoreAll)(await (0, restore_1.describeStopped)(chosen));
281
+ return respond(200, { outcomes });
282
+ }
217
283
  // Only accept loopback Host headers. The server binds 127.0.0.1, but without
218
284
  // this check a malicious site could DNS-rebind its hostname to 127.0.0.1 and
219
285
  // become same-origin, defeating the resume token and reading session content.
@@ -241,6 +307,10 @@ const server = http.createServer(async (req, res) => {
241
307
  await handleResume(req, res);
242
308
  return;
243
309
  }
310
+ if (pathname === '/api/restore' || pathname === '/api/restore/dismiss') {
311
+ await handleRestore(req, res, pathname === '/api/restore' ? 'run' : 'dismiss');
312
+ return;
313
+ }
244
314
  if (pathname === '/' || pathname === '') {
245
315
  const requested = parseInt(url.searchParams.get('n') || '') || dashboard_1.DEFAULT_PANE_COUNT;
246
316
  const paneCount = dashboard_1.PANE_COUNTS.includes(requested) ? requested : dashboard_1.DEFAULT_PANE_COUNT;
@@ -252,7 +322,7 @@ const server = http.createServer(async (req, res) => {
252
322
  waiting: resolveWaiting(session.id, cmuxWait),
253
323
  })));
254
324
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
255
- res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext()));
325
+ res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext(), await buildRestoreBanner()));
256
326
  return;
257
327
  }
258
328
  if (pathname === '/projects') {
@@ -291,6 +361,24 @@ const server = http.createServer(async (req, res) => {
291
361
  res.end(JSON.stringify({ changed: true, mtime, status, ago, waiting, html: (0, dashboard_1.renderPaneBody)(parsed) }));
292
362
  return;
293
363
  }
364
+ // Read-only feed of the dashboard's own view, for other local tools.
365
+ if (pathname === '/api/sessions') {
366
+ const limit = (0, api_1.parseSessionLimit)(url.searchParams.get('limit'));
367
+ const waitingOnly = url.searchParams.get('waiting') === '1';
368
+ const cmuxWait = await buildCmuxWaitMap();
369
+ // Waiting sessions are fetched by id rather than filtered out of the
370
+ // recent window: a session can sit waiting while other projects churn
371
+ // past it, and reading a wide window means parsing every file in it.
372
+ const sessions = waitingOnly
373
+ ? await (0, discover_1.findRecentSessionsByIds)([...cmuxWait.keys()])
374
+ : await (0, discover_1.listRecentSessions)(limit);
375
+ const rows = (0, api_1.orderSessionRows)(sessions
376
+ .map((s) => (0, api_1.toSessionRow)(s, resolveWaiting(s.id, cmuxWait)))
377
+ .filter((r) => !waitingOnly || r.waiting !== null), limit);
378
+ res.writeHead(200, { 'Content-Type': 'application/json' });
379
+ res.end(JSON.stringify(rows));
380
+ return;
381
+ }
294
382
  const projectMatch = pathname.match(/^\/project\/(.+)$/);
295
383
  if (projectMatch && !pathname.includes('/session/')) {
296
384
  const rawName = decodeURIComponent(projectMatch[1]);
@@ -441,7 +529,91 @@ async function startServer(startPort) {
441
529
  }
442
530
  throw new Error(`No available port after ${MAX_PORT_TRIES} tries starting at ${startPort}`);
443
531
  }
532
+ function formatClock(ms) {
533
+ const d = new Date(ms);
534
+ const pad = (n) => String(n).padStart(2, '0');
535
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
536
+ }
537
+ function askYesNo(question) {
538
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
539
+ return new Promise((resolve) => {
540
+ rl.question(question, (answer) => {
541
+ rl.close();
542
+ resolve(/^(y|yes|)$/i.test(answer.trim()));
543
+ });
544
+ });
545
+ }
546
+ async function runRestoreCommand() {
547
+ const dryRun = process.argv.includes('--dry-run');
548
+ const yes = process.argv.includes('--yes') || process.argv.includes('-y');
549
+ const previousRun = (0, snapshot_1.loadSnapshot)().lastRunAt;
550
+ const { stop, agentInstalled } = (0, restore_1.detectLastStop)();
551
+ if (stop?.estimated) {
552
+ console.log('Note: estimated from conversation logs. Sessions left idle before cmux was force-quit may be missing.');
553
+ console.log(agentInstalled
554
+ ? ' (these closed before the snapshot agent started recording)\n'
555
+ : ' Run `npx ccakashic install-agent` to record live sessions every minute for an exact list.\n');
556
+ }
557
+ else if (!agentInstalled) {
558
+ console.log('Tip: `npx ccakashic install-agent` records open sessions every minute for an exact list.\n');
559
+ }
560
+ else if (previousRun && Date.now() - previousRun > 5 * 60_000) {
561
+ console.log(`Note: the last snapshot before this one was at ${formatClock(previousRun)}.\n`);
562
+ }
563
+ if (!stop || !stop.sessions.length) {
564
+ console.log('Nothing to reopen: no closed sessions found (or some from that time are still running).');
565
+ return;
566
+ }
567
+ const items = await (0, restore_1.describeStopped)(stop.sessions);
568
+ console.log(`${items.length} session(s) you had open until ${formatClock(stop.stoppedAt)}${stop.estimated ? ' (estimated)' : ''}:\n`);
569
+ for (const it of items)
570
+ console.log(` • ${it.title}\n ${it.cwd}`);
571
+ console.log('');
572
+ if (dryRun)
573
+ return;
574
+ if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)())) {
575
+ console.log('cmux is not reachable (run this from a terminal inside cmux). Commands to resume by hand:\n');
576
+ for (const it of items)
577
+ console.log((0, cmux_1.buildResumeCommand)(it.cwd, it.sessionId));
578
+ return;
579
+ }
580
+ if (!yes && !(await askYesNo(`Reopen all ${items.length} in new cmux workspaces? [Y/n] `)))
581
+ return;
582
+ const outcomes = await (0, restore_1.restoreAll)(items, (o) => {
583
+ console.log(o.ok ? ` ✓ ${o.title}` : ` ✗ ${o.title} — ${o.message}`);
584
+ });
585
+ const failed = outcomes.filter((o) => !o.ok).length;
586
+ console.log(`\nReopened ${outcomes.length - failed} / ${outcomes.length}.`);
587
+ }
588
+ async function runSubcommand(name) {
589
+ switch (name) {
590
+ case 'snapshot': {
591
+ // One-off record; the launchd agent runs its own copy of the same code.
592
+ (0, snapshot_1.snapshotNow)();
593
+ return true;
594
+ }
595
+ case 'restore':
596
+ await runRestoreCommand();
597
+ return true;
598
+ case 'install-agent':
599
+ await (0, agent_1.installAgent)();
600
+ return true;
601
+ case 'uninstall-agent':
602
+ await (0, agent_1.uninstallAgent)();
603
+ return true;
604
+ default:
605
+ return false;
606
+ }
607
+ }
444
608
  async function main() {
609
+ (0, agent_1.refreshInstalledAgent)();
610
+ const sub = process.argv[2];
611
+ if (sub && !sub.startsWith('-')) {
612
+ if (await runSubcommand(sub))
613
+ return;
614
+ console.error(`Unknown command: ${sub}\nUsage: ccakashic [restore [--dry-run] [--yes] | install-agent | uninstall-agent | snapshot]`);
615
+ process.exit(1);
616
+ }
445
617
  const existing = await findExistingCcakashic(PORT);
446
618
  if (existing) {
447
619
  const url = `http://127.0.0.1:${existing}`;
package/dist/cmux.js CHANGED
@@ -49,6 +49,10 @@ exports.openInCmuxBrowser = openInCmuxBrowser;
49
49
  exports.loadResumeMap = loadResumeMap;
50
50
  exports.saveResumeMapEntry = saveResumeMapEntry;
51
51
  exports.loadWorkspaceToSession = loadWorkspaceToSession;
52
+ exports.loadSessionRegistry = loadSessionRegistry;
53
+ exports.parseWorkspaceEnv = parseWorkspaceEnv;
54
+ exports.liveWorkspaceToSession = liveWorkspaceToSession;
55
+ exports.liveWorkspaceToSessionCached = liveWorkspaceToSessionCached;
52
56
  exports.findLiveWorkspaceForSession = findLiveWorkspaceForSession;
53
57
  const child_process_1 = require("child_process");
54
58
  const fs = __importStar(require("fs"));
@@ -260,6 +264,124 @@ function loadWorkspaceToSession() {
260
264
  }
261
265
  return inv;
262
266
  }
267
+ // --- Live workspace mapping, read from the running processes themselves ---
268
+ //
269
+ // The resume map only knows about sessions ccakashic itself resumed, so a
270
+ // session you started by hand inside cmux has no workspace mapping and never
271
+ // gets a waiting badge. Claude Code registers every running session in
272
+ // ~/.claude/sessions/<pid>.json, and a session launched inside cmux inherits
273
+ // CMUX_WORKSPACE_ID in its environment — together those give the same mapping
274
+ // without ccakashic having been involved.
275
+ //
276
+ // The two sources are complements, not replacements: this one sees only live
277
+ // processes, while the resume map still covers sessions that have since exited.
278
+ const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
279
+ function loadSessionRegistry() {
280
+ let names;
281
+ try {
282
+ names = fs.readdirSync(SESSION_REGISTRY_DIR);
283
+ }
284
+ catch {
285
+ return []; // no registry (older Claude Code, or nothing has run yet)
286
+ }
287
+ const out = [];
288
+ for (const f of names) {
289
+ if (!f.endsWith('.json'))
290
+ continue;
291
+ try {
292
+ const o = JSON.parse(fs.readFileSync(path.join(SESSION_REGISTRY_DIR, f), 'utf-8'));
293
+ if (typeof o?.pid === 'number' && typeof o?.sessionId === 'string') {
294
+ out.push({ pid: o.pid, sessionId: o.sessionId, procStart: o.procStart });
295
+ }
296
+ }
297
+ catch {
298
+ // a half-written or stale record; skip it
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ function isProcessAlive(pid) {
304
+ try {
305
+ process.kill(pid, 0); // signal 0 only probes; it does not signal
306
+ return true;
307
+ }
308
+ catch {
309
+ return false;
310
+ }
311
+ }
312
+ // `ps eww` prints one line per process: pid, then the command, then the
313
+ // process's ENTIRE environment — which routinely holds API keys and tokens.
314
+ // Only CMUX_WORKSPACE_ID is ever pulled out of it; no other variable is
315
+ // stored, returned or logged, and the raw output is not retained.
316
+ function parseWorkspaceEnv(psOutput) {
317
+ const byPid = new Map();
318
+ for (const line of psOutput.split('\n')) {
319
+ const pid = line.match(/^\s*(\d+)\s/);
320
+ if (!pid)
321
+ continue;
322
+ const ws = line.match(/\bCMUX_WORKSPACE_ID=([A-Za-z0-9-]+)/);
323
+ if (ws)
324
+ byPid.set(parseInt(pid[1], 10), ws[1].toUpperCase());
325
+ }
326
+ return byPid;
327
+ }
328
+ function runPlain(bin, args, timeoutMs = 3000) {
329
+ return new Promise((resolve) => {
330
+ (0, child_process_1.execFile)(bin, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
331
+ resolve(err ? '' : stdout);
332
+ });
333
+ });
334
+ }
335
+ // A process's environment is fixed at exec time, so a pid only ever needs
336
+ // looking up once. Keyed with procStart as well so a recycled pid can't
337
+ // inherit the previous process's answer. null means "checked, not a cmux
338
+ // process" — cached too, so those aren't re-probed on every poll.
339
+ const workspaceEnvCache = new Map();
340
+ const envKey = (r) => `${r.pid}:${r.procStart ?? ''}`;
341
+ async function liveWorkspaceToSession() {
342
+ const live = loadSessionRegistry().filter((r) => isProcessAlive(r.pid));
343
+ const result = new Map();
344
+ const unknown = [];
345
+ for (const r of live) {
346
+ const key = envKey(r);
347
+ if (workspaceEnvCache.has(key)) {
348
+ const ws = workspaceEnvCache.get(key);
349
+ if (ws)
350
+ result.set(ws, r.sessionId);
351
+ }
352
+ else {
353
+ unknown.push(r);
354
+ }
355
+ }
356
+ if (unknown.length) {
357
+ // One ps for every new pid at once, not one spawn per session.
358
+ const out = await runPlain('ps', ['eww', '-p', unknown.map((r) => r.pid).join(',')]);
359
+ const byPid = parseWorkspaceEnv(out);
360
+ for (const r of unknown) {
361
+ const ws = byPid.get(r.pid) ?? null;
362
+ workspaceEnvCache.set(envKey(r), ws);
363
+ if (ws)
364
+ result.set(ws, r.sessionId);
365
+ }
366
+ }
367
+ // Drop entries for processes that are gone, so a long-lived server doesn't
368
+ // accumulate one per session ever started.
369
+ const alive = new Set(live.map(envKey));
370
+ for (const key of workspaceEnvCache.keys()) {
371
+ if (!alive.has(key))
372
+ workspaceEnvCache.delete(key);
373
+ }
374
+ return result;
375
+ }
376
+ let cachedLiveWorkspaces = null;
377
+ async function liveWorkspaceToSessionCached() {
378
+ if (cachedLiveWorkspaces && Date.now() - cachedLiveWorkspaces.at < 5_000) {
379
+ return cachedLiveWorkspaces.value;
380
+ }
381
+ const value = await liveWorkspaceToSession();
382
+ cachedLiveWorkspaces = { value, at: Date.now() };
383
+ return value;
384
+ }
263
385
  async function findLiveWorkspaceForSession(sessionId) {
264
386
  const mapped = loadResumeMap()[sessionId];
265
387
  if (!mapped)
package/dist/dashboard.js CHANGED
@@ -4,12 +4,15 @@ exports.DEFAULT_PANE_COUNT = exports.PANE_COUNTS = void 0;
4
4
  exports.timeAgo = timeAgo;
5
5
  exports.paneStatus = paneStatus;
6
6
  exports.renderPaneBody = renderPaneBody;
7
+ exports.paneTitle = paneTitle;
7
8
  exports.waitBadgeHtml = waitBadgeHtml;
9
+ exports.restoreBannerHtml = restoreBannerHtml;
8
10
  exports.generateDashboard = generateDashboard;
9
11
  const template_assets_1 = require("./template-assets");
10
12
  const html_generator_1 = require("./html-generator");
11
13
  const resume_ui_1 = require("./resume-ui");
12
14
  const util_1 = require("./util");
15
+ const cmux_1 = require("./cmux");
13
16
  // Multi-pane dashboard: the N most recently active sessions across all
14
17
  // projects, each pane showing the last 24h of conversation as a scrollable
15
18
  // thread, refreshed by polling /api/pane.
@@ -67,7 +70,30 @@ function waitBadgeHtml(waiting) {
67
70
  const label = waiting === 'permission' ? '\u{1F510} Permission' : '⏳ Your turn';
68
71
  return `<span class="dash-wait-badge dash-wait-${waiting}">${label}</span>`;
69
72
  }
70
- function generateDashboard(panes, paneCount, resume) {
73
+ function restoreBannerHtml(banner) {
74
+ if (!banner || !banner.items.length)
75
+ return '';
76
+ const n = banner.items.length;
77
+ const rows = banner.items.map((it) => {
78
+ const title = it.projectRawName
79
+ ? `<a href="/project/${encodeURIComponent(it.projectRawName)}/session/${encodeURIComponent(it.sessionId)}">${(0, util_1.escapeHtml)(it.title)}</a>`
80
+ : (0, util_1.escapeHtml)(it.title);
81
+ return `<li><label><input type="checkbox" class="dash-restore-check" value="${(0, util_1.escapeHtml)(it.sessionId)}" data-cmd="${(0, util_1.escapeHtml)((0, cmux_1.buildResumeCommand)(it.cwd, it.sessionId))}" checked> ${title}</label> <span class="dash-restore-cwd">${(0, util_1.escapeHtml)(it.cwd)}</span></li>`;
82
+ }).join('');
83
+ const action = banner.cmuxAvailable
84
+ ? `<button type="button" class="dash-restore-run">&#9654; Reopen selected</button>`
85
+ : `<button type="button" class="dash-restore-copy">&#128203; Copy commands</button>`;
86
+ return `<div class="dash-restore" data-stopped-at="${banner.stoppedAt}" data-token="${(0, util_1.escapeHtml)(banner.token)}">
87
+ <div class="dash-restore-head">
88
+ <span>&#8634; <b>${n} session${n === 1 ? '' : 's'}</b> you had open until <span class="dash-restore-at" data-ts="${banner.stoppedAt}"></span>${banner.estimated ? ' <span class="dash-restore-est" title="Estimated from conversation logs; sessions left idle before cmux was force-quit may be missing. Run `npx ccakashic install-agent` for an exact list.">(estimated)</span>' : ''}</span>
89
+ ${action}
90
+ <button type="button" class="dash-restore-dismiss">Dismiss</button>
91
+ <span class="dash-restore-status"></span>
92
+ </div>
93
+ <ul class="dash-restore-list">${rows}</ul>
94
+ </div>`;
95
+ }
96
+ function generateDashboard(panes, paneCount, resume, restore) {
71
97
  const cols = paneCount <= 4 ? Math.max(panes.length, 1) : Math.ceil(paneCount / 2);
72
98
  const rows = paneCount <= 4 ? 1 : 2;
73
99
  const panesHtml = panes.map(({ session: s, bodyHtml, waiting }) => {
@@ -110,6 +136,7 @@ ${dashboardCSS(cols, rows)}
110
136
  <span class="dash-counts">Panes: ${countLinks}</span>
111
137
  <a class="dash-nav-link" href="/projects">All projects &rarr;</a>
112
138
  </div>
139
+ ${restoreBannerHtml(restore)}
113
140
  <div class="dash-grid">
114
141
  ${panesHtml || '<div class="empty">No sessions found</div>'}
115
142
  </div>
@@ -251,6 +278,36 @@ function dashboardCSS(cols, rows) {
251
278
  }
252
279
  .empty { text-align: center; color: var(--text-muted); padding: 40px; }
253
280
 
281
+ .dash-restore {
282
+ flex-shrink: 0;
283
+ max-height: 40vh;
284
+ overflow-y: auto;
285
+ margin: 8px 8px 0;
286
+ padding: 8px 12px;
287
+ border: 1px solid #f97316;
288
+ border-radius: 8px;
289
+ background: rgba(249, 115, 22, 0.08);
290
+ font-size: 0.82rem;
291
+ }
292
+ .dash-restore-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
293
+ .dash-restore-head button {
294
+ font-size: 0.75rem;
295
+ font-weight: 600;
296
+ padding: 3px 10px;
297
+ border-radius: 5px;
298
+ border: 1px solid var(--border);
299
+ background: var(--tool-bg);
300
+ color: var(--text);
301
+ cursor: pointer;
302
+ }
303
+ .dash-restore-head .dash-restore-run { border-color: #f97316; }
304
+ .dash-restore-head button:disabled { opacity: 0.5; cursor: wait; }
305
+ .dash-restore-status, .dash-restore-est { color: var(--text-muted); }
306
+ .dash-restore-list { list-style: none; margin: 6px 0 0; padding: 0; columns: 2 360px; }
307
+ .dash-restore-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 1px 0; }
308
+ .dash-restore-list a { color: var(--text); }
309
+ .dash-restore-cwd { color: var(--text-muted); font-size: 0.72rem; margin-left: 4px; }
310
+
254
311
  @media (max-width: 900px) {
255
312
  .dash-page { height: auto; overflow: auto; }
256
313
  .dash-grid { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-rows: 70vh; }
@@ -321,6 +378,46 @@ function dashboardJS() {
321
378
  }).catch(function() { /* server briefly unavailable; retry next tick */ });
322
379
  }
323
380
 
381
+ var restore = document.querySelector('.dash-restore');
382
+ if (restore) {
383
+ var at = restore.querySelector('.dash-restore-at');
384
+ if (at) at.textContent = new Date(Number(at.dataset.ts)).toLocaleString();
385
+ var status = restore.querySelector('.dash-restore-status');
386
+ var checked = function() {
387
+ return Array.prototype.slice.call(restore.querySelectorAll('.dash-restore-check:checked'));
388
+ };
389
+ var post = function(path, body) {
390
+ body.stoppedAt = Number(restore.dataset.stoppedAt);
391
+ return fetch(path, {
392
+ method: 'POST',
393
+ headers: { 'Content-Type': 'application/json', 'X-Ccakashic-Token': restore.dataset.token },
394
+ body: JSON.stringify(body)
395
+ }).then(function(res) { return res.json(); });
396
+ };
397
+ restore.addEventListener('click', function(e) {
398
+ var btn = e.target.closest ? e.target.closest('button') : null;
399
+ if (!btn) return;
400
+ if (btn.classList.contains('dash-restore-dismiss')) {
401
+ post('/api/restore/dismiss', {}).then(function() { restore.remove(); });
402
+ } else if (btn.classList.contains('dash-restore-copy')) {
403
+ var cmds = checked().map(function(c) { return c.dataset.cmd; }).join('\n');
404
+ navigator.clipboard.writeText(cmds).then(function() { status.textContent = 'Copied'; });
405
+ } else if (btn.classList.contains('dash-restore-run')) {
406
+ var ids = checked().map(function(c) { return c.value; });
407
+ if (!ids.length) return;
408
+ btn.disabled = true;
409
+ status.textContent = 'Reopening ' + ids.length + '…';
410
+ post('/api/restore', { sessions: ids }).then(function(data) {
411
+ if (!data.outcomes) { btn.disabled = false; status.textContent = data.error || 'Reopen failed'; return; }
412
+ var failed = data.outcomes.filter(function(o) { return !o.ok; });
413
+ if (!failed.length) { restore.remove(); return; }
414
+ btn.disabled = false;
415
+ status.textContent = 'Failed: ' + failed.map(function(o) { return o.title + ' (' + o.message + ')'; }).join(', ');
416
+ }).catch(function() { btn.disabled = false; status.textContent = 'Reopen failed: server unreachable'; });
417
+ }
418
+ });
419
+ }
420
+
324
421
  syncWaitingIndicator();
325
422
  setInterval(function() {
326
423
  document.querySelectorAll('.dash-pane').forEach(refreshPane);
package/dist/discover.js CHANGED
@@ -38,6 +38,7 @@ exports.decodeDirName = decodeDirName;
38
38
  exports.listProjects = listProjects;
39
39
  exports.listSessions = listSessions;
40
40
  exports.listRecentSessions = listRecentSessions;
41
+ exports.findRecentSessionsByIds = findRecentSessionsByIds;
41
42
  exports.readCwdFromSession = readCwdFromSession;
42
43
  exports.findSessionForCwd = findSessionForCwd;
43
44
  const fs = __importStar(require("fs"));
@@ -232,6 +233,32 @@ async function listRecentSessions(limit) {
232
233
  projectName: top[i].scan.name,
233
234
  }));
234
235
  }
236
+ // Locate specific sessions by id. scanProjects only stats filenames, so this
237
+ // parses just the matched files — unlike listRecentSessions, which parses every
238
+ // file in its window (getSessionPreview reads a session end to end to total its
239
+ // tokens). Callers that know which ids they want should use this: a session can
240
+ // sit waiting for you while other projects churn past it, so it is not
241
+ // necessarily inside any "most recent N" window.
242
+ async function findRecentSessionsByIds(ids) {
243
+ const wanted = new Set(ids);
244
+ if (!wanted.size)
245
+ return [];
246
+ const scans = await scanProjects();
247
+ const hits = [];
248
+ for (const scan of scans) {
249
+ for (const f of scan.files) {
250
+ if (wanted.has(f.file.replace(/\.jsonl$/, ''))) {
251
+ hits.push({ file: path.join(scan.dir, f.file), scan });
252
+ }
253
+ }
254
+ }
255
+ const previews = await Promise.all(hits.map((h) => getSessionPreview(h.file)));
256
+ return previews.map((s, i) => ({
257
+ ...s,
258
+ projectRawName: hits[i].scan.rawName,
259
+ projectName: hits[i].scan.name,
260
+ }));
261
+ }
235
262
  function readCwdFromSession(filePath) {
236
263
  return new Promise((resolve) => {
237
264
  const rl = readline.createInterface({
package/dist/infer.js ADDED
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readLogFacts = readLogFacts;
37
+ exports.inferLastStop = inferLastStop;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const discover_1 = require("./discover");
41
+ // Best-effort "what was running before the crash" from the conversation logs
42
+ // alone, for when the snapshot agent is not installed.
43
+ //
44
+ // What the logs can and cannot tell:
45
+ // - A session that exits (including during a normal shutdown/reboot) appends a
46
+ // `cost-state` record, usually followed by a line or two of bookkeeping. A
47
+ // burst of those right before a gap is a reliable signature.
48
+ // - A session killed outright (cmux force-quit while hung) writes nothing. All
49
+ // that is left is when it was last active, so a session idling for hours
50
+ // before the hang is missed. Results are therefore marked `estimated`.
51
+ // Sessions that exit on the same shutdown finish within seconds of each other.
52
+ const EXIT_BURST_WINDOW_MS = 3 * 60_000;
53
+ // Without exit records, "recently active before the newest one" is the best proxy.
54
+ const ACTIVITY_WINDOW_MS = 15 * 60_000;
55
+ const LOOKBACK_MS = 7 * 24 * 60 * 60_000;
56
+ const CHUNK = 64 * 1024;
57
+ function readChunk(file, fromEnd) {
58
+ let fd;
59
+ try {
60
+ fd = fs.openSync(file, 'r');
61
+ const size = fs.fstatSync(fd).size;
62
+ const len = Math.min(CHUNK, size);
63
+ const buf = Buffer.alloc(len);
64
+ fs.readSync(fd, buf, 0, len, fromEnd ? size - len : 0);
65
+ return buf.toString('utf-8');
66
+ }
67
+ catch {
68
+ return '';
69
+ }
70
+ finally {
71
+ if (fd !== undefined)
72
+ fs.closeSync(fd);
73
+ }
74
+ }
75
+ function parseLines(text) {
76
+ const out = [];
77
+ for (const line of text.split('\n')) {
78
+ try {
79
+ out.push(JSON.parse(line));
80
+ }
81
+ catch {
82
+ // partial first/last line of the chunk
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ function readLogFacts(file) {
88
+ let entrypoint = null;
89
+ let cwd = null;
90
+ for (const o of parseLines(readChunk(file, false))) {
91
+ entrypoint ??= typeof o?.entrypoint === 'string' ? o.entrypoint : null;
92
+ cwd ??= typeof o?.cwd === 'string' ? o.cwd : null;
93
+ if (entrypoint && cwd)
94
+ break;
95
+ }
96
+ // Only the last few records: an exit writes cost-state and then a little
97
+ // bookkeeping (e.g. artifact-comment-monitor), so it isn't always the last line.
98
+ const exited = parseLines(readChunk(file, true)).slice(-8).some((o) => o?.type === 'cost-state');
99
+ return { interactive: !entrypoint || entrypoint === 'cli', cwd, exited };
100
+ }
101
+ function listLogFiles(root, since) {
102
+ const out = [];
103
+ let dirs;
104
+ try {
105
+ dirs = fs.readdirSync(root);
106
+ }
107
+ catch {
108
+ return out;
109
+ }
110
+ for (const d of dirs) {
111
+ let names;
112
+ try {
113
+ names = fs.readdirSync(path.join(root, d));
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ for (const f of names) {
119
+ if (!f.endsWith('.jsonl'))
120
+ continue;
121
+ const file = path.join(root, d, f);
122
+ try {
123
+ const mtime = fs.statSync(file).mtimeMs;
124
+ if (mtime >= since)
125
+ out.push({ sessionId: f.slice(0, -'.jsonl'.length), file, mtime });
126
+ }
127
+ catch {
128
+ // vanished mid-scan
129
+ }
130
+ }
131
+ }
132
+ return out.sort((a, b) => b.mtime - a.mtime);
133
+ }
134
+ function inferLastStop(live, now = Date.now(), root = discover_1.CLAUDE_DIR, readFacts = readLogFacts) {
135
+ const liveIds = new Set(live.map((s) => s.sessionId));
136
+ const factsCache = new Map();
137
+ const facts = (f) => {
138
+ let v = factsCache.get(f.file);
139
+ if (!v)
140
+ factsCache.set(f.file, (v = readFacts(f.file)));
141
+ return v;
142
+ };
143
+ // Newest interactive session that is no longer running marks the stop.
144
+ const dead = listLogFiles(root, now - LOOKBACK_MS).filter((f) => !liveIds.has(f.sessionId));
145
+ const newest = dead.find((f) => facts(f).interactive && facts(f).cwd);
146
+ if (!newest)
147
+ return null;
148
+ const stoppedAt = Math.floor(newest.mtime); // an integer survives the round trip through the page
149
+ // Same rule as the recorded path: a session started before the stop and still
150
+ // running means the others were closed on purpose, not killed together.
151
+ if (live.some((s) => s.startedAt !== undefined && s.startedAt <= stoppedAt))
152
+ return null;
153
+ const burst = facts(newest).exited;
154
+ const window = burst ? EXIT_BURST_WINDOW_MS : ACTIVITY_WINDOW_MS;
155
+ const sessions = [];
156
+ for (const f of dead) {
157
+ if (f.mtime < stoppedAt - window)
158
+ break; // sorted newest first
159
+ const x = facts(f);
160
+ if (!x.interactive || !x.cwd)
161
+ continue;
162
+ // In a shutdown burst, only sessions that actually exited belong to it.
163
+ if (burst && !x.exited)
164
+ continue;
165
+ sessions.push({ sessionId: f.sessionId, cwd: x.cwd, name: null, proc: 'log', aliveSince: f.mtime, lastSeenAlive: f.mtime });
166
+ }
167
+ return { stoppedAt, sessions, estimated: true };
168
+ }
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.detectLastStop = detectLastStop;
37
+ exports.chooseStop = chooseStop;
38
+ exports.describeStopped = describeStopped;
39
+ exports.restoreAll = restoreAll;
40
+ const fs = __importStar(require("fs"));
41
+ const discover_1 = require("./discover");
42
+ const cmux_1 = require("./cmux");
43
+ const snapshot_1 = require("./snapshot");
44
+ const infer_1 = require("./infer");
45
+ const agent_1 = require("./agent");
46
+ // Starting a dozen `claude` processes at once is what tends to cause the memory
47
+ // pressure this feature recovers from, so space them out a little.
48
+ const STAGGER_MS = 1500;
49
+ // The stop to offer. The agent's records are exact but only reach back to when
50
+ // it started recording; anything else (no agent, or a stop that predates it)
51
+ // is guessed from the conversation logs. Either way the current registry is
52
+ // recorded first so already-resumed sessions count as running.
53
+ function detectLastStop(now = Date.now()) {
54
+ const live = (0, snapshot_1.readLiveSessions)();
55
+ const agentInstalled = fs.existsSync(agent_1.AGENT_PLIST);
56
+ const { state, stop } = chooseStop((0, snapshot_1.recordLive)((0, snapshot_1.loadSnapshot)(), live, now), live, agentInstalled, () => (0, infer_1.inferLastStop)(live, now));
57
+ try {
58
+ (0, snapshot_1.saveSnapshot)(state);
59
+ }
60
+ catch {
61
+ // read-only use still works from the in-memory state
62
+ }
63
+ return { state, stop, agentInstalled };
64
+ }
65
+ // Pure decision between the agent's records and a log estimate; returns the
66
+ // state to persist alongside it.
67
+ function chooseStop(state, live, agentInstalled, infer) {
68
+ if (agentInstalled) {
69
+ const recorded = (0, snapshot_1.findLastStop)(state, new Set(live.map((s) => s.sessionId)));
70
+ if (recorded)
71
+ return { state, stop: recorded };
72
+ }
73
+ const inferred = infer();
74
+ if (!inferred)
75
+ return { state, stop: null };
76
+ // Within the recorded period the agent saw no stop, so trust it over a guess.
77
+ if (agentInstalled && state.recordingSince !== null && inferred.stoppedAt >= state.recordingSince) {
78
+ return { state, stop: null };
79
+ }
80
+ if (state.lastEstimatedStopAt !== null && inferred.stoppedAt < state.lastEstimatedStopAt) {
81
+ return { state, stop: null };
82
+ }
83
+ return { state: { ...state, lastEstimatedStopAt: inferred.stoppedAt }, stop: inferred };
84
+ }
85
+ async function describeStopped(sessions) {
86
+ const previews = await (0, discover_1.findRecentSessionsByIds)(sessions.map((s) => s.sessionId));
87
+ const byId = new Map(previews.map((p) => [p.id, p]));
88
+ return sessions.map((s) => {
89
+ const p = byId.get(s.sessionId);
90
+ return {
91
+ sessionId: s.sessionId,
92
+ cwd: s.cwd,
93
+ title: p?.customTitle || p?.aiTitle || s.name || p?.slug || s.sessionId.slice(0, 8),
94
+ projectRawName: p?.projectRawName ?? null,
95
+ };
96
+ });
97
+ }
98
+ async function restoreAll(items, onProgress) {
99
+ const outcomes = [];
100
+ for (let i = 0; i < items.length; i++) {
101
+ const it = items[i];
102
+ let outcome;
103
+ if (!it.projectRawName) {
104
+ outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: 'no conversation log (nothing to resume)' };
105
+ }
106
+ else if (!fs.existsSync(it.cwd)) {
107
+ outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: `directory no longer exists: ${it.cwd}` };
108
+ }
109
+ else {
110
+ try {
111
+ // Background: keep focus where it is instead of flicking through every tab.
112
+ const { workspaceId } = await (0, cmux_1.resumeInNewWorkspace)(it.cwd, it.sessionId, it.title, true);
113
+ (0, cmux_1.saveResumeMapEntry)(it.sessionId, workspaceId);
114
+ outcome = { sessionId: it.sessionId, title: it.title, ok: true };
115
+ }
116
+ catch (err) {
117
+ outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: err?.message || String(err) };
118
+ }
119
+ if (i < items.length - 1)
120
+ await new Promise((r) => setTimeout(r, STAGGER_MS));
121
+ }
122
+ outcomes.push(outcome);
123
+ onProgress?.(outcome);
124
+ }
125
+ return outcomes;
126
+ }
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SNAPSHOT_FILE = void 0;
37
+ exports.emptyState = emptyState;
38
+ exports.loadSnapshot = loadSnapshot;
39
+ exports.saveSnapshot = saveSnapshot;
40
+ exports.readLiveSessions = readLiveSessions;
41
+ exports.recordLive = recordLive;
42
+ exports.findLastStop = findLastStop;
43
+ exports.snapshotNow = snapshotNow;
44
+ const fs = __importStar(require("fs"));
45
+ const os = __importStar(require("os"));
46
+ const path = __importStar(require("path"));
47
+ // "What was running before the crash?" — a record of live Claude Code sessions
48
+ // that outlives the processes, so they can be brought back in one go after a
49
+ // reboot or a hung cmux.
50
+ //
51
+ // Claude Code keeps ~/.claude/sessions/<pid>.json only while a session runs and
52
+ // removes it on exit (stale ones are swept at the next launch), so the registry
53
+ // itself is gone by the time you need it. `ccakashic snapshot` copies it into
54
+ // our own file on a timer (a launchd agent, see agent.ts) and keeps, per
55
+ // session, when it was last seen alive.
56
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
57
+ exports.SNAPSHOT_FILE = path.join(CONFIG_DIR, 'live-sessions.json');
58
+ const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
59
+ // Sessions killed by the same event share their last sighting, give or take a
60
+ // run that was in progress while they went down.
61
+ const GROUP_WINDOW_MS = 150_000;
62
+ const RETENTION_MS = 14 * 24 * 60 * 60_000;
63
+ function emptyState() {
64
+ return { version: 1, lastRunAt: 0, recordingSince: null, lastEstimatedStopAt: null, dismissedStopAt: null, sessions: {} };
65
+ }
66
+ function loadSnapshot(file = exports.SNAPSHOT_FILE) {
67
+ try {
68
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
69
+ if (data && data.version === 1 && data.sessions && typeof data.sessions === 'object') {
70
+ return { ...emptyState(), ...data };
71
+ }
72
+ }
73
+ catch {
74
+ // not recorded yet
75
+ }
76
+ return emptyState();
77
+ }
78
+ function saveSnapshot(state, file = exports.SNAPSHOT_FILE) {
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ // Atomic: this file is exactly what a crash must not leave half-written.
81
+ const tmp = `${file}.${process.pid}.tmp`;
82
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
83
+ fs.renameSync(tmp, file);
84
+ }
85
+ function isProcessAlive(pid) {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ }
90
+ catch (err) {
91
+ return err?.code === 'EPERM'; // exists, just not ours
92
+ }
93
+ }
94
+ // Interactive sessions whose process is running right now. Headless `claude -p`
95
+ // runs (git hooks, scripts) register too but are not something to resume.
96
+ function readLiveSessions(dir = SESSION_REGISTRY_DIR) {
97
+ let names;
98
+ try {
99
+ names = fs.readdirSync(dir);
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ const out = new Map();
105
+ for (const f of names) {
106
+ if (!f.endsWith('.json'))
107
+ continue;
108
+ try {
109
+ const o = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
110
+ if (typeof o?.pid !== 'number' || typeof o?.sessionId !== 'string' || typeof o?.cwd !== 'string')
111
+ continue;
112
+ if (o.kind && o.kind !== 'interactive')
113
+ continue;
114
+ if (!isProcessAlive(o.pid))
115
+ continue;
116
+ out.set(o.sessionId, {
117
+ sessionId: o.sessionId,
118
+ cwd: o.cwd,
119
+ name: typeof o.name === 'string' ? o.name : null,
120
+ proc: `${o.pid}:${o.procStart ?? ''}`,
121
+ ...(typeof o.startedAt === 'number' ? { startedAt: o.startedAt } : {}),
122
+ });
123
+ }
124
+ catch {
125
+ // half-written record
126
+ }
127
+ }
128
+ return [...out.values()];
129
+ }
130
+ function recordLive(state, live, now) {
131
+ const sessions = {};
132
+ for (const [id, e] of Object.entries(state.sessions)) {
133
+ if (now - e.lastSeenAlive < RETENTION_MS)
134
+ sessions[id] = e;
135
+ }
136
+ for (const s of live) {
137
+ const prev = sessions[s.sessionId];
138
+ const sameProcess = prev && prev.proc === s.proc;
139
+ sessions[s.sessionId] = {
140
+ cwd: s.cwd,
141
+ name: s.name ?? prev?.name ?? null,
142
+ proc: s.proc,
143
+ aliveSince: sameProcess ? prev.aliveSince : now,
144
+ lastSeenAlive: now,
145
+ };
146
+ }
147
+ return { ...state, lastRunAt: now, recordingSince: state.recordingSince ?? now, sessions };
148
+ }
149
+ // The most recent group of sessions that went down together and has not been
150
+ // brought back. A group only counts as a stop when nothing survived it: if some
151
+ // session was alive both before and after, the others were closed one by one on
152
+ // purpose (/exit, closing a tab), not killed by a reboot or a hung cmux.
153
+ function findLastStop(state, liveIds) {
154
+ const dead = [];
155
+ for (const [sessionId, e] of Object.entries(state.sessions)) {
156
+ if (!liveIds.has(sessionId))
157
+ dead.push({ sessionId, ...e });
158
+ }
159
+ if (!dead.length)
160
+ return null;
161
+ const stoppedAt = Math.max(...dead.map((d) => d.lastSeenAlive));
162
+ for (const id of liveIds) {
163
+ const e = state.sessions[id];
164
+ if (e && e.aliveSince <= stoppedAt)
165
+ return null;
166
+ }
167
+ const sessions = dead
168
+ .filter((d) => d.lastSeenAlive >= stoppedAt - GROUP_WINDOW_MS)
169
+ .sort((a, b) => b.lastSeenAlive - a.lastSeenAlive || a.cwd.localeCompare(b.cwd));
170
+ return { stoppedAt, sessions };
171
+ }
172
+ // One launchd tick. Kept free of any import beyond Node built-ins: the agent
173
+ // runs a copy of this compiled file, not the installed package (see agent.ts).
174
+ function snapshotNow(now = Date.now()) {
175
+ saveSnapshot(recordLive(loadSnapshot(), readLiveSessions(), now));
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccakashic",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A cross-project dashboard for your Claude Code sessions (~/.claude/projects/) — browse logs as beautiful HTML, see which sessions are waiting for you, and resume any of them in one click via cmux",
5
5
  "bin": {
6
6
  "ccakashic": "dist/bin/ccakashic.js"