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.
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_PANE_COUNT = exports.PANE_COUNTS = void 0;
4
+ exports.timeAgo = timeAgo;
5
+ exports.paneStatus = paneStatus;
6
+ exports.renderPaneBody = renderPaneBody;
7
+ exports.waitBadgeHtml = waitBadgeHtml;
8
+ exports.generateDashboard = generateDashboard;
9
+ const template_assets_1 = require("./template-assets");
10
+ const html_generator_1 = require("./html-generator");
11
+ const resume_ui_1 = require("./resume-ui");
12
+ const util_1 = require("./util");
13
+ // Multi-pane dashboard: the N most recently active sessions across all
14
+ // projects, each pane showing the last 24h of conversation as a scrollable
15
+ // thread, refreshed by polling /api/pane.
16
+ exports.PANE_COUNTS = [4, 6, 8];
17
+ exports.DEFAULT_PANE_COUNT = 4;
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ // Keep panes light: a busy session can have thousands of messages in 24h.
20
+ const MAX_PANE_MESSAGES = 150;
21
+ function timeAgo(mtime) {
22
+ const diff = Date.now() - mtime;
23
+ if (diff < 60_000)
24
+ return 'just now';
25
+ if (diff < 3_600_000)
26
+ return `${Math.floor(diff / 60_000)}m ago`;
27
+ if (diff < DAY_MS)
28
+ return `${Math.floor(diff / 3_600_000)}h ago`;
29
+ return `${Math.floor(diff / DAY_MS)}d ago`;
30
+ }
31
+ function paneStatus(mtime) {
32
+ const diff = Date.now() - mtime;
33
+ if (diff < util_1.ACTIVE_THRESHOLD_MS)
34
+ return 'active';
35
+ if (diff < 30 * 60_000)
36
+ return 'recent';
37
+ return 'idle';
38
+ }
39
+ function renderPaneBody(parsed) {
40
+ const cutoff = Date.now() - DAY_MS;
41
+ // Exclude only messages we can positively place before the cutoff. Messages
42
+ // with a missing or unparseable timestamp (some tool/meta records) are kept
43
+ // rather than silently dropped — they interleave with timestamped ones and
44
+ // are usually part of the recent tail.
45
+ let messages = parsed.messages.filter((m) => {
46
+ const t = m.timestamp ? new Date(m.timestamp).getTime() : NaN;
47
+ return isNaN(t) || t >= cutoff;
48
+ });
49
+ let note = '';
50
+ if (messages.length === 0) {
51
+ // Nothing in the last 24h: show the tail so the pane isn't empty.
52
+ messages = parsed.messages.slice(-6);
53
+ note = '<div class="dash-pane-note">No activity in the last 24h — showing latest messages</div>';
54
+ }
55
+ else if (messages.length > MAX_PANE_MESSAGES) {
56
+ note = `<div class="dash-pane-note">Showing last ${MAX_PANE_MESSAGES} of ${messages.length} messages from 24h</div>`;
57
+ messages = messages.slice(-MAX_PANE_MESSAGES);
58
+ }
59
+ return note + messages.map(html_generator_1.renderMessage).join('\n');
60
+ }
61
+ function paneTitle(s) {
62
+ return s.customTitle || s.aiTitle || s.slug || s.id.slice(0, 8);
63
+ }
64
+ function waitBadgeHtml(waiting) {
65
+ if (!waiting)
66
+ return '';
67
+ const label = waiting === 'permission' ? '\u{1F510} Permission' : '⏳ Your turn';
68
+ return `<span class="dash-wait-badge dash-wait-${waiting}">${label}</span>`;
69
+ }
70
+ function generateDashboard(panes, paneCount, resume) {
71
+ const cols = paneCount <= 4 ? Math.max(panes.length, 1) : Math.ceil(paneCount / 2);
72
+ const rows = paneCount <= 4 ? 1 : 2;
73
+ const panesHtml = panes.map(({ session: s, bodyHtml, waiting }) => {
74
+ const status = paneStatus(s.lastModified);
75
+ const detailUrl = `/project/${encodeURIComponent(s.projectRawName)}/session/${encodeURIComponent(s.id)}`;
76
+ const projectLabel = s.projectName.split('/').pop() || s.projectName;
77
+ const branch = s.gitBranch && s.gitBranch !== 'HEAD' ? `<span class="dash-meta-item">${(0, util_1.escapeHtml)(s.gitBranch)}</span>` : '';
78
+ return `<div class="dash-pane${waiting ? ' dash-pane-waiting' : ''}" data-project="${(0, util_1.escapeHtml)(s.projectRawName)}" data-session="${(0, util_1.escapeHtml)(s.id)}" data-mtime="${s.lastModified}" data-waiting="${waiting || ''}">
79
+ <div class="dash-pane-header">
80
+ <div class="dash-pane-titles">
81
+ <div class="dash-pane-title"><span class="dash-dot dash-dot-${status}" title="${status}"></span><a href="${detailUrl}">${(0, util_1.escapeHtml)(paneTitle(s))}</a><span class="dash-wait-slot">${waitBadgeHtml(waiting)}</span></div>
82
+ <div class="dash-pane-meta">
83
+ <span class="dash-meta-item dash-meta-project" title="${(0, util_1.escapeHtml)(s.projectName)}">${(0, util_1.escapeHtml)(projectLabel)}</span>
84
+ ${branch}
85
+ <span class="dash-meta-item dash-ago">${timeAgo(s.lastModified)}</span>
86
+ </div>
87
+ </div>
88
+ ${(0, resume_ui_1.resumeButtonsHtml)(s.projectRawName, s, resume)}
89
+ </div>
90
+ <div class="dash-pane-body">${bodyHtml}</div>
91
+ </div>`;
92
+ }).join('\n');
93
+ const countLinks = exports.PANE_COUNTS.map((n) => `<a class="dash-count${n === paneCount ? ' active' : ''}" href="/?n=${n}">${n}</a>`).join('');
94
+ return `<!DOCTYPE html>
95
+ <html lang="en">
96
+ <head>
97
+ <meta charset="utf-8">
98
+ <meta name="viewport" content="width=device-width, initial-scale=1">
99
+ <title>ccakashic — dashboard</title>
100
+ <link rel="icon" id="dash-favicon" href="data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="3" fill="#2563eb"/></svg>')}">
101
+ <style>${(0, template_assets_1.getCSS)()}
102
+ ${(0, resume_ui_1.resumeCSS)()}
103
+ ${dashboardCSS(cols, rows)}
104
+ </style>
105
+ </head>
106
+ <body class="dash-page">
107
+ <div class="dash-topbar">
108
+ <span class="dash-brand">ccakashic</span>
109
+ <span class="dash-sub">last 24h across projects</span>
110
+ <span class="dash-counts">Panes: ${countLinks}</span>
111
+ <a class="dash-nav-link" href="/projects">All projects &rarr;</a>
112
+ </div>
113
+ <div class="dash-grid">
114
+ ${panesHtml || '<div class="empty">No sessions found</div>'}
115
+ </div>
116
+ <script>${(0, template_assets_1.getAppJS)()}
117
+ ${(0, resume_ui_1.resumeJS)(resume)}
118
+ ${dashboardJS()}
119
+ </script>
120
+ </body>
121
+ </html>`;
122
+ }
123
+ function dashboardCSS(cols, rows) {
124
+ return `
125
+ .dash-page {
126
+ height: 100vh;
127
+ display: flex;
128
+ flex-direction: column;
129
+ overflow: hidden;
130
+ }
131
+ .dash-topbar {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 14px;
135
+ padding: 8px 16px;
136
+ border-bottom: 1px solid var(--border);
137
+ flex-shrink: 0;
138
+ }
139
+ .dash-brand { font-weight: 700; font-size: 1rem; }
140
+ .dash-sub { color: var(--text-muted); font-size: 0.8rem; }
141
+ .dash-counts { margin-left: auto; font-size: 0.8rem; color: var(--text-muted); }
142
+ .dash-count {
143
+ display: inline-block;
144
+ padding: 2px 8px;
145
+ margin-left: 4px;
146
+ border: 1px solid var(--border);
147
+ border-radius: 5px;
148
+ color: var(--text);
149
+ text-decoration: none;
150
+ }
151
+ .dash-count.active { border-color: var(--link); color: var(--link); font-weight: 700; }
152
+ .dash-nav-link { color: var(--link); text-decoration: none; font-size: 0.85rem; }
153
+ .dash-nav-link:hover { text-decoration: underline; }
154
+
155
+ .dash-grid {
156
+ flex: 1;
157
+ min-height: 0;
158
+ display: grid;
159
+ grid-template-columns: repeat(${cols}, 1fr);
160
+ grid-template-rows: repeat(${rows}, 1fr);
161
+ gap: 8px;
162
+ padding: 8px;
163
+ }
164
+ .dash-pane {
165
+ display: flex;
166
+ flex-direction: column;
167
+ min-height: 0;
168
+ min-width: 0;
169
+ border: 1px solid var(--border);
170
+ border-radius: 8px;
171
+ overflow: hidden;
172
+ background: var(--bg);
173
+ }
174
+ .dash-pane-header {
175
+ display: flex;
176
+ align-items: flex-start;
177
+ justify-content: space-between;
178
+ gap: 8px;
179
+ padding: 8px 10px;
180
+ border-bottom: 1px solid var(--border);
181
+ background: var(--bg-secondary);
182
+ flex-shrink: 0;
183
+ }
184
+ .dash-pane-titles { min-width: 0; }
185
+ .dash-pane-title {
186
+ font-size: 0.85rem;
187
+ font-weight: 700;
188
+ white-space: nowrap;
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ }
192
+ .dash-pane-title a { color: var(--text); text-decoration: none; }
193
+ .dash-pane-title a:hover { color: var(--link); }
194
+ .dash-pane-meta {
195
+ display: flex;
196
+ gap: 8px;
197
+ font-size: 0.7rem;
198
+ color: var(--text-muted);
199
+ margin-top: 2px;
200
+ white-space: nowrap;
201
+ overflow: hidden;
202
+ }
203
+ .dash-pane-header .resume-actions { margin-top: 0; flex-shrink: 0; }
204
+
205
+ /* Waiting-for-user emphasis: orange frame + glow so it stands out at a glance. */
206
+ .dash-pane-waiting {
207
+ border-color: #f97316;
208
+ box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35);
209
+ }
210
+ .dash-pane-waiting .dash-pane-header { background: rgba(249, 115, 22, 0.12); }
211
+ .dash-wait-slot:empty { display: none; }
212
+ .dash-wait-badge {
213
+ display: inline-block;
214
+ margin-left: 6px;
215
+ padding: 1px 7px;
216
+ border-radius: 10px;
217
+ font-size: 0.68rem;
218
+ font-weight: 700;
219
+ vertical-align: middle;
220
+ white-space: nowrap;
221
+ background: #f97316;
222
+ color: #fff;
223
+ }
224
+ .dash-wait-permission { background: #dc2626; animation: dash-pulse 1.2s ease-in-out infinite; }
225
+
226
+ .dash-dot {
227
+ display: inline-block;
228
+ width: 8px;
229
+ height: 8px;
230
+ border-radius: 50%;
231
+ margin-right: 6px;
232
+ background: var(--text-muted);
233
+ opacity: 0.4;
234
+ }
235
+ .dash-dot-active { background: #22c55e; opacity: 1; animation: dash-pulse 1.6s ease-in-out infinite; }
236
+ .dash-dot-recent { background: #eab308; opacity: 1; }
237
+ @keyframes dash-pulse { 50% { opacity: 0.35; } }
238
+ .dash-pane-body {
239
+ flex: 1;
240
+ min-height: 0;
241
+ overflow-y: auto;
242
+ padding: 8px 10px;
243
+ font-size: 0.85rem;
244
+ }
245
+ .dash-pane-body .msg { max-width: 100%; }
246
+ .dash-pane-note {
247
+ font-size: 0.72rem;
248
+ color: var(--text-muted);
249
+ text-align: center;
250
+ padding: 4px 0 8px;
251
+ }
252
+ .empty { text-align: center; color: var(--text-muted); padding: 40px; }
253
+
254
+ @media (max-width: 900px) {
255
+ .dash-page { height: auto; overflow: auto; }
256
+ .dash-grid { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-rows: 70vh; }
257
+ }
258
+ `;
259
+ }
260
+ function dashboardJS() {
261
+ return `
262
+ (function() {
263
+ var POLL_MS = 20000;
264
+
265
+ function scrollToBottom(body) {
266
+ body.scrollTop = body.scrollHeight;
267
+ }
268
+
269
+ document.querySelectorAll('.dash-pane-body').forEach(scrollToBottom);
270
+
271
+ var BASE_TITLE = 'ccakashic';
272
+ var ICON_NORMAL = document.getElementById('dash-favicon').href;
273
+ function svgIcon(fill) {
274
+ return 'data:image/svg+xml,' + encodeURIComponent(
275
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="3" fill="' + fill + '"/></svg>'
276
+ );
277
+ }
278
+ var ICON_WAIT = svgIcon('#f97316');
279
+
280
+ function waitBadge(state) {
281
+ if (state === 'permission') return '<span class="dash-wait-badge dash-wait-permission">\u{1F510} Permission</span>';
282
+ if (state === 'input') return '<span class="dash-wait-badge dash-wait-input">⏳ Your turn</span>';
283
+ return '';
284
+ }
285
+
286
+ // Reflect the number of sessions awaiting the user into the tab title and
287
+ // favicon, so a glance at the browser tab is enough.
288
+ function syncWaitingIndicator() {
289
+ var waiting = document.querySelectorAll('.dash-pane[data-waiting="input"], .dash-pane[data-waiting="permission"]').length;
290
+ document.title = waiting ? '(' + waiting + ') ' + BASE_TITLE + ' — dashboard' : BASE_TITLE + ' — dashboard';
291
+ var icon = document.getElementById('dash-favicon');
292
+ if (icon) icon.href = waiting ? ICON_WAIT : ICON_NORMAL;
293
+ }
294
+
295
+ function applyWaiting(pane, state) {
296
+ var norm = (state === 'input' || state === 'permission') ? state : '';
297
+ if ((pane.dataset.waiting || '') === norm) return;
298
+ pane.dataset.waiting = norm;
299
+ pane.classList.toggle('dash-pane-waiting', !!norm);
300
+ var slot = pane.querySelector('.dash-wait-slot');
301
+ if (slot) slot.innerHTML = waitBadge(norm);
302
+ }
303
+
304
+ function refreshPane(pane) {
305
+ var body = pane.querySelector('.dash-pane-body');
306
+ var url = '/api/pane?project=' + encodeURIComponent(pane.dataset.project)
307
+ + '&session=' + encodeURIComponent(pane.dataset.session)
308
+ + '&since=' + encodeURIComponent(pane.dataset.mtime);
309
+ return fetch(url).then(function(res) { return res.json(); }).then(function(data) {
310
+ var dot = pane.querySelector('.dash-dot');
311
+ if (dot && data.status) dot.className = 'dash-dot dash-dot-' + data.status;
312
+ var ago = pane.querySelector('.dash-ago');
313
+ if (ago && data.ago) ago.textContent = data.ago;
314
+ if ('waiting' in data) { applyWaiting(pane, data.waiting); syncWaitingIndicator(); }
315
+ if (!data.changed) return;
316
+ pane.dataset.mtime = data.mtime;
317
+ var nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 60;
318
+ body.innerHTML = data.html;
319
+ if (window.ccakashicApplyMarkdown) window.ccakashicApplyMarkdown(body);
320
+ if (nearBottom) scrollToBottom(body);
321
+ }).catch(function() { /* server briefly unavailable; retry next tick */ });
322
+ }
323
+
324
+ syncWaitingIndicator();
325
+ setInterval(function() {
326
+ document.querySelectorAll('.dash-pane').forEach(refreshPane);
327
+ }, POLL_MS);
328
+ })();
329
+ `;
330
+ }
package/dist/discover.js CHANGED
@@ -37,6 +37,8 @@ exports.CLAUDE_DIR = void 0;
37
37
  exports.decodeDirName = decodeDirName;
