claude-live 0.4.0 → 0.4.2

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=Fira+Code:wght@300;400;500&family=Space+Mono:wght@400;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{position:fixed;top:24px;left:24px;z-index:20;font-family:Space Mono,monospace;pointer-events:none}.hud-title{font-size:17px;font-weight:700;color:#fff;letter-spacing:-.5px;margin-bottom:14px;line-height:1}.hud-title span{color:#4ade80}.hud-stat{display:flex;align-items:baseline;gap:8px;margin-bottom:5px}.hud-label{font-size:9px;color:#666;text-transform:uppercase;letter-spacing:1px;width:52px}.hud-value{font-size:13px;color:#ccc}.hud-tool{font-weight:700}.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:Fira Code,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:Space Mono,monospace;font-size:9px;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:7px}.tooltip-meta{font-family:Space Mono,monospace;font-size:10px;color:#aaa;margin-bottom:3px}.tooltip-count{font-family:Space 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)}}.bottom-left-panel{position:fixed;bottom:24px;left:24px;z-index:20;display:flex;flex-direction:column;gap:10px;pointer-events:none}.bottom-left-panel>*{pointer-events:all}.event-log{display:flex;flex-direction:column;gap:5px;max-width:250px}.event-log-entry{display:flex;align-items:center;gap:5px;position:relative;overflow:hidden;animation:log-entry-in .38s cubic-bezier(.16,1,.3,1)}.event-log-entry:after{content:"";position:absolute;top:0;bottom:0;left:-60%;width:60%;background:linear-gradient(90deg,transparent 0%,var(--entry-color, #888) 60%,transparent 100%);opacity:.22;animation:log-scan .55s ease-out forwards;pointer-events:none}@keyframes log-entry-in{0%{opacity:0;transform:translate(-16px)}55%{opacity:1;transform:translate(3px)}78%{transform:translate(-1px)}to{opacity:1;transform:translate(0)}}@keyframes log-scan{0%{left:-60%;opacity:.22}to{left:130%;opacity:0}}.event-log-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0;animation:log-dot-pop .42s cubic-bezier(.34,1.56,.64,1)}@keyframes log-dot-pop{0%{transform:scale(0) rotate(-90deg)}70%{transform:scale(1.5) rotate(8deg)}to{transform:scale(1) rotate(0)}}.event-log-tool{font-family:Space Mono,monospace;font-size:8px;font-weight:700;min-width:38px;text-transform:uppercase;letter-spacing:.04em;animation:log-tool-in .3s ease-out}@keyframes log-tool-in{0%{opacity:0;letter-spacing:.28em}to{opacity:1;letter-spacing:.04em}}.event-log-file{font-family:Fira Code,monospace;font-size:8px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100px}.event-log-session{font-family:Fira Code,monospace;font-size:7px;color:#3a3a3a;white-space:nowrap;margin-left:auto}.event-log-entry--static,.event-log-entry--static .event-log-dot,.event-log-entry--static .event-log-tool{animation:none}.event-log-entry--static:after{display:none}.event-log-history{width:280px;height:calc(100vh - 80px);overflow-y:scroll;display:flex;flex-direction:column;gap:5px;background:#020209eb;border:1px solid rgba(255,255,255,.08);border-radius:4px;padding:6px;box-sizing:border-box}.event-log-history-back{font-family:Space Mono,monospace;font-size:8px;color:#555;background:none;border:none;cursor:pointer;text-align:left;padding:0 0 4px}.event-log-history-back:hover{color:#aaa}.event-log-history-btn{font-family:Space Mono,monospace;font-size:7px;color:#333;background:none;border:none;cursor:pointer;text-align:left;padding:2px 0 0 10px}.event-log-history-btn:hover{color:#777}.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}.top-right-buttons{position:fixed;top:24px;right:24px;z-index:50;display:flex;gap:8px;align-items:center;pointer-events:auto}.audio-toggle{background:none;border:1px solid rgba(255,255,255,.1);border-radius:4px;color:#666;cursor:pointer;transition:color .15s,border-color .15s,background-color .15s;display:flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;font-size:0}.audio-toggle svg{width:16px;height:16px;stroke:currentColor}.audio-toggle:hover{color:#999;border-color:#ffffff40;background-color:#ffffff0d}.audio-toggle:active{background-color:#ffffff1a}.autofit-toggle{background:none;border:1px solid rgba(255,255,255,.1);color:#ccc;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;border-radius:4px;transition:all .2s ease;width:32px;height:32px;padding:0;font-size:0}.autofit-toggle svg{width:16px;height:16px;stroke:currentColor}.autofit-toggle:hover{color:#999;border-color:#ffffff40;background-color:#ffffff0d}.autofit-toggle:active{background-color:#ffffff1a}.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)}
@@ -9,8 +9,8 @@
9
9
  body { background: #080808; overflow: hidden; }
10
10
  #root { width: 100vw; height: 100vh; }
11
11
  </style>
12
- <script type="module" crossorigin src="/assets/index-C2EVAfon.js"></script>
13
- <link rel="stylesheet" crossorigin href="/assets/index-DMfuaPRG.css">
12
+ <script type="module" crossorigin src="/assets/index-B1BUdq7a.js"></script>
13
+ <link rel="stylesheet" crossorigin href="/assets/index-DjcKbX6b.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-live",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Realtime Claude Code activity visualizer",
5
5
  "bin": {
6
6
  "claude-live": "./bin/claude-live.js"
@@ -10,10 +10,12 @@
10
10
  "build": "cd client && vite build",
11
11
  "start": "node server/index.js",
12
12
  "test": "vitest run",
13
- "prepublishOnly": "npm run build"
13
+ "sync-versions": "node scripts/sync-plugin-version.js",
14
+ "prepublishOnly": "npm run sync-versions && npm run build"
14
15
  },
15
16
  "dependencies": {
16
17
  "express": "^4.18.2",
18
+ "matter-js": "^0.20.0",
17
19
  "open": "^10.1.0",
18
20
  "uuid": "^9.0.0"
19
21
  },
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync } from 'fs';
4
+ import { resolve } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = resolve(fileURLToPath(import.meta.url), '..');
8
+
9
+ const packagePath = resolve(__dirname, '../package.json');
10
+ const pluginJsonPath = resolve(__dirname, '../.claude-plugin/plugin.json');
11
+ const marketplaceJsonPath = resolve(__dirname, '../.claude-plugin/marketplace.json');
12
+
13
+ // Read package.json to get the version
14
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
15
+ const version = packageJson.version;
16
+
17
+ console.log(`Syncing plugin versions to ${version}...`);
18
+
19
+ // Update plugin.json
20
+ const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8'));
21
+ pluginJson.version = version;
22
+ writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2) + '\n');
23
+ console.log(`✓ Updated .claude-plugin/plugin.json`);
24
+
25
+ // Update marketplace.json
26
+ const marketplaceJson = JSON.parse(readFileSync(marketplaceJsonPath, 'utf8'));
27
+ marketplaceJson.metadata.version = version;
28
+ marketplaceJson.plugins[0].version = version;
29
+ writeFileSync(marketplaceJsonPath, JSON.stringify(marketplaceJson, null, 2) + '\n');
30
+ console.log(`✓ Updated .claude-plugin/marketplace.json`);
31
+
32
+ console.log(`Done! Plugin versions are now ${version}`);
package/server/index.js CHANGED
@@ -10,6 +10,141 @@ const EVENTS_PER_SESSION = 50 // rolling buffer per session
10
10
  const SESSION_TIMEOUT_MS = 11 * 60 * 1000 // 11 minutes (server-side cleanup)
