claude-code-kanban 2.1.0-rc.1 → 2.1.0-rc.3

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/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 = {
@@ -375,6 +396,51 @@ function readRecentMessages(jsonlPath, limit = 10) {
375
396
  } else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
376
397
  if (typeof obj.message.content === 'string') {
377
398
  const t = obj.message.content;
399
+ const tmMatch = t.match(/<teammate-message\s+([^>]*)>([\s\S]*?)<\/teammate-message>/);
400
+ if (tmMatch) {
401
+ const attrs = tmMatch[1];
402
+ const body = tmMatch[2].trim();
403
+ const getAttr = (name) => (attrs.match(new RegExp(name + '="([^"]*)"')) || [])[1] || null;
404
+ const tid = getAttr('teammate_id');
405
+ const color = getAttr('color');
406
+ const summary = getAttr('summary');
407
+ let protocol = null;
408
+ try {
409
+ const j = JSON.parse(body);
410
+ if (j.type) protocol = j;
411
+ } catch (_) {}
412
+ const isIdle = protocol?.type === 'idle_notification';
413
+ const isProtocol = !!protocol;
414
+ let protocolLabel = null;
415
+ if (protocol) {
416
+ switch (protocol.type) {
417
+ case 'idle_notification': protocolLabel = protocol.idleReason || 'idle'; break;
418
+ case 'task_assignment': protocolLabel = `assigned #${protocol.taskId}: ${protocol.subject || ''}`; break;
419
+ case 'shutdown_request': protocolLabel = `shutdown: ${protocol.reason || 'requested'}`; break;
420
+ case 'shutdown_response': protocolLabel = protocol.approve ? 'shutdown approved' : `shutdown rejected: ${protocol.reason || ''}`; break;
421
+ case 'plan_approval_request': protocolLabel = 'plan approval requested'; break;
422
+ case 'plan_approval_response': protocolLabel = protocol.approve ? 'plan approved' : `plan rejected: ${protocol.feedback || ''}`; break;
423
+ case 'teammate_terminated': protocolLabel = protocol.message || 'shut down'; break;
424
+ default: protocolLabel = protocol.type.replace(/_/g, ' '); break;
425
+ }
426
+ }
427
+ const truncated = !isProtocol && body.length > 500;
428
+ messages.push({
429
+ type: 'teammate',
430
+ teammateId: tid,
431
+ color,
432
+ summary,
433
+ isIdle,
434
+ isProtocol,
435
+ protocolType: protocol?.type || null,
436
+ protocolLabel,
437
+ protocolData: protocol || null,
438
+ text: isProtocol ? null : (truncated ? body.slice(0, 500) + '...' : body),
439
+ fullText: isProtocol ? null : (truncated ? body : null),
440
+ timestamp: obj.timestamp
441
+ });
442
+ continue;
443
+ }
378
444
  const sysLabel = getSystemMessageLabel(t);
379
445
  if (sysLabel === '__skip__') continue;
380
446
  const uTruncated = t.length > 500;
@@ -440,7 +506,9 @@ function buildAgentProgressMap(jsonlPath) {
440
506
  const parentRe = /"parentToolUseID":"([^"]+)"/;
441
507
  const promptRe = /"prompt":"((?:[^"\\]|\\.)*)"/;
442
508
  const bgToolIdRe = /"tool_use_id":"([^"]+)"/;
443
- 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_@-]+)/;
444
512
  for (const line of content.split('\n')) {
445
513
  if (line.includes('"agent_progress"')) {
446
514
  const agentMatch = re.exec(line);
@@ -462,6 +530,12 @@ function buildAgentProgressMap(jsonlPath) {
462
530
  if (toolIdMatch && bgAgentMatch && !map[toolIdMatch[1]]) {
463
531
  map[toolIdMatch[1]] = { agentId: bgAgentMatch[1], prompt: null };
464
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
+ }
465
539
  }
466
540
  }
467
541
  } catch (_) {}
