claude-code-kanban 2.1.0-rc.2 → 2.1.0-rc.4

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.
@@ -4,13 +4,14 @@
4
4
 
5
5
  INPUT=$(cat)
6
6
 
7
- # Single jq call to extract all routing fields (was 3-4 separate calls)
7
+ # Single jq call to extract all routing fields
8
8
  eval "$(echo "$INPUT" | jq -r '
9
9
  @sh "SESSION_ID=\(.session_id // "")",
10
10
  @sh "AGENT_ID=\(.agent_id // "")",
11
11
  @sh "EVENT=\(.hook_event_name // "")",
12
12
  @sh "TOOL_NAME=\(.tool_name // "")",
13
- @sh "AGENT_TYPE_RAW=\(.agent_type // "")"
13
+ @sh "AGENT_TYPE_RAW=\(.agent_type // "")",
14
+ @sh "TEAMMATE_NAME=\(.teammate_name // "")"
14
15
  ')"
15
16
 
16
17
  [ -z "$SESSION_ID" ] && exit 0
@@ -42,6 +43,26 @@ if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TO
42
43
  exit 0
43
44
  fi
44
45
 
46
+ # TeammateIdle has no agent_id — resolve via name→id mapping file
47
+ if [ "$EVENT" = "TeammateIdle" ] && [ -z "$AGENT_ID" ] && [ -n "$TEAMMATE_NAME" ]; then
48
+ DIR="$HOME/.claude/agent-activity/$SESSION_ID"
49
+ MAP_FILE="$DIR/_name-${TEAMMATE_NAME}.id"
50
+ [ ! -f "$MAP_FILE" ] && exit 0
51
+ AGENT_ID=$(cat "$MAP_FILE")
52
+ [ -z "$AGENT_ID" ] && exit 0
53
+ FILE="$DIR/$AGENT_ID.json"
54
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
55
+ STARTED_AT="$TS"
56
+ if [ -f "$FILE" ]; then
57
+ PREV_START=$(jq -r '.startedAt // ""' "$FILE" 2>/dev/null)
58
+ [ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
59
+ fi
60
+ cat > "$FILE" <<EOF
61
+ {"agentId":"$AGENT_ID","type":"$TEAMMATE_NAME","status":"idle","startedAt":"$STARTED_AT","updatedAt":"$TS"}
62
+ EOF
63
+ exit 0
64
+ fi
65
+
45
66
  [ -z "$AGENT_ID" ] && exit 0
46
67
 
47
68
  DIR="$HOME/.claude/agent-activity/$SESSION_ID"
@@ -64,6 +85,16 @@ if [ "$EVENT" = "SubagentStart" ]; then
64
85
  cat > "$FILE" <<EOF
65
86
  {"agentId":"$AGENT_ID","type":"$AGENT_TYPE_RAW","status":"active","startedAt":"$TS","updatedAt":"$TS"}
66
87
  EOF
88
+ # Write name→id mapping for TeammateIdle resolution
89
+ # Remove previous incarnation's agent file to avoid duplicates
90
+ if [ -n "$AGENT_TYPE_RAW" ]; then
91
+ MAP_FILE="$DIR/_name-${AGENT_TYPE_RAW}.id"
92
+ if [ -f "$MAP_FILE" ]; then
93
+ OLD_ID=$(cat "$MAP_FILE")
94
+ [ -n "$OLD_ID" ] && [ "$OLD_ID" != "$AGENT_ID" ] && rm -f "$DIR/$OLD_ID.json"
95
+ fi
96
+ echo -n "$AGENT_ID" > "$MAP_FILE"
97
+ fi
67
98
 
68
99
  elif [ "$EVENT" = "SubagentStop" ]; then
69
100
  AGENT_TYPE="$AGENT_TYPE_RAW"
package/install.js CHANGED
@@ -45,7 +45,27 @@ function prompt(question) {
45
45
  async function runInstall() {
46
46
  console.log(`\n ${bold('claude-code-kanban')} — Agent Log hook installer\n`);
47
47
 
48
- // 1. Check jq
48
+ // 1. Check bash
49
+ process.stdout.write(' Checking bash... ');
50
+ try {
51
+ const bashPath = execSync('which bash', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
52
+ console.log(green(`✓ found (${bashPath})`));
53
+ } catch {
54
+ const shell = process.env.SHELL || process.env.BASH || '';
55
+ if (shell.includes('bash')) {
56
+ console.log(green(`✓ found via $SHELL (${shell})`));
57
+ } else {
58
+ const currentShell = shell || process.env.ComSpec || 'unknown';
59
+ console.log(yellow(`⚠ bash not found (current shell: ${currentShell})`));
60
+ console.log(` ${dim('Hook scripts use #!/bin/bash and require a bash environment')}`);
61
+ if (!(await prompt(` Continue anyway? [Y/n] `))) {
62
+ console.log(`\n ${dim('Install cancelled.')}\n`);
63
+ return;
64
+ }
65
+ }
66
+ }
67
+
68
+ // 2. Check jq
49
69
  process.stdout.write(' Checking jq... ');
50
70
  try {
51
71
  const ver = execSync('jq --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -84,15 +104,11 @@ async function runInstall() {
84
104
  return false;
85
105
  }
86
106
 
87
- // 2. Hook scripts
107
+ // 3. Hook scripts
88
108
  const hookInstalled = await installScript('Hook script', HOOK_SCRIPT_SRC, HOOK_SCRIPT_DEST);
89
109
  const ctxInstalled = await installScript('Context spy', CTX_SCRIPT_SRC, CTX_SCRIPT_DEST);
90
- if (ctxInstalled) {
91
- console.log(`\n ${yellow('To enable context tracking, pipe it before your statusline:')}`);
92
- console.log(` ${dim('"statusLine": { "command": "~/.claude/hooks/context-status.sh | <your-statusline>" }')}`);
93
- }
94
110
 
95
- // 3. Settings.json
111
+ // 4. Settings.json
96
112
  console.log(`\n Settings: ${dim(SETTINGS_PATH)}`);
97
113
  let settings;
98
114
  try {
@@ -141,6 +157,38 @@ async function runInstall() {
141
157
  }
142
158
  }
143
159
 
160
+ // 5. StatusLine setup (separate approval)
161
+ const CTX_COMMAND = '~/.claude/hooks/context-status.sh';
162
+ let statusLineUpdated = false;
163
+ if (ctxInstalled) {
164
+ const hasCtx = settings.statusLine?.command?.includes('context-status.sh');
165
+ if (hasCtx) {
166
+ console.log(`\n StatusLine: ${green('✓')} Already configured`);
167
+ statusLineUpdated = true;
168
+ } else if (!settings.statusLine) {
169
+ console.log(`\n StatusLine: ${dim('not configured')}`);
170
+ if (await prompt(` Set up context tracking statusline? [Y/n] `)) {
171
+ settings.statusLine = { command: CTX_COMMAND };
172
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
173
+ console.log(` ${green('✓')} StatusLine configured`);
174
+ statusLineUpdated = true;
175
+ } else {
176
+ console.log(` ${dim('Skipped')}`);
177
+ }
178
+ } else {
179
+ const existing = settings.statusLine.command;
180
+ console.log(`\n StatusLine: ${dim(`current: ${existing}`)}`);
181
+ if (await prompt(` Prepend context spy to existing statusline? [Y/n] `)) {
182
+ settings.statusLine.command = `${CTX_COMMAND} | ${existing}`;
183
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
184
+ console.log(` ${green('✓')} StatusLine updated`);
185
+ statusLineUpdated = true;
186
+ } else {
187
+ console.log(` ${dim('Skipped')}`);
188
+ }
189
+ }
190
+ }
191
+
144
192
  printSummary(hookInstalled, settingsUpdated);
145
193
  }
146
194
 
@@ -161,20 +209,38 @@ async function runUninstall() {
161
209
  if (fs.existsSync(SETTINGS_PATH)) {
162
210
  try {
163
211
  const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
212
+ let removed = 0;
164
213
  if (settings.hooks) {
165
- let removed = 0;
166
214
  const eventNames = [...new Set(HOOK_EVENTS.map(e => e.event))];
167
215
  for (const event of eventNames) {
168
216
  if (!Array.isArray(settings.hooks[event])) continue;
169
217
  const before = settings.hooks[event].length;
170
- settings.hooks[event] = settings.hooks[event].filter(g =>
171
- !g.hooks?.some(h => h.command === HOOK_COMMAND)
172
- );
218
+ settings.hooks[event] = settings.hooks[event].map(g => {
219
+ if (!g.hooks?.some(h => h.command === HOOK_COMMAND)) return g;
220
+ const filtered = g.hooks.filter(h => h.command !== HOOK_COMMAND);
221
+ return filtered.length > 0 ? { ...g, hooks: filtered } : null;
222
+ }).filter(Boolean);
173
223
  removed += before - settings.hooks[event].length;
174
224
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
175
225
  }
176
226
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
177
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
227
+ }
228
+
229
+ // Strip context-status.sh from statusLine, restore downstream command if any
230
+ if (settings.statusLine?.command?.includes('context-status.sh')) {
231
+ const cmd = settings.statusLine.command;
232
+ const stripped = cmd.replace(/~\/\.claude\/hooks\/context-status\.sh\s*\|\s*/, '').trim();
233
+ if (stripped && stripped !== cmd) {
234
+ settings.statusLine.command = stripped;
235
+ console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
236
+ } else {
237
+ delete settings.statusLine;
238
+ console.log(` StatusLine: ${green('✓')} Removed`);
239
+ }
240
+ }
241
+
242
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
243
+ if (removed > 0) {
178
244
  console.log(` Settings: ${green('✓')} Removed ${removed} hook entries`);
179
245
  } else {
180
246
  console.log(` Settings: ${dim('No hook entries found')}`);
package/lib/parsers.js CHANGED
@@ -55,7 +55,8 @@ function parseTeamConfig(raw) {
55
55
  name: m.name,
56
56
  agentType: m.agentType || null,
57
57
  model: m.model || null,
58
- cwd: m.cwd || null
58
+ cwd: m.cwd || null,
59
+ color: m.color || null
59
60
  })),
60
61
  raw: config
61
62
  };
@@ -287,6 +288,20 @@ function readRecentMessages(jsonlPath, limit = 10) {
287
288
  return text;
288
289
  }).join('\n\n');
289
290
  }
291
+ else if (inp.to) {
292
+ const proto = inp.message && typeof inp.message === 'object' ? inp.message : null;
293
+ if (proto?.type === 'shutdown_request') {
294
+ detail = '→ ' + inp.to + ': shutdown request' + (proto.reason ? ' (' + proto.reason + ')' : '');
295
+ } else if (proto?.type === 'shutdown_response') {
296
+ detail = '→ ' + inp.to + ': ' + (proto.approve ? 'shutdown approved' : 'shutdown rejected');
297
+ } else if (proto?.type === 'plan_approval_response') {
298
+ detail = '→ ' + inp.to + ': ' + (proto.approve ? 'plan approved' : 'plan rejected');
299
+ } else {
300
+ detail = '→ ' + inp.to + (inp.summary ? ': ' + inp.summary : '');
301
+ }
302
+ if (detail.length > 80) detail = detail.slice(0, 80) + '...';
303
+ fullDetail = typeof inp.message === 'string' ? inp.message : JSON.stringify(inp.message);
304
+ }
290
305
  else if (inp.plan) {
291
306
  const titleMatch = inp.plan.match(/^#\s+(.+)/m);
292
307
  detail = titleMatch ? titleMatch[1] : 'Plan';
@@ -336,9 +351,9 @@ function readRecentMessages(jsonlPath, limit = 10) {
336
351
  } else if (block.name === 'ToolSearch') {
337
352
  if (inp.max_results) params.max_results = inp.max_results;
338
353
  } else if (block.name === 'TaskCreate') {
339
- if (inp.description) params.description = inp.description;
354
+ if (inp.subject) params.subject = inp.subject;
340
355
  } else if (block.name === 'TaskUpdate') {
341
- if (inp.taskId) params.taskId = inp.taskId;
356
+ if (inp.taskId) params.taskId = '#' + inp.taskId;
342
357
  if (inp.status) params.status = inp.status;
343
358
  } else if (block.name === 'NotebookEdit') {
344
359
  if (inp.command) params.command = inp.command;
@@ -351,6 +366,12 @@ function readRecentMessages(jsonlPath, limit = 10) {
351
366
  } else if (block.name === 'ExitPlanMode') {
352
367
  if (inp.plan) params.plan = inp.plan;
353
368
  if (inp.planFilePath) params.planFilePath = inp.planFilePath;
369
+ } else if (block.name === 'SendMessage') {
370
+ if (inp.to) params.to = inp.to;
371
+ if (inp.summary) params.summary = inp.summary;
372
+ if (inp.message && typeof inp.message === 'object') {
373
+ params.protocol = inp.message;
374
+ }
354
375
  }
355
376
  }
356
377
  const msg = {
@@ -399,7 +420,8 @@ function readRecentMessages(jsonlPath, limit = 10) {
399
420
  case 'shutdown_response': protocolLabel = protocol.approve ? 'shutdown approved' : `shutdown rejected: ${protocol.reason || ''}`; break;
400
421
  case 'plan_approval_request': protocolLabel = 'plan approval requested'; break;
401
422
  case 'plan_approval_response': protocolLabel = protocol.approve ? 'plan approved' : `plan rejected: ${protocol.feedback || ''}`; break;
402
- default: protocolLabel = protocol.type; break;
423
+ case 'teammate_terminated': protocolLabel = protocol.message || 'shut down'; break;
424
+ default: protocolLabel = protocol.type.replace(/_/g, ' '); break;
403
425
  }
404
426
  }
405
427
  const truncated = !isProtocol && body.length > 500;
@@ -412,6 +434,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
412
434
  isProtocol,
413
435
  protocolType: protocol?.type || null,
414
436
  protocolLabel,
437
+ protocolData: protocol || null,
415
438
  text: isProtocol ? null : (truncated ? body.slice(0, 500) + '...' : body),
416
439
  fullText: isProtocol ? null : (truncated ? body : null),
417
440
  timestamp: obj.timestamp
@@ -483,7 +506,9 @@ function buildAgentProgressMap(jsonlPath) {
483
506
  const parentRe = /"parentToolUseID":"([^"]+)"/;
484
507
  const promptRe = /"prompt":"((?:[^"\\]|\\.)*)"/;
485
508
  const bgToolIdRe = /"tool_use_id":"([^"]+)"/;
