@teddysc/claude-run 0.11.0 → 0.13.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 +19 -2
- package/dist/index.js +251 -2
- package/dist/web/assets/index-Bz7pnCx9.js +328 -0
- package/dist/web/assets/index-CSMaeBYb.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BgICkv1W.js +0 -318
- package/dist/web/assets/index-DM8xkSFM.css +0 -1
package/README.md
CHANGED
|
@@ -30,8 +30,22 @@ bun install -g @teddysc/claude-run@latest
|
|
|
30
30
|
|
|
31
31
|
The browser will open automatically at http://localhost:12001.
|
|
32
32
|
|
|
33
|
+
## Specifications
|
|
34
|
+
|
|
35
|
+
See [spec.md](spec.md)
|
|
36
|
+
|
|
33
37
|
## Changelog
|
|
34
38
|
|
|
39
|
+
### 0.13.0
|
|
40
|
+
- Display message count, duration, and JSONL size in conversation header
|
|
41
|
+
- Sidebar shows compact message count, duration, and JSONL size per session
|
|
42
|
+
- Add sorting options for sessions (by duration or JSONL size, asc/desc)
|
|
43
|
+
|
|
44
|
+
### 0.12.0
|
|
45
|
+
- Rename sessions directly from the web UI
|
|
46
|
+
- Searchable project dropdown with fuzzy matching on name and path
|
|
47
|
+
- Session summary caching for improved performance
|
|
48
|
+
|
|
35
49
|
### 0.11.0
|
|
36
50
|
- Display session metadata (model, start/end times) in conversation header
|
|
37
51
|
- Show full project path with ~ shorthand in header and sidebar tooltips
|
|
@@ -87,10 +101,13 @@ The browser will open automatically at http://localhost:12001.
|
|
|
87
101
|
- **Truncation options** - Limit long tool outputs by line count or character count
|
|
88
102
|
- **Batch export** - Export multiple conversations at once
|
|
89
103
|
- **Real-time streaming** - Watch conversations update live as Claude responds
|
|
90
|
-
- **Filter by project** -
|
|
104
|
+
- **Filter by project** - Searchable dropdown with fuzzy matching on project name and path
|
|
91
105
|
- **Resume sessions** - Copy the resume command to continue any conversation in your terminal
|
|
92
106
|
- **Copy messages** - Click the copy button on any message to copy its text content
|
|
93
|
-
- **Session metadata** - See model and
|
|
107
|
+
- **Session metadata** - See model, message count, duration, and JSONL size in the conversation header
|
|
108
|
+
- **Sidebar stats** - Compact message count, duration, and JSONL size per session
|
|
109
|
+
- **Sidebar sorting** - Sort sessions by duration or JSONL size (asc/desc)
|
|
110
|
+
- **Rename sessions** - Click the conversation title to rename a session
|
|
94
111
|
- **Collapsible sidebar** - Maximize your viewing area
|
|
95
112
|
- **Dark mode** - Easy on the eyes
|
|
96
113
|
- **Clean UI** - Familiar chat interface with collapsible tool calls
|
package/dist/index.js
CHANGED
|
@@ -88,6 +88,8 @@ function mergeSidechainText(messages, sidechainMap) {
|
|
|
88
88
|
var claudeDir = join(homedir(), ".claude");
|
|
89
89
|
var projectsDir = join(claudeDir, "projects");
|
|
90
90
|
var fileIndex = /* @__PURE__ */ new Map();
|
|
91
|
+
var summaryCache = /* @__PURE__ */ new Map();
|
|
92
|
+
var sessionStatsCache = /* @__PURE__ */ new Map();
|
|
91
93
|
var historyCache = null;
|
|
92
94
|
var pendingRequests = /* @__PURE__ */ new Map();
|
|
93
95
|
function initStorage(dir) {
|
|
@@ -102,6 +104,132 @@ function invalidateHistoryCache() {
|
|
|
102
104
|
}
|
|
103
105
|
function addToFileIndex(sessionId, filePath) {
|
|
104
106
|
fileIndex.set(sessionId, filePath);
|
|
107
|
+
summaryCache.delete(sessionId);
|
|
108
|
+
sessionStatsCache.delete(sessionId);
|
|
109
|
+
}
|
|
110
|
+
function invalidateSessionStats(sessionId) {
|
|
111
|
+
sessionStatsCache.delete(sessionId);
|
|
112
|
+
}
|
|
113
|
+
function parseMessageTimestamp(rawTimestamp) {
|
|
114
|
+
if (typeof rawTimestamp === "string") {
|
|
115
|
+
const parsed = Date.parse(rawTimestamp);
|
|
116
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
117
|
+
}
|
|
118
|
+
if (typeof rawTimestamp === "number" && Number.isFinite(rawTimestamp)) {
|
|
119
|
+
return rawTimestamp;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
async function getSessionStats(sessionId) {
|
|
124
|
+
if (sessionStatsCache.has(sessionId)) {
|
|
125
|
+
return sessionStatsCache.get(sessionId);
|
|
126
|
+
}
|
|
127
|
+
const filePath = await findSessionFile(sessionId);
|
|
128
|
+
if (!filePath) {
|
|
129
|
+
const empty = {
|
|
130
|
+
messageCount: 0,
|
|
131
|
+
startTime: null,
|
|
132
|
+
endTime: null,
|
|
133
|
+
fileSizeBytes: null
|
|
134
|
+
};
|
|
135
|
+
sessionStatsCache.set(sessionId, empty);
|
|
136
|
+
return empty;
|
|
137
|
+
}
|
|
138
|
+
let fileSizeBytes = null;
|
|
139
|
+
try {
|
|
140
|
+
const fileStat = await stat(filePath);
|
|
141
|
+
fileSizeBytes = fileStat.size;
|
|
142
|
+
} catch {
|
|
143
|
+
fileSizeBytes = null;
|
|
144
|
+
}
|
|
145
|
+
let messageCount = 0;
|
|
146
|
+
let startTime = null;
|
|
147
|
+
let endTime = null;
|
|
148
|
+
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
149
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
150
|
+
try {
|
|
151
|
+
for await (const line of rl) {
|
|
152
|
+
if (!line.trim()) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const msg = JSON.parse(line);
|
|
157
|
+
if (msg.type !== "user" && msg.type !== "assistant") {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
messageCount += 1;
|
|
161
|
+
const parsed = parseMessageTimestamp(msg.timestamp);
|
|
162
|
+
if (parsed !== null) {
|
|
163
|
+
startTime = startTime === null ? parsed : Math.min(startTime, parsed);
|
|
164
|
+
endTime = endTime === null ? parsed : Math.max(endTime, parsed);
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
rl.close();
|
|
172
|
+
stream.close();
|
|
173
|
+
}
|
|
174
|
+
const stats = {
|
|
175
|
+
messageCount,
|
|
176
|
+
startTime,
|
|
177
|
+
endTime,
|
|
178
|
+
fileSizeBytes
|
|
179
|
+
};
|
|
180
|
+
sessionStatsCache.set(sessionId, stats);
|
|
181
|
+
return stats;
|
|
182
|
+
}
|
|
183
|
+
async function readLastNonEmptyLine(filePath) {
|
|
184
|
+
try {
|
|
185
|
+
const fileStat = await stat(filePath);
|
|
186
|
+
const fileSize = fileStat.size;
|
|
187
|
+
if (fileSize === 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const chunkSize = Math.min(8192, fileSize);
|
|
191
|
+
const start = Math.max(0, fileSize - chunkSize);
|
|
192
|
+
const fileHandle = await open(filePath, "r");
|
|
193
|
+
try {
|
|
194
|
+
const buffer = Buffer.alloc(chunkSize);
|
|
195
|
+
await fileHandle.read(buffer, 0, chunkSize, start);
|
|
196
|
+
const text = buffer.toString("utf-8");
|
|
197
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
198
|
+
if (lines.length === 0) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return lines[lines.length - 1] ?? null;
|
|
202
|
+
} finally {
|
|
203
|
+
await fileHandle.close();
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function getSessionSummary(sessionId) {
|
|
210
|
+
if (summaryCache.has(sessionId)) {
|
|
211
|
+
return summaryCache.get(sessionId) ?? null;
|
|
212
|
+
}
|
|
213
|
+
const filePath = await findSessionFile(sessionId);
|
|
214
|
+
if (!filePath) {
|
|
215
|
+
summaryCache.set(sessionId, null);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const lastLine = await readLastNonEmptyLine(filePath);
|
|
219
|
+
if (!lastLine) {
|
|
220
|
+
summaryCache.set(sessionId, null);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const msg = JSON.parse(lastLine);
|
|
225
|
+
if (msg.type === "summary" && typeof msg.summary === "string" && msg.summary.trim().length > 0) {
|
|
226
|
+
summaryCache.set(sessionId, msg.summary);
|
|
227
|
+
return msg.summary;
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
}
|
|
231
|
+
summaryCache.set(sessionId, null);
|
|
232
|
+
return null;
|
|
105
233
|
}
|
|
106
234
|
function encodeProjectPath(path) {
|
|
107
235
|
return path.replace(/[/.]/g, "-");
|
|
@@ -316,12 +444,18 @@ async function getSessions() {
|
|
|
316
444
|
continue;
|
|
317
445
|
}
|
|
318
446
|
seenIds.add(sessionId);
|
|
447
|
+
const summary = await getSessionSummary(sessionId);
|
|
448
|
+
const stats = await getSessionStats(sessionId);
|
|
319
449
|
sessions.push({
|
|
320
450
|
id: sessionId,
|
|
321
|
-
display: entry.display,
|
|
451
|
+
display: summary ?? entry.display,
|
|
322
452
|
timestamp: entry.timestamp,
|
|
323
453
|
project: entry.project,
|
|
324
|
-
projectName: getProjectName(entry.project)
|
|
454
|
+
projectName: getProjectName(entry.project),
|
|
455
|
+
messageCount: stats.messageCount,
|
|
456
|
+
startTime: stats.startTime,
|
|
457
|
+
endTime: stats.endTime,
|
|
458
|
+
fileSizeBytes: stats.fileSizeBytes
|
|
325
459
|
});
|
|
326
460
|
}
|
|
327
461
|
return sessions.sort((a, b) => b.timestamp - a.timestamp);
|
|
@@ -369,6 +503,8 @@ async function deleteSession(sessionId) {
|
|
|
369
503
|
console.log(`[deleteSession] No file found for session: ${sessionId}`);
|
|
370
504
|
}
|
|
371
505
|
fileIndex.delete(sessionId);
|
|
506
|
+
summaryCache.delete(sessionId);
|
|
507
|
+
sessionStatsCache.delete(sessionId);
|
|
372
508
|
const historyPath = join(claudeDir, "history.jsonl");
|
|
373
509
|
let historyChanged = false;
|
|
374
510
|
try {
|
|
@@ -405,6 +541,87 @@ async function deleteSession(sessionId) {
|
|
|
405
541
|
}
|
|
406
542
|
return fileDeleted || historyChanged;
|
|
407
543
|
}
|
|
544
|
+
async function getSessionFilePath(sessionId) {
|
|
545
|
+
return findSessionFile(sessionId);
|
|
546
|
+
}
|
|
547
|
+
async function renameSessionDisplay(params) {
|
|
548
|
+
const { sessionId, display, project, timestamp } = params;
|
|
549
|
+
const historyPath = join(claudeDir, "history.jsonl");
|
|
550
|
+
let changed = false;
|
|
551
|
+
let summaryChanged = false;
|
|
552
|
+
try {
|
|
553
|
+
const content = await readFile(historyPath, "utf-8");
|
|
554
|
+
const lines = content.split("\n");
|
|
555
|
+
const updated = [];
|
|
556
|
+
for (const line of lines) {
|
|
557
|
+
if (!line.trim()) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const entry = JSON.parse(line);
|
|
562
|
+
const matchesId = entry.sessionId && entry.sessionId === sessionId;
|
|
563
|
+
const matchesFallback = !entry.sessionId && project && typeof timestamp === "number" && entry.project === project && entry.timestamp === timestamp;
|
|
564
|
+
if (matchesId || matchesFallback) {
|
|
565
|
+
if (entry.display !== display) {
|
|
566
|
+
entry.display = display;
|
|
567
|
+
changed = true;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
updated.push(JSON.stringify(entry));
|
|
571
|
+
} catch {
|
|
572
|
+
updated.push(line);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (changed) {
|
|
576
|
+
await writeFile(historyPath, updated.join("\n") + "\n", "utf-8");
|
|
577
|
+
if (historyCache) {
|
|
578
|
+
historyCache = historyCache.map((entry) => {
|
|
579
|
+
const matchesId = entry.sessionId && entry.sessionId === sessionId;
|
|
580
|
+
const matchesFallback = !entry.sessionId && project && typeof timestamp === "number" && entry.project === project && entry.timestamp === timestamp;
|
|
581
|
+
if (matchesId || matchesFallback) {
|
|
582
|
+
return { ...entry, display };
|
|
583
|
+
}
|
|
584
|
+
return entry;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
console.error(`[renameSessionDisplay] Failed to update history.jsonl for ${sessionId}:`, err);
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
const sessionFile = await findSessionFile(sessionId);
|
|
593
|
+
if (sessionFile) {
|
|
594
|
+
try {
|
|
595
|
+
const content = await readFile(sessionFile, "utf-8");
|
|
596
|
+
const lines = content.split("\n");
|
|
597
|
+
let summaryUpdated = false;
|
|
598
|
+
const updatedLines = lines.map((line) => {
|
|
599
|
+
if (!line.trim()) {
|
|
600
|
+
return line;
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
const msg = JSON.parse(line);
|
|
604
|
+
if (msg.type === "summary") {
|
|
605
|
+
msg.summary = display;
|
|
606
|
+
summaryUpdated = true;
|
|
607
|
+
return JSON.stringify(msg);
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
return line;
|
|
612
|
+
});
|
|
613
|
+
if (!summaryUpdated) {
|
|
614
|
+
updatedLines.push(JSON.stringify({ type: "summary", summary: display }));
|
|
615
|
+
}
|
|
616
|
+
await writeFile(sessionFile, updatedLines.join("\n") + "\n", "utf-8");
|
|
617
|
+
summaryCache.set(sessionId, display);
|
|
618
|
+
summaryChanged = true;
|
|
619
|
+
} catch (err) {
|
|
620
|
+
console.error(`[renameSessionDisplay] Failed to update session file for ${sessionId}:`, err);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return changed || summaryChanged;
|
|
624
|
+
}
|
|
408
625
|
async function getConversation(sessionId) {
|
|
409
626
|
return dedupe(`getConversation:${sessionId}`, async () => {
|
|
410
627
|
const filePath = await findSessionFile(sessionId);
|
|
@@ -771,6 +988,37 @@ function createServer(options) {
|
|
|
771
988
|
console.log(`[/api/sessions/delete] Failed:`, failed);
|
|
772
989
|
return c.json({ deleted, failed });
|
|
773
990
|
});
|
|
991
|
+
app.post("/api/sessions/rename", async (c) => {
|
|
992
|
+
let body;
|
|
993
|
+
try {
|
|
994
|
+
body = await c.req.json();
|
|
995
|
+
} catch {
|
|
996
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
997
|
+
}
|
|
998
|
+
const id = body?.id?.trim();
|
|
999
|
+
const display = body?.display?.trim();
|
|
1000
|
+
if (!id || !display) {
|
|
1001
|
+
return c.json({ error: "Missing id/display" }, 400);
|
|
1002
|
+
}
|
|
1003
|
+
const ok = await renameSessionDisplay({
|
|
1004
|
+
sessionId: id,
|
|
1005
|
+
display,
|
|
1006
|
+
project: body.project,
|
|
1007
|
+
timestamp: body.timestamp
|
|
1008
|
+
});
|
|
1009
|
+
if (!ok) {
|
|
1010
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1011
|
+
}
|
|
1012
|
+
return c.json({ ok: true });
|
|
1013
|
+
});
|
|
1014
|
+
app.get("/api/sessions/:id/path", async (c) => {
|
|
1015
|
+
const sessionId = c.req.param("id");
|
|
1016
|
+
if (!sessionId) {
|
|
1017
|
+
return c.json({ error: "Missing session id" }, 400);
|
|
1018
|
+
}
|
|
1019
|
+
const path = await getSessionFilePath(sessionId);
|
|
1020
|
+
return c.json({ path });
|
|
1021
|
+
});
|
|
774
1022
|
app.post("/api/search", async (c) => {
|
|
775
1023
|
let body;
|
|
776
1024
|
try {
|
|
@@ -929,6 +1177,7 @@ function createServer(options) {
|
|
|
929
1177
|
});
|
|
930
1178
|
onSessionChange((sessionId, filePath) => {
|
|
931
1179
|
addToFileIndex(sessionId, filePath);
|
|
1180
|
+
invalidateSessionStats(sessionId);
|
|
932
1181
|
});
|
|
933
1182
|
startWatcher();
|
|
934
1183
|
let httpServer = null;
|