claude-live 2.0.8 → 3.0.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.
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=Oxanium:wght@400;600;700&display=swap";*{margin:0;padding:0;box-sizing:border-box}html,body,#root{width:100%;height:100%;overflow:hidden;background:#050507}#root{position:relative}#root:before{content:"";position:fixed;top:0;right:0;bottom:0;left:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");background-size:150px;opacity:.035;pointer-events:none;z-index:10}.hud-bar{position:fixed;top:14px;right:14px;z-index:50;display:flex;gap:8px;align-items:center;padding:5px 10px;border-radius:20px;background:#06070e8c;border:1px solid rgba(255,255,255,.06);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 4px 20px #0006;pointer-events:auto;font-family:IBM Plex Mono,monospace;animation:hud-fadein .5s ease-out}@keyframes hud-fadein{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}.hud-chip{font-size:11px;color:#b0b4c4;font-variant-numeric:tabular-nums;display:flex;align-items:center;gap:3px;letter-spacing:.02em}.hud-chip-label{font-size:8px;color:#555;text-transform:uppercase;letter-spacing:.08em}.hud-panel{min-width:210px;padding:14px 14px 12px;border-radius:12px;background:linear-gradient(140deg,#10121cb8,#08090ed1),radial-gradient(120% 140% at 0% 0%,#5aa0ff24,#0000);border:1px solid rgba(255,255,255,.08);box-shadow:0 24px 60px #0000008c,inset 0 0 0 1px #ffffff05;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);font-family:IBM Plex Mono,monospace;animation:hud-fadein .6s ease-out}@keyframes hud-fadein{0%{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}.hud-title{display:flex;align-items:center;gap:6px;font-family:Oxanium,sans-serif;font-size:16px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:#e6e8f2;line-height:1}.hud-title-mark{font-size:12px;color:#7ee7d8;animation:mark-spin 12s linear infinite}@keyframes mark-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.hud-title-accent{color:#7ee7d8}.hud-divider{height:1px;margin:10px 0 8px;background:linear-gradient(90deg,#7ee7d899,#fff0);opacity:.5;position:relative;overflow:hidden}.hud-divider:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,transparent,rgba(126,231,216,.8),transparent);animation:divider-sweep 4s ease-in-out infinite}@keyframes divider-sweep{0%,to{transform:translate(-100%);opacity:0}50%{transform:translate(100%);opacity:1}}.hud-stats{display:grid;gap:6px}.hud-stat{display:grid;grid-template-columns:66px 1fr;align-items:center;column-gap:8px}.hud-label{font-size:9px;color:#7b7f8f;text-transform:uppercase;letter-spacing:1.4px}.hud-value{font-size:12px;color:#c8cbd7;display:flex;align-items:center;gap:6px;letter-spacing:.2px}.hud-value--mono{font-variant-numeric:tabular-nums}.hud-dot{width:7px;height:7px;border-radius:50%;transition:background .3s,box-shadow .3s}.hud-dot--on{background:#7ee7d8;box-shadow:0 0 8px #7ee7d899;animation:dot-breathe 2s ease-in-out infinite}.hud-dot--err{background:#f87171;box-shadow:0 0 8px #f8717180;animation:dot-breathe 1s ease-in-out infinite}.hud-dot--warn{background:#fbbf24;box-shadow:0 0 8px #fbbf2466;animation:dot-breathe 1.5s ease-in-out infinite}@keyframes dot-breathe{0%,to{opacity:1;transform:scale(1)}50%{opacity:.6;transform:scale(.85)}}.hud-tool{font-weight:600;transition:color .2s ease}.tooltip{position:fixed;z-index:30;background:#08080ef0;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:10px 14px;pointer-events:none;transition:opacity .1s ease;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);min-width:160px;box-shadow:0 8px 32px #000000b3}.tooltip-label{font-family:IBM Plex Mono,monospace;font-size:12px;color:#fff;font-weight:500;margin-bottom:2px;white-space:nowrap;max-width:260px;overflow:hidden;text-overflow:ellipsis}.tooltip-type{font-family:IBM Plex Mono,monospace;font-size:9px;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:7px}.tooltip-meta{font-family:IBM Plex Mono,monospace;font-size:10px;color:#aaa;margin-bottom:3px}.tooltip-count{font-family:IBM Plex Mono,monospace;font-size:9px;color:#666}.sidebar{position:fixed;right:0;top:0;height:100%;width:280px;background:#06060bf5;border-left:1px solid rgba(255,255,255,.08);z-index:25;transform:translate(100%);transition:transform .25s cubic-bezier(.16,1,.3,1);-webkit-backdrop-filter:blur(24px);backdrop-filter:blur(24px);display:flex;flex-direction:column;overflow:hidden}.sidebar--open{transform:translate(0)}.sidebar-header{padding:24px 20px 16px;border-bottom:1px solid rgba(255,255,255,.06);position:relative}.sidebar-close{position:absolute;top:20px;right:16px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;color:#666;cursor:pointer;font-size:18px;border-radius:4px;transition:color .15s,background .15s;pointer-events:all;line-height:1}.sidebar-close:hover{color:#fff;background:#ffffff14}.sidebar-title{font-family:Fira Code,monospace;font-size:13px;color:#fff;font-weight:500;margin-bottom:4px;padding-right:32px;word-break:break-all}.sidebar-type{font-family:Space Mono,monospace;font-size:9px;color:#666;text-transform:uppercase;letter-spacing:1px}.sidebar-section{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.05)}.sidebar-section-label{font-family:Space Mono,monospace;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}.sidebar-action{font-family:Fira Code,monospace;font-size:15px;font-weight:500;margin-bottom:3px}.sidebar-time{font-family:Space Mono,monospace;font-size:10px;color:#777}.sidebar-count{font-family:Space Mono,monospace;font-size:24px;color:#ccc;font-weight:700}.sidebar-session{font-family:Fira Code,monospace;font-size:11px;color:#4ade80}.perm-badge{display:inline-flex;align-items:center;gap:5px;font-family:Space Mono,monospace;font-size:9px;color:#fbbf24;background:#fbbf241a;border:1px solid rgba(251,191,36,.3);border-radius:3px;padding:2px 6px;margin-top:6px}.perm-dot{width:5px;height:5px;border-radius:50%;background:#fbbf24;animation:perm-pulse 1s ease-in-out infinite}@keyframes perm-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.7)}}.elog{position:fixed;bottom:16px;left:16px;z-index:20;display:flex;flex-direction:column;gap:6px;pointer-events:none;max-width:320px;font-family:IBM Plex Mono,monospace}.elog>*{pointer-events:all}.elog-live{display:flex;flex-direction:column;gap:3px}.elog-row{display:flex;align-items:center;gap:5px;padding:2px 8px 2px 5px;border-radius:4px;background:#06070e40;border:1px solid rgba(255,255,255,.03);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);position:relative;overflow:hidden;animation:elog-slide .35s cubic-bezier(.16,1,.3,1);font-size:7px}.elog-row:after{content:"";position:absolute;top:0;bottom:0;left:-70%;width:70%;background:linear-gradient(90deg,transparent,var(--entry-color, #888) 60%,transparent);opacity:.15;animation:elog-sweep .5s ease-out forwards;pointer-events:none}@keyframes elog-slide{0%{opacity:0;transform:translate(-12px) scale(.97)}60%{opacity:1;transform:translate(2px) scale(1)}to{opacity:1;transform:translate(0) scale(1)}}@keyframes elog-sweep{0%{left:-70%;opacity:.15}to{left:140%;opacity:0}}.elog-row--static{animation:none;background:#06070e4d;-webkit-backdrop-filter:none;backdrop-filter:none}.elog-row--static:after{display:none}.elog-row--static .elog-dot{animation:none}.elog-dot{width:4px;height:4px;border-radius:50%;flex-shrink:0;animation:elog-dot-pop .4s cubic-bezier(.34,1.56,.64,1)}@keyframes elog-dot-pop{0%{transform:scale(0)}70%{transform:scale(1.6)}to{transform:scale(1)}}.elog-tool{font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;min-width:28px;flex-shrink:0}.elog-file{font-size:7px;color:#666;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100px}.elog-id{font-size:7px;color:#3a3a4a;margin-left:auto;flex-shrink:0;letter-spacing:.02em;opacity:.6}.elog-hist-btn{font-family:IBM Plex Mono,monospace;font-size:8px;color:#444;background:none;border:none;cursor:pointer;text-align:left;padding:2px 0 0 6px;letter-spacing:.06em;transition:color .15s}.elog-hist-btn:hover{color:#888}.elog-hist{position:fixed;left:16px;top:16px;bottom:16px;width:320px;border-radius:10px;background:#06070ee0;border:1px solid rgba(255,255,255,.07);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:0 8px 32px #00000080;overflow:hidden;display:flex;flex-direction:column;animation:hud-fadein .3s ease-out;z-index:30}.elog-hist-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.06)}.elog-hist-title{font-size:9px;color:#666;text-transform:uppercase;letter-spacing:.1em}.elog-hist-count{font-size:9px;color:#7ee7d8;background:#7ee7d81a;padding:1px 6px;border-radius:8px;letter-spacing:.04em}.elog-hist-scroll{overflow-y:auto;padding:6px;display:flex;flex-direction:column;gap:2px;flex:1}.elog-hist-scroll::-webkit-scrollbar{width:4px}.elog-hist-scroll::-webkit-scrollbar-track{background:transparent}.elog-hist-scroll::-webkit-scrollbar-thumb{background:#ffffff14;border-radius:2px}.elog-hist-scroll::-webkit-scrollbar-thumb:hover{background:#ffffff26}.help-btn{background:none;border:1px solid rgba(255,255,255,.1);border-radius:4px;color:#444;font-family:Space Mono,monospace;font-size:9px;padding:3px 8px;cursor:pointer;width:fit-content;transition:color .15s,border-color .15s;letter-spacing:.05em;text-transform:uppercase}.help-btn:hover{color:#999;border-color:#ffffff40}.hud-ctrl-sep{width:1px;height:16px;background:#ffffff14;margin:0 2px}.hud-ctrl-btn{width:30px;height:30px;background:transparent;border:1px solid transparent;border-radius:6px;color:#666;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;transition:color .2s,background .2s,border-color .2s;padding:0}.hud-ctrl-btn svg{width:15px;height:15px;stroke:currentColor}.hud-ctrl-btn:hover{color:#c8cbd7;background:#ffffff0f;border-color:#ffffff1a}.hud-ctrl-btn:active{background:#ffffff1a;transform:scale(.95)}.hud-button{width:32px;height:32px;background:#ffffff14;border:1px solid rgba(255,255,255,.12);border-radius:4px;color:#666;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;transition:color .2s}.hud-button:hover{color:#aaa}.help-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:60;background:#0000008c;display:flex;align-items:center;justify-content:center;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.help-panel{background:#08080efa;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:20px 24px;min-width:200px;box-shadow:0 16px 48px #000c}.help-panel-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.help-panel-title{font-family:Space Mono,monospace;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1.5px}.legend-title{font-size:8px;color:#444;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px}.legend-items{display:flex;flex-direction:column;gap:5px}.legend-item{display:flex;align-items:center;gap:8px}.legend-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.legend-badge{width:14px;height:14px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;color:#000;flex-shrink:0}.legend-name{font-size:9px;color:#888}.legend-key{font-size:8px;color:#555;margin-left:auto;padding-left:8px}.legend-perm{display:flex;align-items:center;gap:8px;margin-top:4px;padding-top:6px;border-top:1px solid rgba(255,255,255,.04)}.legend-perm-ring{width:14px;height:14px;border-radius:50%;border:2px dashed #fbbf24;flex-shrink:0}.legend-perm-name{font-size:9px;color:#fbbf24}.debug-toggle{position:fixed;bottom:24px;right:24px;z-index:40;background:#fbbf241f;border:1px solid rgba(251,191,36,.4);border-radius:5px;color:#fbbf24e6;font-family:Space Mono,monospace;font-size:10px;font-weight:700;letter-spacing:.15em;padding:6px 12px;cursor:pointer;transition:color .15s,background .15s,border-color .15s}.debug-toggle:hover{background:#fbbf2438;border-color:#fbbf24b3;color:#fbbf24}.debug-panel{position:fixed;bottom:56px;right:24px;width:260px;background:#06060cf7;border:1px solid rgba(255,255,255,.1);border-radius:8px;z-index:40;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);box-shadow:0 16px 48px #000c;transform:translateY(8px) scale(.97);opacity:0;pointer-events:none;transition:opacity .18s ease,transform .18s ease}.debug-panel--open{opacity:1;transform:translateY(0) scale(1);pointer-events:all}.debug-panel-header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 10px;border-bottom:1px solid rgba(255,255,255,.07)}.debug-panel-title{font-family:Space Mono,monospace;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1.5px}.debug-close{background:none;border:none;color:#555;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;transition:color .15s}.debug-close:hover{color:#ccc}.debug-section{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.05)}.debug-section:last-child{border-bottom:none}.debug-section-label{font-family:Space Mono,monospace;font-size:8px;color:#444;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px}.debug-session-row{display:flex;gap:6px}.debug-select{flex:1;background:#ffffff0d;border:1px solid rgba(255,255,255,.1);border-radius:4px;color:#aaa;font-family:Fira Code,monospace;font-size:10px;padding:4px 7px;outline:none;cursor:pointer}.debug-new-btn{background:#ffffff0f;border:1px solid rgba(255,255,255,.12);border-radius:4px;color:#888;font-family:Space Mono,monospace;font-size:9px;padding:4px 8px;cursor:pointer;white-space:nowrap;transition:color .15s,background .15s}.debug-new-btn:hover{color:#ccc;background:#ffffff1a}.debug-tool-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.debug-tool-btn{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:4px;color:var(--tool-color, #888);font-family:Space Mono,monospace;font-size:9px;padding:6px 4px;cursor:pointer;transition:background .15s,border-color .15s,opacity .15s;text-align:center}.debug-tool-btn:hover{background:color-mix(in srgb,var(--tool-color, #888) 15%,transparent);border-color:color-mix(in srgb,var(--tool-color, #888) 40%,transparent)}.debug-tool-btn:active{opacity:.6}.perm-notifications{position:fixed;left:24px;top:130px;display:flex;flex-direction:column;gap:6px;z-index:100;pointer-events:none;max-width:280px}.perm-notifications-title{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:#fbbf24;text-align:left;margin-bottom:2px;opacity:.7}.perm-notification-item{display:flex;align-items:flex-start;gap:10px;background:#fbbf240f;border:1px solid rgba(251,191,36,.25);border-radius:3px;padding:8px 14px;animation:perm-fadein .3s ease}@keyframes perm-fadein{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}.perm-notification-dot{width:6px;height:6px;border-radius:50%;background:#fbbf24;margin-top:3px;flex-shrink:0;animation:perm-pulse 1s ease-in-out infinite}.perm-notification-body{display:flex;flex-direction:column;gap:2px}.perm-notification-session{font-size:9px;letter-spacing:.15em;color:#fbbf2499;text-transform:uppercase}.perm-notification-msg{font-size:11px;color:#fbbf24e6;max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.panel-overlay{position:fixed;top:24px;right:24px;max-width:400px;max-height:calc(100vh - 48px);background:#020209f2;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px;overflow-y:auto;z-index:100;font-size:12px;line-height:1.6;color:#ccc}.panel-close,.panel-close-btn{position:absolute;top:8px;right:8px;background:none;border:none;color:#666;cursor:pointer;font-size:16px;padding:0;width:24px;height:24px}.panel-close:hover,.panel-close-btn:hover{color:#aaa}.panel-overlay h3{margin:0 0 12px;font-size:14px;font-weight:600;color:#fff}.panel-overlay h4{margin:8px 0 6px;font-size:12px;font-weight:600;color:#aaa;text-transform:uppercase;letter-spacing:.05em}.panel-section{margin-bottom:16px}.panel-section:last-child{margin-bottom:0}.node-types-grid{display:flex;flex-direction:column;gap:8px}.node-type-item{display:flex;align-items:center;gap:8px}.node-type-badge{width:28px;height:28px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#fff;flex-shrink:0}.node-type-info{display:flex;flex-direction:column;gap:2px}.node-type-name{font-size:12px;font-weight:500;color:#fff}.node-type-description{font-size:10px;color:#888}.animations-list{display:flex;flex-direction:column;gap:10px}.animation-item{font-size:11px}.animation-name{font-weight:500;color:#fff;margin-bottom:2px}.animation-description{font-size:10px;color:#888;padding-left:8px;border-left:2px solid rgba(255,255,255,.1)}
@@ -11,8 +11,8 @@
11
11
  body { background: #080808; overflow: hidden; }
12
12
  #root { width: 100vw; height: 100vh; }
13
13
  </style>
14
- <script type="module" crossorigin src="/assets/index-BGs_09Jl.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DjcKbX6b.css">
14
+ <script type="module" crossorigin src="/assets/index-D3vltDuF.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-TkH4paIm.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-live",
3
- "version": "2.0.8",
3
+ "version": "3.0.0",
4
4
  "description": "Realtime Claude Code activity visualizer",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/marisancans/claude-live",
@@ -12,30 +12,36 @@
12
12
  "start": "node server/index.js",
13
13
  "dev": "cd client && vite dev",
14
14
  "build": "cd client && vite build",
15
- "test": "cd client && npx tsc --noEmit"
15
+ "test": "cd client && npx tsc --noEmit",
16
+ "test:server": "vitest run --config vitest.config.server.js"
16
17
  },
17
18
  "files": [
18
19
  "server/",
19
20
  "bin/",
20
21
  "client/dist/",
21
22
  ".claude-plugin/",
22
- "commands/",
23
- "hooks/"
23
+ "commands/"
24
24
  ],
25
25
  "engines": {
26
26
  "node": ">=18"
27
27
  },
28
28
  "devDependencies": {
29
- "howler": "^2.2.4",
30
29
  "@types/howler": "^2.2.12",
31
30
  "@types/react": "^18.2.0",
32
31
  "@types/react-dom": "^18.2.0",
32
+ "@types/three": "^0.183.1",
33
33
  "@vitejs/plugin-react": "^4.2.0",
34
- "pixi-filters": "^6.0.0",
35
- "pixi.js": "^8.0.0",
36
- "react": "^18.2.0",
34
+ "howler": "^2.2.4",
35
+ "react": "^18.2.0",
37
36
  "react-dom": "^18.2.0",
37
+ "three": "^0.183.2",
38
38
  "typescript": "^5.3.0",
39
39
  "vite": "^5.1.0"
40
+ },
41
+ "dependencies": {
42
+ "@dgreenheck/ez-tree": "^1.1.0",
43
+ "@types/three": "^0.183.1",
44
+ "d3-force-3d": "^3.0.6",
45
+ "three": "^0.183.2"
40
46
  }
41
47
  }
