@teddysc/claude-run 0.10.1 → 0.12.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
@@ -30,8 +30,26 @@ 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.12.0
40
+ - Rename sessions directly from the web UI
41
+ - Searchable project dropdown with fuzzy matching on name and path
42
+ - Session summary caching for improved performance
43
+
44
+ ### 0.11.0
45
+ - Display session metadata (model, start/end times) in conversation header
46
+ - Show full project path with ~ shorthand in header and sidebar tooltips
47
+ - Add "Hide Unknown model" filter to hide sessions without detected models
48
+ - Add "Delete Unknown" button to remove blank/orphaned sessions from disk
49
+ - Automatic cleanup of orphaned history entries on startup
50
+ - Add comprehensive logging for delete operations
51
+ - Fix URL state sync when unchecking checkboxes
52
+
35
53
  ### 0.10.0
36
54
  - Enhance message handling: add message ID to ConversationMessage
37
55
  - Normalize content blocks in message processing
@@ -78,12 +96,15 @@ The browser will open automatically at http://localhost:12001.
78
96
  - **Truncation options** - Limit long tool outputs by line count or character count
79
97
  - **Batch export** - Export multiple conversations at once
80
98
  - **Real-time streaming** - Watch conversations update live as Claude responds
81
- - **Filter by project** - Focus on specific projects
99
+ - **Filter by project** - Searchable dropdown with fuzzy matching on project name and path
82
100
  - **Resume sessions** - Copy the resume command to continue any conversation in your terminal
83
101
  - **Copy messages** - Click the copy button on any message to copy its text content
102
+ - **Session metadata** - See model and start/end times in the conversation header
103
+ - **Rename sessions** - Click the conversation title to rename a session
84
104
  - **Collapsible sidebar** - Maximize your viewing area
85
105
  - **Dark mode** - Easy on the eyes
86
106
  - **Clean UI** - Familiar chat interface with collapsible tool calls
107
+ - **Filter & cleanup** - Hide Unknown-model sessions and delete blank sessions from disk
87
108
 
88
109
  ## Usage
89
110
 
