claude-code-kanban 1.17.0 → 1.18.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
@@ -35,6 +35,24 @@ npm install -g claude-code-kanban
35
35
  claude-code-kanban --open
36
36
  ```
37
37
 
38
+ ## Agent Log (subagent tracking)
39
+
40
+ The Kanban footer can show live subagent activity (start/stop/idle) when hooks are installed. A one-time setup installs a lightweight shell script and adds hook entries to `~/.claude/settings.json`.
41
+
42
+ ```bash
43
+ npx claude-code-kanban --install
44
+ ```
45
+
46
+ This will:
47
+ 1. Copy `agent-spy.sh` to `~/.claude/hooks/` (requires `jq`)
48
+ 2. Add `SubagentStart`, `SubagentStop`, and `TeammateIdle` hooks to settings.json
49
+
50
+ All changes are non-destructive — existing settings are preserved. To remove:
51
+
52
+ ```bash
53
+ npx claude-code-kanban --uninstall
54
+ ```
55
+
38
56
  ## Configuration
39
57
 
40
58
  ```bash
@@ -0,0 +1,61 @@
1
+ #!/bin/bash
2
+ # Tracks subagent lifecycle: one JSON file per agent, grouped by session
3
+ # Layout: ~/.claude/agent-activity/{sessionId}/{agentId}.json
4
+
5
+ INPUT=$(cat)
6
+
7
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
8
+ AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty')
9
+ EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
10
+
11
+ [ -z "$SESSION_ID" ] || [ -z "$AGENT_ID" ] && exit 0
12
+
13
+ AGENT_TYPE_RAW=$(echo "$INPUT" | jq -r '.agent_type // empty')
14
+ DIR="$HOME/.claude/agent-activity/$SESSION_ID"
15
+ FILE="$DIR/$AGENT_ID.json"
16
+
17
+ # On Start: skip if no type (internal agents like AskUserQuestion)
18
+ # On Stop/Idle: only skip if no existing file (never tracked)
19
+ if [ -z "$AGENT_TYPE_RAW" ]; then
20
+ if [ "$EVENT" = "SubagentStart" ]; then
21
+ exit 0
22
+ elif [ ! -f "$FILE" ]; then
23
+ exit 0
24
+ fi
25
+ fi
26
+
27
+ mkdir -p "$DIR"
28
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
29
+
30
+ if [ "$EVENT" = "SubagentStart" ]; then
31
+ cat > "$FILE" <<EOF
32
+ {"agentId":"$AGENT_ID","type":"$AGENT_TYPE_RAW","status":"active","startedAt":"$TS","updatedAt":"$TS"}
33
+ EOF
34
+
35
+ elif [ "$EVENT" = "SubagentStop" ]; then
36
+ # Read type and startedAt from existing file if available
37
+ AGENT_TYPE="$AGENT_TYPE_RAW"
38
+ STARTED_AT="$TS"
39
+ if [ -f "$FILE" ]; then
40
+ [ -z "$AGENT_TYPE" ] && AGENT_TYPE=$(jq -r '.type // "unknown"' "$FILE")
41
+ STARTED_AT=$(jq -r '.startedAt // empty' "$FILE")
42
+ [ -z "$STARTED_AT" ] && STARTED_AT="$TS"
43
+ fi
44
+ LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
45
+ LAST_MSG_ESC=$(echo "$LAST_MSG" | jq -Rs '.')
46
+ cat > "$FILE" <<EOF
47
+ {"agentId":"$AGENT_ID","type":"$AGENT_TYPE","status":"stopped","startedAt":"$STARTED_AT","lastMessage":$LAST_MSG_ESC,"stoppedAt":"$TS","updatedAt":"$TS"}
48
+ EOF
49
+
50
+ elif [ "$EVENT" = "TeammateIdle" ]; then
51
+ AGENT_TYPE="$AGENT_TYPE_RAW"
52
+ STARTED_AT="$TS"
53
+ if [ -f "$FILE" ]; then
54
+ [ -z "$AGENT_TYPE" ] && AGENT_TYPE=$(jq -r '.type // "unknown"' "$FILE")
55
+ STARTED_AT=$(jq -r '.startedAt // empty' "$FILE")
56
+ [ -z "$STARTED_AT" ] && STARTED_AT="$TS"
57
+ fi
58
+ cat > "$FILE" <<EOF
59
+ {"agentId":"$AGENT_ID","type":"$AGENT_TYPE","status":"idle","startedAt":"$STARTED_AT","updatedAt":"$TS"}
60
+ EOF
61
+ fi
package/install.js ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const { execSync } = require('child_process');
8
+
9
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
10
+ const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
11
+ const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
12
+ const HOOK_SCRIPT_DEST = path.join(HOOKS_DIR, 'agent-spy.sh');
13
+ const HOOK_SCRIPT_SRC = path.join(__dirname, 'hooks', 'agent-spy.sh');
14
+ const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
15
+
16
+ const HOOK_COMMAND = '~/.claude/hooks/agent-spy.sh';
17
+ const HOOK_EVENTS = ['SubagentStart', 'SubagentStop', 'TeammateIdle'];
18
+
19
+ // ANSI helpers
20
+ const green = s => `\x1b[32m${s}\x1b[0m`;
21
+ const yellow = s => `\x1b[33m${s}\x1b[0m`;
22
+ const red = s => `\x1b[31m${s}\x1b[0m`;
23
+ const bold = s => `\x1b[1m${s}\x1b[0m`;
24
+ const dim = s => `\x1b[2m${s}\x1b[0m`;
25
+
26
+ function prompt(question) {
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
+ return new Promise(resolve => {
29
+ rl.question(question, answer => {
30
+ rl.close();
31
+ resolve(!answer || answer.trim().toLowerCase() !== 'n');
32
+ });
33
+ });
34
+ }
35
+
36
+ async function runInstall() {
37
+ console.log(`\n ${bold('claude-code-kanban')} — Agent Log hook installer\n`);
38
+
39
+ // 1. Check jq
40
+ process.stdout.write(' Checking jq... ');
41
+ try {
42
+ const ver = execSync('jq --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
43
+ console.log(green(`✓ found (${ver})`));
44
+ } catch {
45
+ console.log(yellow('⚠ not found — hook script requires jq for JSON parsing'));
46
+ }
47
+
48
+ // 2. Hook script
49
+ console.log(`\n Hook script: ${dim(HOOK_SCRIPT_DEST)}`);
50
+ let hookInstalled = false;
51
+ if (fs.existsSync(HOOK_SCRIPT_DEST)) {
52
+ const existing = fs.readFileSync(HOOK_SCRIPT_DEST, 'utf8');
53
+ const bundled = fs.readFileSync(HOOK_SCRIPT_SRC, 'utf8');
54
+ if (existing === bundled) {
55
+ console.log(` ${green('✓')} Up to date`);
56
+ hookInstalled = true;
57
+ } else {
58
+ if (await prompt(` Different version found. Update? [Y/n] `)) {
59
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
60
+ fs.copyFileSync(HOOK_SCRIPT_SRC, HOOK_SCRIPT_DEST);
61
+ try { fs.chmodSync(HOOK_SCRIPT_DEST, 0o755); } catch {}
62
+ console.log(` ${green('✓')} Updated`);
63
+ hookInstalled = true;
64
+ } else {
65
+ console.log(` ${dim('Skipped')}`);
66
+ }
67
+ }
68
+ } else {
69
+ if (await prompt(` Not found. Install? [Y/n] `)) {
70
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
71
+ fs.copyFileSync(HOOK_SCRIPT_SRC, HOOK_SCRIPT_DEST);
72
+ try { fs.chmodSync(HOOK_SCRIPT_DEST, 0o755); } catch {}
73
+ console.log(` ${green('✓')} Installed and set executable`);
74
+ hookInstalled = true;
75
+ } else {
76
+ console.log(` ${dim('Skipped')}`);
77
+ }
78
+ }
79
+
80
+ // 3. Settings.json
81
+ console.log(`\n Settings: ${dim(SETTINGS_PATH)}`);
82
+ let settings;
83
+ try {
84
+ if (fs.existsSync(SETTINGS_PATH)) {
85
+ settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
86
+ } else {
87
+ settings = {};
88
+ }
89
+ } catch (e) {
90
+ console.log(` ${red('✗')} Malformed JSON in settings.json — aborting settings update`);
91
+ printSummary(hookInstalled, false);
92
+ return;
93
+ }
94
+
95
+ if (!settings.hooks) settings.hooks = {};
96
+
97
+ const needed = [];
98
+ for (const event of HOOK_EVENTS) {
99
+ if (!settings.hooks[event]) settings.hooks[event] = [];
100
+ const exists = settings.hooks[event].some(g =>
101
+ g.hooks?.some(h => h.command === HOOK_COMMAND)
102
+ );
103
+ if (!exists) needed.push(event);
104
+ }
105
+
106
+ let settingsUpdated = false;
107
+ if (needed.length === 0) {
108
+ console.log(` ${green('✓')} Already configured`);
109
+ settingsUpdated = true;
110
+ } else {
111
+ console.log(` Adding hooks for: ${needed.join(', ')}`);
112
+ if (await prompt(` Update settings? [Y/n] `)) {
113
+ for (const event of needed) {
114
+ settings.hooks[event].push({
115
+ matcher: '',
116
+ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }]
117
+ });
118
+ }
119
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
120
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
121
+ console.log(` ${green('✓')} ${needed.length} hook entries added`);
122
+ settingsUpdated = true;
123
+ } else {
124
+ console.log(` ${dim('Skipped')}`);
125
+ }
126
+ }
127
+
128
+ printSummary(hookInstalled, settingsUpdated);
129
+ }
130
+
131
+ function printSummary(hookOk, settingsOk) {
132
+ console.log('');
133
+ if (hookOk && settingsOk) {
134
+ console.log(` ${green('Agent Log will appear in the Kanban footer when subagents are active.')}`);
135
+ } else {
136
+ console.log(` ${yellow('Partial install — re-run --install to complete setup.')}`);
137
+ }
138
+ console.log('');
139
+ }
140
+
141
+ async function runUninstall() {
142
+ console.log(`\n ${bold('claude-code-kanban')} — Agent Log hook uninstaller\n`);
143
+
144
+ // 1. Remove hook entries from settings.json
145
+ if (fs.existsSync(SETTINGS_PATH)) {
146
+ try {
147
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
148
+ if (settings.hooks) {
149
+ let removed = 0;
150
+ for (const event of HOOK_EVENTS) {
151
+ if (!Array.isArray(settings.hooks[event])) continue;
152
+ const before = settings.hooks[event].length;
153
+ settings.hooks[event] = settings.hooks[event].filter(g =>
154
+ !g.hooks?.some(h => h.command === HOOK_COMMAND)
155
+ );
156
+ removed += before - settings.hooks[event].length;
157
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
158
+ }
159
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
160
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
161
+ console.log(` Settings: ${green('✓')} Removed ${removed} hook entries`);
162
+ } else {
163
+ console.log(` Settings: ${dim('No hook entries found')}`);
164
+ }
165
+ } catch {
166
+ console.log(` Settings: ${red('✗')} Could not parse settings.json`);
167
+ }
168
+ } else {
169
+ console.log(` Settings: ${dim('No settings.json found')}`);
170
+ }
171
+
172
+ // 2. Remove hook script
173
+ if (fs.existsSync(HOOK_SCRIPT_DEST)) {
174
+ fs.unlinkSync(HOOK_SCRIPT_DEST);
175
+ console.log(` Hook script: ${green('✓')} Removed`);
176
+ } else {
177
+ console.log(` Hook script: ${dim('Not found')}`);
178
+ }
179
+
180
+ // 3. Optionally remove agent-activity data
181
+ if (fs.existsSync(AGENT_ACTIVITY_DIR)) {
182
+ if (await prompt(`\n Remove agent activity data (${AGENT_ACTIVITY_DIR})? [y/N] `)) {
183
+ fs.rmSync(AGENT_ACTIVITY_DIR, { recursive: true, force: true });
184
+ console.log(` ${green('✓')} Agent activity data removed`);
185
+ } else {
186
+ console.log(` ${dim('Kept agent activity data')}`);
187
+ }
188
+ }
189
+
190
+ console.log(`\n ${green('Uninstall complete.')}\n`);
191
+ }
192
+
193
+ module.exports = { runInstall, runUninstall };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -40,6 +40,8 @@
40
40
  },