@@ -0,0 +1,68 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { TranscriptParser } from './transcript-parser.js';
4
+
5
+ function listSessionFiles(projectsDir) {
6
+ const files = [];
7
+
8
+ let entries;
9
+ try {
10
+ entries = readdirSync(projectsDir, { withFileTypes: true });
11
+ } catch {
12
+ return files;
13
+ }
14
+
15
+ for (const entry of entries) {
16
+ if (!entry.isDirectory()) continue;
17
+ const dirPath = join(projectsDir, entry.name);
18
+ let sessionFiles;
19
+ try {
20
+ sessionFiles = readdirSync(dirPath).filter(name => name.endsWith('.jsonl'));
21
+ } catch {
22
+ continue;
23
+ }
24
+
25
+ for (const file of sessionFiles) {
26
+ const filePath = join(dirPath, file);
27
+ try {
28
+ files.push({ filePath, mtime: statSync(filePath).mtimeMs });
29
+ } catch {
30
+ // ignore unreadable files
31
+ }
32
+ }
33
+ }
34
+
35
+ return files.sort((a, b) => a.mtime - b.mtime);
36
+ }
37
+
38
+ export function readProjectHistoryFromDisk(projectsDir, projectId, options = {}) {
39
+ const normalizedProjectId = resolve(projectId);
40
+ const maxSessions = options.maxSessions ?? 40;
41
+ const maxEvents = options.maxEvents ?? 4000;
42
+ const files = listSessionFiles(projectsDir);
43
+ const selectedFiles = files.slice(-maxSessions);
44
+ const events = [];
45
+
46
+ for (const { filePath } of selectedFiles) {
47
+ const parser = new TranscriptParser(event => {
48
+ if (typeof event.cwd !== 'string') return;
49
+ if (resolve(event.cwd) !== normalizedProjectId) return;
50
+ events.push(event);
51
+ if (events.length > maxEvents) events.splice(0, events.length - maxEvents);
52
+ });
53
+
54
+ let text;
55
+ try {
56
+ text = readFileSync(filePath, 'utf8');
57
+ } catch {
58
+ continue;
59
+ }
60
+
61
+ for (const line of text.split('\n')) {
62
+ if (!line.trim()) continue;
63
+ parser.processLine(line);
64
+ }
65
+ }
66
+
67
+ return events.sort((a, b) => a.timestamp - b.timestamp);
68
+ }
package/server/index.js CHANGED
@@ -3,6 +3,10 @@ import { readFileSync, existsSync, statSync } from 'fs'
3
3
  import { join, extname, resolve, sep } from 'path'
