claude-code-kanban 1.16.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.16.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'
@@ -2361,7 +2437,7 @@
2361
2437
  try {
2362
2438
  const res = await fetch('/api/tasks/all');
2363
2439
  const allTasks = await res.json();
2364
- let activeTasks = allTasks.filter(t => t.status === 'in_progress');
2440
+ let activeTasks = allTasks.filter(t => t.status === 'in_progress' && !isInternalTask(t));
2365
2441
  if (filterProject) {
2366
2442
  activeTasks = activeTasks.filter(t => matchesProjectFilter(t.project));
2367
2443
  }
@@ -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) {
@@ -2457,8 +2705,9 @@
2457
2705
  sessionView.classList.add('visible');
2458
2706
  document.getElementById('owner-filter-bar').classList.remove('visible');
2459
2707
 
2460
- const totalTasks = currentTasks.length;
2461
- const completed = currentTasks.filter(t => t.status === 'completed').length;
2708
+ const visibleTasks = currentTasks.filter(t => !isInternalTask(t));
2709
+ const totalTasks = visibleTasks.length;
2710
+ const completed = visibleTasks.filter(t => t.status === 'completed').length;
2462
2711
  const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
2463
2712
 
2464
2713
  const isFiltered = filterProject && filterProject !== '__recent__';
@@ -2674,7 +2923,7 @@
2674
2923
  }
2675
2924
 
2676
2925
  function renderKanban() {
2677
- let filtered = currentTasks;
2926
+ let filtered = currentTasks.filter(t => !isInternalTask(t));
2678
2927
  if (ownerFilter) {
2679
2928
  filtered = filtered.filter(t => t.owner === ownerFilter);
2680
2929
  }
@@ -3429,6 +3678,16 @@
3429
3678
  debouncedRefresh(data.sessionId, data.type === 'metadata-update');
3430
3679
  }
3431
3680
 