@@ -506,6 +580,39 @@ function readCompactSummaries(jsonlPath) {
506
580
  return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
507
581
  }
508
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
+
509
616
  module.exports = {
510
617
  parseTask,
511
618
  parseAgent,
@@ -516,5 +623,6 @@ module.exports = {
516
623
  readSessionInfoFromJsonl,
517
624
  readRecentMessages,
518
625
  buildAgentProgressMap,
519
- readCompactSummaries
626
+ readCompactSummaries,
627
+ findTerminatedTeammates
520
628
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.1.0-rc.1",
3
+ "version": "2.1.0-rc.3",
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,15 +713,53 @@ 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>`;
735
+ } else if (m.type === 'teammate') {
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) : '';
746
+ if (m.isIdle) {
747
+ return `<div class="msg-item msg-teammate msg-idle" ${clickable}>
748
+ ${MSG_ICON_IDLE}
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}
750
+ </div>`;
751
+ }
752
+ if (m.isProtocol) {
753
+ return `<div class="msg-item msg-teammate msg-protocol" ${clickable}>
754
+ ${MSG_ICON_TEAMMATE}
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}
756
+ </div>`;
757
+ }
758
+ const summaryText = m.summary ? escapeHtml(m.summary) : escapeHtml((m.text || '').slice(0, 80));
759
+ return `<div class="msg-item msg-teammate" ${clickable}>
760
+ ${MSG_ICON_TEAMMATE}
761
+ <div class="msg-body"><div class="msg-text">${nameSpan} ${summaryText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}${pinBtn}
762
+ </div>`;
716
763
  }
717
764
  return '';
718
765
  })
@@ -736,6 +783,10 @@ const MSG_ICON_TOOL =
736
783
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>';
737
784
  const MSG_ICON_SYSTEM =
738
785
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
786
+ const MSG_ICON_TEAMMATE =
787
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
788
+ const MSG_ICON_IDLE =
789
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6"/></svg>';
739
790
  const AGENT_LOG_ICON =
740
791
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
741
792
  function agentLogButton(agentId) {
@@ -963,12 +1014,25 @@ function showMsgDetail(idx) {
963
1014
  } else {
964
1015
  agentBtn.style.display = 'none';
965
1016
  }
966
- const toolParamsHtml = renderToolParamsHtml(m.params);
967
- 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);
968
1026
  const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
969
1027
  let mainHtml;
970
- 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) {
971
1033
  mainHtml = descHtml || '';
1034
+ } else if (taskResultHtml) {
1035
+ mainHtml = '';
972
1036
  } else if (fullText) {
973
1037
  const detailEscaped = escapeHtml(fullText);
974
1038
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
@@ -976,7 +1040,18 @@ function showMsgDetail(idx) {
976
1040
  } else {
977
1041
  mainHtml = '<em>No details</em>';
978
1042
  }
979
- body.innerHTML = mainHtml + toolParamsHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1043
+ body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1044
+ } else if (m.type === 'teammate') {
1045
+ document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
1046
+ document.getElementById('msg-detail-agent-btn').style.display = 'none';
1047
+ if (m.isProtocol) {
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>`;
1051
+ } else {
1052
+ const text = stripAnsi(m.fullText || m.text || '');
1053
+ body.innerHTML = renderMarkdown(text);
1054
+ }
980
1055
  } else {
981
1056
  const text = stripAnsi(m.fullText || m.text);
982
1057
  document.getElementById('msg-detail-title').textContent =
@@ -1067,6 +1142,69 @@ async function copyWithFeedback(text, btn) {
1067
1142
  //#endregion
1068
1143
 
1069
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
+
1070
1208
  function renderToolParamsHtml(params) {
1071
1209
  if (!params) return '';
1072
1210
  const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'plan']);
@@ -1272,9 +1410,14 @@ function renderAgentFooter() {
1272
1410
  if (overlapped || reSpawn || isActive) filtered.push(group[i]);
1273
1411
  }
1274
1412
  }