package/dist/index.js CHANGED
@@ -11,7 +11,8 @@ 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";
@@ -87,6 +88,7 @@ function mergeSidechainText(messages, sidechainMap) {
87
88
  var claudeDir = join(homedir(), ".claude");
88
89
  var projectsDir = join(claudeDir, "projects");
89
90
  var fileIndex = /* @__PURE__ */ new Map();
91
+ var summaryCache = /* @__PURE__ */ new Map();
90
92
  var historyCache = null;
91
93
  var pendingRequests = /* @__PURE__ */ new Map();
92
94
  function initStorage(dir) {
@@ -101,6 +103,58 @@ function invalidateHistoryCache() {
101
103
  }
102
104
  function addToFileIndex(sessionId, filePath) {
103
105
  fileIndex.set(sessionId, filePath);
106
+ summaryCache.delete(sessionId);
107
+ }
108
+ async function readLastNonEmptyLine(filePath) {
109
+ try {
110
+ const fileStat = await stat(filePath);
111
+ const fileSize = fileStat.size;
112
+ if (fileSize === 0) {
113
+ return null;
114
+ }
115
+ const chunkSize = Math.min(8192, fileSize);
116
+ const start = Math.max(0, fileSize - chunkSize);
117
+ const fileHandle = await open(filePath, "r");
118
+ try {
119
+ const buffer = Buffer.alloc(chunkSize);
120
+ await fileHandle.read(buffer, 0, chunkSize, start);
121
+ const text = buffer.toString("utf-8");
122
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
123
+ if (lines.length === 0) {
124
+ return null;
125
+ }
126
+ return lines[lines.length - 1] ?? null;
127
+ } finally {
128
+ await fileHandle.close();
129
+ }
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ async function getSessionSummary(sessionId) {
135
+ if (summaryCache.has(sessionId)) {
136
+ return summaryCache.get(sessionId) ?? null;
137
+ }
138
+ const filePath = await findSessionFile(sessionId);
139
+ if (!filePath) {
140
+ summaryCache.set(sessionId, null);
141
+ return null;
142
+ }
143
+ const lastLine = await readLastNonEmptyLine(filePath);
144
+ if (!lastLine) {
145
+ summaryCache.set(sessionId, null);
146
+ return null;
147
+ }
148
+ try {
149
+ const msg = JSON.parse(lastLine);
150
+ if (msg.type === "summary" && typeof msg.summary === "string" && msg.summary.trim().length > 0) {
151
+ summaryCache.set(sessionId, msg.summary);
152
+ return msg.summary;
153
+ }
154
+ } catch {
155
+ }
156
+ summaryCache.set(sessionId, null);
157
+ return null;
104
158
  }
105
159
  function encodeProjectPath(path) {
106
160
  return path.replace(/[/.]/g, "-");
@@ -220,8 +274,85 @@ async function findSessionFile(sessionId) {
220
274
  }
221
275
  return null;
222
276
  }
277
+ async function getSessionModel(sessionId) {
278
+ const filePath = await findSessionFile(sessionId);
279
+ if (!filePath) {
280
+ return null;
281
+ }
282
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
283
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
284
+ try {
285
+ for await (const line of rl) {
286
+ if (!line.trim()) {
287
+ continue;
288
+ }
289
+ try {
290
+ const msg = JSON.parse(line);
291
+ if (msg.type !== "user" && msg.type !== "assistant") {
292
+ continue;
293
+ }
294
+ const model = msg.message?.model;
295
+ if (model) {
296
+ return model;
297
+ }
298
+ } catch {
299
+ continue;
300
+ }
301
+ }
302
+ } finally {
303
+ rl.close();
304
+ stream.close();
305
+ }
306
+ return null;
307
+ }
223
308
  async function loadStorage() {
224
309
  await Promise.all([buildFileIndex(), loadHistoryCache()]);
310
+ await cleanupOrphanedHistoryEntries();
311
+ }
312
+ async function cleanupOrphanedHistoryEntries() {
313
+ const history = historyCache ?? await loadHistoryCache();
314
+ const orphanedIds = [];
315
+ for (const entry of history) {
316
+ if (entry.sessionId && !await findSessionFile(entry.sessionId)) {
317
+ orphanedIds.push(entry.sessionId);
318
+ }
319
+ }
320
+ if (orphanedIds.length === 0) {
321
+ return;
322
+ }
323
+ console.log(
324
+ `[cleanup] Found ${orphanedIds.length} orphaned history entries, cleaning up...`
325
+ );
326
+ const historyPath = join(claudeDir, "history.jsonl");
327
+ const orphanedSet = new Set(orphanedIds);
328
+ try {
329
+ const content = await readFile(historyPath, "utf-8");
330
+ const lines = content.split("\n");
331
+ const filtered = [];
332
+ for (const line of lines) {
333
+ if (!line.trim()) continue;
334
+ try {
335
+ const entry = JSON.parse(line);
336
+ if (entry.sessionId && orphanedSet.has(entry.sessionId)) {
337
+ continue;
338
+ }
339
+ } catch {
340
+ }
341
+ filtered.push(line);
342
+ }
343
+ await writeFile(historyPath, filtered.join("\n") + "\n", "utf-8");
344
+ if (historyCache) {
345
+ historyCache = historyCache.filter(
346
+ (entry) => !(entry.sessionId && orphanedSet.has(entry.sessionId))
347
+ );
348
+ }
349
+ for (const id of orphanedIds) {
350
+ fileIndex.delete(id);
351
+ }
352
+ console.log(`[cleanup] Removed ${orphanedIds.length} orphaned entries`);
353
+ } catch (err) {
354
+ console.error("[cleanup] Failed to clean up orphaned entries:", err);
355
+ }
225
356
  }
226
357
  async function getSessions() {
227
358
  return dedupe("getSessions", async () => {
@@ -238,9 +369,10 @@ async function getSessions() {
238
369
  continue;
239
370
  }
240
371
  seenIds.add(sessionId);
372
+ const summary = await getSessionSummary(sessionId);
241
373
  sessions.push({
242
374
  id: sessionId,
243
- display: entry.display,
375
+ display: summary ?? entry.display,
244
376
  timestamp: entry.timestamp,
245
377
  project: entry.project,
246
378
  projectName: getProjectName(entry.project)
@@ -259,6 +391,156 @@ async function getProjects() {
259
391
  }
260
392
  return [...projects].sort();
261
393
  }
394
+ async function getSessionsMetadata(ids) {
395
+ const unique = Array.from(new Set(ids.filter(Boolean)));
396
+ return Promise.all(
397
+ unique.map(async (id) => ({
398
+ id,
399
+ model: await getSessionModel(id)
400
+ }))
401
+ );
402
+ }
403
+ async function deleteSession(sessionId) {
404
+ console.log(`[deleteSession] Attempting to delete: ${sessionId}`);
405
+ const filePath = await findSessionFile(sessionId);
406
+ let fileDeleted = false;
407
+ if (filePath) {
408
+ console.log(`[deleteSession] Found file: ${filePath}`);
409
+ try {
410
+ await unlink(filePath);
411
+ console.log(`[deleteSession] Successfully deleted: ${filePath}`);
412
+ fileDeleted = true;
413
+ } catch (err) {
414
+ if (err.code === "ENOENT") {
415
+ console.log(`[deleteSession] File already deleted: ${filePath}`);
416
+ fileDeleted = true;
417
+ } else {
418
+ console.error(`[deleteSession] Failed to delete ${filePath}:`, err);
419
+ return false;
420
+ }
421
+ }
422
+ } else {
423
+ console.log(`[deleteSession] No file found for session: ${sessionId}`);
424
+ }
425
+ fileIndex.delete(sessionId);
426
+ summaryCache.delete(sessionId);
427
+ const historyPath = join(claudeDir, "history.jsonl");
428
+ let historyChanged = false;
429
+ try {
430
+ const content = await readFile(historyPath, "utf-8");
431
+ const lines = content.split("\n");
432
+ const filtered = [];
433
+ let changed = false;
434
+ for (const line of lines) {
435
+ if (!line.trim()) {
436
+ continue;
437
+ }
438
+ try {
439
+ const entry = JSON.parse(line);
440
+ if (entry.sessionId && entry.sessionId === sessionId) {
441
+ changed = true;
442
+ continue;
443
+ }
444
+ } catch {
445
+ }
446
+ filtered.push(line);
447
+ }
448
+ if (changed) {
449
+ await writeFile(historyPath, filtered.join("\n") + "\n", "utf-8");
450
+ console.log(`[deleteSession] Removed from history.jsonl: ${sessionId}`);
451
+ if (historyCache) {
452
+ historyCache = historyCache.filter(
453
+ (entry) => entry.sessionId !== sessionId
454
+ );
455
+ }
456
+ historyChanged = true;
457
+ }
458
+ } catch (err) {
459
+ console.error(`[deleteSession] Failed to update history.jsonl for ${sessionId}:`, err);
460
+ }
461
+ return fileDeleted || historyChanged;
462
+ }
463
+ async function getSessionFilePath(sessionId) {
464
+ return findSessionFile(sessionId);
465
+ }
466
+ async function renameSessionDisplay(params) {
467
+ const { sessionId, display, project, timestamp } = params;
468
+ const historyPath = join(claudeDir, "history.jsonl");
469
+ let changed = false;
470
+ let summaryChanged = false;
471
+ try {
472
+ const content = await readFile(historyPath, "utf-8");
473
+ const lines = content.split("\n");
474
+ const updated = [];
475
+ for (const line of lines) {
476
+ if (!line.trim()) {
477
+ continue;
478
+ }
479
+ try {
480
+ const entry = JSON.parse(line);
481
+ const matchesId = entry.sessionId && entry.sessionId === sessionId;
482
+ const matchesFallback = !entry.sessionId && project && typeof timestamp === "number" && entry.project === project && entry.timestamp === timestamp;
483
+ if (matchesId || matchesFallback) {
484
+ if (entry.display !== display) {
485
+ entry.display = display;
486
+ changed = true;
487
+ }
488
+ }
489
+ updated.push(JSON.stringify(entry));
490
+ } catch {
491
+ updated.push(line);
492
+ }
493
+ }
494
+ if (changed) {
495
+ await writeFile(historyPath, updated.join("\n") + "\n", "utf-8");
496
+ if (historyCache) {
497
+ historyCache = historyCache.map((entry) => {
498
+ const matchesId = entry.sessionId && entry.sessionId === sessionId;
499
+ const matchesFallback = !entry.sessionId && project && typeof timestamp === "number" && entry.project === project && entry.timestamp === timestamp;
500
+ if (matchesId || matchesFallback) {
501
+ return { ...entry, display };
502
+ }
503
+ return entry;
504
+ });
505
+ }
506
+ }
507
+ } catch (err) {
508
+ console.error(`[renameSessionDisplay] Failed to update history.jsonl for ${sessionId}:`, err);
509
+ return false;
510
+ }
511
+ const sessionFile = await findSessionFile(sessionId);
512
+ if (sessionFile) {
513
+ try {
514
+ const content = await readFile(sessionFile, "utf-8");
515
+ const lines = content.split("\n");
516
+ let summaryUpdated = false;
517
+ const updatedLines = lines.map((line) => {
518
+ if (!line.trim()) {
519
+ return line;
520
+ }
521
+ try {
522
+ const msg = JSON.parse(line);
523
+ if (msg.type === "summary") {
524
+ msg.summary = display;
525
+ summaryUpdated = true;
526
+ return JSON.stringify(msg);
527
+ }
528
+ } catch {
529
+ }
530
+ return line;
531
+ });
532
+ if (!summaryUpdated) {
533
+ updatedLines.push(JSON.stringify({ type: "summary", summary: display }));
534
+ }
535
+ await writeFile(sessionFile, updatedLines.join("\n") + "\n", "utf-8");
536
+ summaryCache.set(sessionId, display);
537
+ summaryChanged = true;
538
+ } catch (err) {
539
+ console.error(`[renameSessionDisplay] Failed to update session file for ${sessionId}:`, err);
540
+ }
541
+ }
542
+ return changed || summaryChanged;
543
+ }
262
544
  async function getConversation(sessionId) {
263
545
  return dedupe(`getConversation:${sessionId}`, async () => {
264
546
  const filePath = await findSessionFile(sessionId);
@@ -582,6 +864,80 @@ function createServer(options) {
582
864
  const projects = await getProjects();
583
865
  return c.json(projects);
584
866
  });
867
+ app.post("/api/sessions/metadata", async (c) => {
868
+ let body;
869
+ try {
870
+ body = await c.req.json();
871
+ } catch {
872
+ return c.json({ error: "Invalid JSON body" }, 400);
873
+ }
874
+ if (!body || !Array.isArray(body.ids)) {
875
+ return c.json({ error: "Missing ids" }, 400);
876
+ }
877
+ const sessions = await getSessionsMetadata(body.ids);
878
+ return c.json({ sessions });
879
+ });
880
+ app.post("/api/sessions/delete", async (c) => {
881
+ let body;
882
+ try {
883
+ body = await c.req.json();
884
+ } catch {
885
+ return c.json({ error: "Invalid JSON body" }, 400);
886
+ }
887
+ if (!body || !Array.isArray(body.ids)) {
888
+ return c.json({ error: "Missing ids" }, 400);
889
+ }
890
+ console.log(`[/api/sessions/delete] Received request to delete ${body.ids.length} sessions:`, body.ids);
891
+ const deleted = [];
892
+ const failed = [];
893
+ for (const id of body.ids) {
894
+ if (typeof id !== "string" || !id) {
895
+ console.log(`[/api/sessions/delete] Skipping invalid id:`, id);
896
+ continue;
897
+ }
898
+ const ok = await deleteSession(id);
899
+ if (ok) {
900
+ deleted.push(id);
901
+ } else {
902
+ failed.push(id);
903
+ }
904
+ }
905
+ console.log(`[/api/sessions/delete] Result: ${deleted.length} deleted, ${failed.length} failed`);
906
+ console.log(`[/api/sessions/delete] Deleted:`, deleted);
907
+ console.log(`[/api/sessions/delete] Failed:`, failed);
908
+ return c.json({ deleted, failed });
909
+ });
910
+ app.post("/api/sessions/rename", async (c) => {
911
+ let body;
912
+ try {
913
+ body = await c.req.json();
914
+ } catch {
915
+ return c.json({ error: "Invalid JSON body" }, 400);
916
+ }
917
+ const id = body?.id?.trim();
918
+ const display = body?.display?.trim();
919
+ if (!id || !display) {
920
+ return c.json({ error: "Missing id/display" }, 400);
921
+ }
922
+ const ok = await renameSessionDisplay({
923
+ sessionId: id,
924
+ display,
925
+ project: body.project,
926
+ timestamp: body.timestamp
927
+ });
928
+ if (!ok) {
929
+ return c.json({ error: "Session not found" }, 404);
930
+ }
931
+ return c.json({ ok: true });
932
+ });
933
+ app.get("/api/sessions/:id/path", async (c) => {
934
+ const sessionId = c.req.param("id");
935
+ if (!sessionId) {
936
+ return c.json({ error: "Missing session id" }, 400);
937
+ }
938
+ const path = await getSessionFilePath(sessionId);
939
+ return c.json({ path });
940
+ });
585
941
  app.post("/api/search", async (c) => {
586
942
  let body;
587
943
  try {