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 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
+ }
@@ -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 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.
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.4.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"