@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 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 {