4
4
  import { fileURLToPath } from 'url'
5
5
  import { dirname } from 'path'
6
+ import { homedir } from 'os'
7
+ import { SessionScanner } from './session-scanner.js'
8
+ import { buildProjectTree, listActiveProjects } from './project-tree.js'
9
+ import { readProjectHistoryFromDisk } from './history-reader.js'
6
10
 
7
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
8
12
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version
@@ -26,22 +30,41 @@ const MIME = {
26
30
  }
27
31
 
28
32
  const clients = new Set()
33
+ const eventHistory = [] // all events seen since server start
34
+ const MAX_HISTORY = 5000
35
+
36
+ function filterHistory(events, { sessionId, projectId }) {
37
+ return events.filter(event => {
38
+ if (sessionId && event.session_id !== sessionId) return false
39
+ if (projectId && resolve(event.cwd || '') !== projectId) return false
40
+ return true
41
+ })
42
+ }
29
43
 
30
44
  function broadcast(data) {
31
45
  const msg = `data: ${JSON.stringify(data)}\n\n`
32
46
  for (const res of clients) {
33
47
  try { res.write(msg) } catch { clients.delete(res) }
34
48
  }
49
+ // Buffer for history API
50
+ if (data.type === 'event') {
51
+ eventHistory.push(data.data)
52
+ if (eventHistory.length > MAX_HISTORY) eventHistory.splice(0, eventHistory.length - MAX_HISTORY)
53
+ }
35
54
  }
