claude-code-kanban 3.9.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parsers.js CHANGED
@@ -115,6 +115,20 @@ function parseJsonlLine(line) {
115
115
  }
116
116
 
117
117
  const TOOL_RESULT_MAX = 1500;
118
+ const USER_TEXT_MAX = 500;
119
+ const INTERRUPT_MARKER = '[Request interrupted by user]';
120
+
121
+ function pushUserMessage(messages, text, timestamp, sysLabel) {
122
+ if (sysLabel === '__skip__') return;
123
+ const truncated = text.length > USER_TEXT_MAX;
124
+ messages.push({
125
+ type: 'user',
126
+ text: truncated ? text.slice(0, USER_TEXT_MAX) + '...' : text,
127
+ fullText: truncated ? text : null,
128
+ timestamp,
129
+ ...(sysLabel && { systemLabel: sysLabel })
130
+ });
131
+ }
118
132
 
119
133
  // Cache: jsonlPath -> { scannedUpTo, customTitle }
120
134
  // Only re-scan the new bytes appended since last scan
@@ -516,17 +530,16 @@ function readRecentMessages(jsonlPath, limit = 10) {
516
530
  });
517
531
  continue;
518
532
  }
519
- const sysLabel = getSystemMessageLabel(t);
520
- if (sysLabel === '__skip__') continue;
521
- const uTruncated = t.length > 500;
522
- messages.push({
523
- type: 'user',
524
- text: uTruncated ? t.slice(0, 500) + '...' : t,
525
- fullText: uTruncated ? t : null,
526
- timestamp: obj.timestamp,
527
- ...(sysLabel && { systemLabel: sysLabel })
528
- });
533
+ pushUserMessage(messages, t, obj.timestamp, getSystemMessageLabel(t));
529
534
  } else if (Array.isArray(obj.message.content)) {
535
+ const joined = obj.message.content
536
+ .filter(b => b.type === 'text' && typeof b.text === 'string' && b.text)
537
+ .map(b => b.text)
538
+ .join('\n')
539
+ .trim();
540
+ if (joined && joined !== INTERRUPT_MARKER) {
541
+ pushUserMessage(messages, joined, obj.timestamp, getSystemMessageLabel(joined));
542
+ }
530
543
  for (const block of obj.message.content) {
531
544
  if (block.type === 'tool_result' && block.tool_use_id) {
532
545
  let resultText = '';
@@ -633,6 +646,9 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
633
646
  function buildSessionDigest(jsonlPath) {
634
647
  const map = {};
635
648
  const terminated = new Map();
649
+ const rejectedToolUseIds = new Set();
650
+ const promptByToolUseId = {};
651
+ const killedAgentIds = new Set();
636
652
  try {
637
653
  const content = readFileSync(jsonlPath, 'utf8');
638
654
  const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
@@ -642,6 +658,7 @@ function buildSessionDigest(jsonlPath) {
642
658
  const bgAgentIdRe = /agentId: ([a-zA-Z0-9_@-]+)/;
643
659
  const tmToolIdRe = /"tool_use_id":"([^"]+)"/;
644
660
  const tmAgentIdRe = /agent_id: ([a-zA-Z0-9_@-]+)/;
661
+ const taskIdRe = /<task-id>([a-zA-Z0-9_-]+)<\/task-id>/;
645
662
  const nameByToolUseId = {};
646
663
  const descByToolUseId = {};
647
664
  for (const line of content.split('\n')) {
@@ -708,10 +725,18 @@ function buildSessionDigest(jsonlPath) {
708
725
  if (b.type === 'tool_use' && b.name === 'Agent' && b.id) {
709
726
  if (b.input?.name) nameByToolUseId[b.id] = b.input.name;
710
727
  if (b.input?.description) descByToolUseId[b.id] = b.input.description;
728
+ if (b.input?.prompt) promptByToolUseId[b.id] = b.input.prompt;
711
729
  }
712
730
  }
713
731
  }
714
732
  } catch (_) {}
733
+ } else if (line.includes('User rejected tool use') && line.includes('"tool_use_id"')) {
734
+ const m = tmToolIdRe.exec(line);
735
+ if (m) rejectedToolUseIds.add(m[1]);
736
+ } else if (line.includes('<task-notification>') &&
737
+ (line.includes('<status>killed</status>') || line.includes('<status>error</status>'))) {
738
+ const idMatch = taskIdRe.exec(line);
739
+ if (idMatch) killedAgentIds.add(idMatch[1]);
715
740
  } else if (line.includes('"toolUseResult"') && line.includes('"agentId"') && line.includes('"tool_result"')) {
716
741
  try {
717
742
  const obj = JSON.parse(line);
@@ -734,7 +759,15 @@ function buildSessionDigest(jsonlPath) {
734
759
  if (descByToolUseId[key]) entry.description = descByToolUseId[key];
735
760
  }
736
761
  } catch (_) {}
737
- return { progressMap: map, terminated };
762
+ const rejectedAgentIds = new Set();
763
+ const rejectedPrompts = new Set();
764
+ for (const toolUseId of rejectedToolUseIds) {
765
+ const entry = map[toolUseId];
766
+ if (entry?.agentId) rejectedAgentIds.add(entry.agentId);
767
+ const prompt = entry?.prompt || promptByToolUseId[toolUseId];
768
+ if (prompt) rejectedPrompts.add(prompt);
769
+ }
770
+ return { progressMap: map, terminated, rejectedAgentIds, rejectedPrompts, killedAgentIds };
738
771
  }
739
772
 
740
773
  function buildAgentProgressMap(jsonlPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.9.0",
3
+ "version": "4.0.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.1.5",
3
+ "version": "2.0.0",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -1,6 +1,7 @@
1
1
  #!/bin/bash
2
- # Tracks subagent lifecycle: one JSON file per agent, grouped by session
3
- # Layout: ~/.claude/.cck/agent-activity/{sessionId}/{agentId}.json
2
+ # Tracks subagent lifecycle: one append-only JSONL file per agent, grouped by session
3
+ # Layout: ~/.claude/.cck/agent-activity/{sessionId}/{agentId}.jsonl
4
+ # Each line is a lifecycle event (start | idle | stop). Server folds last-line-wins.
4
5
 
5
6
  INPUT=$(cat)
6
7
 
@@ -70,23 +71,16 @@ if [ "$EVENT" = "TeammateIdle" ] && [ -z "$AGENT_ID" ] && [ -n "$TEAMMATE_NAME"
70
71
  [ ! -f "$MAP_FILE" ] && exit 0
71
72
  AGENT_ID=$(cat "$MAP_FILE")
72
73
  [ -z "$AGENT_ID" ] && exit 0
73
- FILE="$DIR/$AGENT_ID.json"
74
+ FILE="$DIR/$AGENT_ID.jsonl"
74
75
  TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
75
- STARTED_AT="$TS"
76
- if [ -f "$FILE" ]; then
77
- PREV_START=$(jq -r '.startedAt // ""' "$FILE" 2>/dev/null)
78
- [ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
79
- fi
80
- cat > "$FILE" <<EOF
81
- {"agentId":"$AGENT_ID","type":"$TEAMMATE_NAME","status":"idle","startedAt":"$STARTED_AT","updatedAt":"$TS"}
82
- EOF
76
+ echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$TEAMMATE_NAME\",\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
83
77
  exit 0
84
78
  fi
85
79
 
86
80
  [ -z "$AGENT_ID" ] && exit 0
87
81
 
88
82
  DIR="$CCK_ACTIVITY/$SESSION_ID"
89
- FILE="$DIR/$AGENT_ID.json"
83
+ FILE="$DIR/$AGENT_ID.jsonl"
90
84
 
91
85
  # On Start: skip if no type (internal agents like AskUserQuestion)
92
86
  # On Stop/Idle: only skip if no existing file (never tracked)
@@ -102,48 +96,19 @@ mkdir -p "$DIR"
102
96
  TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
103
97
 
104
98
  if [ "$EVENT" = "SubagentStart" ]; then
105
- cat > "$FILE" <<EOF
106
- {"agentId":"$AGENT_ID","type":"$AGENT_TYPE_RAW","status":"active","startedAt":"$TS","updatedAt":"$TS"}
107
- EOF
108
- # Write name→id mapping for TeammateIdle resolution
109
- # Delete previous file only if not active (idle/stopped = teammate re-spawn, active = parallel subagent)
99
+ echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$AGENT_TYPE_RAW\",\"event\":\"start\",\"status\":\"active\",\"startedAt\":\"$TS\",\"updatedAt\":\"$TS\"}" >> "$FILE"
100
+ # Mapping always points at latest agent of this type (used by TeammateIdle resolution).
110
101
  if [ -n "$AGENT_TYPE_RAW" ]; then
111
- MAP_FILE="$DIR/_name-${AGENT_TYPE_RAW}.id"
112
- if [ -f "$MAP_FILE" ]; then
113
- OLD_ID=$(cat "$MAP_FILE")
114
- if [ -n "$OLD_ID" ] && [ "$OLD_ID" != "$AGENT_ID" ]; then
115
- OLD_FILE="$DIR/$OLD_ID.json"
116
- OLD_STATUS=""
117
- [ -f "$OLD_FILE" ] && OLD_STATUS=$(jq -r '.status // ""' "$OLD_FILE" 2>/dev/null)
118
- [ "$OLD_STATUS" != "active" ] && rm -f "$OLD_FILE"
119
- fi
120
- fi
121
- echo -n "$AGENT_ID" > "$MAP_FILE"
102
+ echo -n "$AGENT_ID" > "$DIR/_name-${AGENT_TYPE_RAW}.id"
122
103
  fi
123
104
 
124
105
  elif [ "$EVENT" = "SubagentStop" ]; then
125
- AGENT_TYPE="$AGENT_TYPE_RAW"
126
- STARTED_AT="$TS"
127
- if [ -f "$FILE" ]; then
128
- eval "$(jq -r '@sh "PREV_TYPE=\(.type // "unknown")", @sh "PREV_START=\(.startedAt // "")"' "$FILE")"
129
- [ -z "$AGENT_TYPE" ] && AGENT_TYPE="$PREV_TYPE"
130
- [ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
131
- fi
132
106
  echo "$INPUT" | jq -c \
133
- --arg id "$AGENT_ID" --arg type "$AGENT_TYPE" --arg started "$STARTED_AT" --arg ts "$TS" \
134
- '{agentId: $id, type: $type, status: "stopped", startedAt: $started,
107
+ --arg id "$AGENT_ID" --arg type "$AGENT_TYPE_RAW" --arg ts "$TS" \
108
+ '{agentId: $id, type: $type, event: "stop", status: "stopped",
135
109
  lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}' \
136
- > "$FILE"
110
+ >> "$FILE"
137
111
 
138
112
  elif [ "$EVENT" = "TeammateIdle" ]; then
139
- AGENT_TYPE="$AGENT_TYPE_RAW"
140
- STARTED_AT="$TS"
141
- if [ -f "$FILE" ]; then
142
- eval "$(jq -r '@sh "PREV_TYPE=\(.type // "unknown")", @sh "PREV_START=\(.startedAt // "")"' "$FILE")"
143
- [ -z "$AGENT_TYPE" ] && AGENT_TYPE="$PREV_TYPE"
144
- [ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
145
- fi
146
- cat > "$FILE" <<EOF
147
- {"agentId":"$AGENT_ID","type":"$AGENT_TYPE","status":"idle","startedAt":"$STARTED_AT","updatedAt":"$TS"}
148
- EOF
113
+ echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$AGENT_TYPE_RAW\",\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
149
114
  fi
package/public/app.js CHANGED
@@ -4,6 +4,8 @@ let currentSessionId = null;
4
4
  let currentTasks = [];
5
5
  let viewMode = 'session';
6
6
  let sessionFilter = 'active';
7
+ // Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
8
+ const activityFilter = new Set(); // kinds: 'waiting' | 'active'
7
9
  let sessionLimit = '20';
8
10
  let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
9
11
  let recentProjects = new Set();
@@ -144,7 +146,6 @@ const inProgressCount = document.getElementById('in-progress-count');
144
146
  const completedCount = document.getElementById('completed-count');
145
147
  const detailPanel = document.getElementById('detail-panel');
146
148
  const detailContent = document.getElementById('detail-content');
147
- const connectionStatus = document.getElementById('connection-status');
148
149
  const CONTENT_TRUNCATE_MAX = 1500;
149
150
  const COLUMNS = [{ el: pendingTasks }, { el: inProgressTasks }, { el: completedTasks }];
150
151
 
@@ -186,7 +187,7 @@ async function fetchSessions(includeTasks = true) {
186
187
 
187
188
  sessions = newSessions;
188
189
  renderSessions();
189
- renderLiveUpdatesFromCache();
190
+ renderActivityChip();
190
191
  } catch (error) {
191
192
  console.error('Failed to fetch sessions:', error);
192
193
  }
@@ -412,15 +413,7 @@ function fuzzyMatch(text, query) {
412
413
 
413
414
  //#endregion
414
415
 
415
- //#region LIVE_UPDATES
416
- function renderLiveUpdatesFromCache() {
417
- let activeTasks = allTasksCache.filter((t) => t.status === 'in_progress' && !isInternalTask(t));
418
- if (filterProject) {
419
- activeTasks = activeTasks.filter((t) => matchesProjectFilter(t.project));
420
- }
421
- renderLiveUpdates(activeTasks);
422
- }
423
-
416
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
424
417
  function toggleSection(containerId, chevronId) {
425
418
  const container = document.getElementById(containerId);
426
419
  const chevron = document.getElementById(chevronId);
@@ -429,38 +422,90 @@ function toggleSection(containerId, chevronId) {
429
422
  localStorage.setItem(`${containerId}Collapsed`, collapsed);
430
423
  }
431
424
 
432
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
433
- function toggleLiveUpdates() {
434
- toggleSection('live-updates', 'live-updates-chevron');
425
+ function isWaitingSession(s) {
426
+ return !!s.hasWaitingForUser;
427
+ }
428
+ function isActiveSession(s) {
429
+ return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
435
430
  }
436
431
 
437
- function renderLiveUpdates(activeTasks) {
438
- const container = document.getElementById('live-updates');
432
+ const ACTIVITY_PREDICATES = {
433
+ waiting: isWaitingSession,
434
+ active: isActiveSession,
435
+ };
439
436
 
440
- if (activeTasks.length === 0) {
441
- container.innerHTML = '<div class="live-empty">No active tasks</div>';
442
- return;
443
- }
437
+ let lastChipKey = '';
444
438
 
445
- container.innerHTML = activeTasks
446
- .map(
447
- (task) => `
448
- <div class="live-item" onclick="openLiveTask('${task.sessionId}', '${task.id}')">
449
- <span class="pulse"></span>
450
- <div class="live-item-content">
451
- <div class="live-item-action" title="${escapeHtml(task.activeForm || task.subject)}">${escapeHtml(task.activeForm || task.subject)}</div>
452
- <div class="live-item-session" title="${escapeHtml(task.sessionName || task.sessionId)}">${escapeHtml(task.sessionName || task.sessionId)}</div>
453
- </div>
454
- </div>
455
- `,
456
- )
439
+ function renderActivityChip() {
440
+ const container = document.getElementById('activity-chips');
441
+ if (!container) return;
442
+
443
+ let waiting = 0;
444
+ let active = 0;
445
+ for (const s of sessions) {
446
+ if (s.hasWaitingForUser) waiting++;
447
+ else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
448
+ }
449
+
450
+ const key = `${waiting}|${active}|${[...activityFilter].sort().join(',')}`;
451
+ if (key === lastChipKey) return;
452
+ lastChipKey = key;
453
+
454
+ const chips = [
455
+ {
456
+ kind: 'waiting',
457
+ count: waiting,
458
+ label: `${waiting} waiting`,
459
+ title: `${waiting} session${waiting === 1 ? '' : 's'} waiting for input`,
460
+ },
461
+ {
462
+ kind: 'active',
463
+ count: active,
464
+ label: `${active} active`,
465
+ title: `${active} session${active === 1 ? '' : 's'} with running work or recent activity`,
466
+ },
467
+ ];
468
+
469
+ container.innerHTML = chips
470
+ .map((c) => {
471
+ const isOn = activityFilter.has(c.kind);
472
+ const classes = [
473
+ 'activity-chip',
474
+ `activity-${c.kind}`,
475
+ c.count === 0 ? 'activity-zero' : '',
476
+ isOn ? 'activity-filter-on' : '',
477
+ ]
478
+ .filter(Boolean)
479
+ .join(' ');
480
+ const hint = isOn ? ' — click to clear filter' : ` — click to filter to ${c.kind}`;
481
+ return `
482
+ <button type="button"
483
+ class="${classes}"
484
+ onclick="setActivityFilter('${c.kind}')"
485
+ aria-pressed="${isOn ? 'true' : 'false'}"
486
+ title="${escapeHtml(c.title + hint)}">
487
+ <span class="activity-dot"></span>
488
+ <span class="activity-label">${escapeHtml(c.label)}</span>
489
+ </button>
490
+ `;
491
+ })
457
492
  .join('');
458
493
  }
459
494
 
460
495
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
461
- async function openLiveTask(sessionId, taskId) {
462
- await fetchTasks(sessionId);
463
- showTaskDetail(taskId, sessionId);
496
+ function setActivityFilter(kind) {
497
+ if (activityFilter.has(kind)) activityFilter.delete(kind);
498
+ else activityFilter.add(kind);
499
+ // active/waiting only make sense with the active session filter on
500
+ const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
501
+ if (targetFilter !== sessionFilter) {
502
+ sessionFilter = targetFilter;
503
+ const dropdown = document.getElementById('session-filter');
504
+ if (dropdown) dropdown.value = targetFilter;
505
+ updateUrl();
506
+ }
507
+ renderSessions();
508
+ renderActivityChip();
464
509
  }
465
510
 
466
511
  let lastCurrentTasksHash = '';
@@ -2239,7 +2284,7 @@ async function showAllTasks() {
2239
2284
  updateUrl();
2240
2285
  renderAllTasks();
2241
2286
  renderSessions();
2242
- renderLiveUpdatesFromCache();
2287
+ renderActivityChip();
2243
2288
  } catch (error) {
2244
2289
  console.error('Failed to fetch all tasks:', error);
2245
2290
  }
@@ -2313,7 +2358,11 @@ function renderSessions() {
2313
2358
  filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
2314
2359
  }
2315
2360
 
2316
- // Apply search filter
2361
+ if (activityFilter.size > 0) {
2362
+ const preds = [...activityFilter].map((k) => ACTIVITY_PREDICATES[k]).filter(Boolean);
2363
+ if (preds.length) filteredSessions = filteredSessions.filter((s) => preds.some((p) => p(s)));
2364
+ }
2365
+
2317
2366
  if (searchQuery) {
2318
2367
  const taskMatchIds = new Set();
2319
2368
  for (const t of allTasksCache) {
@@ -2334,7 +2383,7 @@ function renderSessions() {
2334
2383
  filteredSessions = filteredSessions.filter(matchesSearch);
2335
2384
 
2336
2385
  // Re-add pinned/sticky sessions that match the query but were excluded by active filter
2337
- if (pinnedSessionIds.size > 0 || stickySessionIds.size > 0) {
2386
+ if (activityFilter.size === 0 && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2338
2387
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2339
2388
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
2340
2389
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
@@ -2342,7 +2391,8 @@ function renderSessions() {
2342
2391
  }
2343
2392
 
2344
2393
  // Include pinned/sticky sessions even if they don't match active/recent filter
2345
- if (!searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2394
+ // (skipped when an activity chip filter is on — user explicitly asked for a slice)
2395
+ if (activityFilter.size === 0 && !searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2346
2396
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2347
2397
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
2348
2398
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
@@ -2408,6 +2458,7 @@ function renderSessions() {
2408
2458
  const showCtx = !!session.contextStatus;
2409
2459
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2410
2460
  const bookmarksCount = loadPins(session.id).length;
2461
+ const hasScratchpad = !!(localStorage.getItem(_sessionScratchpadKey(session.id)) || '').trim();
2411
2462
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2412
2463
  return `
2413
2464
  <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
@@ -2424,6 +2475,7 @@ function renderSessions() {
2424
2475
  ${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>` : ''}
2425
2476
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2426
2477
  ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
2478
+ ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
2427
2479
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
2428
2480
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
2429
2481
  ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
@@ -3546,7 +3598,6 @@ async function refreshCurrentView() {
3546
3598
  await showAllTasks();
3547
3599
  } else if (currentSessionId) {
3548
3600
  await fetchTasks(currentSessionId);
3549
- renderLiveUpdatesFromCache();
3550
3601
  } else {
3551
3602
  await fetchSessions();
3552
3603
  }
@@ -3564,9 +3615,17 @@ const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
3564
3615
 
3565
3616
  let _scratchpadKeyOverride = null;
3566
3617
 
3618
+ function _sessionScratchpadKey(sessionId) {
3619
+ return `scratchpad-${sessionId}`;
3620
+ }
3621
+
3622
+ function _isSessionScratchpadKey(key) {
3623
+ return key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:');
3624
+ }
3625
+
3567
3626
  function _scratchpadKey() {
3568
3627
  if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
3569
- if (currentSessionId) return `scratchpad-${currentSessionId}`;
3628
+ if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
3570
3629
  if (currentProjectPath) return `scratchpad-project:${currentProjectPath}`;
3571
3630
  return null;
3572
3631
  }
@@ -3579,6 +3638,11 @@ function toggleScratchpad() {
3579
3638
  }
3580
3639
  }
3581
3640
 
3641
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
3642
+ function openSessionScratchpad(sessionId) {
3643
+ showScratchpad(_sessionScratchpadKey(sessionId));
3644
+ }
3645
+
3582
3646
  function showScratchpad(keyOverride) {
3583
3647
  _scratchpadKeyOverride = keyOverride || null;
3584
3648
  const key = _scratchpadKey();
@@ -3603,11 +3667,16 @@ function saveScratchpad() {
3603
3667
  const key = _scratchpadKey();
3604
3668
  if (!key) return;
3605
3669
  const val = _scratchpadTextarea.value;
3606
- if (val.trim()) {
3670
+ const had = !!(localStorage.getItem(key) || '').trim();
3671
+ const has = !!val.trim();
3672
+ if (has) {
3607
3673
  localStorage.setItem(key, val);
3608
3674
  } else {
3609
3675
  localStorage.removeItem(key);
3610
3676
  }
3677
+ if (had !== has && _isSessionScratchpadKey(key)) {
3678
+ renderSessions();
3679
+ }
3611
3680
  }
3612
3681
 
3613
3682
  _scratchpadTextarea.addEventListener('input', () => {
@@ -3997,7 +4066,6 @@ function _renderStorageLinkedDocs() {
3997
4066
  }
3998
4067
 
3999
4068
  function _storagePreviewLinkedDoc(path) {
4000
- closeStorageManager();
4001
4069
  openPreviewByPath(path);
4002
4070
  }
4003
4071
 
@@ -4027,7 +4095,7 @@ function _findOrphanedKeys() {
4027
4095
  const key = localStorage.key(i);
4028
4096
  if (key.startsWith('pinned-messages-')) {
4029
4097
  if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
4030
- } else if (key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:')) {
4098
+ } else if (_isSessionScratchpadKey(key)) {
4031
4099
  if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
4032
4100
  } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
4033
4101
  if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
@@ -4305,6 +4373,33 @@ document.addEventListener('keydown', (e) => {
4305
4373
  hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
4306
4374
  return;
4307
4375
  }
4376
+ if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
4377
+ e.preventDefault();
4378
+ if (!contextSid || dismissedSessionIds.has(contextSid)) return;
4379
+ const prevIdx = selectedSessionIdx;
4380
+ dismissedSessionIds.add(contextSid);
4381
+ updateDismissBtnState();
4382
+ renderSessions();
4383
+ const newItems = getNavigableItems();
4384
+ const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
4385
+ // If the dismissed session is currently open, navigate to the previous one
4386
+ if (currentSessionId === contextSid || selectedSessionId === contextSid) {
4387
+ selectedSessionId = null;
4388
+ if (targetIdx >= 0) {
4389
+ const targetSid = newItems[targetIdx]?.dataset?.sessionId;
4390
+ if (targetSid) {
4391
+ fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
4392
+ } else {
4393
+ showAllTasks().then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
4394
+ }
4395
+ } else {
4396
+ showAllTasks();
4397
+ }
4398
+ } else if (targetIdx >= 0) {
4399
+ selectSessionByIndex(targetIdx, newItems);
4400
+ }
4401
+ return;
4402
+ }
4308
4403
  if (e.code === 'KeyC' && e.shiftKey) {
4309
4404
  e.preventDefault();
4310
4405
  if (!contextSid) {
@@ -4624,20 +4719,12 @@ function setupEventSource() {
4624
4719
  wasConnected = true;
4625
4720
  retryDelay = 1000;
4626
4721
  hideOffline();
4627
- connectionStatus.innerHTML = `
4628
- <span class="connection-dot live"></span>
4629
- <span>Connected</span>
4630
- `;
4631
4722
  };
4632
4723
 
4633
4724
  eventSource.onerror = () => {
4634
4725
  eventSource.close();
4635
4726
  failCount++;
4636
4727
  console.warn('[SSE] Connection lost, retrying in', retryDelay, 'ms');
4637
- connectionStatus.innerHTML = `
4638
- <span class="connection-dot error"></span>
4639
- <span>Reconnecting...</span>
4640
- `;
4641
4728
  if (failCount >= 2) showOffline();
4642
4729
  setTimeout(connect, retryDelay);
4643
4730
  retryDelay = Math.min(retryDelay * 2, 30000);
@@ -4667,7 +4754,7 @@ function setupEventSource() {
4667
4754
  if (viewMode === 'all') {
4668
4755
  currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
4669
4756
  renderAllTasks();
4670
- renderLiveUpdatesFromCache();
4757
+ renderActivityChip();
4671
4758
  } else if (viewMode === 'project' && currentProjectPath) {
4672
4759
  const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
4673
4760
  if (hasUpdate) fetchProjectView(currentProjectPath);
@@ -5096,8 +5183,10 @@ function getOwnerColor(name) {
5096
5183
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5097
5184
  function filterBySessions(value) {
5098
5185
  sessionFilter = value;
5186
+ if (value !== 'active') activityFilter.clear();
5099
5187
  updateUrl();
5100
5188
  renderSessions();
5189
+ renderActivityChip();
5101
5190
  }
5102
5191
 
5103
5192
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -5360,7 +5449,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
5360
5449
  });
5361
5450
 
5362
5451
  function onMove(e) {
5363
- const w = Math.min(900, Math.max(320, startWidth - (e.clientX - startX)));
5452
+ const w = Math.max(200, startWidth - (e.clientX - startX));
5364
5453
  panel.style.setProperty(cssVar, `${w}px`);
5365
5454
  }
5366
5455
 
@@ -5784,15 +5873,6 @@ function filterByOwner(value) {
5784
5873
 
5785
5874
  //#endregion
5786
5875
 
5787
- //#region LAYOUT_SYNC
5788
- const sidebarHeader = document.querySelector('.sidebar-header');
5789
- const viewHeader = document.querySelector('.view-header');
5790
- new ResizeObserver(() => {
5791
- sidebarHeader.style.height = `${viewHeader.offsetHeight}px`;
5792
- }).observe(viewHeader);
5793
-
5794
- //#endregion
5795
-
5796
5876
  //#region PWA
5797
5877
  if ('serviceWorker' in navigator) {
5798
5878
  navigator.serviceWorker.register('/sw.js');
@@ -5802,14 +5882,10 @@ if ('serviceWorker' in navigator) {
5802
5882
 
5803
5883
  //#region INIT
5804
5884
  loadTheme();
5805
- ['live-updates', 'sessions-filters'].forEach((id) => {
5806
- if (localStorage.getItem(`${id}Collapsed`) === 'true') {
5807
- document.getElementById(id).classList.add('collapsed');
5808
- document
5809
- .getElementById(id === 'live-updates' ? 'live-updates-chevron' : 'sessions-chevron')
5810
- .classList.add('rotated');
5811
- }
5812
- });
5885
+ if (localStorage.getItem('sessions-filtersCollapsed') === 'true') {
5886
+ document.getElementById('sessions-filters').classList.add('collapsed');
5887
+ document.getElementById('sessions-chevron').classList.add('rotated');
5888
+ }
5813
5889
 
5814
5890
  document.addEventListener('DOMContentLoaded', () => {
5815
5891
  if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
package/public/index.html CHANGED
@@ -44,18 +44,7 @@
44
44
  <!-- Sidebar -->
45
45
  <aside class="sidebar">
46
46
  <header class="sidebar-header">
47
- <div class="logo">
48
- <div class="logo-mark">
49
- <svg viewBox="4 6 16 12" fill="none" stroke="currentColor" stroke-width="2.5">
50
- <path d="M5 13l4 4L19 7"/>
51
- </svg>
52
- </div>
53
- <span class="logo-text">Dashboard</span>
54
- </div>
55
- <div id="connection-status" class="connection">
56
- <span class="connection-dot"></span>
57
- <span>Connecting</span>
58
- </div>
47
+ <div id="activity-chips" class="activity-chips"></div>
59
48
  <button id="sidebar-toggle" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar">
60
49
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
61
50
  <path d="M15 18l-6-6 6-6"/>
@@ -63,19 +52,6 @@
63
52
  </button>
64
53
  </header>
65
54
 
66
- <!-- Live Updates -->
67
- <div class="sidebar-section">
68
- <div class="section-header" onclick="toggleLiveUpdates()" style="cursor: pointer;">
69
- <span>Live Updates</span>
70
- <svg id="live-updates-chevron" class="collapse-chevron" viewBox="0 0 24 24">
71
- <path d="M6 9l6 6 6-6"/>
72
- </svg>
73
- </div>
74
- <div id="live-updates" class="live-updates">
75
- <div class="live-empty">No active tasks</div>
76
- </div>
77
- </div>
78
-
79
55
  <!-- Tasks -->
80
56
  <div class="sidebar-section flex-1">
81
57
  <div class="section-header" onclick="toggleSection('sessions-filters', 'sessions-chevron')" style="cursor: pointer;">
@@ -413,6 +389,10 @@
413
389
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">I</kbd></td>
414
390
  <td style="padding: 4px 0; color: var(--text-primary);">Open session info</td>
415
391
  </tr>
392
+ <tr>
393
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+D</kbd></td>
394
+ <td style="padding: 4px 0; color: var(--text-primary);">Dismiss selected session</td>
395
+ </tr>
416
396
  <tr>
417
397
  <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>
418
398
  <td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
package/public/style.css CHANGED
@@ -100,19 +100,118 @@ body::before {
100
100
  }
101
101
 
102
102
  .sidebar-header {
103
- padding: 20px 20px 16px;
103
+ padding: 6px 10px;
104
104
  border-bottom: none;
105
105
  background-image: linear-gradient(to right, transparent, var(--border), transparent);
106
106
  background-size: 100% 1px;
107
107
  background-repeat: no-repeat;
108
108
  background-position: bottom;
109
109
  position: relative;
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ gap: 8px;
114
+ }
115
+
116
+ .activity-chips {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: 6px;
120
+ flex-wrap: wrap;
121
+ min-width: 0;
122
+ }
123
+
124
+ .activity-chip {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 7px;
128
+ padding: 4px 10px 4px 9px;
129
+ font: inherit;
130
+ font-size: 11px;
131
+ font-weight: 500;
132
+ letter-spacing: 0.04em;
133
+ color: var(--text-secondary);
134
+ background: var(--bg-deep);
135
+ border: 1px solid var(--border);
136
+ border-radius: 999px;
137
+ white-space: nowrap;
138
+ cursor: pointer;
139
+ transition:
140
+ color 0.2s ease,
141
+ border-color 0.2s ease,
142
+ background 0.2s ease,
143
+ transform 0.1s ease;
144
+ }
145
+
146
+ .activity-chip:hover {
147
+ background: var(--bg-hover);
148
+ border-color: color-mix(in srgb, var(--text-secondary) 30%, var(--border));
149
+ }
150
+
151
+ .activity-chip:active {
152
+ transform: scale(0.97);
153
+ }
154
+
155
+ .activity-chip.activity-filter-on {
156
+ background: color-mix(in srgb, var(--accent) 14%, var(--bg-deep));
157
+ border-color: var(--accent);
158
+ box-shadow: inset 0 0 0 1px var(--accent);
159
+ }
160
+ .activity-chip.activity-waiting.activity-filter-on {
161
+ border-color: var(--warning);
162
+ box-shadow: inset 0 0 0 1px var(--warning);
163
+ background: color-mix(in srgb, var(--warning) 14%, var(--bg-deep));
164
+ }
165
+
166
+ .activity-dot {
167
+ width: 6px;
168
+ height: 6px;
169
+ border-radius: 50%;
170
+ background: var(--text-muted);
171
+ flex-shrink: 0;
172
+ transition:
173
+ background 0.2s ease,
174
+ box-shadow 0.2s ease;
175
+ }
176
+
177
+ .activity-chip.activity-zero {
178
+ color: var(--text-tertiary);
179
+ border-color: var(--border);
180
+ opacity: 0.6;
181
+ }
182
+ .activity-chip.activity-zero .activity-dot {
183
+ background: var(--text-muted);
184
+ box-shadow: none;
185
+ animation: none;
186
+ }
187
+ .activity-chip.activity-zero:hover {
188
+ opacity: 1;
189
+ }
190
+
191
+ .activity-chip.activity-waiting {
192
+ color: var(--warning);
193
+ border-color: color-mix(in srgb, var(--warning) 40%, var(--border));
194
+ }
195
+ .activity-chip.activity-waiting .activity-dot {
196
+ background: var(--warning);
197
+ box-shadow: 0 0 8px color-mix(in srgb, var(--warning) 60%, transparent);
198
+ }
199
+
200
+ .activity-chip.activity-active {
201
+ color: color-mix(in srgb, var(--accent) 70%, var(--text-secondary));
202
+ border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
203
+ }
204
+ .activity-chip.activity-active .activity-dot {
205
+ background: color-mix(in srgb, var(--accent) 75%, var(--text-secondary));
206
+ box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 30%, transparent);
207
+ animation: pulse 2.5s ease-in-out infinite;
208
+ }
209
+
210
+ .sidebar.collapsed .activity-chips {
211
+ display: none;
110
212
  }
111
213
 
112
214
  .sidebar-toggle-btn {
113
- position: absolute;
114
- top: 20px;
115
- right: 8px;
116
215
  width: 28px;
117
216
  height: 28px;
118
217
  display: flex;
@@ -178,62 +277,6 @@ body::before {
178
277
  background: var(--accent-dim);
179
278
  }
180
279
 
181
- .logo {
182
- display: flex;
183
- align-items: center;
184
- gap: 10px;
185
- }
186
-
187
- .logo-mark {
188
- width: 24px;
189
- height: 24px;
190
- background: var(--accent);
191
- border-radius: 6px;
192
- display: flex;
193
- align-items: center;
194
- justify-content: center;
195
- }
196
-
197
- .logo-mark svg {
198
- width: 14px;
199
- height: 14px;
200
- color: white;
201
- }
202
-
203
- .logo-text {
204
- font-family: var(--serif);
205
- font-size: 17px;
206
- font-weight: 500;
207
- letter-spacing: -0.02em;
208
- }
209
-
210
- .connection {
211
- display: flex;
212
- align-items: center;
213
- gap: 5px;
214
- margin-top: 10px;
215
- font-size: 10px;
216
- color: var(--text-tertiary);
217
- text-transform: uppercase;
218
- letter-spacing: 0.05em;
219
- }
220
-
221
- .connection-dot {
222
- width: 6px;
223
- height: 6px;
224
- border-radius: 50%;
225
- background: var(--warning);
226
- }
227
-
228
- .connection-dot.live {
229
- background: var(--success);
230
- box-shadow: 0 0 8px var(--success);
231
- }
232
-
233
- .connection-dot.error {
234
- background: #ef4444;
235
- }
236
-
237
280
  .offline-overlay {
238
281
  display: none;
239
282
  position: fixed;
@@ -370,7 +413,7 @@ body::before {
370
413
 
371
414
  /* #endregion */
372
415
 
373
- /* #region LIVE_UPDATES */
416
+ /* #region COLLAPSIBLE */
374
417
  .collapse-chevron {
375
418
  width: 14px;
376
419
  height: 14px;
@@ -401,80 +444,6 @@ body::before {
401
444
  overflow: hidden;
402
445
  }
403
446
 
404
- .live-updates {
405
- padding: 0 16px 8px;
406
- max-height: 140px;
407
- overflow-y: auto;
408
- transition:
409
- max-height 0.2s ease,
410
- padding 0.2s ease,
411
- opacity 0.2s ease;
412
- }
413
-
414
- .live-updates.collapsed {
415
- max-height: 0;
416
- padding: 0 16px;
417
- overflow: hidden;
418
- opacity: 0;
419
- }
420
-
421
- .live-empty {
422
- padding: 8px;
423
- text-align: center;
424
- font-size: 11px;
425
- color: var(--text-muted);
426
- }
427
-
428
- .live-item {
429
- display: flex;
430
- align-items: flex-start;
431
- gap: 8px;
432
- padding: 6px 10px;
433
- background: var(--bg-deep);
434
- border: 1px solid transparent;
435
- border-radius: 6px;
436
- margin-bottom: 3px;
437
- cursor: pointer;
438
- transition: all 0.15s ease;
439
- }
440
-
441
- .live-item:hover {
442
- background: var(--bg-hover);
443
- }
444
-
445
- .live-item .pulse {
446
- width: 6px;
447
- height: 6px;
448
- margin-top: 4px;
449
- background: var(--accent);
450
- border-radius: 50%;
451
- flex-shrink: 0;
452
- animation: pulse 2s ease-in-out infinite;
453
- box-shadow: 0 0 8px var(--accent-glow);
454
- }
455
-
456
- .live-item-content {
457
- flex: 1;
458
- min-width: 0;
459
- }
460
-
461
- .live-item-action {
462
- font-size: 11px;
463
- color: var(--text-primary);
464
- white-space: nowrap;
465
- overflow: hidden;
466
- text-overflow: ellipsis;
467
- }
468
-
469
- .live-item-session {
470
- font-size: 10px;
471
- color: var(--text-tertiary);
472
- margin-top: 1px;
473
- white-space: nowrap;
474
- overflow: hidden;
475
- text-overflow: ellipsis;
476
- }
477
-
478
447
  /* #endregion */
479
448
 
480
449
  /* #region SESSIONS */
@@ -762,8 +731,6 @@ body::before {
762
731
  .sidebar.collapsed {
763
732
  width: 48px;
764
733
  }
765
- .sidebar.collapsed .logo-text,
766
- .sidebar.collapsed .connection,
767
734
  .sidebar.collapsed .sidebar-section,
768
735
  .sidebar.collapsed .sidebar-footer {
769
736
  display: none;
@@ -2569,7 +2536,8 @@ body::before {
2569
2536
  }
2570
2537
 
2571
2538
  .linked-docs-badge,
2572
- .bookmarks-badge {
2539
+ .bookmarks-badge,
2540
+ .scratchpad-badge {
2573
2541
  display: inline-flex;
2574
2542
  align-items: center;
2575
2543
  gap: 2px;
@@ -2585,7 +2553,8 @@ body::before {
2585
2553
  }
2586
2554
 
2587
2555
  .linked-docs-badge:hover,
2588
- .bookmarks-badge:hover {
2556
+ .bookmarks-badge:hover,
2557
+ .scratchpad-badge:hover {
2589
2558
  border-color: var(--accent);
2590
2559
  color: var(--text-primary);
2591
2560
  }
@@ -3455,10 +3424,6 @@ pre.mermaid svg {
3455
3424
  }
3456
3425
  }
3457
3426
 
3458
- .connection-dot.live {
3459
- animation: breathe 3s ease-in-out infinite;
3460
- }
3461
-
3462
3427
  /* Progress bar shimmer */
3463
3428
  @keyframes shimmer {
3464
3429
  0% {
package/server.js CHANGED
@@ -102,12 +102,19 @@ const SESSION_STALE_MS = 300000;
102
102
 
103
103
  const WAITING_RESOLVE_GRACE_MS = 15000;
104
104
 
105
+ function readAgentJsonl(filePath) {
106
+ const raw = readFileSync(filePath, 'utf8');
107
+ const merged = {};
108
+ for (const line of raw.split(/\r?\n/)) {
109
+ if (!line.trim()) continue;
110
+ try { Object.assign(merged, JSON.parse(line)); } catch (_) { /* skip malformed */ }
111
+ }
112
+ return merged;
113
+ }
114
+
105
115
  function persistAgent(dir, agent) {
106
- const file = path.join(dir, agent.agentId + '.json');
107
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
108
- fs.writeFile(tmp, JSON.stringify(agent), 'utf8')
109
- .then(() => fs.rename(tmp, file))
110
- .catch(() => { fs.unlink(tmp).catch(() => {}); });
116
+ const file = path.join(dir, agent.agentId + '.jsonl');
117
+ fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
111
118
  }
112
119
 
113
120
  function checkWaitingForUser(agentDir, logMtime) {
@@ -125,6 +132,10 @@ function checkWaitingForUser(agentDir, logMtime) {
125
132
  return null;
126
133
  }
127
134
 
135
+ function agentDisplayName(agent) {
136
+ return agent.type || agent.name;
137
+ }
138
+
128
139
  function isGhostAgent(agent) {
129
140
  if (agent.startedAt !== agent.updatedAt || agent.lastMessage) return false;
130
141
  return (Date.now() - new Date(agent.startedAt).getTime()) >= AGENT_STALE_MS;
@@ -156,9 +167,9 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
156
167
  if (result.waitingForUser) result.hasActive = true;
157
168
  if (stale && !isTeam) return result;
158
169
  try {
159
- for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
170
+ for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
160
171
  try {
161
- const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
172
+ const agent = readAgentJsonl(path.join(agentDir, file));
162
173
  if (isTeam && (agent.status === 'active' || agent.status === 'idle')) {
163
174
  result.hasActive = true;
164
175
  if (agent.status === 'active') result.hasRunning = true;
@@ -1050,17 +1061,17 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1050
1061
  const isTeam = !!teamConfig;
1051
1062
  const teamMemberNames = isTeam ? new Set(teamConfig.members.map(m => m.name)) : null;
1052
1063
 
1053
- const files = readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
1064
+ const files = readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'));
1054
1065
  const agents = [];
1055
1066
  for (const file of files) {
1056
1067
  try {
1057
- const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
1068
+ const agent = readAgentJsonl(path.join(agentDir, file));
1058
1069
  if (isGhostAgent(agent)) continue;
1059
1070
  const agentTs = agent.updatedAt || agent.startedAt;
1060
1071
  const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
1061
1072
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
1062
1073
  if (agent.status === 'active' || agent.status === 'idle') {
1063
- const agentName = agent.type || agent.name;
1074
+ const agentName = agentDisplayName(agent);
1064
1075
  const isTeamMember = isTeam && agentName && teamMemberNames.has(agentName);
1065
1076
  if (!isTeamMember) {
1066
1077
  agent.status = 'stopped';
@@ -1077,7 +1088,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1077
1088
  const terminated = getTerminatedTeammates(meta.jsonlPath);
1078
1089
  if (terminated.size) {
1079
1090
  for (const agent of liveAgents) {
1080
- const agentName = agent.type || agent.name;
1091
+ const agentName = agentDisplayName(agent);
1081
1092
  if (agentName && terminated.has(agentName)) {
1082
1093
  const terminatedAt = terminated.get(agentName);
1083
1094
  if (terminatedAt && agent.startedAt && terminatedAt < agent.startedAt) continue;
@@ -1088,6 +1099,29 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1088
1099
  }
1089
1100
  }
1090
1101
  } catch (_) {}
1102
+ // Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
1103
+ // the parent will never read their output, so they're orphans. Match by agentId
1104
+ // when the digest already correlated tool_use→agent, else fall back to prompt text
1105
+ // (the agent-spy hook doesn't record the spawning tool_use_id).
1106
+ try {
1107
+ const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
1108
+ getSessionDigest(meta.jsonlPath);
1109
+ if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
1110
+ for (const agent of liveAgents) {
1111
+ if (agent.status !== 'active' && agent.status !== 'idle') continue;
1112
+ let reason = null;
1113
+ if (killedAgentIds.has(agent.agentId)) reason = 'killed-by-harness';
1114
+ else if (rejectedAgentIds.has(agent.agentId) || (agent.prompt && rejectedPrompts.has(agent.prompt))) {
1115
+ reason = 'orphaned-by-rejection';
1116
+ }
1117
+ if (!reason) continue;
1118
+ agent.status = 'stopped';
1119
+ agent.stoppedAt = agent.stoppedAt || new Date().toISOString();
1120
+ agent.stopReason = agent.stopReason || reason;
1121
+ persistAgent(agentDir, agent);
1122
+ }
1123
+ }
1124
+ } catch (_) {}
1091
1125
  }
1092
1126
 
1093
1127
  const dirty = new Set();
@@ -1145,14 +1179,40 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1145
1179
  }
1146
1180
  if (Object.keys(teamColors).length) {
1147
1181
  for (const agent of agents) {
1148
- const name = agent.type || agent.name;
1182
+ const name = agentDisplayName(agent);
1149
1183
  if (name && teamColors[name]) agent.color = teamColors[name];
1150
1184
  }
1151
1185
  }
1152
1186
  }
1153
1187
 
1188
+ // Collapse teammate re-spawns: when a teammate goes idle and is later re-engaged,
1189
+ // a fresh agentId is spawned. Hide older idle/stopped entries when a newer same-name
1190
+ // teammate exists; never hide an `active` agent (parallel teammate work would vanish).
1191
+ // Subagents (Explore, general-purpose, etc.) are not in teamMemberNames and bypass
1192
+ // dedup entirely, so parallel siblings of the same subagent type remain visible.
1193
+ let visibleAgents = agents;
1194
+ if (teamMemberNames && teamMemberNames.size) {
1195
+ const groups = new Map();
1196
+ for (const a of agents) {
1197
+ const t = agentDisplayName(a);
1198
+ if (!t || !teamMemberNames.has(t)) continue;
1199
+ const list = groups.get(t) || [];
1200
+ list.push(a);
1201
+ groups.set(t, list);
1202
+ }
1203
+ const hidden = new Set();
1204
+ for (const list of groups.values()) {
1205
+ if (list.length < 2) continue;
1206
+ list.sort((a, b) => new Date(b.startedAt || 0) - new Date(a.startedAt || 0));
1207
+ for (const older of list.slice(1)) {
1208
+ if (older.status === 'idle' || older.status === 'stopped') hidden.add(older.agentId);
1209
+ }
1210
+ }
1211
+ if (hidden.size) visibleAgents = agents.filter(a => !hidden.has(a.agentId));
1212
+ }
1213
+
1154
1214
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
1155
- res.json({ agents, waitingForUser, teamColors });
1215
+ res.json({ agents: visibleAgents, waitingForUser, teamColors });
1156
1216
  } catch (e) {
1157
1217
  res.json({ agents: [], waitingForUser: null });
1158
1218
  }
@@ -1161,13 +1221,14 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1161
1221
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
1162
1222
  const sessionId = resolveSessionId(req.params.sessionId);
1163
1223
  const agentId = sanitizeAgentId(req.params.agentId);
1164
- const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.json');
1224
+ const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.jsonl');
1165
1225
  if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
1166
1226
  try {
1167
- const agent = JSON.parse(readFileSync(agentFile, 'utf8'));
1227
+ const agent = readAgentJsonl(agentFile);
1168
1228
  agent.status = 'stopped';
1169
1229
  agent.stoppedAt = new Date().toISOString();
1170
- writeFileSync(agentFile, JSON.stringify(agent), 'utf8'); // sync response depends on write
1230
+ const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
1231
+ writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
1171
1232
  // Also remove waiting state if present
1172
1233
  const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
1173
1234
  if (existsSync(waitingFile)) unlinkSync(waitingFile);
@@ -1190,6 +1251,31 @@ function subagentJsonlPath(meta, agentId) {
1190
1251
  );
1191
1252
  }
1192
1253
 
1254
+ // Claude Code can scatter a session's records across multiple project dirs
1255
+ // (e.g. main repo + worktree), so the subagent JSONL may live under a
1256
+ // different project dir than meta.jsonlPath. Fall back to scanning when the
1257
+ // derived path is missing.
1258
+ const subagentPathCache = new Map();
1259
+ function resolveSubagentJsonl(meta, sessionId, agentId) {
1260
+ const primary = subagentJsonlPath(meta, agentId);
1261
+ if (existsSync(primary)) return primary;
1262
+ const key = sessionId + '/' + agentId;
1263
+ if (subagentPathCache.has(key)) return subagentPathCache.get(key) || primary;
1264
+ let found = null;
1265
+ try {
1266
+ for (const entry of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1267
+ if (!entry.isDirectory()) continue;
1268
+ const candidate = path.join(
1269
+ PROJECTS_DIR, entry.name, sessionId,
1270
+ 'subagents', 'agent-' + agentId + '.jsonl'
1271
+ );
1272
+ if (existsSync(candidate)) { found = candidate; break; }
1273
+ }
1274
+ } catch (_) { /* projects dir missing */ }
1275
+ subagentPathCache.set(key, found);
1276
+ return found || primary;
1277
+ }
1278
+
1193
1279
  app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1194
1280
  const sessionId = resolveSessionId(req.params.sessionId);
1195
1281
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1197,7 +1283,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1197
1283
  const metadata = loadSessionMetadata();
1198
1284
  const meta = metadata[sessionId];
1199
1285
  if (!meta?.jsonlPath) return res.json({ messages: [], agentId });
1200
- const subagentJsonl = subagentJsonlPath(meta, agentId);
1286
+ const subagentJsonl = resolveSubagentJsonl(meta, sessionId, agentId);
1201
1287
  if (!existsSync(subagentJsonl)) return res.json({ messages: [], agentId });
1202
1288
  const messages = readRecentMessages(subagentJsonl, limit);
1203
1289
  res.json({ messages, agentId });
@@ -1212,7 +1298,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
1212
1298
  res.status(404).json({ error: 'Session not found' });
1213
1299
  return;
1214
1300
  }
1215
- const subagentJsonl = subagentJsonlPath(meta, agentId);
1301
+ const subagentJsonl = resolveSubagentJsonl(meta, sessionId, agentId);
1216
1302
 
1217
1303
  res.writeHead(200, {
1218
1304
  'Content-Type': 'text/event-stream',
@@ -1283,8 +1369,8 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1283
1369
  if (entry.description) msg.agentDescription = entry.description;
1284
1370
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
1285
1371
  try {
1286
- const agentFile = path.join(agentDir, entry.agentId + '.json');
1287
- const agent = JSON.parse(readFileSync(agentFile, 'utf8'));
1372
+ const agentFile = path.join(agentDir, entry.agentId + '.jsonl');
1373
+ const agent = readAgentJsonl(agentFile);
1288
1374
  if (agent.lastMessage) msg.agentLastMessage = agent.lastMessage;
1289
1375
  if (agent.prompt && !msg.agentPrompt) msg.agentPrompt = agent.prompt;
1290
1376
  const prompt = msg.agentPrompt || entry.prompt;
@@ -1771,14 +1857,16 @@ const agentActivityWatcher = chokidar.watch(AGENT_ACTIVITY_DIR, {
1771
1857
  const AGENT_FILE_CAP = 20;
1772
1858
 
1773
1859
  agentActivityWatcher.on('all', (event, filePath) => {
1774
- if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1860
+ const base = path.basename(filePath);
1861
+ const isAgentEvent = filePath.endsWith('.jsonl') || base === '_waiting.json';
1862
+ if ((event === 'add' || event === 'change' || event === 'unlink') && isAgentEvent) {
1775
1863
  const relativePath = path.relative(AGENT_ACTIVITY_DIR, filePath);
1776
1864
  const sessionId = relativePath.split(path.sep)[0];
1777
1865
  // Cleanup: if session dir exceeds cap, delete oldest files by mtime
1778
- if (event === 'add') {
1866
+ if (event === 'add' && filePath.endsWith('.jsonl')) {
1779
1867
  try {
1780
1868
  const sessionDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
1781
- const files = readdirSync(sessionDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
1869
+ const files = readdirSync(sessionDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'));
1782
1870
  if (files.length > AGENT_FILE_CAP) {
1783
1871
  const withStats = files.map(f => {
1784
1872
  const fp = path.join(sessionDir, f);