3681
+ if (data.type === 'plan-update') {
3682
+ refreshOpenPlan();
3683
+ }
3684
+
3685
+ if (data.type === 'agent-update') {
3686
+ if (currentSessionId && data.sessionId === currentSessionId) {
3687
+ fetchAgents(currentSessionId);
3688
+ }
3689
+ }
3690
+
3432
3691
  if (data.type === 'team-update') {
3433
3692
  console.log('[SSE] Team update:', data.teamName);
3434
3693
  debouncedRefresh(data.teamName, false);
@@ -3470,6 +3729,10 @@
3470
3729
  { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
3471
3730
  ];
3472
3731
  const ownerColorCache = {};
3732
+ function isInternalTask(task) {
3733
+ return task.metadata && task.metadata._internal === true;
3734
+ }
3735
+
3473
3736
  function getOwnerColor(name) {
3474
3737
  if (ownerColorCache[name]) return ownerColorCache[name];
3475
3738
  let hash = 5381;
@@ -3665,7 +3928,13 @@
3665
3928
 
3666
3929
  await Promise.all(promises);
3667
3930
 
3668
- const tasks = currentSessionId === sessionId ? currentTasks : [];
3931
+ let tasks = currentSessionId === sessionId ? currentTasks : [];
3932
+ if (tasks.length === 0) {
3933
+ try {
3934
+ const r = await fetch(`/api/sessions/${sessionId}`);
3935
+ if (r.ok) tasks = await r.json();
3936
+ } catch {}
3937
+ }
3669
3938
  _planSessionId = sessionId;
3670
3939
  showInfoModal(session, teamConfig, tasks, planContent);
3671
3940
  }
@@ -3723,8 +3992,13 @@
3723
3992
  // Team info section
3724
3993
  if (teamConfig) {
3725
3994
  const ownerCounts = {};
3995
+ const memberDescriptions = {};
3726
3996
  tasks.forEach(t => {
3727
- if (t.owner) ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
3997
+ if (isInternalTask(t) && t.subject) {
3998
+ memberDescriptions[t.subject] = t.description;
3999
+ } else if (t.owner) {
4000
+ ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
4001
+ }
3728
4002
  });
3729
4003
 
3730
4004
  const members = teamConfig.members || [];
@@ -3739,11 +4013,13 @@
3739
4013
 
3740
4014
  members.forEach(member => {
3741
4015
  const taskCount = ownerCounts[member.name] || 0;
4016
+ const memberDesc = memberDescriptions[member.name];
3742
4017
  html += `
3743
4018
  <div class="team-member-card">
3744
4019
  <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3745
4020
  <div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
3746
4021
  ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
4022
+ ${memberDesc ? `<div class="member-detail" style="margin-top: 4px; font-style: italic; color: var(--text-secondary);">${escapeHtml(memberDesc.split('\n')[0])}</div>` : ''}
3747
4023
  <div class="member-tasks">Tasks: ${taskCount} assigned</div>
3748
4024
  </div>
3749
4025
  `;
@@ -3784,6 +4060,20 @@
3784
4060
 
3785
4061
  let _planSessionId = null;
3786
4062
 
4063
+ function refreshOpenPlan() {
4064
+ if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
4065
+ fetch(`/api/sessions/${_planSessionId}/plan`)
4066
+ .then(r => r.ok ? r.json() : null)
4067
+ .then(data => {
4068
+ if (data?.content) {
4069
+ _pendingPlanContent = data.content;
4070
+ const body = document.getElementById('plan-modal-body');
4071
+ body.innerHTML = DOMPurify.sanitize(marked.parse(_pendingPlanContent));
4072
+ }
4073
+ })
4074
+ .catch(() => {});
4075
+ }
4076
+
3787
4077
  function openPlanForSession(sid) {
3788
4078
  fetch(`/api/sessions/${sid}/plan`).then(r => r.ok ? r.json() : null).catch(() => null)
3789
4079
  .then(data => {
@@ -3832,7 +4122,7 @@
3832
4122
  }
3833
4123
 
3834
4124
  bar.classList.add('visible');
3835
- const owners = [...new Set(currentTasks.map(t => t.owner).filter(Boolean))].sort();
4125
+ const owners = [...new Set(currentTasks.filter(t => !isInternalTask(t)).map(t => t.owner).filter(Boolean))].sort();
3836
4126
  select.innerHTML = '<option value="">All Members</option>' +
3837
4127
  owners.map(o => {
3838
4128
  const c = getOwnerColor(o);
@@ -4132,5 +4422,22 @@
4132
4422
  </div>
4133
4423
  </div>
4134
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>
4135
4442
  </body>
4136
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
  }
@@ -294,6 +306,7 @@ app.get('/api/sessions', async (req, res) => {
294
306
  try {
295
307
  const taskPath = path.join(sessionPath, file);
296
308
  const task = JSON.parse(readFileSync(taskPath, 'utf8'));
309
+ if (task.metadata && task.metadata._internal) continue;
297
310
  if (task.status === 'completed') completed++;
298
311
  else if (task.status === 'in_progress') inProgress++;
299
312
  else pending++;
@@ -367,6 +380,21 @@ app.get('/api/sessions', async (req, res) => {
367
380
  }
368
381
  }
369
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
+
370
398
  // Convert map to array and sort by most recently modified
371
399
  let sessions = Array.from(sessionsMap.values());
372
400
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
@@ -478,6 +506,30 @@ app.get('/api/teams/:name', (req, res) => {
478
506
  res.json(config);
479
507
  });
480
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
+
481
533
  app.get('/api/version', (req, res) => {
482
534
  const pkg = require('./package.json');
483
535
  res.json({ version: pkg.version });
@@ -640,6 +692,13 @@ function broadcast(data) {
640
692
  }
641
693
  }
642
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
+
643
702
  // Watch for file changes (chokidar handles non-existent paths)
644
703
  const watcher = chokidar.watch(TASKS_DIR, {
645
704
  persistent: true,
@@ -706,35 +765,83 @@ plansWatcher.on('all', (event, filePath) => {
706
765
  if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.md')) {
707
766
  lastMetadataRefresh = 0;
708
767
  broadcast({ type: 'metadata-update' });
768
+ if (event === 'change') {
769
+ const slug = path.basename(filePath, '.md');
770
+ broadcast({ type: 'plan-update', slug });
771
+ }
709
772
  }
710
773
  });
711
774
 
712
- app.use('/api', (req, res) => {
713
- 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
714
780
  });
715
781
 
716
- // Start server
717
- const server = app.listen(PORT, () => {
718
- const actualPort = server.address().port;
719
- console.log(`Claude Task Viewer running at http://localhost:${actualPort}`);
782
+ const AGENT_FILE_CAP = 20;
720
783
 
721
- if (process.argv.includes('--open')) {
722
- 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
+ }
723
819
  }
724
820
  });
725
821
 
726
- server.on('error', (err) => {
727
- if (err.code === 'EADDRINUSE') {
728
- console.log(`Port ${PORT} in use, trying random port...`);
729
- const fallback = app.listen(0, () => {
730
- const actualPort = fallback.address().port;
731
- 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}`);
732
825
 
733
- if (process.argv.includes('--open')) {
734
- import('open').then(open => open.default(`http://localhost:${actualPort}`));
735
- }
736
- });
737
- } else {
738
- throw err;
739
- }
740
- });
826
+ if (process.argv.includes('--open')) {
827
+ import('open').then(open => open.default(`http://localhost:${actualPort}`));
828
+ }
829
+ });
830
+
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)