ccakashic 0.2.7 → 0.3.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
@@ -4,11 +4,13 @@
4
4
 
5
5
  # ccakashic
6
6
 
7
- An Akashic Record of your Claude Code sessions — browse Claude Code session logs (`~/.claude/projects/`) as beautiful HTML in your browser.
7
+ An Akashic Record of your Claude Code sessions — a cross-project **dashboard** for your `~/.claude/projects/` logs, rendered as beautiful HTML in your browser. See every recent session side by side, spot at a glance which ones are waiting for your input, and **resume any of them in one click** straight into a [cmux](https://github.com/manaflow-ai/cmux) workspace.
8
8
 
9
- ![Session detail with stats, cost badges, and chat layout](https://raw.githubusercontent.com/ashimon83/ccakashic/main/docs/screenshot1.png)
9
+ ![Cross-project dashboard: recent sessions side by side with live status dots, a waiting-for-you badge, and one-click Resume / Jump](https://raw.githubusercontent.com/ashimon83/ccakashic/main/docs/dashboard.png)
10
10
 
11
- ![Collapsible tool calls, code blocks, and per-message cost](https://raw.githubusercontent.com/ashimon83/ccakashic/main/docs/screenshot2.png)
11
+ ![Session detail with stats, cost badges, and a chat-style layout](https://raw.githubusercontent.com/ashimon83/ccakashic/main/docs/screenshot1.png)
12
+
13
+ ![Expanded tool calls with red/green diffs and per-message cost](https://raw.githubusercontent.com/ashimon83/ccakashic/main/docs/screenshot2.png)
12
14
 
13
15
  ## Usage
14
16
 
@@ -30,7 +32,9 @@ A local HTTP server starts and your browser opens automatically.
30
32
 
31
33
  ## Features
32
34
 
33
- - **Fully browser-based** — Project list Session list Conversation detail
35
+ - **Dashboard** — The top page shows your 4/6/8 most recently active sessions across all projects side by side, each pane an independently scrollable thread of the last 24h, with live status dots (🟢 active / 🟡 recent / ⚪ idle) refreshed by polling
36
+ - **Waiting-for-you indicator** — Sessions cmux is notifying you about (an **unread** "Claude is waiting for your input" / "needs your permission") get an orange frame and a `⏳ Your turn` / `🔐 Permission` badge. cmux marks the notification read the moment you focus that workspace, so the highlight **self-clears** on the next poll once you open the tab — it mirrors cmux's own badge exactly. The browser tab title shows the count (`(2) ccakashic`) and the favicon turns orange, so a glance at the tab tells you how many sessions need you. (Requires cmux; covers sessions resumed through ccakashic, which are tracked in the resume map.)
37
+ - **Fully browser-based** — Dashboard → Project list → Session list → Conversation detail
34
38
  - **Chat-style layout** — User / assistant messages in chat bubbles
35
39
  - **Collapsible tool calls** — Bash, Read, Edit, and other tool invocations collapsed by default
36
40
  - **Diff view** — File edits shown with red/green line highlights
@@ -44,13 +48,38 @@ A local HTTP server starts and your browser opens automatically.
44
48
  - **Dark mode** — Follows `prefers-color-scheme` automatically
45
49
  - **Filter search** — Incremental filtering on list pages
46
50
  - **Keyboard navigation** — `j` / `k` to move between messages
51
+ - **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
47
52
  - **Zero dependencies** — Node.js built-in modules only
48
53
 
54
+ ## cmux integration
55
+
56
+ When the [cmux](https://github.com/manaflow-ai/cmux) terminal is detected (`cmux ping` succeeds), ccakashic becomes a session dashboard:
57
+
58
+ - The UI opens in a cmux browser pane instead of your default browser
59
+ - Every session with a recorded `cwd` gets a `▶ Resume` button: one click creates a new cmux workspace, `cd`s into the session's directory, and runs `claude --resume <session-id>` — the workspace is renamed to the session title
60
+ - Resuming the same session again jumps to the already-open workspace instead of forking the conversation (the button turns into `↪ Jump`)
61
+ - Alt-click `▶ Resume` to open the workspace in the background and stay on the list
62
+ - Sessions that look active (modified in the last 2 minutes) ask for a second click before resuming, since resuming a live session forks it
63
+ - Without cmux, the `📋 Copy` button still gives you a ready-to-paste `cd … && claude --resume …` command
64
+
65
+ Notes:
66
+
67
+ - The resume CSRF token is persisted to `~/.config/ccakashic/token` (mode 600), so restarting the server doesn't break the buttons on already-open tabs
68
+ - cmux's socket only accepts callers inside the cmux process tree, so run `npx ccakashic` from a terminal **inside cmux** (or configure a socket password in cmux settings and export `CMUX_SOCKET_PASSWORD`)
69
+ - 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`)
70
+ - Disable the integration with `--no-cmux` or `CCAKASHIC_NO_CMUX=1`
71
+
49
72
  ## Options
50
73
 
51
74
  ```bash
52
75
  # Custom port (default: 3333)
53
76
  CCAKASHIC_PORT=3000 npx ccakashic
77
+
78
+ # Skip cmux detection (regular browser, no resume buttons)
79
+ npx ccakashic --no-cmux
80
+
81
+ # Start the server without opening a browser
82
+ npx ccakashic --no-open
54
83
  ```
55
84
 
56
85
  ## Requirements
@@ -39,10 +39,13 @@ const fs = __importStar(require("fs"));
39
39
  const os = __importStar(require("os"));
40
40
  const path = __importStar(require("path"));
41
41
  const child_process_1 = require("child_process");
42
+ const util_1 = require("../util");
42
43
  const discover_1 = require("../discover");
43
44
  const parser_1 = require("../parser");
44
45
  const html_generator_1 = require("../html-generator");
45
46
  const pages_1 = require("../pages");
47
+ const dashboard_1 = require("../dashboard");
48
+ const cmux_1 = require("../cmux");
46
49
  // Published at dist/bin/ccakashic.js, so ../../package.json resolves from dist/
47
50
  const pkg = __importStar(require("../../package.json"));
48
51
  function openInBrowser(url) {
@@ -51,11 +54,176 @@ function openInBrowser(url) {
51
54
  : 'xdg-open';
52
55
  (0, child_process_1.exec)(`${cmd} "${url}"`);
53
56
  }
57
+ async function openUrl(url) {
58
+ if (NO_OPEN) {
59
+ console.log(`Not opening a browser (--no-open). URL: ${url}`);
60
+ return;
61
+ }
62
+ if (!NO_CMUX && await (0, cmux_1.isCmuxAvailable)()) {
63
+ try {
64
+ await (0, cmux_1.openInCmuxBrowser)(url);
65
+ console.log('Opened in cmux browser pane (use --no-cmux for a regular browser)');
66
+ return;
67
+ }
68
+ catch {
69
+ // cmux is up but the browser pane failed; use the regular browser
70
+ }
71
+ }
72
+ openInBrowser(url);
73
+ }
54
74
  const PORT = parseInt(process.env.CCAKASHIC_PORT || '') || 3333;
55
75
  const MAX_PORT_TRIES = 20;
56
76
  const LOCK_FILE = path.join(os.tmpdir(), `ccakashic-${os.userInfo().username || 'user'}.json`);
77
+ const NO_CMUX = process.argv.includes('--no-cmux') || !!process.env.CCAKASHIC_NO_CMUX;
78
+ const NO_OPEN = process.argv.includes('--no-open') || !!process.env.CCAKASHIC_NO_OPEN;
79
+ // CSRF guard for /api/resume: any webpage can POST to localhost, but only
80
+ // pages we served know this token. Persisted across restarts so open tabs keep
81
+ // working without a reload.
82
+ const RESUME_TOKEN = (0, util_1.getOrCreateToken)();
83
+ async function buildResumeContext() {
84
+ if (NO_CMUX)
85
+ return undefined;
86
+ const cmuxAvailable = await (0, cmux_1.isCmuxAvailable)();
87
+ const openSessionIds = new Set();
88
+ if (cmuxAvailable) {
89
+ try {
90
+ const live = await (0, cmux_1.listWorkspaceIdsCached)();
91
+ const map = (0, cmux_1.loadResumeMap)();
92
+ for (const [sessionId, wsId] of Object.entries(map)) {
93
+ if (live.has(wsId.toUpperCase()))
94
+ openSessionIds.add(sessionId);
95
+ }
96
+ }
97
+ catch {
98
+ // liveness markers are cosmetic; resume still works without them
99
+ }
100
+ }
101
+ return { token: RESUME_TOKEN, cmuxAvailable, openSessionIds };
102
+ }
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.
106
+ async function buildCmuxWaitMap() {
107
+ const result = new Map();
108
+ if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
109
+ return result;
110
+ try {
111
+ const waiting = await (0, cmux_1.listWaitingWorkspacesCached)();
112
+ const wsToSession = (0, cmux_1.loadWorkspaceToSession)();
113
+ for (const [wsId, reason] of waiting) {
114
+ const sessionId = wsToSession.get(wsId);
115
+ if (sessionId)
116
+ result.set(sessionId, reason);
117
+ }
118
+ }
119
+ catch {
120
+ // notifications are an enrichment; jsonl activity still drives the badge
121
+ }
122
+ return result;
123
+ }
124
+ // Waiting is driven solely by cmux's unread notifications: cmux clears them the
125
+ // moment you focus a workspace, so the dashboard badge self-clears when you open
126
+ // the tab — mirroring cmux exactly. (A jsonl "assistant ended its turn" signal
127
+ // was tried but flags nearly every finished session and never self-clears.)
128
+ function resolveWaiting(sessionId, cmuxWait) {
129
+ return cmuxWait.get(sessionId) ?? null;
130
+ }
131
+ function readJsonBody(req) {
132
+ return new Promise((resolve, reject) => {
133
+ let data = '';
134
+ req.on('data', (chunk) => {
135
+ data += chunk;
136
+ if (data.length > 64 * 1024) {
137
+ reject(new Error('Body too large'));
138
+ req.destroy();
139
+ }
140
+ });
141
+ req.on('end', () => {
142
+ try {
143
+ resolve(JSON.parse(data));
144
+ }
145
+ catch {
146
+ reject(new Error('Invalid JSON'));
147
+ }
148
+ });
149
+ req.on('error', reject);
150
+ });
151
+ }
152
+ async function handleResume(req, res) {
153
+ const respond = (status, body) => {
154
+ res.writeHead(status, { 'Content-Type': 'application/json' });
155
+ res.end(JSON.stringify(body));
156
+ };
157
+ if (req.method !== 'POST')
158
+ return respond(405, { action: 'error', message: 'POST only' });
159
+ if (req.headers['x-ccakashic-token'] !== RESUME_TOKEN) {
160
+ return respond(403, { action: 'error', message: 'Invalid token' });
161
+ }
162
+ let body;
163
+ try {
164
+ body = await readJsonBody(req);
165
+ }
166
+ catch (err) {
167
+ return respond(400, { action: 'error', message: err?.message || 'Bad request' });
168
+ }
169
+ const { project: rawName, session: sessionId, mode } = body || {};
170
+ if (typeof rawName !== 'string' || typeof sessionId !== 'string') {
171
+ return respond(400, { action: 'error', message: 'project and session are required' });
172
+ }
173
+ const projects = await (0, discover_1.listProjects)();
174
+ const project = projects.find((p) => p.rawName === rawName);
175
+ const sessionPath = project ? path.join(project.dir, `${sessionId}.jsonl`) : null;
176
+ if (!project || !sessionPath || !fs.existsSync(sessionPath)) {
177
+ return respond(404, { action: 'error', message: 'Session not found' });
178
+ }
179
+ // Already open via a previous resume → jump instead of forking.
180
+ const liveWorkspace = await (0, cmux_1.findLiveWorkspaceForSession)(sessionId);
181
+ if (liveWorkspace) {
182
+ try {
183
+ await (0, cmux_1.selectWorkspace)(liveWorkspace);
184
+ return respond(200, { action: 'jumped', workspace: liveWorkspace });
185
+ }
186
+ catch {
187
+ // workspace died between the check and the select; fall through
188
+ }
189
+ }
190
+ const cwd = await (0, discover_1.readCwdFromSession)(sessionPath);
191
+ if (!cwd)
192
+ return respond(200, { action: 'error', message: 'No cwd recorded in this session' });
193
+ if (!fs.existsSync(cwd)) {
194
+ return respond(200, { action: 'error', message: `Directory no longer exists: ${cwd}` });
195
+ }
196
+ if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)())) {
197
+ return respond(200, { action: 'unavailable', command: (0, cmux_1.buildResumeCommand)(cwd, sessionId) });
198
+ }
199
+ const sessions = await (0, discover_1.listSessions)(project.dir);
200
+ const preview = sessions.find((s) => s.id === sessionId);
201
+ const title = preview?.customTitle || preview?.aiTitle || preview?.slug || sessionId.slice(0, 8);
202
+ try {
203
+ const result = await (0, cmux_1.resumeInNewWorkspace)(cwd, sessionId, title, mode === 'background');
204
+ (0, cmux_1.saveResumeMapEntry)(sessionId, result.workspaceId);
205
+ return respond(200, { action: 'resumed', workspace: result.workspaceId });
206
+ }
207
+ catch (err) {
208
+ return respond(200, { action: 'error', message: `cmux error: ${err?.message || err}` });
209
+ }
210
+ }
211
+ // Only accept loopback Host headers. The server binds 127.0.0.1, but without
212
+ // this check a malicious site could DNS-rebind its hostname to 127.0.0.1 and
213
+ // become same-origin, defeating the resume token and reading session content.
214
+ function isAllowedHost(host) {
215
+ if (!host)
216
+ return false;
217
+ const hostname = host.replace(/:\d+$/, '').toLowerCase();
218
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]' || hostname === '::1';
219
+ }
57
220
  const server = http.createServer(async (req, res) => {
58
221
  try {
222
+ if (!isAllowedHost(req.headers.host)) {
223
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
224
+ res.end('Forbidden: invalid Host header');
225
+ return;
226
+ }
59
227
  const url = new URL(req.url || '/', `http://localhost`);
60
228
  const pathname = url.pathname;
61
229
  if (pathname === '/__ccakashic') {
@@ -63,12 +231,60 @@ const server = http.createServer(async (req, res) => {
63
231
  res.end(JSON.stringify({ name: 'ccakashic', version: pkg.version }));
64
232
  return;
65
233
  }
234
+ if (pathname === '/api/resume') {
235
+ await handleResume(req, res);
236
+ return;
237
+ }
66
238
  if (pathname === '/' || pathname === '') {
239
+ const requested = parseInt(url.searchParams.get('n') || '') || dashboard_1.DEFAULT_PANE_COUNT;
240
+ const paneCount = dashboard_1.PANE_COUNTS.includes(requested) ? requested : dashboard_1.DEFAULT_PANE_COUNT;
241
+ const recent = await (0, discover_1.listRecentSessions)(paneCount);
242
+ const cmuxWait = await buildCmuxWaitMap();
243
+ const panes = await Promise.all(recent.map(async (session) => ({
244
+ session,
245
+ bodyHtml: (0, dashboard_1.renderPaneBody)(await (0, parser_1.parseSessionCached)(session.path, session.lastModified)),
246
+ waiting: resolveWaiting(session.id, cmuxWait),
247
+ })));
248
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
249
+ res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext()));
250
+ return;
251
+ }
252
+ if (pathname === '/projects') {
67
253
  const projects = await (0, discover_1.listProjects)();
68
254
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
69
255
  res.end((0, pages_1.generateIndex)(projects));
70
256
  return;
71
257
  }
258
+ if (pathname === '/api/pane') {
259
+ const rawName = url.searchParams.get('project') || '';
260
+ const sessionId = url.searchParams.get('session') || '';
261
+ const since = parseFloat(url.searchParams.get('since') || '0');
262
+ const projects = await (0, discover_1.listProjects)();
263
+ const project = projects.find((p) => p.rawName === rawName);
264
+ const sessionPath = project ? path.join(project.dir, `${sessionId}.jsonl`) : null;
265
+ if (!project || !sessionPath || !fs.existsSync(sessionPath)) {
266
+ res.writeHead(404, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ error: 'Session not found' }));
268
+ return;
269
+ }
270
+ const mtime = fs.statSync(sessionPath).mtimeMs;
271
+ const status = (0, dashboard_1.paneStatus)(mtime);
272
+ const ago = (0, dashboard_1.timeAgo)(mtime);
273
+ const cmuxWait = await buildCmuxWaitMap();
274
+ const waiting = resolveWaiting(sessionId, cmuxWait);
275
+ res.writeHead(200, { 'Content-Type': 'application/json' });
276
+ // mtimeMs is sub-millisecond (nanosecond FS resolution) so distinct
277
+ // appends get distinct values; `<=` means "nothing newer since last poll".
278
+ // Waiting still re-evaluates here (cmux notifications change without the
279
+ // jsonl growing) so the badge clears on the next poll after you open a tab.
280
+ if (mtime <= since) {
281
+ res.end(JSON.stringify({ changed: false, status, ago, waiting }));
282
+ return;
283
+ }
284
+ const parsed = await (0, parser_1.parseSessionCached)(sessionPath, mtime);
285
+ res.end(JSON.stringify({ changed: true, mtime, status, ago, waiting, html: (0, dashboard_1.renderPaneBody)(parsed) }));
286
+ return;
287
+ }
72
288
  const projectMatch = pathname.match(/^\/project\/(.+)$/);
73
289
  if (projectMatch && !pathname.includes('/session/')) {
74
290
  const rawName = decodeURIComponent(projectMatch[1]);
@@ -81,7 +297,7 @@ const server = http.createServer(async (req, res) => {
81
297
  }
82
298
  const sessions = await (0, discover_1.listSessions)(project.dir);
83
299
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
84
- res.end((0, pages_1.generateSessionList)(project, sessions));
300
+ res.end((0, pages_1.generateSessionList)(project, sessions, await buildResumeContext()));
85
301
  return;
86
302
  }
87
303
  const sessionMatch = pathname.match(/^\/project\/(.+)\/session\/(.+)$/);
@@ -104,7 +320,13 @@ const server = http.createServer(async (req, res) => {
104
320
  const sessions = await (0, discover_1.listSessions)(project.dir);
105
321
  const session = sessions.find((s) => s.id === sessionId) || { id: sessionId, path: sessionPath };
106
322
  const parsed = await (0, parser_1.parseSession)(sessionPath);
107
- const html = (0, html_generator_1.generate)(parsed, { projectName: project.name, session, backUrl: `/project/${encodeURIComponent(rawName)}` });
323
+ const html = (0, html_generator_1.generate)(parsed, {
324
+ projectName: project.name,
325
+ projectRawName: rawName,
326
+ session,
327
+ backUrl: `/project/${encodeURIComponent(rawName)}`,
328
+ resume: await buildResumeContext(),
329
+ });
108
330
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
109
331
  res.end(html);
110
332
  return;
@@ -219,7 +441,7 @@ async function main() {
219
441
  const url = `http://127.0.0.1:${existing}`;
220
442
  console.log(`Reusing existing ccakashic at ${url}`);
221
443
  writeLockFile(existing);
222
- openInBrowser(await buildOpenUrl(url));
444
+ await openUrl(await buildOpenUrl(url));
223
445
  return;
224
446
  }
225
447
  const result = await startServer(PORT);
@@ -228,7 +450,7 @@ async function main() {
228
450
  const url = `http://127.0.0.1:${port}`;
229
451
  console.log(`Reusing existing ccakashic at ${url}`);
230
452
  writeLockFile(port);
231
- openInBrowser(await buildOpenUrl(url));
453
+ await openUrl(await buildOpenUrl(url));
232
454
  return;
233
455
  }
234
456
  const port = result;
@@ -240,7 +462,7 @@ async function main() {
240
462
  process.on('SIGINT', cleanup);
241
463
  process.on('SIGTERM', cleanup);
242
464
  process.on('exit', cleanupLockFile);
243
- openInBrowser(await buildOpenUrl(url));
465
+ await openUrl(await buildOpenUrl(url));
244
466
  }
245
467
  main().catch((err) => {
246
468
  console.error(err);
package/dist/cmux.js ADDED
@@ -0,0 +1,264 @@
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.isCmuxAvailable = isCmuxAvailable;
37
+ exports.shellQuote = shellQuote;
38
+ exports.buildResumeCommand = buildResumeCommand;
39
+ exports.listWorkspaceIds = listWorkspaceIds;
40
+ exports.listWorkspaceIdsCached = listWorkspaceIdsCached;
41
+ exports.parseWaitingNotifications = parseWaitingNotifications;
42
+ exports.listWaitingWorkspaces = listWaitingWorkspaces;
43
+ exports.listWaitingWorkspacesCached = listWaitingWorkspacesCached;
44
+ exports.currentWorkspaceId = currentWorkspaceId;
45
+ exports.selectWorkspace = selectWorkspace;
46
+ exports.resumeInNewWorkspace = resumeInNewWorkspace;
47
+ exports.parseOkId = parseOkId;
48
+ exports.openInCmuxBrowser = openInCmuxBrowser;
49
+ exports.loadResumeMap = loadResumeMap;
50
+ exports.saveResumeMapEntry = saveResumeMapEntry;
51
+ exports.loadWorkspaceToSession = loadWorkspaceToSession;
52
+ exports.findLiveWorkspaceForSession = findLiveWorkspaceForSession;
53
+ const child_process_1 = require("child_process");
54
+ const fs = __importStar(require("fs"));
55
+ const os = __importStar(require("os"));
56
+ const path = __importStar(require("path"));
57
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
58
+ const RESUME_MAP_FILE = path.join(CONFIG_DIR, 'resume-map.json');
59
+ // Resolve the cmux binary. PATH alone is unreliable: `npx ccakashic` is often
60
+ // launched from a GUI terminal / login shell whose PATH omits Homebrew, so a
61
+ // bare execFile('cmux') fails with ENOENT and the whole integration silently
62
+ // disables (only the Copy button survives). Honor CCAKASHIC_CMUX, then fall
63
+ // back to PATH, then to the common install locations.
64
+ function resolveCmuxBin() {
65
+ const candidates = [
66
+ process.env.CCAKASHIC_CMUX,
67
+ '/opt/homebrew/bin/cmux', // Apple Silicon Homebrew
68
+ '/usr/local/bin/cmux', // Intel Homebrew / manual installs
69
+ ].filter(Boolean);
70
+ for (const c of candidates) {
71
+ try {
72
+ if (fs.existsSync(c))
73
+ return c;
74
+ }
75
+ catch {
76
+ // ignore and try the next candidate
77
+ }
78
+ }
79
+ return 'cmux'; // last resort: rely on PATH
80
+ }
81
+ const CMUX_BIN = resolveCmuxBin();
82
+ function run(args, timeoutMs = 5000) {
83
+ return new Promise((resolve, reject) => {
84
+ (0, child_process_1.execFile)(CMUX_BIN, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
85
+ if (err) {
86
+ if (process.env.CCAKASHIC_DEBUG)
87
+ console.error('[cmux]', args.join(' '), '->', err.message, stderr);
88
+ reject(new Error((stderr || err.message).trim()));
89
+ }
90
+ else {
91
+ resolve(stdout.trim());
92
+ }
93
+ });
94
+ });
95
+ }
96
+ let cachedAvailable = null;
97
+ async function isCmuxAvailable() {
98
+ // ping spawns a process; cache briefly so page renders stay cheap
99
+ if (cachedAvailable && Date.now() - cachedAvailable.at < 10_000) {
100
+ return cachedAvailable.value;
101
+ }
102
+ let value = false;
103
+ try {
104
+ value = (await run(['ping'], 1500)) === 'PONG';
105
+ }
106
+ catch {
107
+ value = false;
108
+ }
109
+ cachedAvailable = { value, at: Date.now() };
110
+ return value;
111
+ }
112
+ function shellQuote(s) {
113
+ return `'` + s.replace(/'/g, `'\\''`) + `'`;
114
+ }
115
+ function buildResumeCommand(cwd, sessionId) {
116
+ return `cd ${shellQuote(cwd)} && claude --resume ${shellQuote(sessionId)}`;
117
+ }
118
+ async function listWorkspaceIds() {
119
+ const out = await run(['--json', '--id-format', 'both', 'list-workspaces']);
120
+ const data = JSON.parse(out);
121
+ const ids = new Set();
122
+ for (const ws of data.workspaces || []) {
123
+ if (ws.id)
124
+ ids.add(String(ws.id).toUpperCase());
125
+ }
126
+ return ids;
127
+ }
128
+ let cachedWorkspaceIds = null;
129
+ // Cached variant for page renders: buildResumeContext runs on every request
130
+ // and only needs workspace liveness to pick the Resume vs Jump button label,
131
+ // which tolerates a few seconds of staleness. Avoids spawning a cmux
132
+ // subprocess on each navigation.
133
+ async function listWorkspaceIdsCached() {
134
+ if (cachedWorkspaceIds && Date.now() - cachedWorkspaceIds.at < 5_000) {
135
+ return cachedWorkspaceIds.value;
136
+ }
137
+ const value = await listWorkspaceIds();
138
+ cachedWorkspaceIds = { value, at: Date.now() };
139
+ return value;
140
+ }
141
+ // Map of workspaceId → wait reason, derived from cmux's UNREAD notifications.
142
+ // cmux marks a notification read once you focus its workspace, so an unread
143
+ // "Claude is waiting" / "needs your permission" is a live "needs attention"
144
+ // signal that matches the desktop notification ring.
145
+ function parseWaitingNotifications(data) {
146
+ const result = new Map();
147
+ if (!Array.isArray(data))
148
+ return result;
149
+ for (const n of data) {
150
+ if (!n || n.is_read || !n.workspace_id)
151
+ continue;
152
+ const body = String(n.body || '').toLowerCase();
153
+ const reason = body.includes('permission') ? 'permission' : 'input';
154
+ // permission outranks a plain input wait if both exist for one workspace
155
+ const ws = String(n.workspace_id).toUpperCase();
156
+ if (reason === 'permission' || !result.has(ws))
157
+ result.set(ws, reason);
158
+ }
159
+ return result;
160
+ }
161
+ async function listWaitingWorkspaces() {
162
+ const out = await run(['--json', 'list-notifications']);
163
+ return parseWaitingNotifications(JSON.parse(out));
164
+ }
165
+ let cachedWaiting = null;
166
+ async function listWaitingWorkspacesCached() {
167
+ if (cachedWaiting && Date.now() - cachedWaiting.at < 5_000)
168
+ return cachedWaiting.value;
169
+ const value = await listWaitingWorkspaces();
170
+ cachedWaiting = { value, at: Date.now() };
171
+ return value;
172
+ }
173
+ async function currentWorkspaceId() {
174
+ try {
175
+ const out = await run(['--json', '--id-format', 'both', 'current-workspace']);
176
+ const data = JSON.parse(out);
177
+ return data.workspace_id ? String(data.workspace_id).toUpperCase() : null;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ async function selectWorkspace(id) {
184
+ await run(['select-workspace', '--workspace', id]);
185
+ }
186
+ async function resumeInNewWorkspace(cwd, sessionId, title, background) {
187
+ const prev = background ? await currentWorkspaceId() : null;
188
+ const out = await run(['new-workspace', '--command', buildResumeCommand(cwd, sessionId)]);
189
+ const workspaceId = parseOkId(out);
190
+ if (title) {
191
+ try {
192
+ await run(['rename-workspace', '--workspace', workspaceId, title]);
193
+ }
194
+ catch {
195
+ // title is cosmetic; the workspace is already running
196
+ }
197
+ }
198
+ // Foreground: jump to the new workspace (new-workspace doesn't reliably
199
+ // focus it). Background: restore the workspace we were on.
200
+ try {
201
+ await selectWorkspace(prev || workspaceId);
202
+ }
203
+ catch {
204
+ // focus is best-effort; the workspace is already running either way
205
+ }
206
+ return { workspaceId };
207
+ }
208
+ function parseOkId(output) {
209
+ const match = output.match(/^OK\s+(\S+)/);
210
+ if (!match)
211
+ throw new Error(`Unexpected cmux output: ${output}`);
212
+ return match[1].toUpperCase();
213
+ }
214
+ async function openInCmuxBrowser(url) {
215
+ await run(['browser', 'open', url]);
216
+ }
217
+ // --- sessionId → workspaceId mapping, so a second Resume click jumps to the
218
+ // --- already-open workspace instead of forking the session.
219
+ function loadResumeMap() {
220
+ try {
221
+ const data = JSON.parse(fs.readFileSync(RESUME_MAP_FILE, 'utf-8'));
222
+ return data && typeof data === 'object' ? data : {};
223
+ }
224
+ catch {
225
+ return {};
226
+ }
227
+ }
228
+ function saveResumeMapEntry(sessionId, workspaceId) {
229
+ try {
230
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
231
+ const map = loadResumeMap();
232
+ map[sessionId] = workspaceId;
233
+ // Atomic write: a crash mid-write would otherwise truncate the file, and
234
+ // loadResumeMap swallows the parse error and returns {} — wiping every
235
+ // mapping. Write to a temp file and rename (atomic on the same fs).
236
+ const tmp = `${RESUME_MAP_FILE}.${process.pid}.tmp`;
237
+ fs.writeFileSync(tmp, JSON.stringify(map, null, 2));
238
+ fs.renameSync(tmp, RESUME_MAP_FILE);
239
+ }
240
+ catch {
241
+ // best-effort: losing the map only means a duplicate workspace later
242
+ }
243
+ }
244
+ // Inverse of the resume map: workspaceId(upper) → sessionId. Only covers
245
+ // sessions ccakashic itself resumed (manually-opened ones aren't tracked).
246
+ function loadWorkspaceToSession() {
247
+ const inv = new Map();
248
+ for (const [sessionId, wsId] of Object.entries(loadResumeMap())) {
249
+ inv.set(wsId.toUpperCase(), sessionId);
250
+ }
251
+ return inv;
252
+ }
253
+ async function findLiveWorkspaceForSession(sessionId) {
254
+ const mapped = loadResumeMap()[sessionId];
255
+ if (!mapped)
256
+ return null;
257
+ try {
258
+ const live = await listWorkspaceIds();
259
+ return live.has(mapped.toUpperCase()) ? mapped.toUpperCase() : null;
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }