ccakashic 0.2.8 → 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 +33 -4
- package/dist/bin/ccakashic.js +227 -5
- package/dist/cmux.js +264 -0
- package/dist/dashboard.js +330 -0
- package/dist/discover.js +78 -28
- package/dist/html-generator.js +49 -47
- package/dist/pages.js +33 -22
- package/dist/parser.js +27 -0
- package/dist/resume-ui.js +148 -0
- package/dist/template-assets.js +32 -20
- package/dist/util.js +80 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# ccakashic
|
|
6
6
|
|
|
7
|
-
An Akashic Record of your Claude Code sessions —
|
|
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
|
-

|
|
10
10
|
|
|
11
|
-

|
|
12
|
+
|
|
13
|
+

|
|
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
|
-
- **
|
|
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
|
package/dist/bin/ccakashic.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|