11
11
  const HEARTBEAT_MS = 15000
12
12
 
13
+ // ── Ported from client constants/nodeKeys (JS) ──────────────────────────────
14
+
15
+ const TOOL_COLOR_HEX = {
16
+ Read: '#4ade80', Edit: '#60a5fa', Write: '#60a5fa',
17
+ Bash: '#f59e0b', Grep: '#a78bfa', Glob: '#a78bfa',
18
+ WebFetch: '#f472b6', Stop: '#888888', Notification: '#34d399',
19
+ }
20
+ const DEFAULT_HEX = '#555555'
21
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'Glob', 'Grep'])
22
+
23
+ function nodeKeyFor(event) {
24
+ const t = event.tool_name
25
+ if (!t) {
26
+ if (event.hook_event_name === 'Stop') return 'session:stop'
27
+ if (event.hook_event_name === 'Notification') {
28
+ const msg = event.tool_input?.message || ''
29
+ return `notification:${msg.slice(0, 20)}`
30
+ }
31
+ return null
32
+ }
33
+ const input = event.tool_input || {}
34
+ if (FILE_TOOLS.has(t)) {
35
+ const fp = input.file_path || input.path || null
36
+ return fp ? `file:${fp}` : null
37
+ }
38
+ if (t === 'Bash') return `bash:${input.command || ''}`
39
+ if (t === 'WebFetch') {
40
+ try { return `web:${new URL(input.url || '').hostname}` } catch { return 'web:unknown' }
41
+ }
42
+ return `tool:${t}`
43
+ }
44
+
45
+ function nodeTypeFor(event) {
46
+ const t = event.tool_name
47
+ if (FILE_TOOLS.has(t || '')) return 'file'
48
+ if (t === 'Bash') return 'bash'
49
+ if (t === 'WebFetch') return 'web'
50
+ if (event.hook_event_name === 'Stop') return 'stop'
51
+ if (event.hook_event_name === 'Notification') return 'notification'
52
+ return 'tool'
53
+ }
54
+
55
+ function labelFor(event) {
56
+ const t = event.tool_name
57
+ const input = event.tool_input || {}
58
+ if (FILE_TOOLS.has(t || '')) {
59
+ const fp = input.file_path || input.path || ''
60
+ return fp.split('/').pop() || fp
61
+ }
62
+ if (t === 'Bash') return `$ ${(input.command || '').slice(0, 22)}`
63
+ if (t === 'WebFetch') { try { return `↗ ${new URL(input.url || '').hostname}` } catch { return '↗ web' } }
64
+ if (event.hook_event_name === 'Stop') return '✓ done'
65
+ if (event.hook_event_name === 'Notification') return (input.message || 'notification').slice(0, 24)
66
+ return t || event.hook_event_name || '?'
67
+ }
68
+
69
+ // ── State snapshot computation ───────────────────────────────────────────────
70
+
71
+ // Compute a lightweight state snapshot from a session's event buffer.
72
+ // Returns null if the session has no events.
73
+ function computeSessionSnapshot(session_id, events) {
74
+ if (events.length === 0) return null
75
+
76
+ let label = null
77
+ let cwd = null
78
+ let stopping = false
79
+ let eventCount = events.length
80
+
81
+ // file nodes: persistent, grow with each touch
82
+ const fileNodes = new Map() // key → { key, nodeType, label, colorHex, baseRadius }
83
+ // ephemeral keys seen in recent N events (they decay fast so only show recent ones)
84
+ const RECENT_N = 15
85
+ const recentEphemeralKeys = new Set()
86
+
87
+ for (let i = 0; i < events.length; i++) {
88
+ const event = events[i]
89
+
90
+ if (event.cwd) cwd = event.cwd
91
+ if (cwd && (!label || label.length <= 8)) {
92
+ const parts = cwd.split('/').filter(Boolean)
93
+ label = parts[parts.length - 1] || null
94
+ }
95
+
96
+ if (event.hook_event_name === 'Stop') {
97
+ stopping = true
98
+ } else if (event.hook_event_name !== 'SessionEnd') {
99
+ stopping = false
100
+ }
101
+
102
+ const key = nodeKeyFor(event)
103
+ if (!key) continue
104
+ const type = nodeTypeFor(event)
105
+ const isFile = type === 'file'
106
+ const colorHex = TOOL_COLOR_HEX[event.tool_name || event.hook_event_name] ?? DEFAULT_HEX
107
+
108
+ if (isFile) {
109
+ if (fileNodes.has(key)) {
110
+ fileNodes.get(key).baseRadius = Math.min(8, fileNodes.get(key).baseRadius + 0.3)
111
+ } else {
112
+ fileNodes.set(key, { key, nodeType: type, label: labelFor(event), colorHex, baseRadius: 2.5 })
113
+ }
114
+ } else if (i >= events.length - RECENT_N) {
115
+ recentEphemeralKeys.add(key)
116
+ }
117
+ }
118
+
119
+ // Build node list: files first, then recent ephemerals
120
+ const nodes = [...fileNodes.values()]
121
+
122
+ // Add recent ephemerals (not already in files)
123
+ const fileKeySet = new Set(fileNodes.keys())
124
+ for (let i = Math.max(0, events.length - RECENT_N); i < events.length; i++) {
125
+ const event = events[i]
126
+ const key = nodeKeyFor(event)
127
+ if (!key || fileKeySet.has(key)) continue
128
+ const type = nodeTypeFor(event)
129
+ const colorHex = TOOL_COLOR_HEX[event.tool_name || event.hook_event_name] ?? DEFAULT_HEX
130
+ if (!nodes.find(n => n.key === key)) {
131
+ nodes.push({ key, nodeType: type, label: labelFor(event), colorHex, baseRadius: 4 })
132
+ }
133
+ }
134
+
135
+ // Ring assignment is handled by the client (server just sends node data)
136
+ return {
137
+ session_id,
138
+ label: label || session_id.slice(0, 8),
139
+ cwd,
140
+ stopping,
141
+ eventCount,
142
+ nodes, // [{ key, nodeType, label, colorHex, baseRadius }]
143
+ }
144
+ }
145
+
146
+ // ── Server ───────────────────────────────────────────────────────────────────
147
+
13
148
  function makeSessionId(ip, ts) {
14
149
  return 'unknown-' + createHash('sha1').update(ip + ts).digest('hex').slice(0, 8)
15
150
  }
