@teddysc/claude-run 0.9.1 → 0.11.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 +16 -0
- package/dist/index.js +263 -3
- package/dist/web/assets/index-BgICkv1W.js +318 -0
- package/dist/web/assets/index-DM8xkSFM.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BIpp2t3t.js +0 -318
- package/dist/web/assets/index-DE3Rh4xE.css +0 -1
package/README.md
CHANGED
|
@@ -32,6 +32,20 @@ The browser will open automatically at http://localhost:12001.
|
|
|
32
32
|
|
|
33
33
|
## Changelog
|
|
34
34
|
|
|
35
|
+
### 0.11.0
|
|
36
|
+
- Display session metadata (model, start/end times) in conversation header
|
|
37
|
+
- Show full project path with ~ shorthand in header and sidebar tooltips
|
|
38
|
+
- Add "Hide Unknown model" filter to hide sessions without detected models
|
|
39
|
+
- Add "Delete Unknown" button to remove blank/orphaned sessions from disk
|
|
40
|
+
- Automatic cleanup of orphaned history entries on startup
|
|
41
|
+
- Add comprehensive logging for delete operations
|
|
42
|
+
- Fix URL state sync when unchecking checkboxes
|
|
43
|
+
|
|
44
|
+
### 0.10.0
|
|
45
|
+
- Enhance message handling: add message ID to ConversationMessage
|
|
46
|
+
- Normalize content blocks in message processing
|
|
47
|
+
- Merge sidechain text in getConversation functions
|
|
48
|
+
|
|
35
49
|
### 0.9.1
|
|
36
50
|
- Fix favicon response handling
|
|
37
51
|
- Enhance server response handling for static assets
|
|
@@ -76,9 +90,11 @@ The browser will open automatically at http://localhost:12001.
|
|
|
76
90
|
- **Filter by project** - Focus on specific projects
|
|
77
91
|
- **Resume sessions** - Copy the resume command to continue any conversation in your terminal
|
|
78
92
|
- **Copy messages** - Click the copy button on any message to copy its text content
|
|
93
|
+
- **Session metadata** - See model and start/end times in the conversation header
|
|
79
94
|
- **Collapsible sidebar** - Maximize your viewing area
|
|
80
95
|
- **Dark mode** - Easy on the eyes
|
|
81
96
|
- **Clean UI** - Familiar chat interface with collapsible tool calls
|
|
97
|
+
- **Filter & cleanup** - Hide Unknown-model sessions and delete blank sessions from disk
|
|
82
98
|
|
|
83
99
|
## Usage
|
|
84
100
|
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,80 @@ import { streamSSE } from "hono/streaming";
|
|
|
11
11
|
import { serve } from "@hono/node-server";
|
|
12
12
|
|
|
13
13
|
// api/storage.ts
|
|
14
|
-
import { readdir, readFile, stat, open } from "fs/promises";
|
|
14
|
+
import { readdir, readFile, stat, open, unlink, writeFile } from "fs/promises";
|
|
15
|
+
import { createReadStream } from "fs";
|
|
15
16
|
import { join, basename } from "path";
|
|
16
17
|
import { homedir } from "os";
|
|
17
18
|
import { createInterface } from "readline";
|
|
19
|
+
function normalizeContentBlocks(content) {
|
|
20
|
+
if (!content || typeof content === "string") {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return Array.isArray(content) ? content : [content];
|
|
24
|
+
}
|
|
25
|
+
function extractTextBlocks(content) {
|
|
26
|
+
return normalizeContentBlocks(content).filter(
|
|
27
|
+
(block) => block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
async function loadSidechainTextByMessageId(filePath) {
|
|
31
|
+
const map = /* @__PURE__ */ new Map();
|
|
32
|
+
const sessionDir = filePath.replace(/\.jsonl$/, "");
|
|
33
|
+
const subagentsDir = join(sessionDir, "subagents");
|
|
34
|
+
try {
|
|
35
|
+
const files = await readdir(subagentsDir);
|
|
36
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
37
|
+
await Promise.all(
|
|
38
|
+
jsonlFiles.map(async (file) => {
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(join(subagentsDir, file), "utf-8");
|
|
41
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
try {
|
|
44
|
+
const msg = JSON.parse(line);
|
|
45
|
+
if (msg.type !== "assistant" || !msg.isSidechain) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const messageId = msg.message?.id;
|
|
49
|
+
if (!messageId) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const textBlocks = extractTextBlocks(msg.message?.content);
|
|
53
|
+
if (textBlocks.length > 0) {
|
|
54
|
+
map.set(messageId, textBlocks);
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
67
|
+
function mergeSidechainText(messages, sidechainMap) {
|
|
68
|
+
if (sidechainMap.size === 0) {
|
|
69
|
+
return messages;
|
|
70
|
+
}
|
|
71
|
+
return messages.map((message) => {
|
|
72
|
+
if (message.type !== "assistant" || !message.message?.id) {
|
|
73
|
+
return message;
|
|
74
|
+
}
|
|
75
|
+
const sidechainBlocks = sidechainMap.get(message.message.id);
|
|
76
|
+
if (!sidechainBlocks || sidechainBlocks.length === 0) {
|
|
77
|
+
return message;
|
|
78
|
+
}
|
|
79
|
+
const existingText = extractTextBlocks(message.message.content);
|
|
80
|
+
if (existingText.length > 0) {
|
|
81
|
+
return message;
|
|
82
|
+
}
|
|
83
|
+
const existingBlocks = normalizeContentBlocks(message.message.content);
|
|
84
|
+
message.message.content = existingBlocks.length > 0 ? [...existingBlocks, ...sidechainBlocks] : sidechainBlocks;
|
|
85
|
+
return message;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
18
88
|
var claudeDir = join(homedir(), ".claude");
|
|
19
89
|
var projectsDir = join(claudeDir, "projects");
|
|
20
90
|
var fileIndex = /* @__PURE__ */ new Map();
|
|
@@ -151,8 +221,85 @@ async function findSessionFile(sessionId) {
|
|
|
151
221
|
}
|
|
152
222
|
return null;
|
|
153
223
|
}
|
|
224
|
+
async function getSessionModel(sessionId) {
|
|
225
|
+
const filePath = await findSessionFile(sessionId);
|
|
226
|
+
if (!filePath) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
230
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
231
|
+
try {
|
|
232
|
+
for await (const line of rl) {
|
|
233
|
+
if (!line.trim()) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const msg = JSON.parse(line);
|
|
238
|
+
if (msg.type !== "user" && msg.type !== "assistant") {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const model = msg.message?.model;
|
|
242
|
+
if (model) {
|
|
243
|
+
return model;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
rl.close();
|
|
251
|
+
stream.close();
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
154
255
|
async function loadStorage() {
|
|
155
256
|
await Promise.all([buildFileIndex(), loadHistoryCache()]);
|
|
257
|
+
await cleanupOrphanedHistoryEntries();
|
|
258
|
+
}
|
|
259
|
+
async function cleanupOrphanedHistoryEntries() {
|
|
260
|
+
const history = historyCache ?? await loadHistoryCache();
|
|
261
|
+
const orphanedIds = [];
|
|
262
|
+
for (const entry of history) {
|
|
263
|
+
if (entry.sessionId && !await findSessionFile(entry.sessionId)) {
|
|
264
|
+
orphanedIds.push(entry.sessionId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (orphanedIds.length === 0) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.log(
|
|
271
|
+
`[cleanup] Found ${orphanedIds.length} orphaned history entries, cleaning up...`
|
|
272
|
+
);
|
|
273
|
+
const historyPath = join(claudeDir, "history.jsonl");
|
|
274
|
+
const orphanedSet = new Set(orphanedIds);
|
|
275
|
+
try {
|
|
276
|
+
const content = await readFile(historyPath, "utf-8");
|
|
277
|
+
const lines = content.split("\n");
|
|
278
|
+
const filtered = [];
|
|
279
|
+
for (const line of lines) {
|
|
280
|
+
if (!line.trim()) continue;
|
|
281
|
+
try {
|
|
282
|
+
const entry = JSON.parse(line);
|
|
283
|
+
if (entry.sessionId && orphanedSet.has(entry.sessionId)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
filtered.push(line);
|
|
289
|
+
}
|
|
290
|
+
await writeFile(historyPath, filtered.join("\n") + "\n", "utf-8");
|
|
291
|
+
if (historyCache) {
|
|
292
|
+
historyCache = historyCache.filter(
|
|
293
|
+
(entry) => !(entry.sessionId && orphanedSet.has(entry.sessionId))
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
for (const id of orphanedIds) {
|
|
297
|
+
fileIndex.delete(id);
|
|
298
|
+
}
|
|
299
|
+
console.log(`[cleanup] Removed ${orphanedIds.length} orphaned entries`);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error("[cleanup] Failed to clean up orphaned entries:", err);
|
|
302
|
+
}
|
|
156
303
|
}
|
|
157
304
|
async function getSessions() {
|
|
158
305
|
return dedupe("getSessions", async () => {
|
|
@@ -190,6 +337,74 @@ async function getProjects() {
|
|
|
190
337
|
}
|
|
191
338
|
return [...projects].sort();
|
|
192
339
|
}
|
|
340
|
+
async function getSessionsMetadata(ids) {
|
|
341
|
+
const unique = Array.from(new Set(ids.filter(Boolean)));
|
|
342
|
+
return Promise.all(
|
|
343
|
+
unique.map(async (id) => ({
|
|
344
|
+
id,
|
|
345
|
+
model: await getSessionModel(id)
|
|
346
|
+
}))
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
async function deleteSession(sessionId) {
|
|
350
|
+
console.log(`[deleteSession] Attempting to delete: ${sessionId}`);
|
|
351
|
+
const filePath = await findSessionFile(sessionId);
|
|
352
|
+
let fileDeleted = false;
|
|
353
|
+
if (filePath) {
|
|
354
|
+
console.log(`[deleteSession] Found file: ${filePath}`);
|
|
355
|
+
try {
|
|
356
|
+
await unlink(filePath);
|
|
357
|
+
console.log(`[deleteSession] Successfully deleted: ${filePath}`);
|
|
358
|
+
fileDeleted = true;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (err.code === "ENOENT") {
|
|
361
|
+
console.log(`[deleteSession] File already deleted: ${filePath}`);
|
|
362
|
+
fileDeleted = true;
|
|
363
|
+
} else {
|
|
364
|
+
console.error(`[deleteSession] Failed to delete ${filePath}:`, err);
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
console.log(`[deleteSession] No file found for session: ${sessionId}`);
|
|
370
|
+
}
|
|
371
|
+
fileIndex.delete(sessionId);
|
|
372
|
+
const historyPath = join(claudeDir, "history.jsonl");
|
|
373
|
+
let historyChanged = false;
|
|
374
|
+
try {
|
|
375
|
+
const content = await readFile(historyPath, "utf-8");
|
|
376
|
+
const lines = content.split("\n");
|
|
377
|
+
const filtered = [];
|
|
378
|
+
let changed = false;
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
if (!line.trim()) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const entry = JSON.parse(line);
|
|
385
|
+
if (entry.sessionId && entry.sessionId === sessionId) {
|
|
386
|
+
changed = true;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
}
|
|
391
|
+
filtered.push(line);
|
|
392
|
+
}
|
|
393
|
+
if (changed) {
|
|
394
|
+
await writeFile(historyPath, filtered.join("\n") + "\n", "utf-8");
|
|
395
|
+
console.log(`[deleteSession] Removed from history.jsonl: ${sessionId}`);
|
|
396
|
+
if (historyCache) {
|
|
397
|
+
historyCache = historyCache.filter(
|
|
398
|
+
(entry) => entry.sessionId !== sessionId
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
historyChanged = true;
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
console.error(`[deleteSession] Failed to update history.jsonl for ${sessionId}:`, err);
|
|
405
|
+
}
|
|
406
|
+
return fileDeleted || historyChanged;
|
|
407
|
+
}
|
|
193
408
|
async function getConversation(sessionId) {
|
|
194
409
|
return dedupe(`getConversation:${sessionId}`, async () => {
|
|
195
410
|
const filePath = await findSessionFile(sessionId);
|
|
@@ -197,6 +412,7 @@ async function getConversation(sessionId) {
|
|
|
197
412
|
return [];
|
|
198
413
|
}
|
|
199
414
|
const messages = [];
|
|
415
|
+
const sidechainMap = await loadSidechainTextByMessageId(filePath);
|
|
200
416
|
try {
|
|
201
417
|
const content = await readFile(filePath, "utf-8");
|
|
202
418
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
@@ -214,7 +430,7 @@ async function getConversation(sessionId) {
|
|
|
214
430
|
} catch (err) {
|
|
215
431
|
console.error("Error reading conversation:", err);
|
|
216
432
|
}
|
|
217
|
-
return messages;
|
|
433
|
+
return mergeSidechainText(messages, sidechainMap);
|
|
218
434
|
});
|
|
219
435
|
}
|
|
220
436
|
async function getConversationStream(sessionId, fromOffset = 0) {
|
|
@@ -223,6 +439,7 @@ async function getConversationStream(sessionId, fromOffset = 0) {
|
|
|
223
439
|
return { messages: [], nextOffset: 0 };
|
|
224
440
|
}
|
|
225
441
|
const messages = [];
|
|
442
|
+
const sidechainMap = await loadSidechainTextByMessageId(filePath);
|
|
226
443
|
let fileHandle;
|
|
227
444
|
try {
|
|
228
445
|
const fileStat = await stat(filePath);
|
|
@@ -258,7 +475,7 @@ async function getConversationStream(sessionId, fromOffset = 0) {
|
|
|
258
475
|
}
|
|
259
476
|
const actualOffset = fromOffset + bytesConsumed;
|
|
260
477
|
const nextOffset = actualOffset > fileSize ? fileSize : actualOffset;
|
|
261
|
-
return { messages, nextOffset };
|
|
478
|
+
return { messages: mergeSidechainText(messages, sidechainMap), nextOffset };
|
|
262
479
|
} catch (err) {
|
|
263
480
|
console.error("Error reading conversation stream:", err);
|
|
264
481
|
return { messages: [], nextOffset: fromOffset };
|
|
@@ -511,6 +728,49 @@ function createServer(options) {
|
|
|
511
728
|
const projects = await getProjects();
|
|
512
729
|
return c.json(projects);
|
|
513
730
|
});
|
|
731
|
+
app.post("/api/sessions/metadata", async (c) => {
|
|
732
|
+
let body;
|
|
733
|
+
try {
|
|
734
|
+
body = await c.req.json();
|
|
735
|
+
} catch {
|
|
736
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
737
|
+
}
|
|
738
|
+
if (!body || !Array.isArray(body.ids)) {
|
|
739
|
+
return c.json({ error: "Missing ids" }, 400);
|
|
740
|
+
}
|
|
741
|
+
const sessions = await getSessionsMetadata(body.ids);
|
|
742
|
+
return c.json({ sessions });
|
|
743
|
+
});
|
|
744
|
+
app.post("/api/sessions/delete", async (c) => {
|
|
745
|
+
let body;
|
|
746
|
+
try {
|
|
747
|
+
body = await c.req.json();
|
|
748
|
+
} catch {
|
|
749
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
750
|
+
}
|
|
751
|
+
if (!body || !Array.isArray(body.ids)) {
|
|
752
|
+
return c.json({ error: "Missing ids" }, 400);
|
|
753
|
+
}
|
|
754
|
+
console.log(`[/api/sessions/delete] Received request to delete ${body.ids.length} sessions:`, body.ids);
|
|
755
|
+
const deleted = [];
|
|
756
|
+
const failed = [];
|
|
757
|
+
for (const id of body.ids) {
|
|
758
|
+
if (typeof id !== "string" || !id) {
|
|
759
|
+
console.log(`[/api/sessions/delete] Skipping invalid id:`, id);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const ok = await deleteSession(id);
|
|
763
|
+
if (ok) {
|
|
764
|
+
deleted.push(id);
|
|
765
|
+
} else {
|
|
766
|
+
failed.push(id);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
console.log(`[/api/sessions/delete] Result: ${deleted.length} deleted, ${failed.length} failed`);
|
|
770
|
+
console.log(`[/api/sessions/delete] Deleted:`, deleted);
|
|
771
|
+
console.log(`[/api/sessions/delete] Failed:`, failed);
|
|
772
|
+
return c.json({ deleted, failed });
|
|
773
|
+
});
|
|
514
774
|
app.post("/api/search", async (c) => {
|
|
515
775
|
let body;
|
|
516
776
|
try {
|