1275
- // 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 };
1276
1415
  const visible = filtered
1277
- .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
+ )
1278
1421
  .slice(0, AGENT_LOG_MAX);
1279
1422
 
1280
1423
  const permFresh = currentWaiting?.timestamp && now - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
@@ -1320,7 +1463,9 @@ function renderAgentFooter() {
1320
1463
  const colonIdx = rawType.indexOf(':');
1321
1464
  const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
1322
1465
  const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
1323
- 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}')">
1324
1469
  <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span></div>
1325
1470
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
1326
1471
  ${msgHtml}
@@ -2961,9 +3106,12 @@ function setupEventSource() {
2961
3106
  function debouncedRefresh(sessionId, isMetadata) {
2962
3107
  if (isMetadata) {
2963
3108
  clearTimeout(metadataRefreshTimer);
2964
- metadataRefreshTimer = setTimeout(() => {
3109
+ metadataRefreshTimer = setTimeout(async () => {
2965
3110
  fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
2966
- if (currentSessionId && !agentLogMode) fetchMessages(currentSessionId);
3111
+ if (currentSessionId) {
3112
+ await fetchAgents(currentSessionId);
3113
+ if (!agentLogMode) fetchMessages(currentSessionId);
3114
+ }
2967
3115
  }, 2000);
2968
3116
  } else {
2969
3117
  pendingTaskSessionIds.add(sessionId);
@@ -3252,13 +3400,46 @@ const ownerColors = [
3252
3400
  { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
3253
3401
  { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
3254
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
+ };
3255
3415
  const ownerColorCache = {};
3416
+ const teamColorMap = {};
3256
3417
  function isInternalTask(task) {
3257
3418
  return task.metadata && task.metadata._internal === true;
3258
3419
  }
3259
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
+
3260
3434
  function getOwnerColor(name) {
3261
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
+ }
3262
3443
  let hash = 5381;
3263
3444
  for (let i = 0; i < name.length; i++) {
3264
3445
  hash = ((hash * 33) ^ name.charCodeAt(i)) | 0;
@@ -3623,6 +3804,10 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3623
3804
  if (session.tasksDir) {
3624
3805
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
3625
3806
  }
3807
+ if (teamConfig?.configPath) {
3808
+ const configDir = teamConfig.configPath.replace(/[/\\][^/\\]+$/, '');
3809
+ infoRows.push(['Team Config', teamConfig.configPath, { openPath: configDir, openFile: teamConfig.configPath }]);
3810
+ }
3626
3811
  const clickableStyle =
3627
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;";
3628
3813
  const plainStyle =
@@ -3638,7 +3823,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3638
3823
  } else {
3639
3824
  html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
3640
3825
  }
3641
- html += `<button onclick="navigator.clipboard.writeText('${copyVal.replace(/'/g, "\\'")}'); this.textContent='✓'; setTimeout(() => this.textContent='Copy', 1000)" style="padding: 2px 8px; font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); cursor: pointer; white-space: nowrap;">Copy</button>`;
3826
+ const jsCopyVal = copyVal.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
3827
+ html += `<button onclick="navigator.clipboard.writeText('${jsCopyVal}'); this.textContent='✓'; setTimeout(() => this.textContent='Copy', 1000)" style="padding: 2px 8px; font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); cursor: pointer; white-space: nowrap;">Copy</button>`;
3642
3828
  });
3643
3829
  html += `</div>`;
3644
3830
 
@@ -3686,10 +3872,12 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3686
3872
  members.forEach((member) => {
3687
3873
  const taskCount = ownerCounts[member.name] || 0;
3688
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}"` : '';
3689
3878
  html += `
3690
- <div class="team-member-card">
3691
- <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3692
- <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>
3693
3881
  ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
3694
3882
  ${memberDesc ? `<div class="member-detail" style="margin-top: 4px; font-style: italic; color: var(--text-secondary);">${escapeHtml(memberDesc.split('\n')[0])}</div>` : ''}
3695
3883
  <div class="member-tasks">Tasks: ${taskCount} assigned</div>
package/public/style.css CHANGED
@@ -2115,6 +2115,36 @@ body::before {
2115
2115
  padding: 0;
2116
2116
  font-size: 11px;
2117
2117
  }
2118
+ .msg-item.msg-teammate {
2119
+ border-left: 3px solid var(--team);
2120
+ }
2121
+ .msg-item.msg-protocol {
2122
+ border-left: 3px solid var(--border);
2123
+ opacity: 0.7;
2124
+ }
2125
+ .msg-item.msg-idle {
2126
+ border-left: 3px solid var(--border);
2127
+ opacity: 0.75;
2128
+ }
2129
+ .msg-item.msg-idle .msg-icon {
2130
+ width: 12px;
2131
+ height: 12px;
2132
+ }
2133
+ .teammate-name {
2134
+ font-weight: 600;
2135
+ font-size: 11px;
2136
+ }
2137
+ .idle-label,
2138
+ .protocol-label {
2139
+ font-size: 10px;
2140
+ color: var(--text-muted);
2141
+ font-style: italic;
2142
+ }
2143
+ .teammate-idle-detail {
2144
+ padding: 12px;
2145
+ text-align: center;
2146
+ color: var(--text-muted);
2147
+ }
2118
2148
  .msg-time {
2119
2149
  font-size: 10px;
2120
2150
  color: var(--text-muted);
@@ -2150,6 +2180,7 @@ body::before {
2150
2180
  }
2151
2181
  .msg-agent-log-btn {
2152
2182
  flex-shrink: 0;
2183
+ margin-left: 0;
2153
2184
  background: none;
2154
2185
  border: none;
2155
2186
  color: var(--text-muted);
@@ -2167,6 +2198,52 @@ body::before {
2167
2198
  opacity: 1;
2168
2199
  color: var(--accent);
2169
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
+ }
2170
2247
 
2171
2248
  /* #endregion */
2172
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');
@@ -55,7 +56,8 @@ const CONTEXT_STATUS_DIR = path.join(CLAUDE_DIR, 'context-status');
55
56
 
56
57
  const PERMISSION_TTL_MS = 1800000;
57
58
  const AGENT_TTL_MS = 3600000;
58
- const AGENT_STALE_MS = 300000;
59
+ const AGENT_STALE_MS = 900000;
60
+ const SESSION_STALE_MS = 300000;
59
61
 
60
62
  const WAITING_RESOLVE_GRACE_MS = 15000;
61
63
 
@@ -74,10 +76,20 @@ function checkWaitingForUser(agentDir, logMtime) {
74
76
  return null;
75
77
  }
76
78
 
79
+ function isGhostAgent(agent) {
80
+ if (agent.startedAt !== agent.updatedAt || agent.lastMessage) return false;
81
+ return (Date.now() - new Date(agent.startedAt).getTime()) >= AGENT_STALE_MS;
82
+ }
83
+
84
+ function getContextStatus(sessionId, meta) {
85
+ return contextStatusCache.get(sessionId) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
86
+ }
87
+
77
88
  function isAgentFresh(agent) {
78
- if (!agent.updatedAt) return true;
79
- if (agent.startedAt === agent.updatedAt && !agent.lastMessage) return false;
80
- return (Date.now() - new Date(agent.updatedAt).getTime()) < AGENT_TTL_MS;
89
+ if (isGhostAgent(agent)) return false;
90
+ const ts = agent.updatedAt || agent.startedAt;
91
+ if (!ts) return true;
92
+ return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
81
93
  }
82
94
 
83
95
  function getSessionLogStat(meta) {
@@ -88,17 +100,20 @@ function getSessionLogStat(meta) {
88
100
  } catch (e) { return { mtime: null, hasMessages: false }; }
89
101
  }
90
102
 
91
- function checkAgentStatus(agentDir, stale, logMtime) {
103
+ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
92
104
  const result = { hasActive: false, hasRunning: false, waitingForUser: null };
93
105
  if (!existsSync(agentDir)) return result;
94
106
  result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
95
107
  if (result.waitingForUser) result.hasActive = true;
96
- if (stale) return result;
108
+ if (stale && !isTeam) return result;
97
109
  try {
98
110
  for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
99
111
  try {
100
112
  const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
101
- 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)) {
102
117
  if (agent.status === 'active') { result.hasActive = true; result.hasRunning = true; }
103
118
  }
104
119
  if (result.hasRunning && result.hasActive) break;
@@ -170,6 +185,7 @@ const messageCache = new Map();
170
185
  const MESSAGE_CACHE_TTL = 5000;
171
186
  const MAX_CACHE_ENTRIES = 200;
172
187
  const progressMapCache = new Map();
188
+ const terminatedCache = new Map();
173
189
  const compactSummaryCache = new Map();
174
190
  const taskCountsCache = new Map();
175
191
  const contextStatusCache = new Map();
@@ -208,36 +224,32 @@ function getTaskCounts(sessionPath) {
208
224
  return result;
209
225
  }
210
226
 
211
- function getProgressMap(jsonlPath) {
227
+ function cachedByMtime(cache, filePath, loadFn, fallback) {
212
228
  try {
213
- const cached = progressMapCache.get(jsonlPath);
214
- if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.map;
215
- 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);
216
232
  if (cached && cached.mtime === st.mtimeMs) {
217
233
  cached.ts = Date.now();
218
- return cached.map;
234
+ return cached.data;
219
235
  }
220
- const map = buildAgentProgressMap(jsonlPath);
221
- progressMapCache.set(jsonlPath, { map, mtime: st.mtimeMs, ts: Date.now() });
222
- evictStaleCache(progressMapCache);
223
- return map;
224
- } 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());
225
249
  }
226
250
 
227
251
  function readRecentMessages(jsonlPath, limit = 10) {
228
- try {
229
- const stat = statSync(jsonlPath);
230
- const cached = messageCache.get(jsonlPath);
231
- if (cached && cached.mtime === stat.mtimeMs && Date.now() - cached.ts < MESSAGE_CACHE_TTL) {
232
- return cached.messages;
233
- }
234
- const messages = _readRecentMessagesUncached(jsonlPath, limit);
235
- messageCache.set(jsonlPath, { messages, mtime: stat.mtimeMs, ts: Date.now() });
236
- evictStaleCache(messageCache);
237
- return messages;
238
- } catch (e) {
239
- return [];
240
- }
252
+ return cachedByMtime(messageCache, jsonlPath, p => _readRecentMessagesUncached(p, limit), []);
241
253
  }
242
254
 
243
255
  /**
@@ -378,6 +390,42 @@ function getSessionDisplayName(sessionId, meta) {
378
390
  return null;
379
391
  }
380
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
+
381
429
  // API: List all sessions
382
430
  app.get('/api/sessions', async (req, res) => {
383
431
  // Prevent browser caching
@@ -422,43 +470,31 @@ app.get('/api/sessions', async (req, res) => {
422
470
  }
423
471
 
424
472
  const isTeam = isTeamSession(entry.name);
425
- const memberCount = isTeam ? (loadTeamConfig(entry.name)?.members?.length || 0) : 0;
473
+ const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
474
+ const memberCount = teamConfig?.members?.length || 0;
426
475
  const planInfo = getPlanInfo(meta.slug);
427
476
 
428
477
  const resolvedAgentDir = (() => {
429
- const tc = loadTeamConfig(entry.name);
430
- const rid = (tc && tc.leadSessionId) ? tc.leadSessionId : entry.name;
478
+ const rid = teamConfig?.leadSessionId || entry.name;
431
479
  return path.join(AGENT_ACTIVITY_DIR, rid);
432
480
  })();
433
- const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime);
434
-
435
- sessionsMap.set(entry.name, {
436
- id: entry.name,
437
- name: getSessionDisplayName(entry.name, meta),
438
- slug: meta.slug || null,
439
- project: meta.project || null,
440
- description: meta.description || null,
441
- gitBranch: meta.gitBranch || null,
442
- 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,
443
485
  taskCount,
444
486
  completed,
445
487
  inProgress,
446
488
  pending,
447
- createdAt: meta.created || null,
448
- modifiedAt: modifiedAt,
489
+ modifiedAt,
449
490
  isTeam,
450
491
  memberCount,
451
- hasMessages: logStat.hasMessages,
452
492
  hasActiveAgents: agentStatus.hasActive,
453
493
  hasRunningAgents: agentStatus.hasRunning,
454
494
  hasWaitingForUser: !!agentStatus.waitingForUser,
455
- hasRecentLog: logAge <= AGENT_STALE_MS,
456
- jsonlPath: meta.jsonlPath || null,
457
495
  tasksDir: sessionPath,
458
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
459
- contextStatus: contextStatusCache.get(entry.name) || (meta.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null,
460
496
  ...planInfo
461
- });
497
+ }));
462
498
  }
463
499
  }
464
500
  }
@@ -475,36 +511,16 @@ app.get('/api/sessions', async (req, res) => {
475
511
  const jsonlMtime = new Date(logMtime).toISOString();
476
512
  if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
477
513
  }
478
- const planInfo = getPlanInfo(meta.slug);
514
+ const metaIsTeam = isTeamSession(sessionId);
479
515
  const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
480
- const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime);
481
- sessionsMap.set(sessionId, {
482
- id: sessionId,
483
- name: getSessionDisplayName(sessionId, meta),
484
- slug: meta.slug || null,
485
- project: meta.project || null,
486
- description: meta.description || null,
487
- gitBranch: meta.gitBranch || null,
488
- customTitle: meta.customTitle || null,
489
- taskCount: 0,
490
- completed: 0,
491
- inProgress: 0,
492
- pending: 0,
493
- createdAt: meta.created || null,
516
+ const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
517
+ sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
518
+ _logStat: logStat,
494
519
  modifiedAt: modifiedAt || new Date(0).toISOString(),
495
- isTeam: false,
496
- memberCount: 0,
497
- hasMessages: logStat.hasMessages,
498
520
  hasActiveAgents: metaAgentStatus.hasActive,
499
521
  hasRunningAgents: metaAgentStatus.hasRunning,
500
522
  hasWaitingForUser: !!metaAgentStatus.waitingForUser,
501
- hasRecentLog: logAge <= AGENT_STALE_MS,
502
- jsonlPath: meta.jsonlPath || null,
503
- tasksDir: null,
504
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
505
- contextStatus: contextStatusCache.get(sessionId) || (meta.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null,
506
- ...planInfo
507
- });
523
+ }));
508
524
  }
509
525
  }
510
526
 
@@ -518,29 +534,12 @@ app.get('/api/sessions', async (req, res) => {
518
534
  const logStat = getSessionLogStat(meta);
519
535
  const waiting = checkWaitingForUser(agentDir, logStat.mtime);
520
536
  if (!waiting) continue;
521
- sessionsMap.set(dir.name, {
522
- id: dir.name,
523
- name: getSessionDisplayName(dir.name, meta),
524
- slug: meta.slug || null,
525
- project: meta.project || null,
526
- description: meta.description || null,
527
- gitBranch: meta.gitBranch || null,
528
- customTitle: meta.customTitle || null,
529
- taskCount: 0,
530
- completed: 0,
531
- inProgress: 0,
532
- pending: 0,
533
- createdAt: meta.created || null,
537
+ sessionsMap.set(dir.name, buildSessionObject(dir.name, meta, {
538
+ _logStat: logStat,
534
539
  modifiedAt: waiting.timestamp || new Date().toISOString(),
535
- isTeam: false,
536
- memberCount: 0,
537
- hasMessages: logStat.hasMessages,
538
540
  hasActiveAgents: true,
539
541
  hasWaitingForUser: true,
540
- jsonlPath: meta.jsonlPath || null,
541
- tasksDir: null,
542
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
543
- });
542
+ }));
544
543
  }
545
544
  } catch (e) { /* ignore */ }
546
545
  }
@@ -554,8 +553,7 @@ app.get('/api/sessions', async (req, res) => {
554
553
  }
555
554
  }
556
555
  for (const leaderId of teamLeaderIds) {
557
- const session = sessionsMap.get(leaderId);
558
- if (session && session.taskCount === 0) {
556
+ if (sessionsMap.has(leaderId)) {
559
557
  sessionsMap.delete(leaderId);
560
558
  }
561
559
  }
@@ -585,7 +583,7 @@ app.get('/api/sessions', async (req, res) => {
585
583
  const s = sessionsMap.get(pid);
586
584
  if (s && !s.contextStatus) {
587
585
  const meta = metadata[pid];
588
- s.contextStatus = contextStatusCache.get(pid) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
586
+ s.contextStatus = getContextStatus(pid, meta);
589
587
  }
590
588
  }
591
589
 
@@ -594,34 +592,17 @@ app.get('/api/sessions', async (req, res) => {
594
592
  if (sessionsMap.has(pid)) continue;
595
593
  const meta = metadata[pid];
596
594
  if (!meta) continue;
597
- const logStat = getSessionLogStat(meta);
598
- const logMtime = logStat.mtime;
595
+ const pinnedLogStat = getSessionLogStat(meta);
596
+ const pinnedLogMtime = pinnedLogStat.mtime;
599
597
  let modifiedAt = meta.created || null;
600
- if (logMtime) {
601
- const jsonlMtime = new Date(logMtime).toISOString();
598
+ if (pinnedLogMtime) {
599
+ const jsonlMtime = new Date(pinnedLogMtime).toISOString();
602
600
  if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
603
601
  }
604
- sessionsMap.set(pid, {
605
- id: pid,
606
- name: getSessionDisplayName(pid, meta),
607
- slug: meta.slug || null,
608
- project: meta.project || null,
609
- description: meta.description || null,
610
- gitBranch: meta.gitBranch || null,
611
- customTitle: meta.customTitle || null,
612
- taskCount: 0, completed: 0, inProgress: 0, pending: 0,
613
- createdAt: meta.created || null,
602
+ sessionsMap.set(pid, buildSessionObject(pid, meta, {
603
+ _logStat: pinnedLogStat,
614
604
  modifiedAt: modifiedAt || new Date(0).toISOString(),
615
- isTeam: false, memberCount: 0,
616
- hasMessages: logStat.hasMessages,
617
- hasActiveAgents: false, hasRunningAgents: false, hasWaitingForUser: false,
618
- hasRecentLog: false,
619
- jsonlPath: meta.jsonlPath || null,
620
- tasksDir: null,
621
- projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
622
- contextStatus: contextStatusCache.get(pid) || (meta.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null,
623
- ...getPlanInfo(meta.slug)
624
- });
605
+ }));
625
606
  }
626
607
 
627
608
  // Convert map to array and sort by most recently modified
@@ -771,6 +752,7 @@ app.post('/api/open-in-editor', (req, res) => {
771
752
  app.get('/api/teams/:name', (req, res) => {
772
753
  const config = loadTeamConfig(req.params.name);
773
754
  if (!config) return res.status(404).json({ error: 'Team not found' });
755
+ config.configPath = path.join(TEAMS_DIR, req.params.name, 'config.json');
774
756
  res.json(config);
775
757
  });
776
758
 
@@ -785,22 +767,60 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
785
767
  const logMtime = getSessionLogStat(meta).mtime;
786
768
  const sessionStale = logMtime ? (Date.now() - logMtime) > AGENT_STALE_MS : true;
787
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
+
788
783
  const files = readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
789
784
  const agents = [];
790
785
  for (const file of files) {
791
786
  try {
792
787
  const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
793
- if (agent.startedAt === agent.updatedAt && !agent.lastMessage) continue;
794
- const agentStale = !sessionStale && agent.updatedAt && (Date.now() - new Date(agent.updatedAt).getTime()) > AGENT_STALE_MS;
788
+ if (isGhostAgent(agent)) continue;
789
+ const agentTs = agent.updatedAt || agent.startedAt;
790
+ const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
795
791
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
796
792
  if (agent.status === 'active' || agent.status === 'idle') {
797
- agent.status = 'stopped';
798
- 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
+ }
799
799
  }
800
800
  }
801
801
  agents.push(agent);
802
802
  } catch (e) { /* skip invalid */ }
803
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
+
804
824
  const agentsNeedingPrompt = agents.filter(a => !a.prompt);
805
825
  if (agentsNeedingPrompt.length && meta.jsonlPath) {
806
826
  try {
@@ -819,8 +839,21 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
819
839
  }
820
840
  } catch (_) {}
821
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
+
822
855
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
823
- res.json({ agents, waitingForUser });
856
+ res.json({ agents, waitingForUser, teamColors });
824
857
  } catch (e) {
825
858
  res.json({ agents: [], waitingForUser: null });
826
859
  }
@@ -828,7 +861,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
828
861
 
829
862
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
830
863
  const sessionId = resolveSessionId(req.params.sessionId);
831
- const agentId = path.basename(req.params.agentId).replace(/[^a-zA-Z0-9_-]/g, '');
864
+ const agentId = sanitizeAgentId(req.params.agentId);
832
865
  const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.json');
833
866
  if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
834
867
  try {
@@ -897,27 +930,28 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
897
930
  awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }
898
931
  });
899
932
 
900
- const cleanup = () => watcher.close();
933
+ let closed = false;
934
+ const cleanup = () => { if (!closed) { closed = true; watcher.close(); } };
935
+
936
+ function emitMessages() {
937
+ const messages = readRecentMessages(subagentJsonl, 50);
938
+ lastSize = statSync(subagentJsonl).size;
939
+ res.write(`event: agent-log-update\ndata: ${JSON.stringify({ messages, agentId })}\n\n`);
940
+ }
901
941
 
902
942
  watcher.on('change', () => {
903
943
  try {
904
- const currentSize = statSync(subagentJsonl).size;
905
- if (currentSize <= lastSize) return;
906
- const messages = readRecentMessages(subagentJsonl, 50);
907
- lastSize = currentSize;
908
- res.write(`event: agent-log-update\ndata: ${JSON.stringify({ messages, agentId })}\n\n`);
944
+ if (statSync(subagentJsonl).size <= lastSize) return;
945
+ emitMessages();
909
946
  } catch (_) {}
910
947
  });
911
948
 
912
949
  watcher.on('add', () => {
913
- try {
914
- const messages = readRecentMessages(subagentJsonl, 50);
915
- lastSize = statSync(subagentJsonl).size;
916
- res.write(`event: agent-log-update\ndata: ${JSON.stringify({ messages, agentId })}\n\n`);
917
- } catch (_) {}
950
+ try { emitMessages(); } catch (_) {}
918
951
  });
919
952
 
920
953
  req.on('close', cleanup);
954
+ res.on('close', cleanup);
921
955
  res.on('error', cleanup);
922
956
  });
923
957