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 +18 -0
- package/hooks/agent-spy.sh +61 -0
- package/install.js +193 -0
- package/package.json +3 -1
- package/public/index.html +314 -7
- package/server.js +133 -26
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.
|
|
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">▾</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 ? '▴' : '▾';
|
|
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 ? '▴' : '▾';
|
|
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
|
|
2461
|
-
const
|
|
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
|
-
|
|
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
|
|
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:
|
|
223
|
-
slug:
|
|
232
|
+
customTitle: teamConfig.description || dir.name,
|
|
233
|
+
slug: null,
|
|
224
234
|
project,
|
|
225
235
|
jsonlPath: parentMeta?.jsonlPath || null,
|
|
226
|
-
description:
|
|
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
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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.
|
|
727
|
-
|
|
728
|
-
console.log(`
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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)
|