486
- const bgAgentIdRe = /agentId: ([a-zA-Z0-9_-]+)/;
509
+ const bgAgentIdRe = /agentId: ([a-zA-Z0-9_@-]+)/;
510
+ const tmToolIdRe = /"tool_use_id":"([^"]+)"/;
511
+ const tmAgentIdRe = /agent_id: ([a-zA-Z0-9_@-]+)/;
487
512
  for (const line of content.split('\n')) {
488
513
  if (line.includes('"agent_progress"')) {
489
514
  const agentMatch = re.exec(line);
@@ -505,6 +530,12 @@ function buildAgentProgressMap(jsonlPath) {
505
530
  if (toolIdMatch && bgAgentMatch && !map[toolIdMatch[1]]) {
506
531
  map[toolIdMatch[1]] = { agentId: bgAgentMatch[1], prompt: null };
507
532
  }
533
+ } else if (line.includes('"teammate_spawned"')) {
534
+ const toolIdMatch = tmToolIdRe.exec(line);
535
+ const agentMatch = tmAgentIdRe.exec(line);
536
+ if (toolIdMatch && agentMatch && !map[toolIdMatch[1]]) {
537
+ map[toolIdMatch[1]] = { agentId: agentMatch[1], prompt: null };
538
+ }
508
539
  }
509
540
  }
510
541
  } catch (_) {}