41
41
  "files": [
42
42
  "server.js",
43
+ "install.js",
44
+ "hooks/agent-spy.sh",
43
45
  "public/**/*"
44
46
  ]
45
47
  }
package/public/index.html CHANGED
@@ -1389,6 +1389,72 @@
1389
1389
  padding: 6px 10px;
1390
1390
  }
1391
1391
 
1392
+ /* Agent footer panel */
1393
+ .agent-footer {
1394
+ display: none;
1395
+ flex-direction: column;
1396
+ border-top: 1px solid var(--border);
1397
+ background: var(--bg-surface);
1398
+ }
1399
+ .agent-footer.visible { display: flex; }
1400
+ .agent-footer-header {
1401
+ display: flex;
1402
+ align-items: center;
1403
+ justify-content: space-between;
1404
+ padding: 6px 24px;
1405
+ cursor: pointer;
1406
+ user-select: none;
1407
+ color: var(--text-tertiary);
1408
+ font-size: 12px;
1409
+ letter-spacing: 0.05em;
1410
+ text-transform: uppercase;
1411
+ }
1412
+ .agent-footer-header:hover { background: var(--bg-hover); }
1413
+ .agent-footer-toggle {
1414
+ background: none; border: none; color: var(--text-tertiary);
1415
+ cursor: pointer; font-size: 14px; padding: 2px 4px;
1416
+ }
1417
+ .agent-footer-content {
1418
+ display: flex;
1419
+ gap: 10px;
1420
+ padding: 0 24px 12px;
1421
+ overflow-x: auto;
1422
+ scrollbar-width: thin;
1423
+ }
1424
+ .agent-footer.collapsed .agent-footer-content { display: none; }
1425
+ .agent-card {
1426
+ display: flex;
1427
+ align-items: center;
1428
+ gap: 8px;
1429
+ padding: 8px 14px;
1430
+ background: var(--bg-elevated);
1431
+ border: 1px solid var(--border);
1432
+ border-radius: 8px;
1433
+ white-space: nowrap;
1434
+ min-width: 0;
1435
+ overflow: hidden;
1436
+ transition: opacity 0.3s;
1437
+ cursor: pointer;
1438
+ }
1439
+ .agent-card:hover { border-color: var(--accent); }
1440
+ .agent-card.fading { opacity: 0.4; }
1441
+ .agent-dot {
1442
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
1443
+ }
1444
+ .agent-dot.active { background: var(--success); box-shadow: 0 0 6px var(--success); }
1445
+ .agent-dot.idle { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
1446
+ .agent-dot.stopped { background: var(--text-muted); }
1447
+ .agent-type {
1448
+ font-size: 13px; font-weight: 500; color: var(--text-primary);
1449
+ }
1450
+ .agent-status {
1451
+ font-size: 11px; color: var(--text-tertiary);
1452
+ }
1453
+ .agent-message {
1454
+ font-size: 11px; color: var(--text-muted);
1455
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1456
+ }
1457
+
1392
1458
  /* Light mode */
1393
1459
  body.light {
1394
1460
  --bg-deep: #e8e6e3;
@@ -2004,6 +2070,13 @@
2004
2070
  <div id="completed-tasks" class="column-tasks" role="list"></div>
2005
2071
  </div>
2006
2072
  </div>
2073
+ <div id="agent-footer" class="agent-footer">
2074
+ <div class="agent-footer-header" onclick="toggleAgentFooter()">
2075
+ <span id="agent-footer-label">Agents</span>
2076
+ <button class="agent-footer-toggle" id="agent-footer-toggle" aria-label="Toggle agent panel">&#x25BE;</button>
2077
+ </div>
2078
+ <div class="agent-footer-content" id="agent-footer-content"></div>
2079
+ </div>
2007
2080
  </div>
2008
2081
  </main>
2009
2082
 
@@ -2042,6 +2115,9 @@
2042
2115
  let allTasksCache = []; // Cache all tasks for search
2043
2116
  let bulkDeleteSessionId = null; // Track session for bulk delete
2044
2117
  let ownerFilter = '';
2118
+ let currentAgents = [];
2119
+ let lastAgentsHash = '';
2120
+ let agentDurationInterval = null;
2045
2121
  let selectedTaskId = null;
2046
2122
  let selectedSessionId = null;
2047
2123
  let focusZone = 'board'; // 'board' | 'sidebar'
@@ -2423,6 +2499,7 @@
2423
2499
  ownerFilter = '';
2424
2500
  updateUrl();
2425
2501
  renderSession();
2502
+ fetchAgents(sessionId);
2426
2503
  } catch (error) {
2427
2504
  console.error('Failed to fetch tasks:', error);
2428
2505
  currentTasks = [];
@@ -2433,11 +2510,182 @@
2433
2510
  }
2434
2511
  }