36
55
 
37
56
  const server = createServer((req, res) => {
38
- // POST /hook receive event, broadcast to SSE clients
39
- if (req.method === 'POST' && req.url === '/hook') {
57
+ const requestUrl = new URL(req.url || '/', 'http://localhost')
58
+ const pathname = requestUrl.pathname
59
+
60
+ // POST /hook — used by debug panel to inject test events
61
+ if (req.method === 'POST' && pathname === '/hook') {
40
62
  let body = ''
41
63
  req.on('data', c => body += c)
42
64
  req.on('end', () => {
43
65
  try {
44
66
  const event = JSON.parse(body)
67
+ if (!event.id) event.id = `hook-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
45
68
  broadcast({ type: 'event', data: event })
46
69
  res.writeHead(200, { 'Content-Type': 'application/json' })
47
70
  res.end('{"ok":true}')
@@ -54,14 +77,70 @@ const server = createServer((req, res) => {
54
77
  }
55
78
 
56
79
  // GET /health — health check
57
- if (req.method === 'GET' && req.url === '/health') {
80
+ if (req.method === 'GET' && pathname === '/health') {
58
81
  res.writeHead(200, { 'Content-Type': 'application/json' })
59
82
  res.end(JSON.stringify({ ok: true, version: VERSION, clients: clients.size, port: PORT }))
60
83
  return
61
84
  }
62
85
 
86
+ if (req.method === 'GET' && pathname === '/api/projects') {
87
+ res.writeHead(200, { 'Content-Type': 'application/json' })
88
+ res.end(JSON.stringify({ projects: listActiveProjects(eventHistory) }))
89
+ return
90
+ }
91
+
92
+ if (req.method === 'GET' && pathname === '/api/project-tree') {
93
+ const projects = listActiveProjects(eventHistory)
94
+ const projectId = requestUrl.searchParams.get('project')
95
+ if (!projectId) {
96
+ res.writeHead(400, { 'Content-Type': 'application/json' })
97
+ res.end(JSON.stringify({ error: 'missing project query parameter' }))
98
+ return
99
+ }
100
+
101
+ const normalizedId = resolve(projectId)
102
+ const project = projects.find(item => resolve(item.root) === normalizedId)
103
+ if (!project) {
104
+ res.writeHead(404, { 'Content-Type': 'application/json' })
105
+ res.end(JSON.stringify({ error: 'project not active or unavailable' }))
106
+ return
107
+ }
108
+
109
+ try {
110
+ const tree = buildProjectTree(project.root)
111
+ res.writeHead(200, { 'Content-Type': 'application/json' })
112
+ res.end(JSON.stringify(tree))
113
+ } catch (error) {
114
+ res.writeHead(500, { 'Content-Type': 'application/json' })
115
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'failed to build tree' }))
116
+ }
117
+ return
118
+ }
119
+
120
+ // GET /api/history?session=ID — events for a specific session since last compact
121
+ if (req.method === 'GET' && pathname === '/api/history') {
122
+ const sessionFilter = requestUrl.searchParams.get('session')
123
+ const projectFilter = requestUrl.searchParams.get('project')
124
+ const persisted = requestUrl.searchParams.get('persisted') === '1'
125
+ const normalizedProject = projectFilter ? resolve(projectFilter) : null
126
+ let events = persisted && normalizedProject
127
+ ? readProjectHistoryFromDisk(PROJECTS_DIR, normalizedProject)
128
+ : eventHistory
129
+
130
+ events = filterHistory(events, { sessionId: sessionFilter, projectId: normalizedProject })
131
+ // Only return events since the last PostCompact — history before that is irrelevant
132
+ let lastCompact = -1
133
+ for (let i = events.length - 1; i >= 0; i--) {
134
+ if (events[i].hook_event_name === 'PostCompact') { lastCompact = i; break }
135
+ }
136
+ if (lastCompact >= 0) events = events.slice(lastCompact + 1)
137
+ res.writeHead(200, { 'Content-Type': 'application/json' })
138
+ res.end(JSON.stringify(events))
139
+ return
140
+ }
141
+
63
142
  // GET /events — SSE stream
64
- if (req.method === 'GET' && req.url === '/events') {
143
+ if (req.method === 'GET' && pathname === '/events') {
65
144
  res.writeHead(200, {
66
145
  'Content-Type': 'text/event-stream',
67
146
  'Cache-Control': 'no-cache',
@@ -77,7 +156,7 @@ const server = createServer((req, res) => {
77
156
  }
78
157
 
79
158
  // Static files
80
- const urlPath = new URL(req.url, 'http://localhost').pathname
159
+ const urlPath = pathname
81
160
  let filePath = join(DIST, urlPath === '/' ? 'index.html' : urlPath)
82
161
  if (!resolve(filePath).startsWith(resolve(DIST) + sep) && resolve(filePath) !== resolve(DIST)) {
83
162
  filePath = join(DIST, 'index.html')
@@ -96,6 +175,21 @@ const server = createServer((req, res) => {
96
175
  res.end(readFileSync(filePath))
97
176
  })
98
177
 
99
- server.listen(PORT, () => {
100
- console.log(`claude-live running at http://localhost:${PORT}`)
178
+ const PROJECTS_DIR = process.env.CLAUDE_PROJECTS_DIR
179
+ || join(homedir(), '.claude', 'projects')
180
+
181
+ const scanner = new SessionScanner(PROJECTS_DIR, event => {
182
+ broadcast({ type: 'event', data: event })
101
183
  })
184
+
185
+ // Only auto-start when run directly (not imported for testing)
186
+ const isMainModule = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
187
+ if (isMainModule) {
188
+ server.listen(PORT, () => {
189
+ console.log(`claude-live running at http://localhost:${PORT}`)
190
+ scanner.start()
191
+ console.log(`watching ${PROJECTS_DIR} for sessions`)
192
+ })
193
+ }
194
+
195
+ export { server, scanner, broadcast }
@@ -0,0 +1,178 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { basename, join, resolve, sep } from 'node:path';
3
+
4
+ const DEFAULT_IGNORES = new Set([
5
+ '.git',
6
+ '.cache',
7
+ '.next',
8
+ '.nuxt',
9
+ '.output',
10
+ '.parcel-cache',
11
+ '.pnpm-store',
12
+ '.superpowers',
13
+ '.svelte-kit',
14
+ '.turbo',
15
+ '.worktrees',
16
+ '.yarn',
17
+ 'build',
18
+ 'coverage',
19
+ 'dist',
20
+ 'node_modules',
21
+ 'out',
22
+ 'target',
23
+ 'tmp',
24
+ 'temp',
25
+ ]);
26
+
27
+ const DEFAULT_LIMITS = {
28
+ maxDepth: 7,
29
+ maxChildren: 48,
30
+ maxNodes: 1200,
31
+ };
32
+
33
+ function toPosixPath(value) {
34
+ return value.split(sep).join('/');
35
+ }
36
+
37
+ function makeProjectLabel(rootPath) {
38
+ return basename(rootPath) || rootPath;
39
+ }
40
+
41
+ function shouldIgnore(entry) {
42
+ if (DEFAULT_IGNORES.has(entry.name)) return true;
43
+ if (entry.isSymbolicLink()) return true;
44
+ return false;
45
+ }
46
+
47
+ function compareEntries(a, b) {
48
+ if (a.isDirectory() && !b.isDirectory()) return -1;
49
+ if (!a.isDirectory() && b.isDirectory()) return 1;
50
+ return a.name.localeCompare(b.name);
51
+ }
52
+
53
+ export function listActiveProjects(events) {
54
+ const projects = new Map();
55
+
56
+ for (const event of events) {
57
+ if (!event || typeof event.cwd !== 'string' || !event.cwd) continue;
58
+ const projectId = resolve(event.cwd);
59
+ let project = projects.get(projectId);
60
+ if (!project) {
61
+ project = {
62
+ id: projectId,
63
+ root: projectId,
64
+ label: makeProjectLabel(projectId),
65
+ eventCount: 0,
66
+ lastEventTime: 0,
67
+ sessionIds: new Set(),
68
+ };
69
+ projects.set(projectId, project);
70
+ }
71
+
72
+ project.eventCount += 1;
73
+ project.lastEventTime = Math.max(project.lastEventTime, Number(event.timestamp) || 0);
74
+ if (event.session_id) project.sessionIds.add(event.session_id);
75
+ }
76
+
77
+ return [...projects.values()]
78
+ .map(project => ({
79
+ id: project.id,
80
+ root: project.root,
81
+ label: project.label,
82
+ eventCount: project.eventCount,
83
+ lastEventTime: project.lastEventTime,
84
+ sessionCount: project.sessionIds.size,
85
+ }))
86
+ .sort((a, b) => b.lastEventTime - a.lastEventTime || a.label.localeCompare(b.label));
87
+ }
88
+
89
+ export function buildProjectTree(rootPath, options = {}) {
90
+ const root = resolve(rootPath);
91
+ const limits = { ...DEFAULT_LIMITS, ...options };
92
+ const stats = {
93
+ directories: 0,
94
+ files: 0,
95
+ totalNodes: 0,
96
+ maxDepthReached: 0,
97
+ truncated: false,
98
+ };
99
+
100
+ function visit(absPath, relPath, depth) {
101
+ if (stats.totalNodes >= limits.maxNodes) {
102
+ stats.truncated = true;
103
+ return null;
104
+ }
105
+
106
+ let st;
107
+ try {
108
+ st = statSync(absPath);
109
+ } catch {
110
+ return null;
111
+ }
112
+
113
+ const pathId = relPath || '.';
114
+ const name = relPath ? basename(absPath) : makeProjectLabel(root);
115
+ stats.totalNodes += 1;
116
+ stats.maxDepthReached = Math.max(stats.maxDepthReached, depth);
117
+
118
+ if (!st.isDirectory()) {
119
+ stats.files += 1;
120
+ return {
121
+ id: pathId,
122
+ name,
123
+ path: pathId,
124
+ type: 'file',
125
+ depth,
126
+ };
127
+ }
128
+
129
+ stats.directories += 1;
130
+ const node = {
131
+ id: pathId,
132
+ name,
133
+ path: pathId,
134
+ type: 'folder',
135
+ depth,
136
+ children: [],
137
+ };
138
+
139
+ if (depth >= limits.maxDepth) {
140
+ try {
141
+ if (readdirSync(absPath).length > 0) stats.truncated = true;
142
+ } catch {
143
+ // ignore unreadable directories
144
+ }
145
+ return node;
146
+ }
147
+
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(absPath, { withFileTypes: true })
151
+ .filter(entry => !shouldIgnore(entry))
152
+ .sort(compareEntries);
153
+ } catch {
154
+ return node;
155
+ }
156
+
157
+ if (entries.length > limits.maxChildren) {
158
+ entries = entries.slice(0, limits.maxChildren);
159
+ stats.truncated = true;
160
+ }
161
+
162
+ for (const entry of entries) {
163
+ const childRelPath = relPath ? toPosixPath(join(relPath, entry.name)) : entry.name;
164
+ const childNode = visit(join(absPath, entry.name), childRelPath, depth + 1);
165
+ if (childNode) node.children.push(childNode);
166
+ }
167
+
168
+ return node;
169
+ }
170
+
171
+ return {
172
+ projectId: root,
173
+ rootPath: root,
174
+ label: makeProjectLabel(root),
175
+ tree: visit(root, '', 0),
176
+ stats,
177
+ };
178
+ }
@@ -0,0 +1,150 @@
1
+ import { readdirSync, statSync, openSync, readSync, closeSync, watch } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { TranscriptParser } from './transcript-parser.js';
4
+
5
+ const MAX_SESSIONS = 50;
6
+ const SCAN_INTERVAL = 5000;
7
+ const POLL_INTERVAL = 3000;
8
+ const ACTIVE_AGE_MS = 10 * 60 * 1000; // Only watch sessions modified in last 10 minutes
9
+
10
+ export class SessionScanner {
11
+ constructor(projectsDir, onEvent) {
12
+ this.projectsDir = projectsDir;
13
+ this.onEvent = onEvent;
14
+ this.sessions = new Map();
15
+ this._scanTimer = null;
16
+ this._pollTimer = null;
17
+ this._dirWatcher = null;
18
+ }
19
+
20
+ start() {
21
+ this.scan();
22
+ this._scanTimer = setInterval(() => this.scan(), SCAN_INTERVAL);
23
+ this._pollTimer = setInterval(() => this.pollAll(), POLL_INTERVAL);
24
+ try {
25
+ this._dirWatcher = watch(this.projectsDir, { recursive: false }, () => {
26
+ this.scan();
27
+ });
28
+ } catch {
29
+ // fs.watch may not be supported
30
+ }
31
+ }
32
+
33
+ stop() {
34
+ if (this._scanTimer) { clearInterval(this._scanTimer); this._scanTimer = null; }
35
+ if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
36
+ if (this._dirWatcher) { this._dirWatcher.close(); this._dirWatcher = null; }
37
+ for (const session of this.sessions.values()) {
38
+ if (session.watcher) { session.watcher.close(); }
39
+ }
40
+ this.sessions.clear();
41
+ }
42
+
43
+ scan() {
44
+ let subdirs;
45
+ try {
46
+ subdirs = readdirSync(this.projectsDir, { withFileTypes: true })
47
+ .filter(d => d.isDirectory())
48
+ .map(d => d.name);
49
+ } catch {
50
+ return;
51
+ }
52
+
53
+ // Prune stale sessions (no longer recently modified)
54
+ const now = Date.now();
55
+ for (const [path, session] of this.sessions) {
56
+ try {
57
+ const mtime = statSync(path).mtimeMs;
58
+ if (now - mtime >= ACTIVE_AGE_MS) {
59
+ if (session.watcher) session.watcher.close();
60
+ this.sessions.delete(path);
61
+ }
62
+ } catch {
63
+ if (session.watcher) session.watcher.close();
64
+ this.sessions.delete(path);
65
+ }
66
+ }
67
+
68
+ // Collect all JSONL files with their mtimes
69
+ const candidates = [];
70
+ for (const subdir of subdirs) {
71
+ const dirPath = join(this.projectsDir, subdir);
72
+ let files;
73
+ try {
74
+ files = readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
75
+ } catch {
76
+ continue;
77
+ }
78
+ for (const file of files) {
79
+ const filePath = join(dirPath, file);
80
+ if (this.sessions.has(filePath)) continue;
81
+ try {
82
+ const mtime = statSync(filePath).mtimeMs;
83
+ // Only consider recently active sessions
84
+ if (now - mtime < ACTIVE_AGE_MS) {
85
+ candidates.push({ filePath, mtime });
86
+ }
87
+ } catch { continue; }
88
+ }
89
+ }
90
+
91
+ // Sort by most recent first so active sessions get priority
92
+ candidates.sort((a, b) => b.mtime - a.mtime);
93
+
94
+ for (const { filePath } of candidates) {
95
+ if (this.sessions.size >= MAX_SESSIONS) break;
96
+
97
+ const parser = new TranscriptParser(this.onEvent);
98
+ let watcher = null;
99
+ try {
100
+ watcher = watch(filePath, () => this._readNewLines(this.sessions.get(filePath)));
101
+ } catch { /* ignore */ }
102
+
103
+ const session = { filePath, offset: 0, parser, watcher };
104
+ this.sessions.set(filePath, session);
105
+ this._readNewLines(session);
106
+ }
107
+ }
108
+
109
+ pollAll() {
110
+ for (const session of this.sessions.values()) {
111
+ this._readNewLines(session);
112
+ }
113
+ }
114
+
115
+ _readNewLines(session) {
116
+ if (!session) return;
117
+ let size;
118
+ try {
119
+ size = statSync(session.filePath).size;
120
+ } catch {
121
+ return;
122
+ }
123
+
124
+ if (size === session.offset) return;
125
+
126
+ if (size < session.offset) {
127
+ session.offset = 0;
128
+ session.parser = new TranscriptParser(this.onEvent);
129
+ }
130
+
131
+ const bytesToRead = size - session.offset;
132
+ const buf = Buffer.alloc(bytesToRead);
133
+ let fd;
134
+ try {
135
+ fd = openSync(session.filePath, 'r');
136
+ readSync(fd, buf, 0, bytesToRead, session.offset);
137
+ } finally {
138
+ if (fd !== undefined) closeSync(fd);
139
+ }
140
+
141
+ const text = buf.toString('utf8');
142
+ const lines = text.split('\n');
143
+ for (const line of lines) {
144
+ if (line.trim()) {
145
+ session.parser.processLine(line);
146
+ }
147
+ }
148
+ session.offset = size;
149
+ }
150
+ }