@@ -549,6 +580,39 @@ function readCompactSummaries(jsonlPath) {
549
580
  return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
550
581
  }
551
582
 
583
+ function findTerminatedTeammates(jsonlPath) {
584
+ const terminated = new Map();
585
+ try {
586
+ const content = readFileSync(jsonlPath, 'utf8');
587
+ for (const line of content.split('\n')) {
588
+ if (!line.includes('teammate-message')) continue;
589
+ if (!line.includes('teammate_terminated') && !line.includes('shutdown_response')) continue;
590
+ try {
591
+ const obj = JSON.parse(line);
592
+ if (obj.type !== 'user') continue;
593
+ const text = typeof obj.message?.content === 'string' ? obj.message.content : null;
594
+ if (!text) continue;
595
+ const ts = obj.timestamp || null;
596
+ for (const tmMatch of text.matchAll(/<teammate-message\s+[^>]*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g)) {
597
+ try {
598
+ const tid = tmMatch[1];
599
+ const body = tmMatch[2].trim();
600
+ const protocol = JSON.parse(body);
601
+ if (protocol.type === 'teammate_terminated') {
602
+ const name = protocol.from || (protocol.message?.match(/^(\S+)\s/)?.[1]) || tid;
603
+ if (name !== 'system') terminated.set(name, ts);
604
+ } else if (protocol.type === 'shutdown_response' && protocol.approve) {
605
+ const name = protocol.from || tid;
606
+ if (name !== 'system') terminated.set(name, ts);
607
+ }
608
+ } catch (_) {}
609
+ }
610
+ } catch (_) {}
611
+ }
612
+ } catch (_) {}
613
+ return terminated;
614
+ }
615
+
552
616
  module.exports = {
553
617
  parseTask,
554
618
  parseAgent,
@@ -559,5 +623,6 @@ module.exports = {
559
623
  readSessionInfoFromJsonl,
560
624
  readRecentMessages,
561
625
  buildAgentProgressMap,
562
- readCompactSummaries
626
+ readCompactSummaries,
627
+ findTerminatedTeammates
563
628
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.1.0-rc.2",
3
+ "version": "2.1.0-rc.4",
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": {
package/public/app.js CHANGED
@@ -109,10 +109,10 @@ async function fetchSessions() {
109
109
  console.log('[fetchSessions] Starting...');
110
110
  try {
111
111
  const pinnedParam = pinnedSessionIds.size > 0 ? `&pinned=${[...pinnedSessionIds].join(',')}` : '';
112
- const res = await fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`);
113
- const newSessions = await res.json();
114
- const tasksRes = await fetch('/api/tasks/all');
115
- const newTasks = await tasksRes.json();
112
+ const [newSessions, newTasks] = await Promise.all([
113
+ fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`).then((r) => r.json()),
114
+ fetch('/api/tasks/all').then((r) => r.json()),
115
+ ]);
116
116
 
117
117
  const sessionsHash = JSON.stringify(newSessions);
118
118
  const tasksHash = JSON.stringify(newTasks);
@@ -436,10 +436,12 @@ async function fetchTasks(sessionId) {
436
436
  currentPins = loadPins(sessionId);
437
437
  ownerFilter = '';
438
438
  lastMessagesHash = '';
439
+ for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
440
+ for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
439
441
  sessionJustSelected = true;
440
442
  updateUrl();
441
443
  renderSession();
442
- fetchAgents(sessionId);
444
+ await fetchAgents(sessionId);
443
445
  if (!agentLogMode) fetchMessages(sessionId);
444
446
  } catch (error) {
445
447
  console.error('Failed to fetch tasks:', error);
@@ -472,7 +474,10 @@ async function fetchAgents(sessionId) {
472
474
  if (hash === lastAgentsHash) return;
473
475
  lastAgentsHash = hash;
474
476
  currentAgents = agents;
477
+ updateTeamColors(agents, data.teamColors);
478
+ for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
475
479
  renderAgentFooter();
480
+ if (currentSessionId === sessionId) renderKanban();
476
481
  } catch (e) {
477
482
  console.error('[fetchAgents]', e);
478
483
  }
@@ -494,8 +499,12 @@ function toggleMessagePanel() {
494
499
  }
495
500
 
496
501
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
497
- function viewAgentLog(agentId) {
498
- const agent = currentAgents.find((a) => a.agentId === agentId);
502
+ async function viewAgentLog(agentId) {
503
+ let agent = currentAgents.find((a) => a.agentId === agentId);
504
+ if (!agent && currentSessionId) {
505
+ await fetchAgents(currentSessionId);
506
+ agent = currentAgents.find((a) => a.agentId === agentId);
507
+ }
499
508
  if (!agent) return;
500
509
  const shortId = agentId.length > 8 ? agentId.slice(0, 8) : agentId;
501
510
  agentLogMode = { agentId, sessionId: currentSessionId, agentType: agent.type || 'unknown' };
@@ -704,33 +713,52 @@ function renderMessages(messages) {
704
713
  m.tool === 'Agent' && m.agentId
705
714
  ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
706
715
  : '';
707
- const agentLogBtn = m.tool === 'Agent' && m.agentId ? agentLogButton(m.agentId) : '';
708
- const itemClick =
716
+ let agentLogBtn = '';
717
+ if (m.tool === 'Agent' && m.agentId) {
718
+ agentLogBtn = agentLogButton(m.agentId);
719
+ } else if (m.tool === 'SendMessage' && m.params?.to) {
720
+ const recipient = currentAgents.find((a) => (a.type || a.name) === m.params.to);
721
+ if (recipient) agentLogBtn = agentLogButton(recipient.agentId);
722
+ }
723
+ const recipientColor =
724
+ m.tool === 'SendMessage' && m.params?.to ? resolveNamedColor(teamColorMap[m.params.to]) : null;
725
+ const borderStyle = recipientColor ? `border-left:3px solid ${recipientColor.color};` : '';
726
+ const combinedStyle = `style="${borderStyle}cursor:pointer"`;
727
+ const itemClickAttr =
709
728
  m.tool === 'Agent' && m.agentId
710
- ? `onclick="showAgentModal('${escapeHtml(m.agentId)}')" style="cursor:pointer"`
711
- : clickable;
712
- return `<div class="msg-item msg-tool" ${itemClick}>
729
+ ? `onclick="showAgentModal('${escapeHtml(m.agentId)}')" ${combinedStyle}`
730
+ : `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" ${combinedStyle}`;
731
+ return `<div class="msg-item msg-tool" ${itemClickAttr}>
713
732
  ${MSG_ICON_TOOL}
714
733
  <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
715
734
  </div>`;
716
735
  } else if (m.type === 'teammate') {
717
- const nameSpan = `<span class="teammate-name" style="${m.color ? `color:${escapeHtml(m.color)}` : ''}">${escapeHtml(m.teammateId || 'teammate')}</span>`;
736
+ if (m.teammateId && m.color && !teamColorMap[m.teammateId]) teamColorMap[m.teammateId] = m.color;
737
+ const tmColor = m.color ? resolveNamedColor(m.color)?.color || m.color : '';
738
+ const nameSpan = `<span class="teammate-name" style="${tmColor ? `color:${escapeHtml(tmColor)}` : ''}">${escapeHtml(m.teammateId || 'teammate')}</span>`;
739
+ let tmLookupName = m.teammateId;
740
+ if (m.teammateId === 'system' && m.protocolType === 'teammate_terminated' && m.protocolData?.message) {
741
+ const shutMatch = m.protocolData.message.match(/^(.+?) has shut down/);
742
+ if (shutMatch) tmLookupName = shutMatch[1];
743
+ }
744
+ const tmAgent = tmLookupName ? currentAgents.find((a) => (a.type || a.name) === tmLookupName) : null;
745
+ const tmLogBtn = tmAgent ? agentLogButton(tmAgent.agentId) : '';
718
746
  if (m.isIdle) {
719
747
  return `<div class="msg-item msg-teammate msg-idle" ${clickable}>
720
748
  ${MSG_ICON_IDLE}
721
- <div class="msg-body"><div class="msg-text">${nameSpan} <span class="idle-label">${escapeHtml(m.protocolLabel || 'idle')}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
749
+ <div class="msg-body"><div class="msg-text">${nameSpan} <span class="idle-label">${escapeHtml(m.protocolLabel || 'idle')}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}
722
750
  </div>`;
723
751
  }
724
752
  if (m.isProtocol) {
725
753
  return `<div class="msg-item msg-teammate msg-protocol" ${clickable}>
726
754
  ${MSG_ICON_TEAMMATE}
727
- <div class="msg-body"><div class="msg-text">${nameSpan} <span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
755
+ <div class="msg-body"><div class="msg-text">${nameSpan} <span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}
728
756
  </div>`;
729
757
  }
730
758
  const summaryText = m.summary ? escapeHtml(m.summary) : escapeHtml((m.text || '').slice(0, 80));
731
759
  return `<div class="msg-item msg-teammate" ${clickable}>
732
760
  ${MSG_ICON_TEAMMATE}
733
- <div class="msg-body"><div class="msg-text">${nameSpan} ${summaryText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
761
+ <div class="msg-body"><div class="msg-text">${nameSpan} ${summaryText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}${pinBtn}
734
762
  </div>`;
735
763
  }
736
764
  return '';
@@ -986,12 +1014,25 @@ function showMsgDetail(idx) {
986
1014
  } else {
987
1015
  agentBtn.style.display = 'none';
988
1016
  }
989
- const toolParamsHtml = renderToolParamsHtml(m.params);
990
- const toolResultHtml = renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
1017
+ const sendProto = m.tool === 'SendMessage' && m.params?.protocol;
1018
+ const toolParamsHtml = renderToolParamsHtml(
1019
+ sendProto ? Object.fromEntries(Object.entries(m.params).filter(([k]) => k !== 'protocol')) : m.params,
1020
+ );
1021
+ const hideResult = m.tool === 'SendMessage' || TASK_TOOLS.has(m.tool);
1022
+ const taskResultHtml = TASK_TOOLS.has(m.tool) ? renderTaskResult(m.toolResult) : '';
1023
+ const toolResultHtml = hideResult
1024
+ ? ''
1025
+ : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
991
1026
  const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
992
1027
  let mainHtml;
993
- if (hasAgentTabs) {
1028
+ if (sendProto) {
1029
+ mainHtml = descHtml + renderProtocolDetail(m.params.protocol);
1030
+ } else if (m.tool === 'SendMessage' && fullText) {
1031
+ mainHtml = `${descHtml}<div class="markdown-body">${renderMarkdown(fullText)}</div>`;
1032
+ } else if (hasAgentTabs) {
994
1033
  mainHtml = descHtml || '';
1034
+ } else if (taskResultHtml) {
1035
+ mainHtml = '';
995
1036
  } else if (fullText) {
996
1037
  const detailEscaped = escapeHtml(fullText);
997
1038
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
@@ -999,12 +1040,14 @@ function showMsgDetail(idx) {
999
1040
  } else {
1000
1041
  mainHtml = '<em>No details</em>';
1001
1042
  }
1002
- body.innerHTML = mainHtml + toolParamsHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1043
+ body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1003
1044
  } else if (m.type === 'teammate') {
1004
1045
  document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
1005
1046
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1006
1047
  if (m.isProtocol) {
1007
- body.innerHTML = `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
1048
+ body.innerHTML = m.protocolData
1049
+ ? renderProtocolDetail(m.protocolData)
1050
+ : `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
1008
1051
  } else {
1009
1052
  const text = stripAnsi(m.fullText || m.text || '');
1010
1053
  body.innerHTML = renderMarkdown(text);
@@ -1099,6 +1142,69 @@ async function copyWithFeedback(text, btn) {
1099
1142
  //#endregion
1100
1143
 
1101
1144
  //#region TOOL_RENDERING
1145
+ const PROTOCOL_SKIP_KEYS = new Set(['type', 'from', 'timestamp', 'paneId', 'backendType']);
1146
+ function renderProtocolDetail(data) {
1147
+ if (!data || typeof data !== 'object') return '';
1148
+ const typeBadge = data.type
1149
+ ? `<span class="protocol-type-badge">${escapeHtml(data.type.replace(/_/g, ' '))}</span>`
1150
+ : '';
1151
+ const fields = Object.entries(data)
1152
+ .filter(([k]) => !PROTOCOL_SKIP_KEYS.has(k))
1153
+ .map(([k, v]) => {
1154
+ const label = escapeHtml(
1155
+ k
1156
+ .replace(/([A-Z])/g, ' $1')
1157
+ .replace(/_/g, ' ')
1158
+ .trim()
1159
+ .toLowerCase(),
1160
+ );
1161
+ let val;
1162
+ if (typeof v === 'boolean') {
1163
+ val = `<span class="protocol-bool protocol-bool-${v}">${v ? 'yes' : 'no'}</span>`;
1164
+ } else if (v == null) {
1165
+ val = `<span style="color:var(--text-muted)">null</span>`;
1166
+ } else {
1167
+ val = escapeHtml(String(v));
1168
+ }
1169
+ return `<div class="protocol-field"><span class="protocol-field-key">${label}</span>${val}</div>`;
1170
+ });
1171
+ return `<div class="protocol-detail">${typeBadge}${fields.length ? `<div class="protocol-fields">${fields.join('')}</div>` : ''}</div>`;
1172
+ }
1173
+
1174
+ const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']);
1175
+ function renderTaskResult(toolResult) {
1176
+ if (!toolResult) return '';
1177
+ const lines = toolResult.trim().split('\n');
1178
+ const fields = [];
1179
+ for (const line of lines) {
1180
+ const m = line.match(/^([A-Za-z #]+):\s*(.+)$/);
1181
+ if (m) fields.push([m[1].trim(), m[2].trim()]);
1182
+ }
1183
+ if (!fields.length) return '';
1184
+ const title = fields.find(([k]) => /^Task/.test(k));
1185
+ const status = fields.find(([k]) => k === 'Status');
1186
+ const rest = fields.filter(([k]) => !/^Task/.test(k) && k !== 'Status');
1187
+ const statusColors = {
1188
+ pending: 'var(--text-muted)',
1189
+ in_progress: 'var(--info)',
1190
+ completed: 'var(--success)',
1191
+ deleted: 'var(--danger)',
1192
+ };
1193
+ const sc = status ? statusColors[status[1]] || 'var(--text-muted)' : '';
1194
+ let html = '<div class="protocol-detail">';
1195
+ if (title) html += `<span class="protocol-type-badge">${escapeHtml(title[1])}</span>`;
1196
+ if (status)
1197
+ html += `<span style="display:inline-block;font-size:10px;font-weight:600;color:${sc};text-transform:uppercase;margin-bottom:6px">${escapeHtml(status[1])}</span>`;
1198
+ if (rest.length) {
1199
+ html += '<div class="protocol-fields">';
1200
+ for (const [k, v] of rest) {
1201
+ html += `<div class="protocol-field"><span class="protocol-field-key">${escapeHtml(k.toLowerCase())}</span>${escapeHtml(v)}</div>`;
1202
+ }
1203
+ html += '</div>';
1204
+ }
1205
+ return `${html}</div>`;
1206
+ }
1207
+
1102
1208
  function renderToolParamsHtml(params) {
1103
1209
  if (!params) return '';
1104
1210
  const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'plan']);
@@ -1304,9 +1410,14 @@ function renderAgentFooter() {
1304
1410
  if (overlapped || reSpawn || isActive) filtered.push(group[i]);
1305
1411
  }
1306
1412
  }
1307
- // Sort by updatedAt desc, keep up to 7 most recent
1413
+ // Sort: active/idle first, then by updatedAt desc
1414
+ const statusOrder = { active: 0, idle: 1, stopped: 2 };
1308
1415
  const visible = filtered
1309
- .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
1416
+ .sort(
1417
+ (a, b) =>
1418
+ (statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
1419
+ new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
1420
+ )
1310
1421
  .slice(0, AGENT_LOG_MAX);
1311
1422
 
1312
1423
  const permFresh = currentWaiting?.timestamp && now - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
@@ -1352,7 +1463,9 @@ function renderAgentFooter() {
1352
1463
  const colonIdx = rawType.indexOf(':');
1353
1464
  const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
1354
1465
  const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
1355
- return `<div class="agent-card" onclick="showAgentModal('${a.agentId}')">
1466
+ const agentColor = resolveNamedColor(a.color);
1467
+ const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
1468
+ return `<div class="agent-card"${colorStyle} onclick="showAgentModal('${a.agentId}')">
1356
1469
  <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span></div>
1357
1470
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
1358
1471
  ${msgHtml}
@@ -2993,9 +3106,12 @@ function setupEventSource() {
2993
3106
  function debouncedRefresh(sessionId, isMetadata) {
2994
3107
  if (isMetadata) {
2995
3108
  clearTimeout(metadataRefreshTimer);
2996
- metadataRefreshTimer = setTimeout(() => {
3109
+ metadataRefreshTimer = setTimeout(async () => {
2997
3110
  fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
2998
- if (currentSessionId && !agentLogMode) fetchMessages(currentSessionId);
3111
+ if (currentSessionId) {
3112
+ await fetchAgents(currentSessionId);
3113
+ if (!agentLogMode) fetchMessages(currentSessionId);
3114
+ }
2999
3115
  }, 2000);
3000
3116
  } else {
3001
3117
  pendingTaskSessionIds.add(sessionId);
@@ -3284,13 +3400,46 @@ const ownerColors = [
3284
3400
  { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
3285
3401
  { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
3286
3402
  ];
3403
+ const namedColorMap = {
3404
+ red: { bg: 'rgba(239, 68, 68, 0.14)', color: '#dc2626' },
3405
+ blue: { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' },
3406
+ green: { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' },
3407
+ purple: { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' },
3408
+ orange: { bg: 'rgba(234, 88, 12, 0.14)', color: '#c2410c' },
3409
+ pink: { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' },
3410
+ yellow: { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' },
3411
+ teal: { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' },
3412
+ indigo: { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' },
3413
+ cyan: { bg: 'rgba(6, 182, 212, 0.14)', color: '#0891b2' },
3414
+ };
3287
3415
  const ownerColorCache = {};
3416
+ const teamColorMap = {};
3288
3417
  function isInternalTask(task) {
3289
3418
  return task.metadata && task.metadata._internal === true;
3290
3419
  }
3291
3420
 
3421
+ function resolveNamedColor(colorName) {
3422
+ if (!colorName) return null;
3423
+ return namedColorMap[colorName.toLowerCase()] || null;
3424
+ }
3425
+
3426
+ function updateTeamColors(agents, colors) {
3427
+ if (colors) Object.assign(teamColorMap, colors);
3428
+ for (const a of agents) {
3429
+ const name = a.type || a.name;
3430
+ if (name && a.color) teamColorMap[name] = a.color;
3431
+ }
3432
+ }
3433
+
3292
3434
  function getOwnerColor(name) {
3293
3435
  if (ownerColorCache[name]) return ownerColorCache[name];
3436
+ if (teamColorMap[name]) {
3437
+ const c = resolveNamedColor(teamColorMap[name]);
3438
+ if (c) {
3439
+ ownerColorCache[name] = c;
3440
+ return c;
3441
+ }
3442
+ }
3294
3443
  let hash = 5381;
3295
3444
  for (let i = 0; i < name.length; i++) {
3296
3445
  hash = ((hash * 33) ^ name.charCodeAt(i)) | 0;
@@ -3655,6 +3804,10 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3655
3804
  if (session.tasksDir) {
3656
3805
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
3657
3806
  }
3807
+ if (teamConfig?.configPath) {
3808
+ const configDir = teamConfig.configPath.replace(/[/\\][^/\\]+$/, '');
3809
+ infoRows.push(['Team Config', teamConfig.configPath, { openPath: configDir, openFile: teamConfig.configPath }]);
3810
+ }
3658
3811
  const clickableStyle =
3659
3812
  "font-family: 'IBM Plex Mono', monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; color: var(--accent-text); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px;";
3660
3813
  const plainStyle =
@@ -3719,10 +3872,12 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3719
3872
  members.forEach((member) => {
3720
3873
  const taskCount = ownerCounts[member.name] || 0;
3721
3874
  const memberDesc = memberDescriptions[member.name];
3875
+ const mc = resolveNamedColor(member.color);
3876
+ const borderStyle = mc ? ` style="border-left:3px solid ${mc.color}"` : '';
3877
+ const nameStyle = mc ? ` style="color:${mc.color}"` : '';
3722
3878
  html += `
3723
- <div class="team-member-card">
3724
- <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3725
- <div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
3879
+ <div class="team-member-card"${borderStyle}>
3880
+ <div class="member-name"${nameStyle}>${escapeHtml(member.name)}</div>
3726
3881
  ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
3727
3882
  ${memberDesc ? `<div class="member-detail" style="margin-top: 4px; font-style: italic; color: var(--text-secondary);">${escapeHtml(memberDesc.split('\n')[0])}</div>` : ''}
3728
3883
  <div class="member-tasks">Tasks: ${taskCount} assigned</div>
package/public/style.css CHANGED
@@ -2124,7 +2124,7 @@ body::before {
2124
2124
  }
2125
2125
  .msg-item.msg-idle {
2126
2126
  border-left: 3px solid var(--border);
2127
- opacity: 0.55;
2127
+ opacity: 0.75;
2128
2128
  }
2129
2129
  .msg-item.msg-idle .msg-icon {
2130
2130
  width: 12px;
@@ -2180,6 +2180,7 @@ body::before {
2180
2180
  }
2181
2181
  .msg-agent-log-btn {
2182
2182
  flex-shrink: 0;
2183
+ margin-left: 0;
2183
2184
  background: none;
2184
2185
  border: none;
2185
2186
  color: var(--text-muted);
@@ -2197,6 +2198,52 @@ body::before {
2197
2198
  opacity: 1;
2198
2199
  color: var(--accent);
2199
2200
  }
2201
+ .protocol-detail {
2202
+ padding: 8px 12px;
2203
+ }
2204
+ .protocol-detail + div {
2205
+ margin-top: 8px;
2206
+ padding-top: 8px;
2207
+ border-top: 1px solid var(--border);
2208
+ }
2209
+ .protocol-type-badge {
2210
+ display: inline-block;
2211
+ padding: 2px 10px;
2212
+ border-radius: 4px;
2213
+ background: var(--bg-secondary);
2214
+ color: var(--text-secondary);
2215
+ font-size: 0.8rem;
2216
+ font-weight: 600;
2217
+ text-transform: capitalize;
2218
+ margin-bottom: 8px;
2219
+ }
2220
+ .protocol-fields {
2221
+ display: grid;
2222
+ grid-template-columns: auto 1fr;
2223
+ gap: 4px 10px;
2224
+ align-items: baseline;
2225
+ font-size: 0.85rem;
2226
+ }
2227
+ .protocol-field {
2228
+ display: contents;
2229
+ }
2230
+ .protocol-field-key {
2231
+ color: var(--text-muted);
2232
+ white-space: nowrap;
2233
+ text-align: right;
2234
+ }
2235
+ .protocol-field-key::after {
2236
+ content: ':';
2237
+ }
2238
+ .protocol-bool {
2239
+ font-weight: 600;
2240
+ }
2241
+ .protocol-bool-true {
2242
+ color: #4caf50;
2243
+ }
2244
+ .protocol-bool-false {
2245
+ color: #ef5350;
2246
+ }
2200
2247
 
2201
2248
  /* #endregion */
2202
2249
 
package/server.js CHANGED
@@ -14,7 +14,8 @@ const {
14
14
  readRecentMessages: _readRecentMessagesUncached,
15
15
  readSessionInfoFromJsonl,
16
16
  buildAgentProgressMap,
17
- readCompactSummaries
17
+ readCompactSummaries,
18
+ findTerminatedTeammates
18
19
  } = require('./lib/parsers');
19
20
 
20
21
  const isSetupCommand = process.argv.includes('--install') || process.argv.includes('--uninstall');
@@ -56,6 +57,7 @@ const CONTEXT_STATUS_DIR = path.join(CLAUDE_DIR, 'context-status');
56
57
  const PERMISSION_TTL_MS = 1800000;
57
58
  const AGENT_TTL_MS = 3600000;
58
59
  const AGENT_STALE_MS = 900000;
60
+ const SESSION_STALE_MS = 300000;
59
61
 
60
62
  const WAITING_RESOLVE_GRACE_MS = 15000;
61
63
 
@@ -84,9 +86,10 @@ function getContextStatus(sessionId, meta) {
84
86
  }
85
87
 
86
88
  function isAgentFresh(agent) {
87
- if (!agent.updatedAt) return true;
88
89
  if (isGhostAgent(agent)) return false;
89
- return (Date.now() - new Date(agent.updatedAt).getTime()) < AGENT_TTL_MS;
90
+ const ts = agent.updatedAt || agent.startedAt;
91
+ if (!ts) return true;
92
+ return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
90
93
  }
91
94
 
92
95
  function getSessionLogStat(meta) {
@@ -97,17 +100,20 @@ function getSessionLogStat(meta) {
97
100
  } catch (e) { return { mtime: null, hasMessages: false }; }
98
101
  }
99
102
 
100
- function checkAgentStatus(agentDir, stale, logMtime) {
103
+ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
101
104
  const result = { hasActive: false, hasRunning: false, waitingForUser: null };
102
105
  if (!existsSync(agentDir)) return result;
103
106
  result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
104
107
  if (result.waitingForUser) result.hasActive = true;
105
- if (stale) return result;
108
+ if (stale && !isTeam) return result;
106
109
  try {
107
110
  for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
108
111
  try {
109
112
  const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
110
- if (isAgentFresh(agent)) {
113
+ if (isTeam && (agent.status === 'active' || agent.status === 'idle')) {
114
+ result.hasActive = true;
115
+ if (agent.status === 'active') result.hasRunning = true;
116
+ } else if (isAgentFresh(agent)) {
111
117
  if (agent.status === 'active') { result.hasActive = true; result.hasRunning = true; }
112
118
  }
113
119
  if (result.hasRunning && result.hasActive) break;
@@ -179,6 +185,7 @@ const messageCache = new Map();
179
185
  const MESSAGE_CACHE_TTL = 5000;
180
186
  const MAX_CACHE_ENTRIES = 200;
181
187
  const progressMapCache = new Map();
188
+ const terminatedCache = new Map();
182
189
  const compactSummaryCache = new Map();
183
190
  const taskCountsCache = new Map();
184
191
  const contextStatusCache = new Map();
@@ -217,36 +224,32 @@ function getTaskCounts(sessionPath) {
217
224
  return result;
218
225
  }
219
226
 
220
- function getProgressMap(jsonlPath) {
227
+ function cachedByMtime(cache, filePath, loadFn, fallback) {
221
228
  try {
222
- const cached = progressMapCache.get(jsonlPath);
223
- if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.map;
224
- const st = statSync(jsonlPath);
229
+ const cached = cache.get(filePath);
230
+ if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.data;
231
+ const st = statSync(filePath);
225
232
  if (cached && cached.mtime === st.mtimeMs) {
226
233
  cached.ts = Date.now();
227
- return cached.map;
234
+ return cached.data;
228
235
  }
229
- const map = buildAgentProgressMap(jsonlPath);
230
- progressMapCache.set(jsonlPath, { map, mtime: st.mtimeMs, ts: Date.now() });
231
- evictStaleCache(progressMapCache);
232
- return map;
233
- } catch (_) { return {}; }
236
+ const data = loadFn(filePath);
237
+ cache.set(filePath, { data, mtime: st.mtimeMs, ts: Date.now() });
238
+ evictStaleCache(cache);
239
+ return data;
240
+ } catch (_) { return fallback; }
241
+ }
242
+
243
+ function getProgressMap(jsonlPath) {
244
+ return cachedByMtime(progressMapCache, jsonlPath, buildAgentProgressMap, {});
245
+ }
246
+
247
+ function getTerminatedTeammates(jsonlPath) {
248
+ return cachedByMtime(terminatedCache, jsonlPath, findTerminatedTeammates, new Set());
234
249
  }
235
250
 
236
251
  function readRecentMessages(jsonlPath, limit = 10) {
237
- try {
238
- const stat = statSync(jsonlPath);
239
- const cached = messageCache.get(jsonlPath);
240
- if (cached && cached.mtime === stat.mtimeMs && Date.now() - cached.ts < MESSAGE_CACHE_TTL) {
241
- return cached.messages;
242
- }
243
- const messages = _readRecentMessagesUncached(jsonlPath, limit);
244
- messageCache.set(jsonlPath, { messages, mtime: stat.mtimeMs, ts: Date.now() });
245
- evictStaleCache(messageCache);
246
- return messages;
247
- } catch (e) {
248
- return [];
249
- }
252
+ return cachedByMtime(messageCache, jsonlPath, p => _readRecentMessagesUncached(p, limit), []);
250
253
  }
251
254
 
252
255
  /**
@@ -387,6 +390,42 @@ function getSessionDisplayName(sessionId, meta) {
387
390
  return null;
388
391
  }
389
392
 
393
+ function buildSessionObject(id, meta, overrides = {}) {
394
+ const logStat = overrides._logStat || getSessionLogStat(meta);
395
+ const logMtime = logStat.mtime;
396
+ const logAge = logMtime ? Date.now() - logMtime : Infinity;
397
+ return {
398
+ id,
399
+ name: getSessionDisplayName(id, meta),
400
+ slug: meta.slug || null,
401
+ project: meta.project || null,
402
+ description: meta.description || null,
403
+ gitBranch: meta.gitBranch || null,
404
+ customTitle: meta.customTitle || null,
405
+ taskCount: 0,
406
+ completed: 0,
407
+ inProgress: 0,
408
+ pending: 0,
409
+ createdAt: meta.created || null,
410
+ modifiedAt: overrides.modifiedAt || new Date(0).toISOString(),
411
+ isTeam: false,
412
+ memberCount: 0,
413
+ hasMessages: logStat.hasMessages,
414
+ hasActiveAgents: false,
415
+ hasRunningAgents: false,
416
+ hasWaitingForUser: false,
417
+ hasRecentLog: logAge <= SESSION_STALE_MS,
418
+ jsonlPath: meta.jsonlPath || null,
419
+ tasksDir: null,
420
+ projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
421
+ contextStatus: getContextStatus(id, meta),
422
+ ...getPlanInfo(meta.slug),
423
+ ...overrides,
424
+ // Remove internal-only field
425
+ _logStat: undefined,
426
+ };
427
+ }
428
+
390
429
  // API: List all sessions
391
430
  app.get('/api/sessions', async (req, res) => {
392
431
  // Prevent browser caching
@@ -439,35 +478,23 @@ app.get('/api/sessions', async (req, res) => {
439
478
  const rid = teamConfig?.leadSessionId || entry.name;
440
479
  return path.join(AGENT_ACTIVITY_DIR, rid);
441
480
  })();
442
- const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime);
443
-
444
- sessionsMap.set(entry.name, {
445
- id: entry.name,
446
- name: getSessionDisplayName(entry.name, meta),
447
- slug: meta.slug || null,
448
- project: meta.project || null,
449
- description: meta.description || null,
450
- gitBranch: meta.gitBranch || null,
451
- customTitle: meta.customTitle || null,
481
+ const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
482
+
483
+ sessionsMap.set(entry.name, buildSessionObject(entry.name, meta, {
484
+ _logStat: logStat,
452
485
  taskCount,
453
486
  completed,
454
487
  inProgress,
455
488
  pending,
456
- createdAt: meta.created || null,
457
- modifiedAt: modifiedAt,
489
+ modifiedAt,
458
490
  isTeam,
459
491
  memberCount,
460
- hasMessages: logStat.hasMessages,
461
492
  hasActiveAgents: agentStatus.hasActive,
462
493
  hasRunningAgents: agentStatus.hasRunning,
463
494
  hasWaitingForUser: !!agentStatus.waitingForUser,
464
- hasRecentLog: logAge <= AGENT_STALE_MS,
465
- jsonlPath: meta.jsonlPath || null,
466
495
  tasksDir: sessionPath,
467
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
468
- contextStatus: getContextStatus(entry.name, meta),
469
496
  ...planInfo
470
- });
497
+ }));
471
498
  }
472
499
  }
473
500
  }
@@ -484,36 +511,16 @@ app.get('/api/sessions', async (req, res) => {
484
511
  const jsonlMtime = new Date(logMtime).toISOString();
485
512
  if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
486
513
  }
487
- const planInfo = getPlanInfo(meta.slug);
514
+ const metaIsTeam = isTeamSession(sessionId);
488
515
  const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
489
- const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime);
490
- sessionsMap.set(sessionId, {
491
- id: sessionId,
492
- name: getSessionDisplayName(sessionId, meta),
493
- slug: meta.slug || null,
494
- project: meta.project || null,
495
- description: meta.description || null,
496
- gitBranch: meta.gitBranch || null,
497
- customTitle: meta.customTitle || null,
498
- taskCount: 0,
499
- completed: 0,
500
- inProgress: 0,
501
- pending: 0,
502
- createdAt: meta.created || null,
516
+ const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
517
+ sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
518
+ _logStat: logStat,
503
519
  modifiedAt: modifiedAt || new Date(0).toISOString(),
504
- isTeam: false,
505
- memberCount: 0,
506
- hasMessages: logStat.hasMessages,
507
520
  hasActiveAgents: metaAgentStatus.hasActive,
508
521
  hasRunningAgents: metaAgentStatus.hasRunning,
509
522
  hasWaitingForUser: !!metaAgentStatus.waitingForUser,
510
- hasRecentLog: logAge <= AGENT_STALE_MS,
511
- jsonlPath: meta.jsonlPath || null,
512
- tasksDir: null,
513
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
514
- contextStatus: getContextStatus(sessionId, meta),
515
- ...planInfo
516
- });
523
+ }));
517
524
  }
518
525
  }
519
526
 
@@ -527,29 +534,12 @@ app.get('/api/sessions', async (req, res) => {
527
534
  const logStat = getSessionLogStat(meta);
528
535
  const waiting = checkWaitingForUser(agentDir, logStat.mtime);
529
536
  if (!waiting) continue;
530
- sessionsMap.set(dir.name, {
531
- id: dir.name,
532
- name: getSessionDisplayName(dir.name, meta),
533
- slug: meta.slug || null,
534
- project: meta.project || null,
535
- description: meta.description || null,
536
- gitBranch: meta.gitBranch || null,
537
- customTitle: meta.customTitle || null,
538
- taskCount: 0,
539
- completed: 0,
540
- inProgress: 0,
541
- pending: 0,
542
- createdAt: meta.created || null,
537
+ sessionsMap.set(dir.name, buildSessionObject(dir.name, meta, {
538
+ _logStat: logStat,
543
539
  modifiedAt: waiting.timestamp || new Date().toISOString(),
544
- isTeam: false,
545
- memberCount: 0,
546
- hasMessages: logStat.hasMessages,
547
540
  hasActiveAgents: true,
548
541
  hasWaitingForUser: true,
549
- jsonlPath: meta.jsonlPath || null,
550
- tasksDir: null,
551
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
552
- });
542
+ }));
553
543
  }
554
544
  } catch (e) { /* ignore */ }
555
545
  }
@@ -563,8 +553,7 @@ app.get('/api/sessions', async (req, res) => {
563
553
  }
564
554
  }
565
555
  for (const leaderId of teamLeaderIds) {
566
- const session = sessionsMap.get(leaderId);
567
- if (session && session.taskCount === 0) {
556
+ if (sessionsMap.has(leaderId)) {
568
557
  sessionsMap.delete(leaderId);
569
558
  }
570
559
  }
@@ -603,34 +592,17 @@ app.get('/api/sessions', async (req, res) => {
603
592
  if (sessionsMap.has(pid)) continue;
604
593
  const meta = metadata[pid];
605
594
  if (!meta) continue;
606
- const logStat = getSessionLogStat(meta);
607
- const logMtime = logStat.mtime;
595
+ const pinnedLogStat = getSessionLogStat(meta);
596
+ const pinnedLogMtime = pinnedLogStat.mtime;
608
597
  let modifiedAt = meta.created || null;
609
- if (logMtime) {
610
- const jsonlMtime = new Date(logMtime).toISOString();
598
+ if (pinnedLogMtime) {
599
+ const jsonlMtime = new Date(pinnedLogMtime).toISOString();
611
600
  if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
612
601
  }
613
- sessionsMap.set(pid, {
614
- id: pid,
615
- name: getSessionDisplayName(pid, meta),
616
- slug: meta.slug || null,
617
- project: meta.project || null,
618
- description: meta.description || null,
619
- gitBranch: meta.gitBranch || null,
620
- customTitle: meta.customTitle || null,
621
- taskCount: 0, completed: 0, inProgress: 0, pending: 0,
622
- createdAt: meta.created || null,
602
+ sessionsMap.set(pid, buildSessionObject(pid, meta, {
603
+ _logStat: pinnedLogStat,
623
604
  modifiedAt: modifiedAt || new Date(0).toISOString(),
624
- isTeam: false, memberCount: 0,
625
- hasMessages: logStat.hasMessages,
626
- hasActiveAgents: false, hasRunningAgents: false, hasWaitingForUser: false,
627
- hasRecentLog: false,
628
- jsonlPath: meta.jsonlPath || null,
629
- tasksDir: null,
630
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
631
- contextStatus: getContextStatus(pid, meta),
632
- ...getPlanInfo(meta.slug)
633
- });
605
+ }));
634
606
  }
635
607
 
636
608
  // Convert map to array and sort by most recently modified
@@ -780,6 +752,7 @@ app.post('/api/open-in-editor', (req, res) => {
780
752
  app.get('/api/teams/:name', (req, res) => {
781
753
  const config = loadTeamConfig(req.params.name);
782
754
  if (!config) return res.status(404).json({ error: 'Team not found' });
755
+ config.configPath = path.join(TEAMS_DIR, req.params.name, 'config.json');
783
756
  res.json(config);
784
757
  });
785
758
 
@@ -794,22 +767,60 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
794
767
  const logMtime = getSessionLogStat(meta).mtime;
795
768
  const sessionStale = logMtime ? (Date.now() - logMtime) > AGENT_STALE_MS : true;
796
769
 
770
+ let teamConfig = loadTeamConfig(req.params.sessionId);
771
+ if (!teamConfig && existsSync(TEAMS_DIR)) {
772
+ try {
773
+ for (const td of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
774
+ if (!td.isDirectory()) continue;
775
+ const cfg = loadTeamConfig(td.name);
776
+ if (cfg && cfg.leadSessionId === sessionId) { teamConfig = cfg; break; }
777
+ }
778
+ } catch (_) {}
779
+ }
780
+ const isTeam = !!teamConfig;
781
+ const teamMemberNames = isTeam ? new Set(teamConfig.members.map(m => m.name)) : null;
782
+
797
783
  const files = readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
798
784
  const agents = [];
799
785
  for (const file of files) {
800
786
  try {
801
787
  const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
802
788
  if (isGhostAgent(agent)) continue;
803
- const agentStale = !sessionStale && agent.updatedAt && (Date.now() - new Date(agent.updatedAt).getTime()) > AGENT_STALE_MS;
789
+ const agentTs = agent.updatedAt || agent.startedAt;
790
+ const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
804
791
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
805
792
  if (agent.status === 'active' || agent.status === 'idle') {
806
- agent.status = 'stopped';
807
- if (!agent.stoppedAt) agent.stoppedAt = agent.updatedAt || agent.startedAt;
793
+ const agentName = agent.type || agent.name;
794
+ const isTeamMember = isTeam && agentName && teamMemberNames.has(agentName);
795
+ if (!isTeamMember) {
796
+ agent.status = 'stopped';
797
+ if (!agent.stoppedAt) agent.stoppedAt = agent.updatedAt || agent.startedAt;
798
+ }
808
799
  }
809
800
  }
810
801
  agents.push(agent);
811
802
  } catch (e) { /* skip invalid */ }
812
803
  }
804
+ const liveAgents = agents.filter(a => a.status === 'active' || a.status === 'idle');
805
+ if (liveAgents.length && meta.jsonlPath) {
806
+ try {
807
+ const terminated = getTerminatedTeammates(meta.jsonlPath);
808
+ if (terminated.size) {
809
+ for (const agent of liveAgents) {
810
+ const agentName = agent.type || agent.name;
811
+ if (agentName && terminated.has(agentName)) {
812
+ const terminatedAt = terminated.get(agentName);
813
+ if (terminatedAt && agent.startedAt && terminatedAt < agent.startedAt) continue;
814
+ agent.status = 'stopped';
815
+ agent.stoppedAt = agent.stoppedAt || new Date().toISOString();
816
+ const agentFile = path.join(agentDir, agent.agentId + '.json');
817
+ fs.writeFile(agentFile, JSON.stringify(agent), 'utf8').catch(() => {});
818
+ }
819
+ }
820
+ }
821
+ } catch (_) {}
822
+ }
823
+
813
824
  const agentsNeedingPrompt = agents.filter(a => !a.prompt);
814
825
  if (agentsNeedingPrompt.length && meta.jsonlPath) {
815
826
  try {
@@ -828,8 +839,21 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
828
839
  }
829
840
  } catch (_) {}
830
841
  }
842
+ const teamColors = {};
843
+ if (teamConfig?.members) {
844
+ for (const m of teamConfig.members) {
845
+ if (m.name && m.color) teamColors[m.name] = m.color;
846
+ }
847
+ if (Object.keys(teamColors).length) {
848
+ for (const agent of agents) {
849
+ const name = agent.type || agent.name;
850
+ if (name && teamColors[name]) agent.color = teamColors[name];
851
+ }
852
+ }
853
+ }
854
+
831
855
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
832
- res.json({ agents, waitingForUser });
856
+ res.json({ agents, waitingForUser, teamColors });
833
857
  } catch (e) {
834
858
  res.json({ agents: [], waitingForUser: null });
835
859
  }