ccakashic 0.2.8 → 0.3.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
@@ -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,182 @@ 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
+ const cwd = await (0, discover_1.readCwdFromSession)(sessionPath);
180
+ if (!cwd)
181
+ return respond(200, { action: 'error', message: 'No cwd recorded in this session' });
182
+ if (!fs.existsSync(cwd)) {
183
+ return respond(200, { action: 'error', message: `Directory no longer exists: ${cwd}` });
184
+ }
185
+ const command = (0, cmux_1.buildResumeCommand)(cwd, sessionId);
186
+ if (NO_CMUX) {
187
+ return respond(200, { action: 'unavailable', command });
188
+ }
189
+ // Any cmux call can fail if the long-running server has lost contact with the
190
+ // current cmux instance (Mac sleep/wake, cmux restart). Rather than surface a
191
+ // hard error or hide the button, fall back to handing back the copy command
192
+ // with a hint to restart ccakashic if it persists.
193
+ try {
194
+ // Already open via a previous resume → jump instead of forking.
195
+ const liveWorkspace = await (0, cmux_1.findLiveWorkspaceForSession)(sessionId);
196
+ if (liveWorkspace) {
197
+ await (0, cmux_1.selectWorkspace)(liveWorkspace);
198
+ return respond(200, { action: 'jumped', workspace: liveWorkspace });
199
+ }
200
+ const sessions = await (0, discover_1.listSessions)(project.dir);
201
+ const preview = sessions.find((s) => s.id === sessionId);
202
+ const title = preview?.customTitle || preview?.aiTitle || preview?.slug || sessionId.slice(0, 8);
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
+ if (process.env.CCAKASHIC_DEBUG)
209
+ console.error('[resume] cmux failed:', err?.message || err);
210
+ return respond(200, {
211
+ action: 'unavailable',
212
+ command,
213
+ message: 'cmux unreachable — copied the command instead. If this persists, restart ccakashic.',
214
+ });
215
+ }
216
+ }
217
+ // Only accept loopback Host headers. The server binds 127.0.0.1, but without
218
+ // this check a malicious site could DNS-rebind its hostname to 127.0.0.1 and
219
+ // become same-origin, defeating the resume token and reading session content.
220
+ function isAllowedHost(host) {
221
+ if (!host)
222
+ return false;
223
+ const hostname = host.replace(/:\d+$/, '').toLowerCase();
224
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]' || hostname === '::1';
225
+ }
57
226
  const server = http.createServer(async (req, res) => {
58
227
  try {
228
+ if (!isAllowedHost(req.headers.host)) {
229
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
230
+ res.end('Forbidden: invalid Host header');
231
+ return;
232
+ }
59
233
  const url = new URL(req.url || '/', `http://localhost`);
60
234
  const pathname = url.pathname;
61
235
  if (pathname === '/__ccakashic') {
@@ -63,12 +237,60 @@ const server = http.createServer(async (req, res) => {
63
237
  res.end(JSON.stringify({ name: 'ccakashic', version: pkg.version }));
64
238
  return;
65
239
  }
240
+ if (pathname === '/api/resume') {
241
+ await handleResume(req, res);
242
+ return;
243
+ }
66
244
  if (pathname === '/' || pathname === '') {
245
+ const requested = parseInt(url.searchParams.get('n') || '') || dashboard_1.DEFAULT_PANE_COUNT;
246
+ const paneCount = dashboard_1.PANE_COUNTS.includes(requested) ? requested : dashboard_1.DEFAULT_PANE_COUNT;
247
+ const recent = await (0, discover_1.listRecentSessions)(paneCount);
248
+ const cmuxWait = await buildCmuxWaitMap();
249
+ const panes = await Promise.all(recent.map(async (session) => ({
250
+ session,
251
+ bodyHtml: (0, dashboard_1.renderPaneBody)(await (0, parser_1.parseSessionCached)(session.path, session.lastModified)),
252
+ waiting: resolveWaiting(session.id, cmuxWait),
253
+ })));
254
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
255
+ res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext()));
256
+ return;
257
+ }
258
+ if (pathname === '/projects') {
67
259
  const projects = await (0, discover_1.listProjects)();
68
260
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
69
261
  res.end((0, pages_1.generateIndex)(projects));
70
262
  return;
71
263
  }
264
+ if (pathname === '/api/pane') {
265
+ const rawName = url.searchParams.get('project') || '';
266
+ const sessionId = url.searchParams.get('session') || '';
267
+ const since = parseFloat(url.searchParams.get('since') || '0');
268
+ const projects = await (0, discover_1.listProjects)();
269
+ const project = projects.find((p) => p.rawName === rawName);
270
+ const sessionPath = project ? path.join(project.dir, `${sessionId}.jsonl`) : null;
271
+ if (!project || !sessionPath || !fs.existsSync(sessionPath)) {
272
+ res.writeHead(404, { 'Content-Type': 'application/json' });
273
+ res.end(JSON.stringify({ error: 'Session not found' }));
274
+ return;
275
+ }
276
+ const mtime = fs.statSync(sessionPath).mtimeMs;
277
+ const status = (0, dashboard_1.paneStatus)(mtime);
278
+ const ago = (0, dashboard_1.timeAgo)(mtime);
279
+ const cmuxWait = await buildCmuxWaitMap();
280
+ const waiting = resolveWaiting(sessionId, cmuxWait);
281
+ res.writeHead(200, { 'Content-Type': 'application/json' });
282
+ // mtimeMs is sub-millisecond (nanosecond FS resolution) so distinct
283
+ // appends get distinct values; `<=` means "nothing newer since last poll".
284
+ // Waiting still re-evaluates here (cmux notifications change without the
285
+ // jsonl growing) so the badge clears on the next poll after you open a tab.
286
+ if (mtime <= since) {
287
+ res.end(JSON.stringify({ changed: false, status, ago, waiting }));
288
+ return;
289
+ }
290
+ const parsed = await (0, parser_1.parseSessionCached)(sessionPath, mtime);
291
+ res.end(JSON.stringify({ changed: true, mtime, status, ago, waiting, html: (0, dashboard_1.renderPaneBody)(parsed) }));
292
+ return;
293
+ }
72
294
  const projectMatch = pathname.match(/^\/project\/(.+)$/);
73
295
  if (projectMatch && !pathname.includes('/session/')) {
74
296
  const rawName = decodeURIComponent(projectMatch[1]);
@@ -81,7 +303,7 @@ const server = http.createServer(async (req, res) => {
81
303
  }
82
304
  const sessions = await (0, discover_1.listSessions)(project.dir);
83
305
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
84
- res.end((0, pages_1.generateSessionList)(project, sessions));
306
+ res.end((0, pages_1.generateSessionList)(project, sessions, await buildResumeContext()));
85
307
  return;
86
308
  }
87
309
  const sessionMatch = pathname.match(/^\/project\/(.+)\/session\/(.+)$/);
@@ -104,7 +326,13 @@ const server = http.createServer(async (req, res) => {
104
326
  const sessions = await (0, discover_1.listSessions)(project.dir);
105
327
  const session = sessions.find((s) => s.id === sessionId) || { id: sessionId, path: sessionPath };
106
328
  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)}` });
329
+ const html = (0, html_generator_1.generate)(parsed, {
330
+ projectName: project.name,
331
+ projectRawName: rawName,
332
+ session,
333
+ backUrl: `/project/${encodeURIComponent(rawName)}`,
334
+ resume: await buildResumeContext(),
335
+ });
108
336
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
109
337
  res.end(html);
110
338
  return;
@@ -219,7 +447,7 @@ async function main() {
219
447
  const url = `http://127.0.0.1:${existing}`;
220
448
  console.log(`Reusing existing ccakashic at ${url}`);
221
449
  writeLockFile(existing);
222
- openInBrowser(await buildOpenUrl(url));
450
+ await openUrl(await buildOpenUrl(url));
223
451
  return;
224
452
  }
225
453
  const result = await startServer(PORT);
@@ -228,7 +456,7 @@ async function main() {
228
456
  const url = `http://127.0.0.1:${port}`;
229
457
  console.log(`Reusing existing ccakashic at ${url}`);
230
458
  writeLockFile(port);
231
- openInBrowser(await buildOpenUrl(url));
459
+ await openUrl(await buildOpenUrl(url));
232
460
  return;
233
461
  }
234
462
  const port = result;
@@ -240,7 +468,7 @@ async function main() {
240
468
  process.on('SIGINT', cleanup);
241
469
  process.on('SIGTERM', cleanup);
242
470
  process.on('exit', cleanupLockFile);
243
- openInBrowser(await buildOpenUrl(url));
471
+ await openUrl(await buildOpenUrl(url));
244
472
  }
245
473
  main().catch((err) => {
246
474
  console.error(err);
package/dist/cmux.js ADDED
@@ -0,0 +1,274 @@
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 everAvailable = false;
97
+ let cachedUnavailableAt = 0;
98
+ async function isCmuxAvailable() {
99
+ // Sticky-positive: once cmux has answered, keep reporting available for the
100
+ // life of the process. A long-running server can transiently lose contact
101
+ // with cmux (Mac sleep/wake, cmux app restart) and `ping` then fails — but we
102
+ // must NOT hide the Resume UI on such a blip. The buttons stay; if cmux is
103
+ // genuinely unreachable at click time, /api/resume degrades to the copy
104
+ // command. Negative results are cached only ~5s so first contact is quick.
105
+ if (everAvailable)
106
+ return true;
107
+ if (cachedUnavailableAt && Date.now() - cachedUnavailableAt < 5_000)
108
+ return false;
109
+ let value = false;
110
+ try {
111
+ value = (await run(['ping'], 2500)) === 'PONG';
112
+ }
113
+ catch {
114
+ value = false;
115
+ }
116
+ if (value)
117
+ everAvailable = true;
118
+ else
119
+ cachedUnavailableAt = Date.now();
120
+ return value;
121
+ }
122
+ function shellQuote(s) {
123
+ return `'` + s.replace(/'/g, `'\\''`) + `'`;
124
+ }
125
+ function buildResumeCommand(cwd, sessionId) {
126
+ return `cd ${shellQuote(cwd)} && claude --resume ${shellQuote(sessionId)}`;
127
+ }
128
+ async function listWorkspaceIds() {
129
+ const out = await run(['--json', '--id-format', 'both', 'list-workspaces']);
130
+ const data = JSON.parse(out);
131
+ const ids = new Set();
132
+ for (const ws of data.workspaces || []) {
133
+ if (ws.id)
134
+ ids.add(String(ws.id).toUpperCase());
135
+ }
136
+ return ids;
137
+ }
138
+ let cachedWorkspaceIds = null;
139
+ // Cached variant for page renders: buildResumeContext runs on every request
140
+ // and only needs workspace liveness to pick the Resume vs Jump button label,
141
+ // which tolerates a few seconds of staleness. Avoids spawning a cmux
142
+ // subprocess on each navigation.
143
+ async function listWorkspaceIdsCached() {
144
+ if (cachedWorkspaceIds && Date.now() - cachedWorkspaceIds.at < 5_000) {
145
+ return cachedWorkspaceIds.value;
146
+ }
147
+ const value = await listWorkspaceIds();
148
+ cachedWorkspaceIds = { value, at: Date.now() };
149
+ return value;
150
+ }
151
+ // Map of workspaceId → wait reason, derived from cmux's UNREAD notifications.
152
+ // cmux marks a notification read once you focus its workspace, so an unread
153
+ // "Claude is waiting" / "needs your permission" is a live "needs attention"
154
+ // signal that matches the desktop notification ring.
155
+ function parseWaitingNotifications(data) {
156
+ const result = new Map();
157
+ if (!Array.isArray(data))
158
+ return result;
159
+ for (const n of data) {
160
+ if (!n || n.is_read || !n.workspace_id)
161
+ continue;
162
+ const body = String(n.body || '').toLowerCase();
163
+ const reason = body.includes('permission') ? 'permission' : 'input';
164
+ // permission outranks a plain input wait if both exist for one workspace
165
+ const ws = String(n.workspace_id).toUpperCase();
166
+ if (reason === 'permission' || !result.has(ws))
167
+ result.set(ws, reason);
168
+ }
169
+ return result;
170
+ }
171
+ async function listWaitingWorkspaces() {
172
+ const out = await run(['--json', 'list-notifications']);
173
+ return parseWaitingNotifications(JSON.parse(out));
174
+ }
175
+ let cachedWaiting = null;
176
+ async function listWaitingWorkspacesCached() {
177
+ if (cachedWaiting && Date.now() - cachedWaiting.at < 5_000)
178
+ return cachedWaiting.value;
179
+ const value = await listWaitingWorkspaces();
180
+ cachedWaiting = { value, at: Date.now() };
181
+ return value;
182
+ }
183
+ async function currentWorkspaceId() {
184
+ try {
185
+ const out = await run(['--json', '--id-format', 'both', 'current-workspace']);
186
+ const data = JSON.parse(out);
187
+ return data.workspace_id ? String(data.workspace_id).toUpperCase() : null;
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ async function selectWorkspace(id) {
194
+ await run(['select-workspace', '--workspace', id]);
195
+ }
196
+ async function resumeInNewWorkspace(cwd, sessionId, title, background) {
197
+ const prev = background ? await currentWorkspaceId() : null;
198
+ const out = await run(['new-workspace', '--command', buildResumeCommand(cwd, sessionId)]);
199
+ const workspaceId = parseOkId(out);
200
+ if (title) {
201
+ try {
202
+ await run(['rename-workspace', '--workspace', workspaceId, title]);
203
+ }
204
+ catch {
205
+ // title is cosmetic; the workspace is already running
206
+ }
207
+ }
208
+ // Foreground: jump to the new workspace (new-workspace doesn't reliably
209
+ // focus it). Background: restore the workspace we were on.
210
+ try {
211
+ await selectWorkspace(prev || workspaceId);
212
+ }
213
+ catch {
214
+ // focus is best-effort; the workspace is already running either way
215
+ }
216
+ return { workspaceId };
217
+ }
218
+ function parseOkId(output) {
219
+ const match = output.match(/^OK\s+(\S+)/);
220
+ if (!match)
221
+ throw new Error(`Unexpected cmux output: ${output}`);
222
+ return match[1].toUpperCase();
223
+ }
224
+ async function openInCmuxBrowser(url) {
225
+ await run(['browser', 'open', url]);
226
+ }
227
+ // --- sessionId → workspaceId mapping, so a second Resume click jumps to the
228
+ // --- already-open workspace instead of forking the session.
229
+ function loadResumeMap() {
230
+ try {
231
+ const data = JSON.parse(fs.readFileSync(RESUME_MAP_FILE, 'utf-8'));
232
+ return data && typeof data === 'object' ? data : {};
233
+ }
234
+ catch {
235
+ return {};
236
+ }
237
+ }
238
+ function saveResumeMapEntry(sessionId, workspaceId) {
239
+ try {
240
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
241
+ const map = loadResumeMap();
242
+ map[sessionId] = workspaceId;
243
+ // Atomic write: a crash mid-write would otherwise truncate the file, and
244
+ // loadResumeMap swallows the parse error and returns {} — wiping every
245
+ // mapping. Write to a temp file and rename (atomic on the same fs).
246
+ const tmp = `${RESUME_MAP_FILE}.${process.pid}.tmp`;
247
+ fs.writeFileSync(tmp, JSON.stringify(map, null, 2));
248
+ fs.renameSync(tmp, RESUME_MAP_FILE);
249
+ }
250
+ catch {
251
+ // best-effort: losing the map only means a duplicate workspace later
252
+ }
253
+ }
254
+ // Inverse of the resume map: workspaceId(upper) → sessionId. Only covers
255
+ // sessions ccakashic itself resumed (manually-opened ones aren't tracked).
256
+ function loadWorkspaceToSession() {
257
+ const inv = new Map();
258
+ for (const [sessionId, wsId] of Object.entries(loadResumeMap())) {
259
+ inv.set(wsId.toUpperCase(), sessionId);
260
+ }
261
+ return inv;
262
+ }
263
+ async function findLiveWorkspaceForSession(sessionId) {
264
+ const mapped = loadResumeMap()[sessionId];
265
+ if (!mapped)
266
+ return null;
267
+ try {
268
+ const live = await listWorkspaceIds();
269
+ return live.has(mapped.toUpperCase()) ? mapped.toUpperCase() : null;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }