ccakashic 0.5.0 → 0.6.1

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,7 @@ 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)
56
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
57
58
  - **Zero dependencies** — Node.js built-in modules only
58
59
 
@@ -74,6 +75,27 @@ Notes:
74
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`)
75
76
  - Disable the integration with `--no-cmux` or `CCAKASHIC_NO_CMUX=1`
76
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
+
77
99
  ## Options
78
100
 
79
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
+ }
@@ -38,6 +38,7 @@ 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");
@@ -46,6 +47,9 @@ const parser_1 = require("../parser");
46
47
  const html_generator_1 = require("../html-generator");
47
48
  const pages_1 = require("../pages");
48
49
  const dashboard_1 = require("../dashboard");
50
+ const snapshot_1 = require("../snapshot");
51
+ const restore_1 = require("../restore");
52
+ const agent_1 = require("../agent");
49
53
  const cmux_1 = require("../cmux");
50
54
  // Published at dist/bin/ccakashic.js, so ../../package.json resolves from dist/
51
55
  const pkg = __importStar(require("../../package.json"));
@@ -224,6 +228,58 @@ async function handleResume(req, res) {
224
228
  });
225
229
  }
226
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
+ }
227
283
  // Only accept loopback Host headers. The server binds 127.0.0.1, but without
228
284
  // this check a malicious site could DNS-rebind its hostname to 127.0.0.1 and
229
285
  // become same-origin, defeating the resume token and reading session content.
@@ -251,6 +307,10 @@ const server = http.createServer(async (req, res) => {
251
307
  await handleResume(req, res);
252
308
  return;
253
309
  }
310
+ if (pathname === '/api/restore' || pathname === '/api/restore/dismiss') {
311
+ await handleRestore(req, res, pathname === '/api/restore' ? 'run' : 'dismiss');
312
+ return;
313
+ }
254
314
  if (pathname === '/' || pathname === '') {
255
315
  const requested = parseInt(url.searchParams.get('n') || '') || dashboard_1.DEFAULT_PANE_COUNT;
256
316
  const paneCount = dashboard_1.PANE_COUNTS.includes(requested) ? requested : dashboard_1.DEFAULT_PANE_COUNT;
@@ -262,7 +322,7 @@ const server = http.createServer(async (req, res) => {
262
322
  waiting: resolveWaiting(session.id, cmuxWait),
263
323
  })));
264
324
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
265
- res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext()));
325
+ res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext(), await buildRestoreBanner()));
266
326
  return;
267
327
  }
268
328
  if (pathname === '/projects') {
@@ -469,7 +529,91 @@ async function startServer(startPort) {
469
529
  }
470
530
  throw new Error(`No available port after ${MAX_PORT_TRIES} tries starting at ${startPort}`);
471
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
+ }
472
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
+ }
473
617
  const existing = await findExistingCcakashic(PORT);
474
618
  if (existing) {
475
619
  const url = `http://127.0.0.1:${existing}`;
package/dist/dashboard.js CHANGED
@@ -6,11 +6,13 @@ exports.paneStatus = paneStatus;
6
6
  exports.renderPaneBody = renderPaneBody;
7
7
  exports.paneTitle = paneTitle;
8
8
  exports.waitBadgeHtml = waitBadgeHtml;
9
+ exports.restoreBannerHtml = restoreBannerHtml;
9
10
  exports.generateDashboard = generateDashboard;
10
11
  const template_assets_1 = require("./template-assets");
11
12
  const html_generator_1 = require("./html-generator");
12
13
  const resume_ui_1 = require("./resume-ui");
13
14
  const util_1 = require("./util");
15
+ const cmux_1 = require("./cmux");
14
16
  // Multi-pane dashboard: the N most recently active sessions across all
15
17
  // projects, each pane showing the last 24h of conversation as a scrollable
16
18
  // thread, refreshed by polling /api/pane.
@@ -68,7 +70,30 @@ function waitBadgeHtml(waiting) {
68
70
  const label = waiting === 'permission' ? '\u{1F510} Permission' : '⏳ Your turn';
69
71
  return `<span class="dash-wait-badge dash-wait-${waiting}">${label}</span>`;
70
72
  }
