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 +33 -4
- package/dist/bin/ccakashic.js +233 -5
- package/dist/cmux.js +274 -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
|
@@ -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 →</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
|
-
|
|
55
|
-
|
|
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
|
|
59
|
-
const projects = [];
|
|
91
|
+
const scans = [];
|
|
60
92
|
for (const entry of entries) {
|
|
61
93
|
if (!entry.isDirectory())
|
|
62
94
|
continue;
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
95
|
+
const dir = path.join(exports.CLAUDE_DIR, entry.name);
|
|
96
|
+
const files = statSessionFiles(dir);
|
|
97
|
+
if (files.length === 0)
|
|
66
98
|
continue;
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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({
|