@@ -28,7 +163,6 @@ function normalizeEvent(raw, remoteIp) {
28
163
  agent_type: raw.agent_type ?? null,
29
164
  cwd: raw.cwd ?? null,
30
165
  error: raw.error ?? null,
31
- // Extended fields
32
166
  tool_use_id: raw.tool_use_id ?? null,
33
167
  prompt: raw.prompt ?? null,
34
168
  model: raw.model ?? null,
@@ -91,13 +225,15 @@ export function createServer({ port = 43451 } = {}) {
91
225
  res.setHeader('Cache-Control', 'no-cache')
92
226
  res.setHeader('Connection', 'keep-alive')
93
227
  res.flushHeaders()
94
- // replay all events from all sessions
95
- for (const session of sessionBuffers.values()) {
96
- for (const event of session.events) {
97
- res.write(`data: ${JSON.stringify(event)}\n\n`)
98
- }
228
+
229
+ // Send state snapshot instead of replaying raw events
230
+ const sessions = []
231
+ for (const [sid, session] of sessionBuffers) {
232
+ const snap = computeSessionSnapshot(sid, session.events)
233
+ if (snap) sessions.push(snap)
99
234
  }
100
- res.write(`data: ${JSON.stringify({ type: 'replay_done' })}\n\n`)
235
+ res.write(`data: ${JSON.stringify({ type: 'state_snapshot', sessions })}\n\n`)
236
+
101
237
  clients.add(res)
102
238
  const heartbeat = setInterval(() => {
103
239
  try { res.write(': heartbeat\n\n') } catch { clients.delete(res); clearInterval(heartbeat) }
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Extended test with more agents and longer animation sequence
3
+
4
+ import http from 'http';
5
+ import { URL } from 'url';
6
+
7
+ const SERVER = 'http://localhost:43453';
8
+ const SESSION_ID = 'test-session-' + Date.now();
9
+
10
+ function sendHook(eventName, data = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const payload = JSON.stringify({
13
+ hook_event_name: eventName,
14
+ session_id: SESSION_ID,
15
+ timestamp: Date.now(),
16
+ ...data
17
+ });
18
+
19
+ const url = new URL('/hook', SERVER);
20
+ const options = {
21
+ hostname: url.hostname,
22
+ port: url.port || 80,
23
+ path: url.pathname,
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'Content-Length': Buffer.byteLength(payload)
28
+ }
29
+ };
30
+
31
+ const req = http.request(options, (res) => {
32
+ resolve(`${eventName}: ${res.statusCode}`);
33
+ });
34
+
35
+ req.on('error', reject);
36
+ req.write(payload);
37
+ req.end();
38
+ });
39
+ }
40
+
41
+ async function wait(ms) {
42
+ return new Promise(r => setTimeout(r, ms));
43
+ }
44
+
45
+ async function agentAction(agentId, tool, filePath, delay = 800) {
46
+ await sendHook('PreToolUse', {
47
+ tool_name: tool,
48
+ tool_input: { file_path: filePath, pattern: filePath },
49
+ agent_id: agentId
50
+ });
51
+ await wait(delay);
52
+ await sendHook('PostToolUse', {
53
+ tool_name: tool,
54
+ tool_input: { file_path: filePath, pattern: filePath },
55
+ agent_id: agentId
56
+ });
57
+ }
58
+
59
+ async function testAgentAnimationsLong() {
60
+ console.log('🚀 Extended Agent Animation Test (4 agents, many actions)\n');
61
+
62
+ try {
63
+ // Spawn 4 agents
64
+ console.log('📍 Spawning 4 agents...');
65
+ for (let i = 1; i <= 4; i++) {
66
+ await sendHook('SubagentStart', {
67
+ agent_id: `agent-${i}`,
68
+ agent_type: 'claude-opus-4-6'
69
+ });
70
+ console.log(` Agent ${i} spawned`);
71
+ await wait(300);
72
+ }
73
+
74
+ await wait(1000);
75
+
76
+ // Agent 1: Multiple reads
77
+ console.log('\n📖 Agent 1: Multiple read operations...');
78
+ await agentAction('agent-1', 'Read', 'client/src/store.ts', 1000);
79
+ await wait(500);
80
+ await agentAction('agent-1', 'Read', 'client/src/types.ts', 1000);
81
+ await wait(500);
82
+ await agentAction('agent-1', 'Read', 'README.md', 1000);
83
+
84
+ // Agent 2: Grep operations
85
+ console.log('\n🔎 Agent 2: Search operations...');
86
+ await agentAction('agent-2', 'Grep', 'agentPositionMap', 1000);
87
+ await wait(500);
88
+ await agentAction('agent-2', 'Grep', 'getAnimationOrigin', 1000);
89
+
90
+ // Agent 3: Glob operations
91
+ console.log('\n📂 Agent 3: File matching operations...');
92
+ await agentAction('agent-3', 'Glob', 'client/src/**/*.tsx', 1200);
93
+ await wait(600);
94
+ await agentAction('agent-3', 'Glob', '**/*.ts', 1200);
95
+
96
+ // Agent 4: Mixed operations
97
+ console.log('\n🔄 Agent 4: Mixed operations...');
98
+ await agentAction('agent-4', 'Read', 'package.json', 1000);
99
+ await wait(500);
100
+ await agentAction('agent-4', 'Grep', 'vite', 1000);
101
+
102
+ await wait(1000);
103
+
104
+ // More concurrent-looking operations
105
+ console.log('\n⚡ Agents 1 & 2: Concurrent operations...');
106
+ await Promise.all([
107
+ agentAction('agent-1', 'Read', 'client/src/canvas/renderer.ts', 1200),
108
+ agentAction('agent-2', 'Grep', 'projectile', 1200)
109
+ ]);
110
+
111
+ await wait(800);
112
+
113
+ // Agent 3 continues
114
+ console.log('\n📂 Agent 3: More operations...');
115
+ await agentAction('agent-3', 'Glob', 'docs/**/*.md', 1200);
116
+
117
+ await wait(1000);
118
+
119
+ // Final operations before termination
120
+ console.log('\n🎬 Final actions before termination...');
121
+ await agentAction('agent-1', 'Read', 'client/src/canvas/graph.ts', 1000);
122
+ await wait(400);
123
+ await agentAction('agent-4', 'Grep', 'animation', 1000);
124
+ await wait(400);
125
+ await agentAction('agent-2', 'Read', '.gitignore', 1000);
126
+
127
+ await wait(1500);
128
+
129
+ // Terminate agents one by one
130
+ console.log('\n🛑 Terminating agents...');
131
+ for (let i = 1; i <= 4; i++) {
132
+ await sendHook('SubagentStop', { agent_id: `agent-${i}` });
133
+ console.log(` Agent ${i} terminated (star fading out)`);
134
+ await wait(600);
135
+ }
136
+
137
+ console.log('\n✅ Extended test complete! All agents have terminated.\n');
138
+ } catch (err) {
139
+ console.error('❌ Error:', err.message);
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ testAgentAnimationsLong();
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // Test script to trigger agent animation routing by sending hooks to claude-live server
3
+
4
+ import http from 'http';
5
+ import { URL } from 'url';
6
+
7
+ const SERVER = 'http://localhost:43453';
8
+ const SESSION_ID = 'test-session-' + Date.now();
9
+
10
+ function sendHook(eventName, data = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const payload = JSON.stringify({
13
+ hook_event_name: eventName,
14
+ session_id: SESSION_ID,
15
+ timestamp: Date.now(),
16
+ ...data
17
+ });
18
+
19
+ const url = new URL('/hook', SERVER);
20
+ const options = {
21
+ hostname: url.hostname,
22
+ port: url.port || 80,
23
+ path: url.pathname,
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'Content-Length': Buffer.byteLength(payload)
28
+ }
29
+ };
30
+
31
+ const req = http.request(options, (res) => {
32
+ resolve(`${eventName}: ${res.statusCode}`);
33
+ });
34
+
35
+ req.on('error', reject);
36
+ req.write(payload);
37
+ req.end();
38
+ });
39
+ }
40
+
41
+ async function testAgentAnimations() {
42
+ console.log('🚀 Testing Agent Animation Routing...\n');
43
+
44
+ try {
45
+ // Agent 1: Spawn and read
46
+ console.log('📍 Agent 1: Spawning...');
47
+ await sendHook('SubagentStart', {
48
+ agent_id: 'agent-1',
49
+ agent_type: 'claude-opus-4-6'
50
+ });
51
+ await new Promise(r => setTimeout(r, 500));
52
+
53
+ console.log('📖 Agent 1: Reading file...');
54
+ await sendHook('PreToolUse', {
55
+ tool_name: 'Read',
56
+ tool_input: { file_path: 'client/src/store.ts' },
57
+ agent_id: 'agent-1'
58
+ });
59
+ await new Promise(r => setTimeout(r, 300));
60
+
61
+ await sendHook('PostToolUse', {
62
+ tool_name: 'Read',
63
+ tool_input: { file_path: 'client/src/store.ts' },
64
+ agent_id: 'agent-1'
65
+ });
66
+ await new Promise(r => setTimeout(r, 500));
67
+
68
+ // Agent 2: Spawn and read
69
+ console.log('📍 Agent 2: Spawning...');
70
+ await sendHook('SubagentStart', {
71
+ agent_id: 'agent-2',
72
+ agent_type: 'claude-opus-4-6'
73
+ });
74
+ await new Promise(r => setTimeout(r, 500));
75
+
76
+ console.log('📖 Agent 2: Reading file...');
77
+ await sendHook('PreToolUse', {
78
+ tool_name: 'Read',
79
+ tool_input: { file_path: 'README.md' },
80
+ agent_id: 'agent-2'
81
+ });
82
+ await new Promise(r => setTimeout(r, 300));
83
+
84
+ await sendHook('PostToolUse', {
85
+ tool_name: 'Read',
86
+ tool_input: { file_path: 'README.md' },
87
+ agent_id: 'agent-2'
88
+ });
89
+ await new Promise(r => setTimeout(r, 500));
90
+
91
+ // Agent 1: Glob operation
92
+ console.log('🔍 Agent 1: Globbing...');
93
+ await sendHook('PreToolUse', {
94
+ tool_name: 'Glob',
95
+ tool_input: { pattern: 'client/src/**/*.tsx' },
96
+ agent_id: 'agent-1'
97
+ });
98
+ await new Promise(r => setTimeout(r, 300));
99
+
100
+ await sendHook('PostToolUse', {
101
+ tool_name: 'Glob',
102
+ tool_input: { pattern: 'client/src/**/*.tsx' },
103
+ agent_id: 'agent-1'
104
+ });
105
+ await new Promise(r => setTimeout(r, 500));
106
+
107
+ // Terminate agents
108
+ console.log('🛑 Agent 1: Terminating...');
109
+ await sendHook('SubagentStop', {
110
+ agent_id: 'agent-1'
111
+ });
112
+ await new Promise(r => setTimeout(r, 300));
113
+
114
+ console.log('🛑 Agent 2: Terminating...');
115
+ await sendHook('SubagentStop', {
116
+ agent_id: 'agent-2'
117
+ });
118
+
119
+ console.log('\n✅ Test complete! Check http://localhost:43451 for animations.\n');
120
+ } catch (err) {
121
+ console.error('❌ Error:', err.message);
122
+ process.exit(1);
123
+ }
124
+ }
125
+
126
+ testAgentAnimations();
package/test-agents.js ADDED
@@ -0,0 +1,61 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ // Get all files in current directory
8
+ function getAllFiles(dir = ".") {
9
+ try {
10
+ return fs
11
+ .readdirSync(dir)
12
+ .filter((f) => fs.statSync(path.join(dir, f)).isFile())
13
+ .map((f) => path.join(dir, f));
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+
19
+ // Pick random items from array
20
+ function pickRandom(arr, count) {
21
+ const shuffled = [...arr].sort(() => Math.random() - 0.5);
22
+ return shuffled.slice(0, Math.min(count, arr.length));
23
+ }
24
+
25
+ // Agent function
26
+ async function agent(id, interval = 2000) {
27
+ console.log(`[Agent ${id}] Started`);
28
+
29
+ const tick = () => {
30
+ const allFiles = getAllFiles();
31
+ const randomCount = Math.floor(Math.random() * 3) + 1; // 1-3 files
32
+ const randomFiles = pickRandom(allFiles, randomCount);
33
+
34
+ console.log(
35
+ `[Agent ${id}] Processing ${randomFiles.length} files:`,
36
+ randomFiles.join(", ")
37
+ );
38
+
39
+ // Simulate work
40
+ randomFiles.forEach((file) => {
41
+ try {
42
+ const stats = fs.statSync(file);
43
+ console.log(` ├─ ${file}: ${stats.size} bytes`);
44
+ } catch {
45
+ console.log(` ├─ ${file}: (error reading)`);
46
+ }
47
+ });
48
+ };
49
+
50
+ // Run immediately and then every interval
51
+ tick();
52
+ setInterval(tick, interval);
53
+ }
54
+
55
+ // Start 3 agents
56
+ console.log("Starting 3 test agents...\n");
57
+ agent(1);
58
+ agent(2);
59
+ agent(3);
60
+
61
+ console.log("\nPress Ctrl+C to stop\n");
@@ -66,4 +66,76 @@ describe('store', () => {
66
66
  expect(node.age).toBeGreaterThanOrEqual(80)
67
67
  }
68
68
  })
69
+
70
+ it('spawns ResponseSnakes on PostToolUse Write with words', () => {
71
+ const store = createStore()
72
+ store.addEvent(makeEvent({ id: '1', tool_input: { file_path: '/test.ts' }, tool_name: 'Write' }))
73
+ // First pass: replay phase (skipAnimations=true)
74
+ store.addEvent(makeEvent({
75
+ id: '2',
76
+ hook_event_name: 'PostToolUse',
77
+ tool_name: 'Write',
78
+ tool_input: { file_path: '/test.ts', content: 'function hello world test animation' },
79
+ tool_response: null
80
+ }), true)
81
+ let cluster = store.getSessions().get('sess-1')!
82
+ expect(cluster.promptSnakes.length).toBe(0) // No snakes during replay
83
+
84
+ // Second pass: live phase (skipAnimations=false)
85
+ store.addEvent(makeEvent({
86
+ id: '3',
87
+ hook_event_name: 'PostToolUse',
88
+ tool_name: 'Write',
89
+ tool_input: { file_path: '/test.ts', content: 'function hello world test animation' },
90
+ tool_response: null
91
+ }), false)
92
+ cluster = store.getSessions().get('sess-1')!
93
+ expect(cluster.promptSnakes.length).toBe(1)
94
+ expect(cluster.promptSnakes[0].words.length).toBeGreaterThan(0)
95
+ expect(cluster.promptSnakes[0].words[0]).toBe('function')
96
+ })
97
+
98
+ it('spawns ResponseSnakes on PostToolUse Read with file content', () => {
99
+ const store = createStore()
100
+ store.addEvent(makeEvent({ id: '1', tool_input: { file_path: '/test.ts' } }))
101
+ store.addEvent(makeEvent({
102
+ id: '2',
103
+ hook_event_name: 'PostToolUse',
104
+ tool_name: 'Read',
105
+ tool_input: { file_path: '/test.ts' },
106
+ tool_response: { type: 'text', file: { filePath: '/test.ts', content: 'const x = 42 testing' } }
107
+ }), false)
108
+ const cluster = store.getSessions().get('sess-1')!
109
+ expect(cluster.promptSnakes.length).toBe(1)
110
+ expect(cluster.promptSnakes[0].words[0]).toBe('const')
111
+ })
112
+
113
+ it('spawns ResponseSnakes on PostToolUse Bash with stdout', () => {
114
+ const store = createStore()
115
+ store.addEvent(makeEvent({ id: '1', tool_input: { file_path: '/test.sh' } }))
116
+ store.addEvent(makeEvent({
117
+ id: '2',
118
+ hook_event_name: 'PostToolUse',
119
+ tool_name: 'Bash',
120
+ tool_input: { command: 'echo hello world' },
121
+ tool_response: { stdout: 'hello world from bash output', stderr: '', interrupted: false }
122
+ }), false)
123
+ const cluster = store.getSessions().get('sess-1')!
124
+ expect(cluster.promptSnakes.length).toBe(1)
125
+ expect(cluster.promptSnakes[0].words[0]).toBe('hello')
126
+ })
127
+
128
+ it('skips ResponseSnakes during replay phase', () => {
129
+ const store = createStore()
130
+ store.addEvent(makeEvent({ id: '1', tool_input: { file_path: '/test.ts' } }))
131
+ store.addEvent(makeEvent({
132
+ id: '2',
133
+ hook_event_name: 'PostToolUse',
134
+ tool_name: 'Write',
135
+ tool_input: { content: 'test content words' },
136
+ tool_response: null
137
+ }), true) // skipAnimations=true
138
+ const cluster = store.getSessions().get('sess-1')!
139
+ expect(cluster.promptSnakes.length).toBe(0)
140
+ })
69
141
  })