claude-code-kanban 1.18.0 → 1.19.0-rc.1

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,43 @@
4
4
 
5
5
  INPUT=$(cat)
6
6
 
7
- SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
8
- AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty')
9
- EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
7
+ # Single jq call to extract all routing fields (was 3-4 separate calls)
8
+ eval "$(echo "$INPUT" | jq -r '
9
+ @sh "SESSION_ID=\(.session_id // "")",
10
+ @sh "AGENT_ID=\(.agent_id // "")",
11
+ @sh "EVENT=\(.hook_event_name // "")",
12
+ @sh "TOOL_NAME=\(.tool_name // "")",
13
+ @sh "AGENT_TYPE_RAW=\(.agent_type // "")"
14
+ ')"
10
15
 
11
- [ -z "$SESSION_ID" ] || [ -z "$AGENT_ID" ] && exit 0
16
+ [ -z "$SESSION_ID" ] && exit 0
17
+
18
+ # PostToolUse: clear waiting state (permission granted or question answered)
19
+ if [ "$EVENT" = "PostToolUse" ]; then
20
+ WFILE="$HOME/.claude/agent-activity/$SESSION_ID/_waiting.json"
21
+ [ -f "$WFILE" ] && rm -f "$WFILE"
22
+ exit 0
23
+ fi
24
+
25
+ # Waiting-for-user events → write _waiting.json marker
26
+ if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" = "AskUserQuestion" ]; }; then
27
+ DIR="$HOME/.claude/agent-activity/$SESSION_ID"
28
+ mkdir -p "$DIR"
29
+ KIND="permission"
30
+ [ "$EVENT" = "PreToolUse" ] && KIND="question"
31
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
32
+ echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" '{
33
+ status: "waiting",
34
+ kind: $kind,
35
+ toolName: (.tool_name // "unknown"),
36
+ toolInput: ((.tool_input | tostring)[0:200] // ""),
37
+ timestamp: $ts
38
+ }' > "$DIR/_waiting.json"
39
+ exit 0
40
+ fi
41
+
42
+ [ -z "$AGENT_ID" ] && exit 0
12
43
 
13
- AGENT_TYPE_RAW=$(echo "$INPUT" | jq -r '.agent_type // empty')
14
44
  DIR="$HOME/.claude/agent-activity/$SESSION_ID"
15
45
  FILE="$DIR/$AGENT_ID.json"
16
46
 
@@ -33,27 +63,26 @@ if [ "$EVENT" = "SubagentStart" ]; then
33
63
  EOF
34
64
 
35
65
  elif [ "$EVENT" = "SubagentStop" ]; then
36
- # Read type and startedAt from existing file if available
37
66
  AGENT_TYPE="$AGENT_TYPE_RAW"
38
67
  STARTED_AT="$TS"
39
68
  if [ -f "$FILE" ]; then
40
- [ -z "$AGENT_TYPE" ] && AGENT_TYPE=$(jq -r '.type // "unknown"' "$FILE")
41
- STARTED_AT=$(jq -r '.startedAt // empty' "$FILE")
42
- [ -z "$STARTED_AT" ] && STARTED_AT="$TS"
69
+ eval "$(jq -r '@sh "PREV_TYPE=\(.type // "unknown")", @sh "PREV_START=\(.startedAt // "")"' "$FILE")"
70
+ [ -z "$AGENT_TYPE" ] && AGENT_TYPE="$PREV_TYPE"
71
+ [ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
43
72
  fi
44
- LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
45
- LAST_MSG_ESC=$(echo "$LAST_MSG" | jq -Rs '.')
46
- cat > "$FILE" <<EOF
47
- {"agentId":"$AGENT_ID","type":"$AGENT_TYPE","status":"stopped","startedAt":"$STARTED_AT","lastMessage":$LAST_MSG_ESC,"stoppedAt":"$TS","updatedAt":"$TS"}
48
- EOF
73
+ echo "$INPUT" | jq -c \
74
+ --arg id "$AGENT_ID" --arg type "$AGENT_TYPE" --arg started "$STARTED_AT" --arg ts "$TS" \
75
+ '{agentId: $id, type: $type, status: "stopped", startedAt: $started,
76
+ lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}' \
77
+ > "$FILE"
49
78
 
50
79
  elif [ "$EVENT" = "TeammateIdle" ]; then
51
80
  AGENT_TYPE="$AGENT_TYPE_RAW"
52
81
  STARTED_AT="$TS"
53
82
  if [ -f "$FILE" ]; then
54
- [ -z "$AGENT_TYPE" ] && AGENT_TYPE=$(jq -r '.type // "unknown"' "$FILE")
55
- STARTED_AT=$(jq -r '.startedAt // empty' "$FILE")
56
- [ -z "$STARTED_AT" ] && STARTED_AT="$TS"
83
+ eval "$(jq -r '@sh "PREV_TYPE=\(.type // "unknown")", @sh "PREV_START=\(.startedAt // "")"' "$FILE")"
84
+ [ -z "$AGENT_TYPE" ] && AGENT_TYPE="$PREV_TYPE"
85
+ [ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
57
86
  fi
58
87
  cat > "$FILE" <<EOF
59
88
  {"agentId":"$AGENT_ID","type":"$AGENT_TYPE","status":"idle","startedAt":"$STARTED_AT","updatedAt":"$TS"}
package/install.js CHANGED
@@ -14,7 +14,14 @@ const HOOK_SCRIPT_SRC = path.join(__dirname, 'hooks', 'agent-spy.sh');
14
14
  const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
15
15
 
16
16
  const HOOK_COMMAND = '~/.claude/hooks/agent-spy.sh';
17
- const HOOK_EVENTS = ['SubagentStart', 'SubagentStop', 'TeammateIdle'];
17
+ const HOOK_EVENTS = [
18
+ { event: 'SubagentStart' },
19
+ { event: 'SubagentStop' },
20
+ { event: 'TeammateIdle' },
21
+ { event: 'PermissionRequest' },
22
+ { event: 'PreToolUse', matcher: 'AskUserQuestion' },
23
+ { event: 'PostToolUse' },
24
+ ];
18
25
 
19
26
  // ANSI helpers
20
27
  const green = s => `\x1b[32m${s}\x1b[0m`;
@@ -95,12 +102,13 @@ async function runInstall() {
95
102
  if (!settings.hooks) settings.hooks = {};
96
103
 
97
104
  const needed = [];
98
- for (const event of HOOK_EVENTS) {
105
+ for (const { event, matcher } of HOOK_EVENTS) {
99
106
  if (!settings.hooks[event]) settings.hooks[event] = [];
107
+ const matcherStr = matcher || '';
100
108
  const exists = settings.hooks[event].some(g =>
101
- g.hooks?.some(h => h.command === HOOK_COMMAND)
109
+ g.matcher === matcherStr && g.hooks?.some(h => h.command === HOOK_COMMAND)
102
110
  );
103
- if (!exists) needed.push(event);
111
+ if (!exists) needed.push({ event, matcher: matcherStr });
104
112
  }
105
113
 
106
114
  let settingsUpdated = false;
@@ -108,11 +116,11 @@ async function runInstall() {
108
116
  console.log(` ${green('✓')} Already configured`);
109
117
  settingsUpdated = true;
110
118
  } else {
111
- console.log(` Adding hooks for: ${needed.join(', ')}`);
119
+ console.log(` Adding hooks for: ${needed.map(n => n.matcher ? `${n.event}:${n.matcher}` : n.event).join(', ')}`);
112
120
  if (await prompt(` Update settings? [Y/n] `)) {
113
- for (const event of needed) {
121
+ for (const { event, matcher } of needed) {
114
122
  settings.hooks[event].push({
115
- matcher: '',
123
+ matcher,
116
124
  hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }]
117
125
  });
118
126
  }
@@ -147,7 +155,8 @@ async function runUninstall() {
147
155
  const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
148
156
  if (settings.hooks) {
149
157
  let removed = 0;
150
- for (const event of HOOK_EVENTS) {
158
+ const eventNames = [...new Set(HOOK_EVENTS.map(e => e.event))];
159
+ for (const event of eventNames) {
151
160
  if (!Array.isArray(settings.hooks[event])) continue;
152
161
  const before = settings.hooks[event].length;
153
162
  settings.hooks[event] = settings.hooks[event].filter(g =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.18.0",
3
+ "version": "1.19.0-rc.1",
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": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
- "dev": "node server.js --open"
11
+ "dev": "node server.js --open",
12
+ "test": "bash tests/test-agent-spy.sh"
12
13
  },
13
14
  "repository": {
14
15
  "type": "git",
package/public/index.html CHANGED
@@ -472,6 +472,20 @@
472
472
  margin-top: 6px;
473
473
  }
474
474
 
475
+ .session-latest-msg {
476
+ font-size: 11px;
477
+ color: var(--text-muted);
478
+ margin-top: 2px;
479
+ white-space: nowrap;
480
+ overflow: hidden;
481
+ text-overflow: ellipsis;
482
+ }
483
+ .session-latest-msg .lm-label {
484
+ color: var(--text-tertiary);
485
+ font-weight: 500;
486
+ font-size: 10px;
487
+ }
488
+
475
489
  /* Footer */
476
490
  .sidebar-footer {
477
491
  flex-shrink: 0;
@@ -563,6 +577,24 @@
563
577
  margin-top: 4px;
564
578
  }
565
579
 
580
+ .latest-message {
581
+ flex: 1;
582
+ font-size: 11px;
583
+ color: var(--text-secondary);
584
+ white-space: nowrap;
585
+ overflow: hidden;
586
+ text-overflow: ellipsis;
587
+ text-align: right;
588
+ padding: 0 16px;
589
+ min-width: 0;
590
+ display: none;
591
+ }
592
+ .latest-message.visible { display: block; }
593
+ .latest-message .lm-label {
594
+ color: var(--accent);
595
+ font-weight: 500;
596
+ }
597
+
566
598
  .view-actions {
567
599
  display: flex;
568
600
  align-items: center;
@@ -856,7 +888,7 @@
856
888
  position: fixed;
857
889
  top: 0;
858
890
  right: 0;
859
- width: 440px;
891
+ width: 540px;
860
892
  height: 100vh;
861
893
  background: var(--bg-surface);
862
894
  border-left: 1px solid var(--border);
@@ -1389,6 +1421,110 @@
1389
1421
  padding: 6px 10px;
1390
1422
  }
1391
1423
 
1424
+ /* Message log panel */
1425
+ .message-panel {
1426
+ position: fixed;
1427
+ top: 0;
1428
+ right: 0;
1429
+ width: 540px;
1430
+ height: 100vh;
1431
+ background: var(--bg-surface);
1432
+ border-left: 1px solid var(--border);
1433
+ box-shadow: -8px 0 24px rgba(0, 0, 0, 0.15);
1434
+ display: none;
1435
+ flex-direction: column;
1436
+ z-index: 99;
1437
+ overflow: hidden;
1438
+ }
1439
+ .message-panel.visible { display: flex; }
1440
+ .message-panel-header {
1441
+ padding: 16px 20px;
1442
+ display: flex;
1443
+ align-items: center;
1444
+ justify-content: space-between;
1445
+ background-image: linear-gradient(to right, transparent, var(--border), transparent);
1446
+ background-size: 100% 1px;
1447
+ background-repeat: no-repeat;
1448
+ background-position: bottom;
1449
+ }
1450
+ .message-panel-header h3 {
1451
+ font-family: var(--serif);
1452
+ font-size: 14px;
1453
+ font-weight: 500;
1454
+ }
1455
+ .message-panel-content {
1456
+ flex: 1;
1457
+ overflow-y: auto;
1458
+ padding: 12px 16px;
1459
+ display: flex;
1460
+ flex-direction: column;
1461
+ gap: 8px;
1462
+ }
1463
+ .msg-item {
1464
+ display: flex;
1465
+ gap: 8px;
1466
+ padding: 8px 10px;
1467
+ border-radius: 6px;
1468
+ font-size: 12px;
1469
+ line-height: 1.5;
1470
+ background: var(--bg-elevated);
1471
+ border: 1px solid var(--border);
1472
+ }
1473
+ .msg-item.msg-user {
1474
+ border-left: 3px solid var(--text-muted);
1475
+ }
1476
+ .msg-item.msg-assistant {
1477
+ border-left: 3px solid var(--accent);
1478
+ }
1479
+ .msg-item.msg-tool {
1480
+ border-left: 3px solid var(--team);
1481
+ font-size: 11px;
1482
+ padding: 5px 10px;
1483
+ color: var(--text-secondary);
1484
+ }
1485
+ .msg-icon {
1486
+ flex-shrink: 0;
1487
+ width: 16px;
1488
+ height: 16px;
1489
+ margin-top: 2px;
1490
+ opacity: 0.6;
1491
+ }
1492
+ .msg-body { flex: 1; min-width: 0; }
1493
+ .msg-text {
1494
+ white-space: pre-wrap;
1495
+ word-break: break-word;
1496
+ color: var(--text-primary);
1497
+ display: -webkit-box;
1498
+ -webkit-line-clamp: 3;
1499
+ -webkit-box-orient: vertical;
1500
+ overflow: hidden;
1501
+ }
1502
+ .msg-tool .msg-text { color: var(--text-secondary); }
1503
+ .msg-detail-pre {
1504
+ white-space: pre-wrap;
1505
+ word-break: break-all;
1506
+ margin: 0;
1507
+ font-family: var(--font-mono);
1508
+ font-size: 0.85rem;
1509
+ }
1510
+ .msg-cmd .msg-text code {
1511
+ background: var(--bg-hover);
1512
+ padding: 2px 6px;
1513
+ border-radius: 3px;
1514
+ font-size: 11px;
1515
+ }
1516
+ .msg-time {
1517
+ font-size: 10px;
1518
+ color: var(--text-muted);
1519
+ margin-top: 4px;
1520
+ }
1521
+ .msg-empty {
1522
+ text-align: center;
1523
+ color: var(--text-muted);
1524
+ padding: 40px 20px;
1525
+ font-size: 12px;
1526
+ }
1527
+
1392
1528
  /* Agent footer panel */
1393
1529
  .agent-footer {
1394
1530
  display: none;
@@ -1455,6 +1591,27 @@
1455
1591
  max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1456
1592
  }
1457
1593
 
1594
+ .agent-badge { font-size: 12px; cursor: default; }
1595
+
1596
+ /* Permission pending — indicated by ❓ badge only */
1597
+ .permission-badge {
1598
+ display: inline-flex;
1599
+ align-items: center;
1600
+ gap: 6px;
1601
+ padding: 6px 12px;
1602
+ background: var(--warning-dim);
1603
+ border: 1px solid var(--warning);
1604
+ border-radius: 8px;
1605
+ font-size: 12px;
1606
+ color: var(--warning);
1607
+ white-space: nowrap;
1608
+ animation: permission-pulse 1.5s ease-in-out infinite;
1609
+ }
1610
+ @keyframes permission-pulse {
1611
+ 0%, 100% { opacity: 1; }
1612
+ 50% { opacity: 0.5; }
1613
+ }
1614
+
1458
1615
  /* Light mode */
1459
1616
  body.light {
1460
1617
  --bg-deep: #e8e6e3;
@@ -2006,6 +2163,7 @@
2006
2163
  <h1 id="session-title" class="view-title">Session</h1>
2007
2164
  <p id="session-meta" class="view-meta"></p>
2008
2165
  </div>
2166
+ <div id="latest-message" class="latest-message"></div>
2009
2167
  <div class="view-actions">
2010
2168
  <div class="view-progress">
2011
2169
  <div class="progress-bar">
@@ -2013,6 +2171,11 @@
2013
2171
  </div>
2014
2172
  <span id="progress-percent" class="progress-text">0%</span>
2015
2173
  </div>
2174
+ <button id="message-toggle" class="icon-btn" onclick="toggleMessagePanel()" title="Session log (L)" aria-label="Toggle session log">
2175
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2176
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
2177
+ </svg>
2178
+ </button>
2016
2179
  <button id="theme-toggle" class="icon-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
2017
2180
  <svg id="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2018
2181
  <path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
@@ -2099,6 +2262,18 @@
2099
2262
  </header>
2100
2263
  <div id="detail-content" class="detail-content"></div>
2101
2264
  </aside>
2265
+
2266
+ <aside id="message-panel" class="message-panel">
2267
+ <header class="message-panel-header">
2268
+ <h3>Session Log</h3>
2269
+ <button class="detail-close" onclick="toggleMessagePanel()" aria-label="Close message panel">
2270
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2271
+ <path d="M6 18L18 6M6 6l12 12"/>
2272
+ </svg>
2273
+ </button>
2274
+ </header>
2275
+ <div id="message-panel-content" class="message-panel-content"></div>
2276
+ </aside>
2102
2277
  </div>
2103
2278
 
2104
2279
  <script>
@@ -2116,7 +2291,12 @@
2116
2291
  let bulkDeleteSessionId = null; // Track session for bulk delete
2117
2292
  let ownerFilter = '';
2118
2293
  let currentAgents = [];
2294
+ let currentWaiting = null;
2119
2295
  let lastAgentsHash = '';
2296
+ let messagePanelOpen = false;
2297
+ let lastMessagesHash = '';
2298
+ let lastInlineMessage = '';
2299
+ let currentMessages = [];
2120
2300
  let agentDurationInterval = null;
2121
2301
  let selectedTaskId = null;
2122
2302
  let selectedSessionId = null;
@@ -2497,9 +2677,13 @@
2497
2677
  currentTasks = newTasks;
2498
2678
  currentSessionId = sessionId;
2499
2679
  ownerFilter = '';
2680
+ lastMessagesHash = '';
2681
+ lastInlineMessage = '';
2682
+ document.getElementById('latest-message').classList.remove('visible');
2500
2683
  updateUrl();
2501
2684
  renderSession();
2502
2685
  fetchAgents(sessionId);
2686
+ fetchMessages(sessionId);
2503
2687
  } catch (error) {
2504
2688
  console.error('Failed to fetch tasks:', error);
2505
2689
  currentTasks = [];
@@ -2511,15 +2695,17 @@
2511
2695
  }
2512
2696
 
2513
2697
  const AGENT_COOLDOWN_MS = 3 * 60 * 1000;
2514
- const AGENT_STALE_MS = 5 * 60 * 1000;
2698
+ const AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for force-stopping
2515
2699
  const AGENT_LOG_MAX = 8;
2516
2700
 
2517
2701
  async function fetchAgents(sessionId) {
2518
2702
  try {
2519
2703
  const res = await fetch(`/api/sessions/${sessionId}/agents`);
2520
- if (!res.ok) { currentAgents = []; renderAgentFooter(); return; }
2521
- const agents = await res.json();
2522
- const hash = JSON.stringify(agents);
2704
+ if (!res.ok) { currentAgents = []; currentWaiting = null; renderAgentFooter(); return; }
2705
+ const data = await res.json();
2706
+ const agents = Array.isArray(data) ? data : (data.agents || []);
2707
+ currentWaiting = data.waitingForUser || null;
2708
+ const hash = JSON.stringify(data);
2523
2709
  if (hash === lastAgentsHash) return;
2524
2710
  lastAgentsHash = hash;
2525
2711
  currentAgents = agents;
@@ -2529,6 +2715,133 @@
2529
2715
  }
2530
2716
  }
2531
2717
 
2718
+ function toggleMessagePanel() {
2719
+ const panel = document.getElementById('message-panel');
2720
+ messagePanelOpen = !messagePanelOpen;
2721
+ panel.classList.toggle('visible', messagePanelOpen);
2722
+ document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
2723
+ if (messagePanelOpen && currentSessionId) {
2724
+ if (currentMessages.length) renderMessages(currentMessages);
2725
+ fetchMessages(currentSessionId);
2726
+ }
2727
+ }
2728
+
2729
+ async function fetchMessages(sessionId) {
2730
+ try {
2731
+ const res = await fetch(`/api/sessions/${sessionId}/messages?limit=15`);
2732
+ if (!res.ok) return;
2733
+ const data = await res.json();
2734
+ const hash = JSON.stringify(data.messages);
2735
+ if (hash === lastMessagesHash) return;
2736
+ lastMessagesHash = hash;
2737
+ currentMessages = data.messages;
2738
+ updateLatestMessage(data.messages);
2739
+ if (messagePanelOpen) renderMessages(data.messages);
2740
+ } catch (e) {
2741
+ console.error('[fetchMessages]', e);
2742
+ }
2743
+ }
2744
+
2745
+ function parseCommandMessage(text) {
2746
+ const nameMatch = text.match(/<command-name>([^<]+)<\/command-name>/);
2747
+ if (nameMatch) return nameMatch[1].trim();
2748
+ const msgMatch = text.match(/<command-message>([^<]+)<\/command-message>/);
2749
+ if (msgMatch) return '/' + msgMatch[1].trim();
2750
+ return null;
2751
+ }
2752
+
2753
+ function cleanMessageText(text) {
2754
+ const cmd = parseCommandMessage(text);
2755
+ if (cmd) return cmd;
2756
+ return text
2757
+ .replace(/<[^>]+>/g, '')
2758
+ .replace(/\*\*/g, '')
2759
+ .replace(/^#+\s*/gm, '')
2760
+ .replace(/\n/g, ' ')
2761
+ .replace(/\s+/g, ' ')
2762
+ .trim();
2763
+ }
2764
+
2765
+ function updateLatestMessage(messages) {
2766
+ const el = document.getElementById('latest-message');
2767
+ let last = null;
2768
+ for (let i = messages.length - 1; i >= 0; i--) {
2769
+ if (messages[i].type === 'assistant' || messages[i].type === 'user') { last = messages[i]; break; }
2770
+ }
2771
+ if (!last) { el.classList.remove('visible'); lastInlineMessage = ''; return; }
2772
+ const label = last.type === 'assistant' ? 'Claude' : 'User';
2773
+ const cleaned = cleanMessageText(last.text);
2774
+ const text = cleaned.length > 120 ? cleaned.slice(0, 120) + '...' : cleaned;
2775
+ const html = `<span class="lm-label">${escapeHtml(label)}:</span> ${escapeHtml(text)}`;
2776
+ el.innerHTML = html;
2777
+ el.classList.add('visible');
2778
+ if (lastInlineMessage !== html) {
2779
+ lastInlineMessage = html;
2780
+ renderSessions();
2781
+ }
2782
+ }
2783
+
2784
+ function renderMessages(messages) {
2785
+ const container = document.getElementById('message-panel-content');
2786
+ if (!messages.length) {
2787
+ container.innerHTML = '<div class="msg-empty">No messages found for this session</div>';
2788
+ return;
2789
+ }
2790
+ container.innerHTML = messages.map((m, i) => {
2791
+ const clickable = `onclick="showMsgDetail(${i})" style="cursor:pointer"`;
2792
+ if (m.type === 'user') {
2793
+ const cmd = parseCommandMessage(m.text);
2794
+ const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
2795
+ const isCmd = !!cmd;
2796
+ return `<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
2797
+ <svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
2798
+ <div class="msg-body"><div class="msg-text">${isCmd ? '<code>' + escapeHtml(displayText) + '</code>' : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
2799
+ </div>`;
2800
+ } else if (m.type === 'assistant') {
2801
+ return `<div class="msg-item msg-assistant" ${clickable}>
2802
+ <svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="9" cy="16" r="1.5"/><circle cx="15" cy="16" r="1.5"/><path d="M12 2v4M8 7h8"/></svg>
2803
+ <div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? escapeHtml(m.model) + ' · ' : ''}${formatDate(m.timestamp)}</div></div>
2804
+ </div>`;
2805
+ } else if (m.type === 'tool_use') {
2806
+ const toolDetail = m.detail ? ` <span style="color:var(--text-muted)">${escapeHtml(m.detail)}</span>` : '';
2807
+ return `<div class="msg-item msg-tool" ${clickable}>
2808
+ <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>
2809
+ <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
2810
+ </div>`;
2811
+ }
2812
+ return '';
2813
+ }).join('');
2814
+ container.scrollTop = container.scrollHeight;
2815
+ }
2816
+
2817
+ function showMsgDetail(idx) {
2818
+ const m = currentMessages[idx];
2819
+ if (!m) return;
2820
+ const body = document.getElementById('msg-detail-body');
2821
+ if (m.type === 'tool_use') {
2822
+ document.getElementById('msg-detail-title').textContent = m.tool;
2823
+ const fullText = m.fullDetail || m.detail || '';
2824
+ const descHtml = m.description && m.description !== fullText ? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>` : '';
2825
+ body.innerHTML = fullText ? descHtml + `<pre class="msg-detail-pre">${escapeHtml(fullText)}</pre>` : '<em>No details</em>';
2826
+ } else {
2827
+ const text = m.fullText || m.text;
2828
+ document.getElementById('msg-detail-title').textContent = m.type === 'assistant' ? 'Claude' : 'User';
2829
+ if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
2830
+ body.innerHTML = DOMPurify.sanitize(marked.parse(text));
2831
+ } else {
2832
+ body.textContent = text;
2833
+ }
2834
+ }
2835
+ const meta = [formatDate(m.timestamp)];
2836
+ if (m.model) meta.unshift(m.model);
2837
+ document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
2838
+ document.getElementById('msg-detail-modal').classList.add('visible');
2839
+ }
2840
+
2841
+ function closeMsgDetailModal() {
2842
+ document.getElementById('msg-detail-modal').classList.remove('visible');
2843
+ }
2844
+
2532
2845
  function formatDuration(ms) {
2533
2846
  const s = Math.floor(ms / 1000);
2534
2847
  if (s < 60) return `${s}s`;
@@ -2543,13 +2856,7 @@
2543
2856
  const label = document.getElementById('agent-footer-label');
2544
2857
  const now = Date.now();
2545
2858
 
2546
- // Mark stale "active" agents as stopped (SubagentStop unreliable for shutdown agents)
2547
- const agents = currentAgents.map(a => {
2548
- if (a.status === 'active' && a.startedAt && (now - new Date(a.startedAt).getTime()) > AGENT_STALE_MS) {
2549
- return { ...a, status: 'stopped', stoppedAt: a.updatedAt };
2550
- }
2551
- return a;
2552
- });
2859
+ const agents = currentAgents;
2553
2860
  // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
2554
2861
  // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
2555
2862
  const byType = {};
@@ -2572,7 +2879,10 @@
2572
2879
  .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
2573
2880
  .slice(0, AGENT_LOG_MAX);
2574
2881
 
2575
- if (visible.length === 0) {
2882
+ const permFresh = currentWaiting && currentWaiting.timestamp &&
2883
+ (now - new Date(currentWaiting.timestamp).getTime()) < 120000;
2884
+
2885
+ if (visible.length === 0 && !permFresh) {
2576
2886
  footer.classList.remove('visible');
2577
2887
  clearInterval(agentDurationInterval);
2578
2888
  agentDurationInterval = null;
@@ -2586,7 +2896,11 @@
2586
2896
  footer.classList.toggle('collapsed', collapsed);
2587
2897
  document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '&#x25B4;' : '&#x25BE;';
2588
2898
 
2589
- content.innerHTML = visible.map(a => {
2899
+ const permHtml = permFresh
2900
+ ? `<div class="permission-badge">${currentWaiting.kind === 'question' ? '❓ Question pending' : '⏳ Awaiting: ' + escapeHtml(currentWaiting.toolName || 'unknown')}</div>`
2901
+ : '';
2902
+
2903
+ content.innerHTML = permHtml + visible.map(a => {
2590
2904
  const elapsed = a.status === 'stopped' && a.stoppedAt
2591
2905
  ? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
2592
2906
  : now - new Date(a.startedAt || a.updatedAt).getTime();
@@ -2685,7 +2999,7 @@
2685
2999
  viewMode = 'all';
2686
3000
  currentSessionId = null;
2687
3001
  ownerFilter = '';
2688
- currentAgents = []; lastAgentsHash = ''; renderAgentFooter();
3002
+ currentAgents = []; currentWaiting = null; lastAgentsHash = ''; renderAgentFooter();
2689
3003
  const res = await fetch('/api/tasks/all');
2690
3004
  let tasks = await res.json();
2691
3005
  if (filterProject) {
@@ -2728,7 +3042,12 @@
2728
3042
 
2729
3043
  let filteredSessions = sessions;
2730
3044
  if (sessionFilter === 'active') {
2731
- filteredSessions = filteredSessions.filter(s => s.pending > 0 || s.inProgress > 0 || s.hasPlan);
3045
+ const ACTIVE_PLAN_MS = 15 * 60 * 1000;
3046
+ const RECENTLY_MODIFIED_MS = 5 * 60 * 1000;
3047
+ const now = Date.now();
3048
+ filteredSessions = filteredSessions.filter(s => s.pending > 0 || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser ||
3049
+ (s.hasPlan && (now - new Date(s.modifiedAt).getTime()) <= ACTIVE_PLAN_MS) ||
3050
+ (now - new Date(s.modifiedAt).getTime()) <= RECENTLY_MODIFIED_MS);
2732
3051
  }
2733
3052
  if (filterProject) {
2734
3053
  filteredSessions = filteredSessions.filter(s => matchesProjectFilter(s.project));
@@ -2804,15 +3123,15 @@
2804
3123
  : modifiedDisplay;
2805
3124
 
2806
3125
  // Build tooltip
2807
- const tooltip = [timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
3126
+ const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
2808
3127
 
2809
3128
  const isTeam = session.isTeam;
2810
3129
  const memberCount = session.memberCount || 0;
2811
3130
 
2812
3131
  return `
2813
- <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''}" title="${tooltip}">
3132
+ <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''}" title="${tooltip}">
2814
3133
  <div class="session-name">${escapeHtml(primaryName)}</div>
2815
- ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
3134
+ ${isActive && lastInlineMessage ? `<div class="session-latest-msg">${lastInlineMessage}</div>` : secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2816
3135
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
2817
3136
  ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
2818
3137
  <div class="session-progress">
@@ -2820,6 +3139,8 @@
2820
3139
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>${memberCount}</span>` : ''}
2821
3140
  ${(isTeam || session.project) ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2822
3141
  ${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
3142
+ ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
3143
+ ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
2823
3144
  ${hasInProgress ? '<span class="pulse"></span>' : ''}
2824
3145
  </span>
2825
3146
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
@@ -3517,7 +3838,12 @@
3517
3838
  return;
3518
3839
  }
3519
3840
 
3520
- if (document.querySelector('.modal-overlay.visible')) return;
3841
+ if (document.querySelector('.modal-overlay.visible')) {
3842
+ if (e.key === 'Escape') {
3843
+ document.querySelectorAll('.modal-overlay.visible').forEach(m => m.classList.remove('visible'));
3844
+ }
3845
+ return;
3846
+ }
3521
3847
 
3522
3848
  if (e.key === '[') {
3523
3849
  e.preventDefault();
@@ -3525,6 +3851,12 @@
3525
3851
  return;
3526
3852
  }
3527
3853
 
3854
+ if ((e.key === 'L' || e.key === 'l') && e.shiftKey) {
3855
+ e.preventDefault();
3856
+ toggleMessagePanel();
3857
+ return;
3858
+ }
3859
+
3528
3860
  // Tab toggles focus zone
3529
3861
  if (e.key === 'Tab') {
3530
3862
  e.preventDefault();
@@ -3605,8 +3937,9 @@
3605
3937
  return;
3606
3938
  }
3607
3939
 
3608
- if (e.key === 'Escape' && detailPanel.classList.contains('visible')) {
3609
- closeDetailPanel();
3940
+ if (e.key === 'Escape') {
3941
+ if (detailPanel.classList.contains('visible')) closeDetailPanel();
3942
+ else if (messagePanelOpen) toggleMessagePanel();
3610
3943
  }
3611
3944
 
3612
3945
  if (e.key === 'p' || e.key === 'P') {
@@ -3637,12 +3970,19 @@
3637
3970
  function setupEventSource() {
3638
3971
  let retryDelay = 1000;
3639
3972
  let eventSource;
3973
+ let wasConnected = false;
3640
3974
 
3641
3975
  function connect() {
3642
3976
  eventSource = new EventSource('/api/events');
3643
3977
 
3644
3978
  eventSource.onopen = () => {
3645
- retryDelay = 1000; // Reset on successful connection
3979
+ if (wasConnected) {
3980
+ console.warn('[SSE] Reconnected after drop — forcing full refresh');
3981
+ fetchSessions().catch(() => {});
3982
+ if (currentSessionId) fetchTasks(currentSessionId);
3983
+ }
3984
+ wasConnected = true;
3985
+ retryDelay = 1000;
3646
3986
  connectionStatus.innerHTML = `
3647
3987
  <span class="connection-dot live"></span>
3648
3988
  <span>Connected</span>
@@ -3651,24 +3991,37 @@
3651
3991
 
3652
3992
  eventSource.onerror = () => {
3653
3993
  eventSource.close();
3994
+ console.warn('[SSE] Connection lost, retrying in', retryDelay, 'ms');
3654
3995
  connectionStatus.innerHTML = `
3655
3996
  <span class="connection-dot error"></span>
3656
3997
  <span>Reconnecting...</span>
3657
3998
  `;
3658
3999
  setTimeout(connect, retryDelay);
3659
- retryDelay = Math.min(retryDelay * 2, 30000); // Max 30s
4000
+ retryDelay = Math.min(retryDelay * 2, 30000);
3660
4001
  };
3661
4002
 
3662
- let refreshTimer = null;
4003
+ let taskRefreshTimer = null;
4004
+ let metadataRefreshTimer = null;
4005
+ let pendingTaskSessionIds = new Set();
4006
+
3663
4007
  function debouncedRefresh(sessionId, isMetadata) {
3664
- clearTimeout(refreshTimer);
3665
- const delay = isMetadata ? 2000 : 500;
3666
- refreshTimer = setTimeout(() => {
3667
- fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
3668
- if (currentSessionId && (isMetadata || sessionId === currentSessionId)) {
3669
- fetchTasks(currentSessionId);
3670
- }
3671
- }, delay);
4008
+ if (isMetadata) {
4009
+ clearTimeout(metadataRefreshTimer);
4010
+ metadataRefreshTimer = setTimeout(() => {
4011
+ fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
4012
+ if (currentSessionId) fetchMessages(currentSessionId);
4013
+ }, 2000);
4014
+ } else {
4015
+ pendingTaskSessionIds.add(sessionId);
4016
+ clearTimeout(taskRefreshTimer);
4017
+ taskRefreshTimer = setTimeout(() => {
4018
+ fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
4019
+ if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
4020
+ fetchTasks(currentSessionId);
4021
+ }
4022
+ pendingTaskSessionIds.clear();
4023
+ }, 500);
4024
+ }
3672
4025
  }
3673
4026
 
3674
4027
  eventSource.onmessage = (event) => {
@@ -3683,6 +4036,7 @@
3683
4036
  }
3684
4037
 
3685
4038
  if (data.type === 'agent-update') {
4039
+ fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
3686
4040
  if (currentSessionId && data.sessionId === currentSessionId) {
3687
4041
  fetchAgents(currentSessionId);
3688
4042
  }
@@ -3691,13 +4045,15 @@
3691
4045
  if (data.type === 'team-update') {
3692
4046
  console.log('[SSE] Team update:', data.teamName);
3693
4047
  debouncedRefresh(data.teamName, false);
3694
- if (currentSessionId && data.teamName === currentSessionId) {
3695
- fetchTasks(currentSessionId);
3696
- }
3697
4048
  }
3698
4049
  };
3699
4050
  }
3700
4051
 
4052
+ // Fallback poll every 30s in case SSE silently drops
4053
+ setInterval(() => {
4054
+ fetchSessions().catch(() => {});
4055
+ }, 30000);
4056
+
3701
4057
  connect();
3702
4058
  }
3703
4059
 
@@ -4211,6 +4567,22 @@
4211
4567
  });
4212
4568
  </script>
4213
4569
 
4570
+ <!-- Message Detail Modal -->
4571
+ <div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
4572
+ <div class="modal" onclick="event.stopPropagation()" style="max-width: 640px;">
4573
+ <div class="modal-header">
4574
+ <h3 class="modal-title" id="msg-detail-title">Message</h3>
4575
+ <button class="modal-close" aria-label="Close" onclick="closeMsgDetailModal()">
4576
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
4577
+ </button>
4578
+ </div>
4579
+ <div class="modal-body">
4580
+ <div id="msg-detail-body" style="white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.6; max-height: 60vh; overflow-y: auto;"></div>
4581
+ <div id="msg-detail-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted);"></div>
4582
+ </div>
4583
+ </div>
4584
+ </div>
4585
+
4214
4586
  <!-- Help Modal -->
4215
4587
  <div id="help-modal" class="modal-overlay" onclick="closeHelpModal()">
4216
4588
  <div class="modal" onclick="event.stopPropagation()">
@@ -4285,6 +4657,10 @@
4285
4657
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">D</kbd></td>
4286
4658
  <td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
4287
4659
  </tr>
4660
+ <tr>
4661
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+L</kbd></td>
4662
+ <td style="padding: 4px 0; color: var(--text-primary);">Toggle session log</td>
4663
+ </tr>
4288
4664
  </table>
4289
4665
  </div>
4290
4666
  </div>
package/server.js CHANGED
@@ -43,6 +43,70 @@ const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
43
43
  const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
44
44
  const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
45
45
 
46
+ const PERMISSION_TTL_MS = 1800000;
47
+ const AGENT_TTL_MS = 3600000;
48
+ const AGENT_STALE_MS = 120000;
49
+
50
+ function checkWaitingForUser(agentDir) {
51
+ try {
52
+ const waitFile = path.join(agentDir, '_waiting.json');
53
+ if (!existsSync(waitFile)) return null;
54
+ const data = JSON.parse(readFileSync(waitFile, 'utf8'));
55
+ if (data.status === 'waiting' && data.timestamp) {
56
+ const age = Date.now() - new Date(data.timestamp).getTime();
57
+ if (age < PERMISSION_TTL_MS) return data;
58
+ }
59
+ } catch (e) { /* skip invalid */ }
60
+ return null;
61
+ }
62
+
63
+ function isAgentFresh(agent) {
64
+ if (!agent.updatedAt) return true;
65
+ return (Date.now() - new Date(agent.updatedAt).getTime()) < AGENT_TTL_MS;
66
+ }
67
+
68
+ function isSessionStale(meta) {
69
+ if (!meta.jsonlPath) return false;
70
+ try {
71
+ const mtime = statSync(meta.jsonlPath).mtime;
72
+ return (Date.now() - mtime.getTime()) > AGENT_STALE_MS;
73
+ } catch (e) { return false; }
74
+ }
75
+
76
+ function checkActiveAgents(sessionId, meta) {
77
+ const teamConfig = loadTeamConfig(sessionId);
78
+ const resolvedId = (teamConfig && teamConfig.leadSessionId) ? teamConfig.leadSessionId : sessionId;
79
+ const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedId);
80
+ if (!existsSync(agentDir)) return false;
81
+ try {
82
+ if (meta && isSessionStale(meta)) return false;
83
+ if (checkWaitingForUser(agentDir)) return true;
84
+ for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
85
+ try {
86
+ const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
87
+ if ((agent.status === 'active' || agent.status === 'idle') && isAgentFresh(agent)) {
88
+ return true;
89
+ }
90
+ } catch (e) { /* skip invalid */ }
91
+ }
92
+ } catch (e) { /* ignore */ }
93
+ return false;
94
+ }
95
+
96
+ function checkRunningAgents(agentDir, meta) {
97
+ if (!existsSync(agentDir)) return false;
98
+ if (meta && isSessionStale(meta)) return false;
99
+ try {
100
+ for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
101
+ try {
102
+ const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
103
+ if (agent.status === 'active' && isAgentFresh(agent)) return true;
104
+ } catch (e) { /* skip invalid */ }
105
+ }
106
+ } catch (e) { /* ignore */ }
107
+ return false;
108
+ }
109
+
46
110
  function isTeamSession(sessionId) {
47
111
  return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
48
112
  }
@@ -78,55 +142,121 @@ app.use(express.json());
78
142
  // Serve static files
79
143
  app.use(express.static(path.join(__dirname, 'public')));
80
144
 
81
- /**
82
- * Read customTitle and slug from a JSONL file
83
- * Returns { customTitle, slug } - customTitle from /rename, slug from session
84
- */
85
- function readSessionInfoFromJsonl(jsonlPath) {
86
- const result = { customTitle: null, slug: null, projectPath: null };
145
+ const messageCache = new Map();
146
+ const MESSAGE_CACHE_TTL = 5000;
87
147
 
148
+ function readRecentMessages(jsonlPath, limit = 10) {
149
+ let fd;
88
150
  try {
89
- if (!existsSync(jsonlPath)) return result;
90
-
91
- // Read first 64KB - should contain custom-title and at least one message with slug/cwd
92
- const fd = require('fs').openSync(jsonlPath, 'r');
93
- const buffer = Buffer.alloc(65536);
94
- const bytesRead = require('fs').readSync(fd, buffer, 0, 65536, 0);
95
- require('fs').closeSync(fd);
96
-
97
- const content = buffer.toString('utf8', 0, bytesRead);
98
- const lines = content.split('\n');
99
-
100
- for (const line of lines) {
101
- if (!line.trim()) continue;
102
- try {
103
- const data = JSON.parse(line);
151
+ const stat = statSync(jsonlPath);
152
+ const cached = messageCache.get(jsonlPath);
153
+ if (cached && cached.mtime === stat.mtimeMs && Date.now() - cached.ts < MESSAGE_CACHE_TTL) {
154
+ return cached.messages;
155
+ }
104
156
 
105
- // Check for custom-title entry (from /rename command)
106
- if (data.type === 'custom-title' && data.customTitle) {
107
- result.customTitle = data.customTitle;
108
- }
157
+ fd = require('fs').openSync(jsonlPath, 'r');
158
+ const messages = [];
159
+ let readSize = 65536;
109
160
 
110
- // Check for slug in user/assistant messages
111
- if (data.slug && !result.slug) {
112
- result.slug = data.slug;
113
- }
161
+ while (messages.length < limit && readSize <= stat.size) {
162
+ const start = Math.max(0, stat.size - readSize);
163
+ const bufSize = Math.min(readSize, stat.size);
164
+ const buf = Buffer.alloc(bufSize);
165
+ require('fs').readSync(fd, buf, 0, bufSize, start);
114
166
 
115
- // Extract project path from cwd field (actual path, no encoding issues)
116
- if (data.cwd && !result.projectPath) {
117
- result.projectPath = data.cwd;
118
- }
167
+ const text = buf.toString('utf8');
168
+ const firstNewline = text.indexOf('\n');
169
+ const clean = firstNewline >= 0 ? text.substring(firstNewline + 1) : text;
119
170
 
120
- // Stop early if we found all three
121
- if (result.customTitle && result.slug && result.projectPath) break;
122
- } catch (e) {
123
- // Skip malformed lines
171
+ messages.length = 0;
172
+ for (const line of clean.split('\n')) {
173
+ if (!line.trim()) continue;
174
+ try {
175
+ const obj = JSON.parse(line);
176
+ if (obj.type === 'assistant' && obj.message?.content && Array.isArray(obj.message.content)) {
177
+ for (const block of obj.message.content) {
178
+ if (block.type === 'text' && block.text) {
179
+ const truncated = block.text.length > 500;
180
+ messages.push({
181
+ type: 'assistant',
182
+ text: truncated ? block.text.slice(0, 500) + '...' : block.text,
183
+ fullText: truncated ? block.text : null,
184
+ timestamp: obj.timestamp,
185
+ model: obj.message.model || null
186
+ });
187
+ } else if (block.type === 'tool_use') {
188
+ let detail = null;
189
+ let fullDetail = null;
190
+ let inp = null;
191
+ if (block.input) {
192
+ inp = typeof block.input === 'string' ? (() => { try { return JSON.parse(block.input); } catch(_) { return {}; } })() : block.input;
193
+ if (inp.file_path) { detail = inp.file_path.replace(/^.*[/\\]/, ''); fullDetail = inp.file_path; }
194
+ else if (inp.command) { detail = inp.command.length > 80 ? inp.command.slice(0, 80) + '...' : inp.command; fullDetail = inp.command; }
195
+ else if (inp.pattern) { detail = inp.pattern; fullDetail = inp.pattern; }
196
+ else if (inp.query) { detail = inp.query; fullDetail = inp.query; }
197
+ else if (inp.description) { detail = inp.description; fullDetail = inp.description; }
198
+ }
199
+ messages.push({
200
+ type: 'tool_use',
201
+ tool: block.name,
202
+ detail,
203
+ fullDetail: fullDetail !== detail ? fullDetail : null,
204
+ description: inp?.description || null,
205
+ timestamp: obj.timestamp
206
+ });
207
+ }
208
+ }
209
+ } else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
210
+ if (typeof obj.message.content === 'string') {
211
+ const t = obj.message.content;
212
+ const uTruncated = t.length > 500;
213
+ messages.push({
214
+ type: 'user',
215
+ text: uTruncated ? t.slice(0, 500) + '...' : t,
216
+ fullText: uTruncated ? t : null,
217
+ timestamp: obj.timestamp
218
+ });
219
+ }
220
+ }
221
+ } catch (e) { /* partial line */ }
124
222
  }
223
+
224
+ if (readSize >= stat.size) break;
225
+ readSize *= 4;
125
226
  }
227
+
228
+ require('fs').closeSync(fd);
229
+ fd = null;
230
+ const result = messages.slice(-limit);
231
+ messageCache.set(jsonlPath, { messages: result, mtime: stat.mtimeMs, ts: Date.now() });
232
+ return result;
126
233
  } catch (e) {
127
- // Return partial results
234
+ if (fd) try { require('fs').closeSync(fd); } catch (_) {}
235
+ return [];
128
236
  }
237
+ }
129
238
 
239
+ /**
240
+ * Extract slug and projectPath from first few lines of a session JSONL.
241
+ * Slug and cwd appear in the first few entries, so 4KB is plenty.
242
+ */
243
+ function readSessionInfoFromJsonl(jsonlPath) {
244
+ const result = { slug: null, projectPath: null };
245
+ try {
246
+ if (!existsSync(jsonlPath)) return result;
247
+ const fd = require('fs').openSync(jsonlPath, 'r');
248
+ const buf = Buffer.alloc(4096);
249
+ const n = require('fs').readSync(fd, buf, 0, 4096, 0);
250
+ require('fs').closeSync(fd);
251
+ for (const line of buf.toString('utf8', 0, n).split('\n')) {
252
+ try {
253
+ const data = JSON.parse(line);
254
+ if (data.slug) result.slug = data.slug;
255
+ if (data.cwd) result.projectPath = data.cwd;
256
+ if (result.slug && result.projectPath) break;
257
+ } catch (e) {}
258
+ }
259
+ } catch (e) {}
130
260
  return result;
131
261
  }
132
262
 
@@ -168,7 +298,6 @@ function loadSessionMetadata() {
168
298
  }
169
299
 
170
300
  metadata[sessionId] = {
171
- customTitle: sessionInfo.customTitle,
172
301
  slug: sessionInfo.slug,
173
302
  project: sessionInfo.projectPath || null,
174
303
  jsonlPath: jsonlPath
@@ -196,7 +325,6 @@ function loadSessionMetadata() {
196
325
  if (entry.sessionId) {
197
326
  if (!metadata[entry.sessionId]) {
198
327
  metadata[entry.sessionId] = {
199
- customTitle: null,
200
328
  slug: null,
201
329
  project: entry.projectPath || null,
202
330
  jsonlPath: null
@@ -229,8 +357,7 @@ function loadSessionMetadata() {
229
357
  const project = parentMeta?.project || leadMember?.cwd || teamConfig.working_dir || null;
230
358
 
231
359
  metadata[dir.name] = {
232
- customTitle: teamConfig.description || dir.name,
233
- slug: null,
360
+ slug: teamConfig.description || dir.name,
234
361
  project,
235
362
  jsonlPath: parentMeta?.jsonlPath || null,
236
363
  description: teamConfig.description || parentMeta?.description || null,
@@ -249,9 +376,6 @@ function loadSessionMetadata() {
249
376
  return metadata;
250
377
  }
251
378
 
252
- /**
253
- * Get display name for a session: customTitle > slug > null (frontend shows UUID)
254
- */
255
379
  function getPlanInfo(slug) {
256
380
  if (!slug) return { hasPlan: false, planTitle: null };
257
381
  const planPath = path.join(PLANS_DIR, `${slug}.md`);
@@ -266,7 +390,6 @@ function getPlanInfo(slug) {
266
390
  }
267
391
 
268
392
  function getSessionDisplayName(sessionId, meta) {
269
- if (meta?.customTitle) return meta.customTitle;
270
393
  if (meta?.slug) return meta.slug;
271
394
  return null; // Frontend will show UUID as fallback
272
395
  }
@@ -331,6 +454,12 @@ app.get('/api/sessions', async (req, res) => {
331
454
  const memberCount = isTeam ? (loadTeamConfig(entry.name)?.members?.length || 0) : 0;
332
455
  const planInfo = getPlanInfo(meta.slug);
333
456
 
457
+ const resolvedAgentDir = (() => {
458
+ const tc = loadTeamConfig(entry.name);
459
+ const rid = (tc && tc.leadSessionId) ? tc.leadSessionId : entry.name;
460
+ return path.join(AGENT_ACTIVITY_DIR, rid);
461
+ })();
462
+
334
463
  sessionsMap.set(entry.name, {
335
464
  id: entry.name,
336
465
  name: getSessionDisplayName(entry.name, meta),
@@ -346,6 +475,9 @@ app.get('/api/sessions', async (req, res) => {
346
475
  modifiedAt: modifiedAt,
347
476
  isTeam,
348
477
  memberCount,
478
+ hasActiveAgents: checkActiveAgents(entry.name, meta),
479
+ hasRunningAgents: checkRunningAgents(resolvedAgentDir, meta),
480
+ hasWaitingForUser: !!checkWaitingForUser(resolvedAgentDir),
349
481
  ...planInfo
350
482
  });
351
483
  }
@@ -360,6 +492,7 @@ app.get('/api/sessions', async (req, res) => {
360
492
  try { modifiedAt = statSync(meta.jsonlPath).mtime.toISOString(); } catch (e) {}
361
493
  }
362
494
  const planInfo = getPlanInfo(meta.slug);
495
+ const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
363
496
  sessionsMap.set(sessionId, {
364
497
  id: sessionId,
365
498
  name: getSessionDisplayName(sessionId, meta),
@@ -375,11 +508,45 @@ app.get('/api/sessions', async (req, res) => {
375
508
  modifiedAt: modifiedAt || new Date(0).toISOString(),
376
509
  isTeam: false,
377
510
  memberCount: 0,
511
+ hasActiveAgents: checkActiveAgents(sessionId, meta),
512
+ hasRunningAgents: checkRunningAgents(metaAgentDir, meta),
513
+ hasWaitingForUser: !!checkWaitingForUser(metaAgentDir),
378
514
  ...planInfo
379
515
  });
380
516
  }
381
517
  }
382
518
 
519
+ // Add sessions from agent-activity that have _waiting.json but no tasks/metadata
520
+ if (existsSync(AGENT_ACTIVITY_DIR)) {
521
+ try {
522
+ for (const dir of readdirSync(AGENT_ACTIVITY_DIR, { withFileTypes: true })) {
523
+ if (!dir.isDirectory() || sessionsMap.has(dir.name)) continue;
524
+ const agentDir = path.join(AGENT_ACTIVITY_DIR, dir.name);
525
+ const waiting = checkWaitingForUser(agentDir);
526
+ if (!waiting) continue;
527
+ const meta = metadata[dir.name] || {};
528
+ sessionsMap.set(dir.name, {
529
+ id: dir.name,
530
+ name: getSessionDisplayName(dir.name, meta),
531
+ slug: meta.slug || null,
532
+ project: meta.project || null,
533
+ description: meta.description || null,
534
+ gitBranch: meta.gitBranch || null,
535
+ taskCount: 0,
536
+ completed: 0,
537
+ inProgress: 0,
538
+ pending: 0,
539
+ createdAt: meta.created || null,
540
+ modifiedAt: waiting.timestamp || new Date().toISOString(),
541
+ isTeam: false,
542
+ memberCount: 0,
543
+ hasActiveAgents: true,
544
+ hasWaitingForUser: true,
545
+ });
546
+ }
547
+ } catch (e) { /* ignore */ }
548
+ }
549
+
383
550
  // Hide leader UUID sessions that are represented by a team session
384
551
  const teamLeaderIds = new Set();
385
552
  for (const [sid, session] of sessionsMap) {
@@ -515,21 +682,42 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
515
682
  sessionId = teamConfig.leadSessionId;
516
683
  }
517
684
  const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
518
- if (!existsSync(agentDir)) return res.json([]);
685
+ if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
519
686
  try {
520
- const files = readdirSync(agentDir).filter(f => f.endsWith('.json'));
687
+ const metadata = loadSessionMetadata();
688
+ const meta = metadata[sessionId] || {};
689
+ const sessionStale = isSessionStale(meta);
690
+
691
+ const files = readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
521
692
  const agents = [];
522
693
  for (const file of files) {
523
694
  try {
524
- agents.push(JSON.parse(readFileSync(path.join(agentDir, file), 'utf8')));
695
+ const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
696
+ if (!isAgentFresh(agent)) continue;
697
+ if (sessionStale && (agent.status === 'active' || agent.status === 'idle')) {
698
+ agent.status = 'stopped';
699
+ if (!agent.stoppedAt) agent.stoppedAt = agent.updatedAt || agent.startedAt;
700
+ }
701
+ agents.push(agent);
525
702
  } catch (e) { /* skip invalid */ }
526
703
  }
527
- res.json(agents);
704
+ const waitingForUser = checkWaitingForUser(agentDir);
705
+ res.json({ agents, waitingForUser });
528
706
  } catch (e) {
529
- res.json([]);
707
+ res.json({ agents: [], waitingForUser: null });
530
708
  }
531
709
  });
532
710
 
711
+ app.get('/api/sessions/:sessionId/messages', (req, res) => {
712
+ const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
713
+ const metadata = loadSessionMetadata();
714
+ const meta = metadata[req.params.sessionId];
715
+ const jsonlPath = meta?.jsonlPath;
716
+ if (!jsonlPath) return res.json({ messages: [], sessionId: req.params.sessionId });
717
+ const messages = readRecentMessages(jsonlPath, limit);
718
+ res.json({ messages, sessionId: req.params.sessionId });
719
+ });
720
+
533
721
  app.get('/api/version', (req, res) => {
534
722
  const pkg = require('./package.json');
535
723
  res.json({ version: pkg.version });
@@ -675,9 +863,14 @@ app.get('/api/events', (req, res) => {
675
863
  res.setHeader('Connection', 'keep-alive');
676
864
 
677
865
  clients.add(res);
866
+ console.log(`[SSE] Client connected (total: ${clients.size})`);
867
+
868
+ const heartbeat = setInterval(() => res.write(':heartbeat\n\n'), 30000);
678
869
 
679
870
  req.on('close', () => {
871
+ clearInterval(heartbeat);
680
872
  clients.delete(res);
873
+ console.log(`[SSE] Client disconnected (total: ${clients.size})`);
681
874
  });
682
875
 
683
876
  // Send initial ping
@@ -782,14 +975,14 @@ const agentActivityWatcher = chokidar.watch(AGENT_ACTIVITY_DIR, {
782
975
  const AGENT_FILE_CAP = 20;
783
976
 
784
977
  agentActivityWatcher.on('all', (event, filePath) => {
785
- if ((event === 'add' || event === 'change') && filePath.endsWith('.json')) {
978
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
786
979
  const relativePath = path.relative(AGENT_ACTIVITY_DIR, filePath);
787
980
  const sessionId = relativePath.split(path.sep)[0];
788
981
  // Cleanup: if session dir exceeds cap, delete oldest files by mtime
789
982
  if (event === 'add') {
790
983
  try {
791
984
  const sessionDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
792
- const files = readdirSync(sessionDir).filter(f => f.endsWith('.json'));
985
+ const files = readdirSync(sessionDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
793
986
  if (files.length > AGENT_FILE_CAP) {
794
987
  const withStats = files.map(f => {
795
988
  const fp = path.join(sessionDir, f);