2435
2512
 
2513
+ const AGENT_COOLDOWN_MS = 3 * 60 * 1000;
2514
+ const AGENT_STALE_MS = 5 * 60 * 1000;
2515
+ const AGENT_LOG_MAX = 8;
2516
+
2517
+ async function fetchAgents(sessionId) {
2518
+ try {
2519
+ const res = await fetch(`/api/sessions/${sessionId}/agents`);
2520
+ if (!res.ok) { currentAgents = []; renderAgentFooter(); return; }
2521
+ const agents = await res.json();
2522
+ const hash = JSON.stringify(agents);
2523
+ if (hash === lastAgentsHash) return;
2524
+ lastAgentsHash = hash;
2525
+ currentAgents = agents;
2526
+ renderAgentFooter();
2527
+ } catch (e) {
2528
+ console.error('[fetchAgents]', e);
2529
+ }
2530
+ }
2531
+
2532
+ function formatDuration(ms) {
2533
+ const s = Math.floor(ms / 1000);
2534
+ if (s < 60) return `${s}s`;
2535
+ const m = Math.floor(s / 60);
2536
+ if (m < 60) return `${m}m ${s % 60}s`;
2537
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
2538
+ }
2539
+
2540
+ function renderAgentFooter() {
2541
+ const footer = document.getElementById('agent-footer');
2542
+ const content = document.getElementById('agent-footer-content');
2543
+ const label = document.getElementById('agent-footer-label');
2544
+ const now = Date.now();
2545
+
2546
+ // Mark stale "active" agents as stopped (SubagentStop unreliable for shutdown agents)
2547
+ const agents = currentAgents.map(a => {
2548
+ if (a.status === 'active' && a.startedAt && (now - new Date(a.startedAt).getTime()) > AGENT_STALE_MS) {
2549
+ return { ...a, status: 'stopped', stoppedAt: a.updatedAt };
2550
+ }
2551
+ return a;
2552
+ });
2553
+ // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
2554
+ // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
2555
+ const byType = {};
2556
+ for (const a of agents) { (byType[a.type] = byType[a.type] || []).push(a); }
2557
+ const filtered = [];
2558
+ for (const group of Object.values(byType)) {
2559
+ group.sort((a, b) => new Date(a.startedAt || 0) - new Date(b.startedAt || 0));
2560
+ filtered.push(group[0]);
2561
+ for (let i = 1; i < group.length; i++) {
2562
+ const prev = group[i - 1];
2563
+ const prevStop = prev.stoppedAt ? new Date(prev.stoppedAt).getTime() : Infinity;
2564
+ const curStart = new Date(group[i].startedAt || 0).getTime();
2565
+ const overlapped = curStart < prevStop;
2566
+ const reSpawn = curStart - prevStop > 30000;
2567
+ if (overlapped || reSpawn) filtered.push(group[i]);
2568
+ }
2569
+ }
2570
+ // Sort by updatedAt desc, keep up to 7 most recent
2571
+ const visible = filtered
2572
+ .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
2573
+ .slice(0, AGENT_LOG_MAX);
2574
+
2575
+ if (visible.length === 0) {
2576
+ footer.classList.remove('visible');
2577
+ clearInterval(agentDurationInterval);
2578
+ agentDurationInterval = null;
2579
+ return;
2580
+ }
2581
+
2582
+ footer.classList.add('visible');
2583
+ label.textContent = `Agents Log (${visible.length})`;
2584
+
2585
+ const collapsed = localStorage.getItem('agentFooterCollapsed') === 'true';
2586
+ footer.classList.toggle('collapsed', collapsed);
2587
+ document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '&#x25B4;' : '&#x25BE;';
2588
+
2589
+ content.innerHTML = visible.map(a => {
2590
+ const elapsed = a.status === 'stopped' && a.stoppedAt
2591
+ ? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
2592
+ : now - new Date(a.startedAt || a.updatedAt).getTime();
2593
+ const statusText = a.status === 'stopped'
2594
+ ? `stopped · ${formatDuration(elapsed)}`
2595
+ : a.status === 'idle'
2596
+ ? `idle · ${formatDuration(elapsed)}`
2597
+ : `active · ${formatDuration(elapsed)}`;
2598
+ const msgTrimmed = a.lastMessage?.trim() || '';
2599
+ const msgTrunc = msgTrimmed.length > 60 ? msgTrimmed.substring(0, 60) + '…' : msgTrimmed;
2600
+ const msgHtml = msgTrunc
2601
+ ? `<div class="agent-message" title="${escapeHtml(msgTrimmed)}">${escapeHtml(msgTrunc)}</div>` : '';
2602
+ return `<div class="agent-card" onclick="showAgentModal('${a.agentId}')">
2603
+ <span class="agent-dot ${a.status}"></span>
2604
+ <div>
2605
+ <div class="agent-type">${escapeHtml(a.type || 'unknown')}</div>
2606
+ <div class="agent-status">${statusText}</div>
2607
+ ${msgHtml}
2608
+ </div>
2609
+ </div>`;
2610
+ }).join('');
2611
+
2612
+ clearInterval(agentDurationInterval);
2613
+ if (visible.some(a => a.status === 'active' || a.status === 'idle')) {
2614
+ agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
2615
+ } else {
2616
+ agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
2617
+ }
2618
+ }
2619
+
2620
+ function toggleAgentFooter() {
2621
+ const footer = document.getElementById('agent-footer');
2622
+ const collapsed = !footer.classList.contains('collapsed');
2623
+ footer.classList.toggle('collapsed', collapsed);
2624
+ localStorage.setItem('agentFooterCollapsed', collapsed);
2625
+ document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '&#x25B4;' : '&#x25BE;';
2626
+ }
2627
+
2628
+ function showAgentModal(agentId) {
2629
+ const agent = currentAgents.find(a => a.agentId === agentId);
2630
+ if (!agent) return;
2631
+ const modal = document.getElementById('agent-modal');
2632
+ const title = document.getElementById('agent-modal-title');
2633
+ const body = document.getElementById('agent-modal-body');
2634
+ const now = Date.now();
2635
+ const started = agent.startedAt ? new Date(agent.startedAt) : null;
2636
+ const stopped = agent.stoppedAt ? new Date(agent.stoppedAt) : null;
2637
+ const elapsed = stopped && started
2638
+ ? stopped.getTime() - started.getTime()
2639
+ : started ? now - started.getTime() : 0;
2640
+
2641
+ const statusDot = `<span class="agent-dot ${agent.status}" style="display:inline-block;vertical-align:middle;margin-right:6px;"></span>`;
2642
+ title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}`;
2643
+
2644
+ const rows = [
2645
+ ['Status', agent.status],
2646
+ ['Agent ID', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.agentId)}</code>`],
2647
+ ['Duration', formatDuration(elapsed)],
2648
+ ];
2649
+ if (started) rows.push(['Started', started.toLocaleTimeString()]);
2650
+ if (stopped) rows.push(['Stopped', stopped.toLocaleTimeString()]);
2651
+
2652
+ let html = `<table style="width:100%;border-collapse:collapse;">` +
2653
+ rows.map(([k, v]) =>
2654
+ `<tr><td style="padding:6px 12px 6px 0;color:var(--text-tertiary);white-space:nowrap;vertical-align:top;">${k}</td><td style="padding:6px 0;color:var(--text-primary);">${v}</td></tr>`
2655
+ ).join('') + `</table>`;
2656
+
2657
+ if (agent.lastMessage) {
2658
+ const sanitized = typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined'
2659
+ ? DOMPurify.sanitize(marked.parse(agent.lastMessage.trim()))
2660
+ : `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(agent.lastMessage.trim())}</pre>`;
2661
+ html += `<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px;">
2662
+ <div style="font-size:12px;color:var(--text-tertiary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.05em;">Last Message</div>
2663
+ <div class="detail-desc" style="font-size:13px;">${sanitized}</div>
2664
+ </div>`;
2665
+ }
2666
+
2667
+ body.innerHTML = html;
2668
+ modal.classList.add('visible');
2669
+ const keyHandler = (e) => {
2670
+ if (e.key === 'Escape') {
2671
+ e.preventDefault();
2672
+ closeAgentModal();
2673
+ document.removeEventListener('keydown', keyHandler);
2674
+ }
2675
+ };
2676
+ document.addEventListener('keydown', keyHandler);
2677
+ }
2678
+
2679
+ function closeAgentModal() {
2680
+ document.getElementById('agent-modal').classList.remove('visible');
2681
+ }
2682
+
2436
2683
  async function showAllTasks() {
2437
2684
  try {
2438
2685
  viewMode = 'all';
2439
2686
  currentSessionId = null;
2440
2687
  ownerFilter = '';
2688
+ currentAgents = []; lastAgentsHash = ''; renderAgentFooter();
2441
2689
  const res = await fetch('/api/tasks/all');
2442
2690
  let tasks = await res.json();
2443
2691
  if (filterProject) {
@@ -3434,6 +3682,12 @@
3434
3682
  refreshOpenPlan();
3435
3683
  }
3436
3684
 
3685
+ if (data.type === 'agent-update') {
3686
+ if (currentSessionId && data.sessionId === currentSessionId) {
3687
+ fetchAgents(currentSessionId);
3688
+ }
3689
+ }
3690
+
3437
3691
  if (data.type === 'team-update') {
3438
3692
  console.log('[SSE] Team update:', data.teamName);
3439
3693
  debouncedRefresh(data.teamName, false);
@@ -4168,5 +4422,22 @@
4168
4422
  </div>
4169
4423
  </div>
4170
4424
  </div>
4425
+
4426
+ <div id="agent-modal" class="modal-overlay plan-modal-overlay" onclick="closeAgentModal()">
4427
+ <div class="modal plan-modal" onclick="event.stopPropagation()">
4428
+ <div class="modal-header">
4429
+ <h3 id="agent-modal-title" class="modal-title">Agent</h3>
4430
+ <button class="modal-close" aria-label="Close dialog" onclick="closeAgentModal()">
4431
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4432
+ <path d="M18 6L6 18M6 6l12 12"/>
4433
+ </svg>
4434
+ </button>
4435
+ </div>
4436
+ <div id="agent-modal-body" class="modal-body" style="overflow-y: auto; flex: 1;"></div>
4437
+ <div class="modal-footer">
4438
+ <button class="btn btn-primary" onclick="closeAgentModal()">Close</button>
4439
+ </div>
4440
+ </div>
4441
+ </div>
4171
4442
  </body>
4172
4443
  </html>
package/server.js CHANGED
@@ -8,6 +8,15 @@ const readline = require('readline');
8
8
  const chokidar = require('chokidar');
9
9
  const os = require('os');
10
10
 
11
+ const isSetupCommand = process.argv.includes('--install') || process.argv.includes('--uninstall');
12
+
13
+ if (isSetupCommand) {
14
+ const { runInstall, runUninstall } = require('./install');
15
+ (process.argv.includes('--install') ? runInstall() : runUninstall())
16
+ .then(() => process.exit(0))
17
+ .catch(e => { console.error(e.message); process.exit(1); });
18
+ }
19
+
11
20
  const app = express();
12
21
  const PORT = process.env.PORT || 3456;
13
22
 
@@ -32,6 +41,7 @@ const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
32
41
  const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
33
42
  const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
34
43
  const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
44
+ const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
35
45
 
36
46
  function isTeamSession(sessionId) {
37
47
  return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
@@ -219,13 +229,15 @@ function loadSessionMetadata() {
219
229
  const project = parentMeta?.project || leadMember?.cwd || teamConfig.working_dir || null;
220
230
 
221
231
  metadata[dir.name] = {
222
- customTitle: parentMeta?.customTitle || null,
223
- slug: parentMeta?.slug || null,
232
+ customTitle: teamConfig.description || dir.name,
233
+ slug: null,
224
234
  project,
225
235
  jsonlPath: parentMeta?.jsonlPath || null,
226
- description: parentMeta?.description || teamConfig.description || null,
236
+ description: teamConfig.description || parentMeta?.description || null,
227
237
  gitBranch: parentMeta?.gitBranch || null,
228
- created: parentMeta?.created || null
238
+ created: parentMeta?.created || null,
239
+ isTeamLeader: false,
240
+ teamLeaderId: teamConfig.leadSessionId || null
229
241
  };
230
242
  }
231
243
  }
@@ -368,6 +380,21 @@ app.get('/api/sessions', async (req, res) => {
368
380
  }
369
381
  }
370
382
 
383
+ // Hide leader UUID sessions that are represented by a team session
384
+ const teamLeaderIds = new Set();
385
+ for (const [sid, session] of sessionsMap) {
386
+ if (session.isTeam) {
387
+ const cfg = loadTeamConfig(sid);
388
+ if (cfg?.leadSessionId) teamLeaderIds.add(cfg.leadSessionId);
389
+ }
390
+ }
391
+ for (const leaderId of teamLeaderIds) {
392
+ const session = sessionsMap.get(leaderId);
393
+ if (session && session.taskCount === 0) {
394
+ sessionsMap.delete(leaderId);
395
+ }
396
+ }
397
+
371
398
  // Convert map to array and sort by most recently modified
372
399
  let sessions = Array.from(sessionsMap.values());
373
400
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
@@ -479,6 +506,30 @@ app.get('/api/teams/:name', (req, res) => {
479
506
  res.json(config);
480
507
  });
481
508
 
509
+ // API: Get agents for a session
510
+ app.get('/api/sessions/:sessionId/agents', (req, res) => {
511
+ let sessionId = req.params.sessionId;
512
+ // For team sessions, resolve to leader's session UUID
513
+ const teamConfig = loadTeamConfig(sessionId);
514
+ if (teamConfig && teamConfig.leadSessionId) {
515
+ sessionId = teamConfig.leadSessionId;
516
+ }
517
+ const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
518
+ if (!existsSync(agentDir)) return res.json([]);
519
+ try {
520
+ const files = readdirSync(agentDir).filter(f => f.endsWith('.json'));
521
+ const agents = [];
522
+ for (const file of files) {
523
+ try {
524
+ agents.push(JSON.parse(readFileSync(path.join(agentDir, file), 'utf8')));
525
+ } catch (e) { /* skip invalid */ }
526
+ }
527
+ res.json(agents);
528
+ } catch (e) {
529
+ res.json([]);
530
+ }
531
+ });
532
+
482
533
  app.get('/api/version', (req, res) => {
483
534
  const pkg = require('./package.json');
484
535
  res.json({ version: pkg.version });
@@ -641,6 +692,13 @@ function broadcast(data) {
641
692
  }
642
693
  }
643
694
 
695
+ app.use('/api', (req, res) => {
696
+ res.status(404).json({ error: 'Not found' });
697
+ });
698
+
699
+ // File watchers and server startup (skip for --install/--uninstall)
700
+ if (!isSetupCommand) {
701
+
644
702
  // Watch for file changes (chokidar handles non-existent paths)
645
703
  const watcher = chokidar.watch(TASKS_DIR, {
646
704
  persistent: true,
@@ -714,32 +772,76 @@ plansWatcher.on('all', (event, filePath) => {
714
772
  }
715
773
  });
716
774
 
717
- app.use('/api', (req, res) => {
718
- res.status(404).json({ error: 'Not found' });
775
+ // Watch agent-activity directory for subagent lifecycle events
776
+ const agentActivityWatcher = chokidar.watch(AGENT_ACTIVITY_DIR, {
777
+ persistent: true,
778
+ ignoreInitial: true,
779
+ depth: 2
719
780
  });
720
781
 
721
- // Start server
722
- const server = app.listen(PORT, () => {
723
- const actualPort = server.address().port;
724
- console.log(`Claude Task Viewer running at http://localhost:${actualPort}`);
782
+ const AGENT_FILE_CAP = 20;
725
783
 
726
- if (process.argv.includes('--open')) {
727
- import('open').then(open => open.default(`http://localhost:${actualPort}`));
784
+ agentActivityWatcher.on('all', (event, filePath) => {
785
+ if ((event === 'add' || event === 'change') && filePath.endsWith('.json')) {
786
+ const relativePath = path.relative(AGENT_ACTIVITY_DIR, filePath);
787
+ const sessionId = relativePath.split(path.sep)[0];
788
+ // Cleanup: if session dir exceeds cap, delete oldest files by mtime
789
+ if (event === 'add') {
790
+ try {
791
+ const sessionDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
792
+ const files = readdirSync(sessionDir).filter(f => f.endsWith('.json'));
793
+ if (files.length > AGENT_FILE_CAP) {
794
+ const withStats = files.map(f => {
795
+ const fp = path.join(sessionDir, f);
796
+ return { file: fp, mtime: statSync(fp).mtimeMs };
797
+ }).sort((a, b) => a.mtime - b.mtime);
798
+ const toDelete = withStats.slice(0, files.length - AGENT_FILE_CAP);
799
+ for (const { file } of toDelete) {
800
+ fs.unlink(file).catch(() => {});
801
+ }
802
+ }
803
+ } catch (e) { /* ignore */ }
804
+ }
805
+ broadcast({ type: 'agent-update', sessionId });
806
+ // For team sessions, also broadcast with team name so frontend picks it up
807
+ if (existsSync(TEAMS_DIR)) {
808
+ try {
809
+ const teamDirs = readdirSync(TEAMS_DIR, { withFileTypes: true }).filter(d => d.isDirectory());
810
+ for (const td of teamDirs) {
811
+ const cfg = loadTeamConfig(td.name);
812
+ if (cfg && cfg.leadSessionId === sessionId) {
813
+ broadcast({ type: 'agent-update', sessionId: td.name });
814
+ break;
815
+ }
816
+ }
817
+ } catch (e) { /* ignore */ }
818
+ }
728
819
  }
729
820
  });
730
821
 
731
- server.on('error', (err) => {
732
- if (err.code === 'EADDRINUSE') {
733
- console.log(`Port ${PORT} in use, trying random port...`);
734
- const fallback = app.listen(0, () => {
735
- const actualPort = fallback.address().port;
736
- console.log(`Claude Task Viewer running at http://localhost:${actualPort}`);
822
+ const server = app.listen(PORT, () => {
823
+ const actualPort = server.address().port;
824
+ console.log(`Claude Task Viewer running at http://localhost:${actualPort}`);
825
+
826
+ if (process.argv.includes('--open')) {
827
+ import('open').then(open => open.default(`http://localhost:${actualPort}`));
828
+ }
829
+ });
737
830
 
738
- if (process.argv.includes('--open')) {
739
- import('open').then(open => open.default(`http://localhost:${actualPort}`));
740
- }
741
- });
742
- } else {
743
- throw err;
744
- }
745
- });
831
+ server.on('error', (err) => {
832
+ if (err.code === 'EADDRINUSE') {
833
+ console.log(`Port ${PORT} in use, trying random port...`);
834
+ const fallback = app.listen(0, () => {
835
+ const actualPort = fallback.address().port;
836
+ console.log(`Claude Task Viewer running at http://localhost:${actualPort}`);
837
+
838
+ if (process.argv.includes('--open')) {
839
+ import('open').then(open => open.default(`http://localhost:${actualPort}`));
840
+ }
841
+ });
842
+ } else {
843
+ throw err;
844
+ }
845
+ });
846
+
847
+ } // end if (!isSetupCommand)