38
38
  exports.listProjects = listProjects;
39
39
  exports.listSessions = listSessions;
40
+ exports.listRecentSessions = listRecentSessions;
41
+ exports.readCwdFromSession = readCwdFromSession;
40
42
  exports.findSessionForCwd = findSessionForCwd;
41
43
  const fs = __importStar(require("fs"));
42
44
  const os = __importStar(require("os"));
@@ -51,42 +53,69 @@ function decodeDirName(dirName) {
51
53
  }
52
54
  return dirName;
53
55
  }
54
- async function listProjects() {
55
- if (!fs.existsSync(exports.CLAUDE_DIR)) {
56
+ // Stat every .jsonl in a project dir once. Tolerant of files/dirs disappearing
57
+ // mid-scan (a live `claude` process may be rotating them).
58
+ function statSessionFiles(projectDir) {
59
+ let names;
60
+ try {
61
+ names = fs.readdirSync(projectDir);
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ const out = [];
67
+ for (const f of names) {
68
+ if (!f.endsWith('.jsonl'))
69
+ continue;
70
+ try {
71
+ out.push({ file: f, mtimeMs: fs.statSync(path.join(projectDir, f)).mtimeMs });
72
+ }
73
+ catch {
74
+ // removed between readdir and stat
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ // Single source of truth for walking ~/.claude/projects. Both listProjects and
80
+ // listRecentSessions build on this so the filesystem is walked/statted once.
81
+ async function scanProjects() {
82
+ if (!fs.existsSync(exports.CLAUDE_DIR))
83
+ return [];
84
+ let entries;
85
+ try {
86
+ entries = fs.readdirSync(exports.CLAUDE_DIR, { withFileTypes: true });
87
+ }
88
+ catch {
56
89
  return [];
57
90
  }
58
- const entries = fs.readdirSync(exports.CLAUDE_DIR, { withFileTypes: true });
59
- const projects = [];
91
+ const scans = [];
60
92
  for (const entry of entries) {
61
93
  if (!entry.isDirectory())
62
94
  continue;
63
- const projectDir = path.join(exports.CLAUDE_DIR, entry.name);
64
- const jsonlFiles = fs.readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
65
- if (jsonlFiles.length === 0)
95
+ const dir = path.join(exports.CLAUDE_DIR, entry.name);
96
+ const files = statSessionFiles(dir);
97
+ if (files.length === 0)
66
98
  continue;
67
- let lastModified = 0;
68
- for (const f of jsonlFiles) {
69
- const stat = fs.statSync(path.join(projectDir, f));
70
- if (stat.mtimeMs > lastModified)
71
- lastModified = stat.mtimeMs;
72
- }
73
- // Prefer the real cwd recorded in the session over the lossy directory-name
74
- // decoding (dots and slashes both collapse to dashes in the dir name).
99
+ // Prefer the real cwd recorded in the latest session over the lossy
100
+ // directory-name decoding (dots and slashes both collapse to dashes).
75
101
  let name = decodeDirName(entry.name);
76
- const latest = latestSessionFile(projectDir);
77
- if (latest) {
78
- const cwd = await readCwdFromSession(path.join(projectDir, latest.file));
79
- if (cwd)
80
- name = cwd;
81
- }
82
- projects.push({
83
- name,
84
- rawName: entry.name,
85
- dir: projectDir,
86
- sessionCount: jsonlFiles.length,
87
- lastModified: new Date(lastModified),
88
- });
102
+ const latest = files.reduce((a, b) => (b.mtimeMs > a.mtimeMs ? b : a));
103
+ const cwd = await readCwdFromSession(path.join(dir, latest.file));
104
+ if (cwd)
105
+ name = cwd;
106
+ scans.push({ rawName: entry.name, dir, name, files });
89
107
  }
108
+ return scans;
109
+ }
110
+ async function listProjects() {
111
+ const scans = await scanProjects();
112
+ const projects = scans.map((s) => ({
113
+ name: s.name,
114
+ rawName: s.rawName,
115
+ dir: s.dir,
116
+ sessionCount: s.files.length,
117
+ lastModified: new Date(Math.max(...s.files.map((f) => f.mtimeMs))),
118
+ }));
90
119
  projects.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
91
120
  return projects;
92
121
  }
@@ -95,6 +124,7 @@ async function getSessionPreview(filePath) {
95
124
  const result = {
96
125
  id: path.basename(filePath, '.jsonl'),
97
126
  path: filePath,
127
+ cwd: null,
98
128
  timestamp: null,
99
129
  lastModified: fs.statSync(filePath).mtimeMs,
100
130
  preview: '',
@@ -122,6 +152,9 @@ async function getSessionPreview(filePath) {
122
152
  if (!result.timestamp && obj.timestamp) {
123
153
  result.timestamp = obj.timestamp;
124
154
  }
155
+ if (!result.cwd && obj.cwd) {
156
+ result.cwd = obj.cwd;
157
+ }
125
158
  if (!result.gitBranch && obj.gitBranch) {
126
159
  result.gitBranch = obj.gitBranch;
127
160
  }
@@ -182,6 +215,23 @@ async function listSessions(projectDir) {
182
215
  sessions.sort((a, b) => b.lastModified - a.lastModified);
183
216
  return sessions;
184
217
  }
218
+ async function listRecentSessions(limit) {
219
+ const scans = await scanProjects();
220
+ const entries = [];
221
+ for (const scan of scans) {
222
+ for (const f of scan.files) {
223
+ entries.push({ file: path.join(scan.dir, f.file), mtime: f.mtimeMs, scan });
224
+ }
225
+ }
226
+ entries.sort((a, b) => b.mtime - a.mtime);
227
+ const top = entries.slice(0, limit);
228
+ const previews = await Promise.all(top.map((e) => getSessionPreview(e.file)));
229
+ return previews.map((s, i) => ({
230
+ ...s,
231
+ projectRawName: top[i].scan.rawName,
232
+ projectName: top[i].scan.name,
233
+ }));
234
+ }
185
235
  function readCwdFromSession(filePath) {
186
236
  return new Promise((resolve) => {
187
237
  const rl = readline.createInterface({