71
- 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) {
72
97
  const cols = paneCount <= 4 ? Math.max(panes.length, 1) : Math.ceil(paneCount / 2);
73
98
  const rows = paneCount <= 4 ? 1 : 2;
74
99
  const panesHtml = panes.map(({ session: s, bodyHtml, waiting }) => {
@@ -111,6 +136,7 @@ ${dashboardCSS(cols, rows)}
111
136
  <span class="dash-counts">Panes: ${countLinks}</span>
112
137
  <a class="dash-nav-link" href="/projects">All projects &rarr;</a>
113
138
  </div>
139
+ ${restoreBannerHtml(restore)}
114
140
  <div class="dash-grid">
115
141
  ${panesHtml || '<div class="empty">No sessions found</div>'}
116
142
  </div>
@@ -252,6 +278,36 @@ function dashboardCSS(cols, rows) {
252
278
  }
253
279
  .empty { text-align: center; color: var(--text-muted); padding: 40px; }
254
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
+
255
311
  @media (max-width: 900px) {
256
312
  .dash-page { height: auto; overflow: auto; }
257
313
  .dash-grid { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-rows: 70vh; }
@@ -322,6 +378,46 @@ function dashboardJS() {
322
378
  }).catch(function() { /* server briefly unavailable; retry next tick */ });
323
379
  }
324
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
+
325
421
  syncWaitingIndicator();
326
422
  setInterval(function() {
327
423
  document.querySelectorAll('.dash-pane').forEach(refreshPane);
package/dist/discover.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CLAUDE_DIR = void 0;
37
+ exports.listKnownSessionIds = listKnownSessionIds;
37
38
  exports.decodeDirName = decodeDirName;
38
39
  exports.listProjects = listProjects;
39
40
  exports.listSessions = listSessions;
@@ -46,6 +47,32 @@ const os = __importStar(require("os"));
46
47
  const path = __importStar(require("path"));
47
48
  const readline = __importStar(require("readline"));
48
49
  exports.CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
50
+ // Every session id that has a conversation log, by filename only (no parsing).
51
+ // A session with no log cannot be resumed, so restore uses this to skip them.
52
+ function listKnownSessionIds(root = exports.CLAUDE_DIR) {
53
+ const ids = new Set();
54
+ let dirs;
55
+ try {
56
+ dirs = fs.readdirSync(root);
57
+ }
58
+ catch {
59
+ return ids;
60
+ }
61
+ for (const d of dirs) {
62
+ let names;
63
+ try {
64
+ names = fs.readdirSync(path.join(root, d));
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ for (const f of names) {
70
+ if (f.endsWith('.jsonl'))
71
+ ids.add(f.slice(0, -'.jsonl'.length));
72
+ }
73
+ }
74
+ return ids;
75
+ }
49
76
  function decodeDirName(dirName) {
50
77
  // Directory names encode paths: /Users/foo/bar → -Users-foo-bar
51
78
  // This is lossy (dots become dashes too), but good enough for display
package/dist/infer.js ADDED
@@ -0,0 +1,174 @@
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
+ const snapshot_1 = require("./snapshot");
42
+ // Best-effort "what was running before the crash" from the conversation logs
43
+ // alone, for when the snapshot agent is not installed.
44
+ //
45
+ // What the logs can and cannot tell:
46
+ // - A session that exits (including during a normal shutdown/reboot) appends a
47
+ // `cost-state` record, usually followed by a line or two of bookkeeping. A
48
+ // burst of those right before a gap is a reliable signature.
49
+ // - A session killed outright (cmux force-quit while hung) writes nothing. All
50
+ // that is left is when it was last active, so a session idling for hours
51
+ // before the hang is missed. Results are therefore marked `estimated`.
52
+ // Sessions that exit on the same shutdown finish within seconds of each other.
53
+ const EXIT_BURST_WINDOW_MS = 3 * 60_000;
54
+ // Without exit records, "recently active before the newest one" is the best proxy.
55
+ const ACTIVITY_WINDOW_MS = 15 * 60_000;
56
+ const LOOKBACK_MS = 7 * 24 * 60 * 60_000;
57
+ const CHUNK = 64 * 1024;
58
+ function readChunk(file, fromEnd) {
59
+ let fd;
60
+ try {
61
+ fd = fs.openSync(file, 'r');
62
+ const size = fs.fstatSync(fd).size;
63
+ const len = Math.min(CHUNK, size);
64
+ const buf = Buffer.alloc(len);
65
+ fs.readSync(fd, buf, 0, len, fromEnd ? size - len : 0);
66
+ return buf.toString('utf-8');
67
+ }
68
+ catch {
69
+ return '';
70
+ }
71
+ finally {
72
+ if (fd !== undefined)
73
+ fs.closeSync(fd);
74
+ }
75
+ }
76
+ function parseLines(text) {
77
+ const out = [];
78
+ for (const line of text.split('\n')) {
79
+ try {
80
+ out.push(JSON.parse(line));
81
+ }
82
+ catch {
83
+ // partial first/last line of the chunk
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ function readLogFacts(file) {
89
+ let entrypoint = null;
90
+ let cwd = null;
91
+ for (const o of parseLines(readChunk(file, false))) {
92
+ entrypoint ??= typeof o?.entrypoint === 'string' ? o.entrypoint : null;
93
+ cwd ??= typeof o?.cwd === 'string' ? o.cwd : null;
94
+ if (entrypoint && cwd)
95
+ break;
96
+ }
97
+ // Only the last few records: an exit writes cost-state and then a little
98
+ // bookkeeping (e.g. artifact-comment-monitor), so it isn't always the last line.
99
+ const exited = parseLines(readChunk(file, true)).slice(-8).some((o) => o?.type === 'cost-state');
100
+ return { interactive: !entrypoint || entrypoint === 'cli', cwd, exited };
101
+ }
102
+ function listLogFiles(root, since) {
103
+ const out = [];
104
+ let dirs;
105
+ try {
106
+ dirs = fs.readdirSync(root);
107
+ }
108
+ catch {
109
+ return out;
110
+ }
111
+ for (const d of dirs) {
112
+ let names;
113
+ try {
114
+ names = fs.readdirSync(path.join(root, d));
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ for (const f of names) {
120
+ if (!f.endsWith('.jsonl'))
121
+ continue;
122
+ const file = path.join(root, d, f);
123
+ try {
124
+ const mtime = fs.statSync(file).mtimeMs;
125
+ if (mtime >= since)
126
+ out.push({ sessionId: f.slice(0, -'.jsonl'.length), file, mtime });
127
+ }
128
+ catch {
129
+ // vanished mid-scan
130
+ }
131
+ }
132
+ }
133
+ return out.sort((a, b) => b.mtime - a.mtime);
134
+ }
135
+ function inferLastStop(live, now = Date.now(), root = discover_1.CLAUDE_DIR, readFacts = readLogFacts, notOlderThan = 0) {
136
+ const liveIds = new Set(live.map((s) => s.sessionId));
137
+ const factsCache = new Map();
138
+ const facts = (f) => {
139
+ let v = factsCache.get(f.file);
140
+ if (!v)
141
+ factsCache.set(f.file, (v = readFacts(f.file)));
142
+ return v;
143
+ };
144
+ const dead = listLogFiles(root, now - LOOKBACK_MS)
145
+ .filter((f) => !liveIds.has(f.sessionId) && facts(f).interactive && facts(f).cwd);
146
+ if (!dead.length)
147
+ return null;
148
+ // Same shape as the recorded path: prefer a group that stopped together over a
149
+ // lone session that happens to be newer (a scheduled run, a quick question).
150
+ // Without exit records the times are "last active", so they spread out more.
151
+ const anchor = dead[0];
152
+ const gap = facts(anchor).exited ? EXIT_BURST_WINDOW_MS : ACTIVITY_WINDOW_MS;
153
+ const group = (0, snapshot_1.pickStoppedGroup)(dead, (f) => f.mtime, gap);
154
+ const burst = facts(group[0]).exited;
155
+ const stoppedAt = Math.floor(group[0].mtime); // an integer survives the round trip through the page
156
+ if (stoppedAt < notOlderThan)
157
+ return null;
158
+ // A session started before the stop and still running means the others were
159
+ // closed on purpose, not taken down together.
160
+ if (live.some((s) => s.startedAt !== undefined && s.startedAt <= stoppedAt))
161
+ return null;
162
+ const sessions = group
163
+ // In a shutdown burst, only sessions that actually exited belong to it.
164
+ .filter((f) => !burst || facts(f).exited)
165
+ .map((f) => ({
166
+ sessionId: f.sessionId,
167
+ cwd: facts(f).cwd,
168
+ name: null,
169
+ proc: 'log',
170
+ aliveSince: f.mtime,
171
+ lastSeenAlive: f.mtime,
172
+ }));
173
+ return { stoppedAt, sessions, estimated: true };
174
+ }
@@ -0,0 +1,130 @@
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 known = (0, discover_1.listKnownSessionIds)();
57
+ const { state, stop } = chooseStop((0, snapshot_1.recordLive)((0, snapshot_1.loadSnapshot)(), live, now), live, agentInstalled, (notOlderThan) => (0, infer_1.inferLastStop)(live, now, undefined, undefined, notOlderThan), (id) => known.has(id));
58
+ try {
59
+ (0, snapshot_1.saveSnapshot)(state);
60
+ }
61
+ catch {
62
+ // read-only use still works from the in-memory state
63
+ }
64
+ return { state, stop, agentInstalled };
65
+ }
66
+ // Pure decision between the agent's records and a log estimate; returns the
67
+ // state to persist alongside it.
68
+ function chooseStop(state, live, agentInstalled, infer, resumable = () => true) {
69
+ // Never dig further back than the last group offered: once those are reopened,
70
+ // the group before them is just history, not something that stopped on you.
71
+ const floor = state.lastOfferedStopAt ?? 0;
72
+ const liveIds = new Set(live.map((s) => s.sessionId));
73
+ const stop = (agentInstalled ? (0, snapshot_1.findLastStop)(state, liveIds, resumable, floor) : null)
74
+ // Within the recorded period the agent's records are the truth, so the log
75
+ // estimate is only for stops from before recording began.
76
+ ?? inferBeforeRecording(state, agentInstalled, infer, floor);
77
+ if (!stop)
78
+ return { state, stop: null };
79
+ return { state: { ...state, lastOfferedStopAt: Math.max(floor, stop.stoppedAt) }, stop };
80
+ }
81
+ function inferBeforeRecording(state, agentInstalled, infer, floor) {
82
+ const inferred = infer(floor);
83
+ if (!inferred)
84
+ return null;
85
+ if (agentInstalled && state.recordingSince !== null && inferred.stoppedAt >= state.recordingSince)
86
+ return null;
87
+ return inferred;
88
+ }
89
+ async function describeStopped(sessions) {
90
+ const previews = await (0, discover_1.findRecentSessionsByIds)(sessions.map((s) => s.sessionId));
91
+ const byId = new Map(previews.map((p) => [p.id, p]));
92
+ return sessions.map((s) => {
93
+ const p = byId.get(s.sessionId);
94
+ return {
95
+ sessionId: s.sessionId,
96
+ cwd: s.cwd,
97
+ title: p?.customTitle || p?.aiTitle || s.name || p?.slug || s.sessionId.slice(0, 8),
98
+ projectRawName: p?.projectRawName ?? null,
99
+ };
100
+ });
101
+ }
102
+ async function restoreAll(items, onProgress) {
103
+ const outcomes = [];
104
+ for (let i = 0; i < items.length; i++) {
105
+ const it = items[i];
106
+ let outcome;
107
+ if (!it.projectRawName) {
108
+ outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: 'no conversation log (nothing to resume)' };
109
+ }
110
+ else if (!fs.existsSync(it.cwd)) {
111
+ outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: `directory no longer exists: ${it.cwd}` };
112
+ }
113
+ else {
114
+ try {
115
+ // Background: keep focus where it is instead of flicking through every tab.
116
+ const { workspaceId } = await (0, cmux_1.resumeInNewWorkspace)(it.cwd, it.sessionId, it.title, true);
117
+ (0, cmux_1.saveResumeMapEntry)(it.sessionId, workspaceId);
118
+ outcome = { sessionId: it.sessionId, title: it.title, ok: true };
119
+ }
120
+ catch (err) {
121
+ outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: err?.message || String(err) };
122
+ }
123
+ if (i < items.length - 1)
124
+ await new Promise((r) => setTimeout(r, STAGGER_MS));
125
+ }
126
+ outcomes.push(outcome);
127
+ onProgress?.(outcome);
128
+ }
129
+ return outcomes;
130
+ }
@@ -0,0 +1,205 @@
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.pickStoppedGroup = pickStoppedGroup;
44
+ exports.snapshotNow = snapshotNow;
45
+ const fs = __importStar(require("fs"));
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ // "What was running before the crash?" — a record of live Claude Code sessions
49
+ // that outlives the processes, so they can be brought back in one go after a
50
+ // reboot or a hung cmux.
51
+ //
52
+ // Claude Code keeps ~/.claude/sessions/<pid>.json only while a session runs and
53
+ // removes it on exit (stale ones are swept at the next launch), so the registry
54
+ // itself is gone by the time you need it. `ccakashic snapshot` copies it into
55
+ // our own file on a timer (a launchd agent, see agent.ts) and keeps, per
56
+ // session, when it was last seen alive.
57
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
58
+ exports.SNAPSHOT_FILE = path.join(CONFIG_DIR, 'live-sessions.json');
59
+ const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
60
+ // Sessions killed by the same event share their last sighting, give or take the
61
+ // run that was in progress while they went down. Measured between consecutive
62
+ // sightings, not from the newest one: a session that was opened and closed after
63
+ // the stop must not push the real group out of a fixed window.
64
+ const GROUP_GAP_MS = 150_000;
65
+ const RETENTION_MS = 14 * 24 * 60 * 60_000;
66
+ function emptyState() {
67
+ return { version: 1, lastRunAt: 0, recordingSince: null, lastOfferedStopAt: null, dismissedStopAt: null, sessions: {} };
68
+ }
69
+ function loadSnapshot(file = exports.SNAPSHOT_FILE) {
70
+ try {
71
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
72
+ if (data && data.version === 1 && data.sessions && typeof data.sessions === 'object') {
73
+ const state = { ...emptyState(), ...data };
74
+ // 0.6.0 called it lastEstimatedStopAt, when only the log estimate used it.
75
+ if (state.lastOfferedStopAt === null && typeof data.lastEstimatedStopAt === 'number') {
76
+ state.lastOfferedStopAt = data.lastEstimatedStopAt;
77
+ }
78
+ return state;
79
+ }
80
+ }
81
+ catch {
82
+ // not recorded yet
83
+ }
84
+ return emptyState();
85
+ }
86
+ function saveSnapshot(state, file = exports.SNAPSHOT_FILE) {
87
+ fs.mkdirSync(path.dirname(file), { recursive: true });
88
+ // Atomic: this file is exactly what a crash must not leave half-written.
89
+ const tmp = `${file}.${process.pid}.tmp`;
90
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
91
+ fs.renameSync(tmp, file);
92
+ }
93
+ function isProcessAlive(pid) {
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch (err) {
99
+ return err?.code === 'EPERM'; // exists, just not ours
100
+ }
101
+ }
102
+ // Interactive sessions whose process is running right now. Headless `claude -p`
103
+ // runs (git hooks, scripts) register too but are not something to resume.
104
+ function readLiveSessions(dir = SESSION_REGISTRY_DIR) {
105
+ let names;
106
+ try {
107
+ names = fs.readdirSync(dir);
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ const out = new Map();
113
+ for (const f of names) {
114
+ if (!f.endsWith('.json'))
115
+ continue;
116
+ try {
117
+ const o = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
118
+ if (typeof o?.pid !== 'number' || typeof o?.sessionId !== 'string' || typeof o?.cwd !== 'string')
119
+ continue;
120
+ if (o.kind && o.kind !== 'interactive')
121
+ continue;
122
+ if (!isProcessAlive(o.pid))
123
+ continue;
124
+ out.set(o.sessionId, {
125
+ sessionId: o.sessionId,
126
+ cwd: o.cwd,
127
+ name: typeof o.name === 'string' ? o.name : null,
128
+ proc: `${o.pid}:${o.procStart ?? ''}`,
129
+ ...(typeof o.startedAt === 'number' ? { startedAt: o.startedAt } : {}),
130
+ });
131
+ }
132
+ catch {
133
+ // half-written record
134
+ }
135
+ }
136
+ return [...out.values()];
137
+ }
138
+ function recordLive(state, live, now) {
139
+ const sessions = {};
140
+ for (const [id, e] of Object.entries(state.sessions)) {
141
+ if (now - e.lastSeenAlive < RETENTION_MS)
142
+ sessions[id] = e;
143
+ }
144
+ for (const s of live) {
145
+ const prev = sessions[s.sessionId];
146
+ const sameProcess = prev && prev.proc === s.proc;
147
+ sessions[s.sessionId] = {
148
+ cwd: s.cwd,
149
+ name: s.name ?? prev?.name ?? null,
150
+ proc: s.proc,
151
+ aliveSince: sameProcess ? prev.aliveSince : now,
152
+ lastSeenAlive: now,
153
+ };
154
+ }
155
+ return { ...state, lastRunAt: now, recordingSince: state.recordingSince ?? now, sessions };
156
+ }
157
+ // The most recent group of sessions that went down together and has not been
158
+ // brought back. A group only counts as a stop when nothing survived it: if some
159
+ // session was alive both before and after, the others were closed one by one on
160
+ // purpose (/exit, closing a tab), not killed by a reboot or a hung cmux.
161
+ function findLastStop(state, liveIds,
162
+ // Sessions without a conversation log can't be resumed; leaving them in would
163
+ // let a throwaway session started after the stop stand in for the group.
164
+ resumable = () => true, notOlderThan = 0) {
165
+ const dead = [];
166
+ for (const [sessionId, e] of Object.entries(state.sessions)) {
167
+ if (!liveIds.has(sessionId) && resumable(sessionId))
168
+ dead.push({ sessionId, ...e });
169
+ }
170
+ if (!dead.length)
171
+ return null;
172
+ dead.sort((a, b) => b.lastSeenAlive - a.lastSeenAlive || a.cwd.localeCompare(b.cwd));
173
+ const group = pickStoppedGroup(dead, (d) => d.lastSeenAlive);
174
+ const stoppedAt = group[0].lastSeenAlive;
175
+ if (stoppedAt < notOlderThan)
176
+ return null;
177
+ // Something running from before the stop means the rest were closed one by
178
+ // one on purpose, not taken down together.
179
+ for (const id of liveIds) {
180
+ const e = state.sessions[id];
181
+ if (e && e.aliveSince <= stoppedAt)
182
+ return null;
183
+ }
184
+ return { stoppedAt, sessions: group };
185
+ }
186
+ // The most recent group that went down together, newest first. Sessions come
187
+ // and go all day — a scheduled run, a quick question — and any of them would
188
+ // otherwise stand in for the last stop just by being the newest. So a group of
189
+ // several wins over a lone session, even a more recent one.
190
+ function pickStoppedGroup(deadNewestFirst, at, gapMs = GROUP_GAP_MS) {
191
+ const clusters = [];
192
+ for (const item of deadNewestFirst) {
193
+ const current = clusters[clusters.length - 1];
194
+ if (current && at(current[current.length - 1]) - at(item) <= gapMs)
195
+ current.push(item);
196
+ else
197
+ clusters.push([item]);
198
+ }
199
+ return clusters.find((c) => c.length > 1) ?? clusters[0];
200
+ }
201
+ // One launchd tick. Kept free of any import beyond Node built-ins: the agent
202
+ // runs a copy of this compiled file, not the installed package (see agent.ts).
203
+ function snapshotNow(now = Date.now()) {
204
+ saveSnapshot(recordLive(loadSnapshot(), readLiveSessions(), now));
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccakashic",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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"