ccakashic 0.4.0 → 0.5.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 +1 -0
- package/dist/api.js +48 -0
- package/dist/bin/ccakashic.js +31 -3
- package/dist/cmux.js +122 -0
- package/dist/dashboard.js +1 -0
- package/dist/discover.js +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ A local HTTP server starts and your browser opens automatically.
|
|
|
53
53
|
- **Filter search** — Incremental filtering on list pages
|
|
54
54
|
- **Keyboard navigation** — `j` / `k` to move between messages, `p` / `n` to move between your own prompts
|
|
55
55
|
- **One-click resume in cmux** — `▶ Resume` spawns a [cmux](https://github.com/manaflow-ai/cmux) workspace that runs `cd <session cwd> && claude --resume <id>`; `📋 Copy` copies the same command for any terminal
|
|
56
|
+
- **Read-only JSON feed** — `GET /api/sessions?limit=40&waiting=1` returns what the dashboard shows (title, project, branch, model, `status`, `waiting`, `detailUrl`, `resumeCommand`) so other local tools can reuse the waiting signal. `waiting=1` returns only the sessions asking for you, and is the cheap path — it looks those up by id instead of parsing a whole window of session files
|
|
56
57
|
- **Zero dependencies** — Node.js built-in modules only
|
|
57
58
|
|
|
58
59
|
## cmux integration
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SESSION_LIMIT = exports.DEFAULT_SESSION_LIMIT = void 0;
|
|
4
|
+
exports.toSessionRow = toSessionRow;
|
|
5
|
+
exports.parseSessionLimit = parseSessionLimit;
|
|
6
|
+
exports.orderSessionRows = orderSessionRows;
|
|
7
|
+
const dashboard_1 = require("./dashboard");
|
|
8
|
+
const cmux_1 = require("./cmux");
|
|
9
|
+
// Read-only JSON view of what the dashboard already computes, so other local
|
|
10
|
+
// tools can treat ccakashic as the source of truth for "which session is
|
|
11
|
+
// waiting for me" instead of re-deriving it from ~/.claude. The waiting signal
|
|
12
|
+
// in particular cannot be rebuilt elsewhere: it maps cmux's unread
|
|
13
|
+
// notifications through ccakashic's own resume map.
|
|
14
|
+
exports.DEFAULT_SESSION_LIMIT = 40;
|
|
15
|
+
// Each row costs a full session-file read, so cap what one request can ask for.
|
|
16
|
+
exports.MAX_SESSION_LIMIT = 100;
|
|
17
|
+
const PREVIEW_MAX = 200;
|
|
18
|
+
function toSessionRow(session, waiting) {
|
|
19
|
+
return {
|
|
20
|
+
id: session.id,
|
|
21
|
+
projectRawName: session.projectRawName,
|
|
22
|
+
projectName: session.projectName,
|
|
23
|
+
cwd: session.cwd,
|
|
24
|
+
title: (0, dashboard_1.paneTitle)(session),
|
|
25
|
+
preview: (session.preview || '').slice(0, PREVIEW_MAX),
|
|
26
|
+
gitBranch: session.gitBranch,
|
|
27
|
+
model: session.model,
|
|
28
|
+
lastModified: session.lastModified,
|
|
29
|
+
status: (0, dashboard_1.paneStatus)(session.lastModified),
|
|
30
|
+
waiting,
|
|
31
|
+
detailUrl: `/project/${encodeURIComponent(session.projectRawName)}/session/${encodeURIComponent(session.id)}`,
|
|
32
|
+
// /api/resume is POST-only and requires the CSRF token, so there is no GET
|
|
33
|
+
// URL to hand out — and publishing that token from an unauthenticated
|
|
34
|
+
// endpoint would defeat it. Callers that want to act on a session either
|
|
35
|
+
// open detailUrl and press Resume, or run resumeCommand themselves.
|
|
36
|
+
resumeUrl: null,
|
|
37
|
+
resumeCommand: session.cwd ? (0, cmux_1.buildResumeCommand)(session.cwd, session.id) : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parseSessionLimit(raw) {
|
|
41
|
+
const n = parseInt(raw || '', 10);
|
|
42
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
43
|
+
return exports.DEFAULT_SESSION_LIMIT;
|
|
44
|
+
return Math.min(n, exports.MAX_SESSION_LIMIT);
|
|
45
|
+
}
|
|
46
|
+
function orderSessionRows(rows, limit) {
|
|
47
|
+
return rows.slice().sort((a, b) => b.lastModified - a.lastModified).slice(0, limit);
|
|
48
|
+
}
|
package/dist/bin/ccakashic.js
CHANGED
|
@@ -41,6 +41,7 @@ const path = __importStar(require("path"));
|
|
|
41
41
|
const child_process_1 = require("child_process");
|
|
42
42
|
const util_1 = require("../util");
|
|
43
43
|
const discover_1 = require("../discover");
|
|
44
|
+
const api_1 = require("../api");
|
|
44
45
|
const parser_1 = require("../parser");
|
|
45
46
|
const html_generator_1 = require("../html-generator");
|
|
46
47
|
const pages_1 = require("../pages");
|
|
@@ -100,16 +101,25 @@ async function buildResumeContext() {
|
|
|
100
101
|
}
|
|
101
102
|
return { token: RESUME_TOKEN, cmuxAvailable, openSessionIds };
|
|
102
103
|
}
|
|
103
|
-
// sessionId → wait reason, from cmux's unread notifications
|
|
104
|
-
//
|
|
105
|
-
//
|
|
104
|
+
// sessionId → wait reason, from cmux's unread notifications resolved through
|
|
105
|
+
// two independent workspace→session sources. Empty when cmux is
|
|
106
|
+
// unavailable/disabled.
|
|
106
107
|
async function buildCmuxWaitMap() {
|
|
107
108
|
const result = new Map();
|
|
108
109
|
if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
|
|
109
110
|
return result;
|
|
110
111
|
try {
|
|
111
112
|
const waiting = await (0, cmux_1.listWaitingWorkspacesCached)();
|
|
113
|
+
// The resume map only covers sessions ccakashic resumed, which left every
|
|
114
|
+
// hand-started session permanently unbadged. The live map reads
|
|
115
|
+
// CMUX_WORKSPACE_ID from each running session's own process and covers
|
|
116
|
+
// those. They complement each other — the resume map still resolves
|
|
117
|
+
// sessions that have since exited — so the live one is layered on top,
|
|
118
|
+
// winning conflicts because it reflects the process attached right now.
|
|
112
119
|
const wsToSession = (0, cmux_1.loadWorkspaceToSession)();
|
|
120
|
+
for (const [wsId, sessionId] of await (0, cmux_1.liveWorkspaceToSessionCached)()) {
|
|
121
|
+
wsToSession.set(wsId, sessionId);
|
|
122
|
+
}
|
|
113
123
|
for (const [wsId, reason] of waiting) {
|
|
114
124
|
const sessionId = wsToSession.get(wsId);
|
|
115
125
|
if (sessionId)
|
|
@@ -291,6 +301,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
291
301
|
res.end(JSON.stringify({ changed: true, mtime, status, ago, waiting, html: (0, dashboard_1.renderPaneBody)(parsed) }));
|
|
292
302
|
return;
|
|
293
303
|
}
|
|
304
|
+
// Read-only feed of the dashboard's own view, for other local tools.
|
|
305
|
+
if (pathname === '/api/sessions') {
|
|
306
|
+
const limit = (0, api_1.parseSessionLimit)(url.searchParams.get('limit'));
|
|
307
|
+
const waitingOnly = url.searchParams.get('waiting') === '1';
|
|
308
|
+
const cmuxWait = await buildCmuxWaitMap();
|
|
309
|
+
// Waiting sessions are fetched by id rather than filtered out of the
|
|
310
|
+
// recent window: a session can sit waiting while other projects churn
|
|
311
|
+
// past it, and reading a wide window means parsing every file in it.
|
|
312
|
+
const sessions = waitingOnly
|
|
313
|
+
? await (0, discover_1.findRecentSessionsByIds)([...cmuxWait.keys()])
|
|
314
|
+
: await (0, discover_1.listRecentSessions)(limit);
|
|
315
|
+
const rows = (0, api_1.orderSessionRows)(sessions
|
|
316
|
+
.map((s) => (0, api_1.toSessionRow)(s, resolveWaiting(s.id, cmuxWait)))
|
|
317
|
+
.filter((r) => !waitingOnly || r.waiting !== null), limit);
|
|
318
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
319
|
+
res.end(JSON.stringify(rows));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
294
322
|
const projectMatch = pathname.match(/^\/project\/(.+)$/);
|
|
295
323
|
if (projectMatch && !pathname.includes('/session/')) {
|
|
296
324
|
const rawName = decodeURIComponent(projectMatch[1]);
|
package/dist/cmux.js
CHANGED
|
@@ -49,6 +49,10 @@ exports.openInCmuxBrowser = openInCmuxBrowser;
|
|
|
49
49
|
exports.loadResumeMap = loadResumeMap;
|
|
50
50
|
exports.saveResumeMapEntry = saveResumeMapEntry;
|
|
51
51
|
exports.loadWorkspaceToSession = loadWorkspaceToSession;
|
|
52
|
+
exports.loadSessionRegistry = loadSessionRegistry;
|
|
53
|
+
exports.parseWorkspaceEnv = parseWorkspaceEnv;
|
|
54
|
+
exports.liveWorkspaceToSession = liveWorkspaceToSession;
|
|
55
|
+
exports.liveWorkspaceToSessionCached = liveWorkspaceToSessionCached;
|
|
52
56
|
exports.findLiveWorkspaceForSession = findLiveWorkspaceForSession;
|
|
53
57
|
const child_process_1 = require("child_process");
|
|
54
58
|
const fs = __importStar(require("fs"));
|
|
@@ -260,6 +264,124 @@ function loadWorkspaceToSession() {
|
|
|
260
264
|
}
|
|
261
265
|
return inv;
|
|
262
266
|
}
|
|
267
|
+
// --- Live workspace mapping, read from the running processes themselves ---
|
|
268
|
+
//
|
|
269
|
+
// The resume map only knows about sessions ccakashic itself resumed, so a
|
|
270
|
+
// session you started by hand inside cmux has no workspace mapping and never
|
|
271
|
+
// gets a waiting badge. Claude Code registers every running session in
|
|
272
|
+
// ~/.claude/sessions/<pid>.json, and a session launched inside cmux inherits
|
|
273
|
+
// CMUX_WORKSPACE_ID in its environment — together those give the same mapping
|
|
274
|
+
// without ccakashic having been involved.
|
|
275
|
+
//
|
|
276
|
+
// The two sources are complements, not replacements: this one sees only live
|
|
277
|
+
// processes, while the resume map still covers sessions that have since exited.
|
|
278
|
+
const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
|
|
279
|
+
function loadSessionRegistry() {
|
|
280
|
+
let names;
|
|
281
|
+
try {
|
|
282
|
+
names = fs.readdirSync(SESSION_REGISTRY_DIR);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return []; // no registry (older Claude Code, or nothing has run yet)
|
|
286
|
+
}
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const f of names) {
|
|
289
|
+
if (!f.endsWith('.json'))
|
|
290
|
+
continue;
|
|
291
|
+
try {
|
|
292
|
+
const o = JSON.parse(fs.readFileSync(path.join(SESSION_REGISTRY_DIR, f), 'utf-8'));
|
|
293
|
+
if (typeof o?.pid === 'number' && typeof o?.sessionId === 'string') {
|
|
294
|
+
out.push({ pid: o.pid, sessionId: o.sessionId, procStart: o.procStart });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// a half-written or stale record; skip it
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function isProcessAlive(pid) {
|
|
304
|
+
try {
|
|
305
|
+
process.kill(pid, 0); // signal 0 only probes; it does not signal
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// `ps eww` prints one line per process: pid, then the command, then the
|
|
313
|
+
// process's ENTIRE environment — which routinely holds API keys and tokens.
|
|
314
|
+
// Only CMUX_WORKSPACE_ID is ever pulled out of it; no other variable is
|
|
315
|
+
// stored, returned or logged, and the raw output is not retained.
|
|
316
|
+
function parseWorkspaceEnv(psOutput) {
|
|
317
|
+
const byPid = new Map();
|
|
318
|
+
for (const line of psOutput.split('\n')) {
|
|
319
|
+
const pid = line.match(/^\s*(\d+)\s/);
|
|
320
|
+
if (!pid)
|
|
321
|
+
continue;
|
|
322
|
+
const ws = line.match(/\bCMUX_WORKSPACE_ID=([A-Za-z0-9-]+)/);
|
|
323
|
+
if (ws)
|
|
324
|
+
byPid.set(parseInt(pid[1], 10), ws[1].toUpperCase());
|
|
325
|
+
}
|
|
326
|
+
return byPid;
|
|
327
|
+
}
|
|
328
|
+
function runPlain(bin, args, timeoutMs = 3000) {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
(0, child_process_1.execFile)(bin, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
331
|
+
resolve(err ? '' : stdout);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// A process's environment is fixed at exec time, so a pid only ever needs
|
|
336
|
+
// looking up once. Keyed with procStart as well so a recycled pid can't
|
|
337
|
+
// inherit the previous process's answer. null means "checked, not a cmux
|
|
338
|
+
// process" — cached too, so those aren't re-probed on every poll.
|
|
339
|
+
const workspaceEnvCache = new Map();
|
|
340
|
+
const envKey = (r) => `${r.pid}:${r.procStart ?? ''}`;
|
|
341
|
+
async function liveWorkspaceToSession() {
|
|
342
|
+
const live = loadSessionRegistry().filter((r) => isProcessAlive(r.pid));
|
|
343
|
+
const result = new Map();
|
|
344
|
+
const unknown = [];
|
|
345
|
+
for (const r of live) {
|
|
346
|
+
const key = envKey(r);
|
|
347
|
+
if (workspaceEnvCache.has(key)) {
|
|
348
|
+
const ws = workspaceEnvCache.get(key);
|
|
349
|
+
if (ws)
|
|
350
|
+
result.set(ws, r.sessionId);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
unknown.push(r);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (unknown.length) {
|
|
357
|
+
// One ps for every new pid at once, not one spawn per session.
|
|
358
|
+
const out = await runPlain('ps', ['eww', '-p', unknown.map((r) => r.pid).join(',')]);
|
|
359
|
+
const byPid = parseWorkspaceEnv(out);
|
|
360
|
+
for (const r of unknown) {
|
|
361
|
+
const ws = byPid.get(r.pid) ?? null;
|
|
362
|
+
workspaceEnvCache.set(envKey(r), ws);
|
|
363
|
+
if (ws)
|
|
364
|
+
result.set(ws, r.sessionId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Drop entries for processes that are gone, so a long-lived server doesn't
|
|
368
|
+
// accumulate one per session ever started.
|
|
369
|
+
const alive = new Set(live.map(envKey));
|
|
370
|
+
for (const key of workspaceEnvCache.keys()) {
|
|
371
|
+
if (!alive.has(key))
|
|
372
|
+
workspaceEnvCache.delete(key);
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
let cachedLiveWorkspaces = null;
|
|
377
|
+
async function liveWorkspaceToSessionCached() {
|
|
378
|
+
if (cachedLiveWorkspaces && Date.now() - cachedLiveWorkspaces.at < 5_000) {
|
|
379
|
+
return cachedLiveWorkspaces.value;
|
|
380
|
+
}
|
|
381
|
+
const value = await liveWorkspaceToSession();
|
|
382
|
+
cachedLiveWorkspaces = { value, at: Date.now() };
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
263
385
|
async function findLiveWorkspaceForSession(sessionId) {
|
|
264
386
|
const mapped = loadResumeMap()[sessionId];
|
|
265
387
|
if (!mapped)
|
package/dist/dashboard.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.DEFAULT_PANE_COUNT = exports.PANE_COUNTS = void 0;
|
|
|
4
4
|
exports.timeAgo = timeAgo;
|
|
5
5
|
exports.paneStatus = paneStatus;
|
|
6
6
|
exports.renderPaneBody = renderPaneBody;
|
|
7
|
+
exports.paneTitle = paneTitle;
|
|
7
8
|
exports.waitBadgeHtml = waitBadgeHtml;
|
|
8
9
|
exports.generateDashboard = generateDashboard;
|
|
9
10
|
const template_assets_1 = require("./template-assets");
|
package/dist/discover.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.decodeDirName = decodeDirName;
|
|
|
38
38
|
exports.listProjects = listProjects;
|
|
39
39
|
exports.listSessions = listSessions;
|
|
40
40
|
exports.listRecentSessions = listRecentSessions;
|
|
41
|
+
exports.findRecentSessionsByIds = findRecentSessionsByIds;
|
|
41
42
|
exports.readCwdFromSession = readCwdFromSession;
|
|
42
43
|
exports.findSessionForCwd = findSessionForCwd;
|
|
43
44
|
const fs = __importStar(require("fs"));
|
|
@@ -232,6 +233,32 @@ async function listRecentSessions(limit) {
|
|
|
232
233
|
projectName: top[i].scan.name,
|
|
233
234
|
}));
|
|
234
235
|
}
|
|
236
|
+
// Locate specific sessions by id. scanProjects only stats filenames, so this
|
|
237
|
+
// parses just the matched files — unlike listRecentSessions, which parses every
|
|
238
|
+
// file in its window (getSessionPreview reads a session end to end to total its
|
|
239
|
+
// tokens). Callers that know which ids they want should use this: a session can
|
|
240
|
+
// sit waiting for you while other projects churn past it, so it is not
|
|
241
|
+
// necessarily inside any "most recent N" window.
|
|
242
|
+
async function findRecentSessionsByIds(ids) {
|
|
243
|
+
const wanted = new Set(ids);
|
|
244
|
+
if (!wanted.size)
|
|
245
|
+
return [];
|
|
246
|
+
const scans = await scanProjects();
|
|
247
|
+
const hits = [];
|
|
248
|
+
for (const scan of scans) {
|
|
249
|
+
for (const f of scan.files) {
|
|
250
|
+
if (wanted.has(f.file.replace(/\.jsonl$/, ''))) {
|
|
251
|
+
hits.push({ file: path.join(scan.dir, f.file), scan });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const previews = await Promise.all(hits.map((h) => getSessionPreview(h.file)));
|
|
256
|
+
return previews.map((s, i) => ({
|
|
257
|
+
...s,
|
|
258
|
+
projectRawName: hits[i].scan.rawName,
|
|
259
|
+
projectName: hits[i].scan.name,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
235
262
|
function readCwdFromSession(filePath) {
|
|
236
263
|
return new Promise((resolve) => {
|
|
237
264
|
const rl = readline.createInterface({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccakashic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A cross-project dashboard for your Claude Code sessions (~/.claude/projects/) — browse logs as beautiful HTML, see which sessions are waiting for you, and resume any of them in one click via cmux",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ccakashic": "dist/bin/ccakashic.js"
|