claude-code-kanban 3.8.0 → 3.10.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/cli.js CHANGED
@@ -14,13 +14,14 @@ const COMMANDS = {
14
14
  summary: 'List or open Claude Code sessions',
15
15
  verbs: {
16
16
  list: {
17
- summary: 'List sessions',
18
- usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--json]',
17
+ summary: 'List sessions (pinned/sticky always included)',
18
+ usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
19
19
  flags: {
20
20
  '--active': 'Only sessions with recent activity (sidebar-style filter)',
21
21
  '--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
22
22
  '--project <name>': 'Filter by project name (substring match)',
23
23
  '--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
24
+ '--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
24
25
  '--json': 'Output JSON instead of a table',
25
26
  },
26
27
  run: runSessionListCli,
@@ -52,6 +53,15 @@ const COMMANDS = {
52
53
  },
53
54
  run: runSessionPinCli,
54
55
  },
56
+ pins: {
57
+ summary: 'List sessions pinned/stickied via the dashboard or CLI',
58
+ usage: 'claude-code-kanban session pins [--sticky] [--json]',
59
+ flags: {
60
+ '--sticky': 'Only sessions in sticky state',
61
+ '--json': 'Output JSON instead of a table',
62
+ },
63
+ run: runSessionPinsCli,
64
+ },
55
65
  peek: {
56
66
  summary: 'Show the last N messages from a session',
57
67
  usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
@@ -239,13 +249,23 @@ function parseLimit(args, { fallback, allowAll = false }) {
239
249
  return { ok: true, limit: n };
240
250
  }
241
251
 
242
- async function fetchSessionsList(limit) {
252
+ async function fetchSessionsList(limit, pinnedIds = []) {
243
253
  const q = limit === null ? 'all' : String(limit);
244
- const res = await cliFetch(`/api/sessions?limit=${q}`);
254
+ const pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
255
+ const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}`);
245
256
  if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
246
257
  return res.json();
247
258
  }
248
259
 
260
+ async function fetchPinsMap() {
261
+ try {
262
+ const res = await cliFetch('/api/session/pins');
263
+ if (!res.ok) return {};
264
+ const { pins = {} } = await res.json();
265
+ return pins;
266
+ } catch { return {}; }
267
+ }
268
+
249
269
  async function resolveSessionByIdOrPrefix(idArg) {
250
270
  let res;
251
271
  try {
@@ -273,6 +293,7 @@ async function resolveSessionByIdOrPrefix(idArg) {
273
293
 
274
294
  async function runSessionListCli(args) {
275
295
  const activeOnly = args.includes('--active');
296
+ const noPins = args.includes('--no-pins');
276
297
  const projectFilter = getArgValue(args, 'project');
277
298
  const daysArg = getArgValue(args, 'days');
278
299
  const days = daysArg !== null ? parseFloat(daysArg) : null;
@@ -284,27 +305,40 @@ async function runSessionListCli(args) {
284
305
  if (!parsed.ok) { console.error(parsed.error); return 1; }
285
306
  const limit = parsed.limit;
286
307
  const asJson = args.includes('--json');
308
+ const pinsMap = noPins ? {} : await fetchPinsMap();
309
+ const pinnedIds = Object.keys(pinsMap);
287
310
  const hasClientFilter = activeOnly || days !== null || projectFilter;
288
311
  let list;
289
312
  try {
290
- list = await fetchSessionsList(hasClientFilter ? null : limit);
313
+ list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds);
291
314
  } catch (e) {
292
315
  reportCliError(e);
293
316
  return 1;
294
317
  }
295
- if (activeOnly) list = list.filter(isSessionActive);
318
+ const pinOf = id => pinsMap[id] || null;
319
+ if (activeOnly) list = list.filter(s => pinOf(s.id) || isSessionActive(s));
296
320
  if (days !== null) {
297
321
  const cutoff = Date.now() - days * 86_400_000;
298
- list = list.filter(s => s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff);
322
+ list = list.filter(s => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
299
323
  }
300
324
  if (projectFilter) {
301
325
  const needle = projectFilter.toLowerCase();
302
326
  list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
303
327
  }
304
- const totalMatched = list.length;
305
- if (limit !== null && list.length > limit) list = list.slice(0, limit);
328
+ const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
329
+ list.sort((a, b) => {
330
+ const r = pinRank(a.id) - pinRank(b.id);
331
+ if (r !== 0) return r;
332
+ return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
333
+ });
334
+ if (limit !== null && list.length > limit) {
335
+ const top = list.slice(0, limit);
336
+ const topIds = new Set(top.map(s => s.id));
337
+ const extraPinned = list.filter(s => pinOf(s.id) && !topIds.has(s.id));
338
+ list = [...top, ...extraPinned];
339
+ }
306
340
  if (asJson) {
307
- console.log(JSON.stringify(list, null, 2));
341
+ console.log(JSON.stringify(list.map(s => ({ ...s, pinState: pinOf(s.id) })), null, 2));
308
342
  return 0;
309
343
  }
310
344
  if (!list.length) {
@@ -313,6 +347,7 @@ async function runSessionListCli(args) {
313
347
  }
314
348
  const rows = list.map(s => ({
315
349
  id: s.id.slice(0, 8),
350
+ pin: pinOf(s.id) || '',
316
351
  status: sessionStatus(s),
317
352
  age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
318
353
  tasks: `${s.completed}/${s.taskCount}`,
@@ -321,17 +356,15 @@ async function runSessionListCli(args) {
321
356
  }));
322
357
  const w = {
323
358
  id: 8,
359
+ pin: Math.max(3, ...rows.map(r => r.pin.length)),
324
360
  status: Math.max(6, ...rows.map(r => r.status.length)),
325
361
  age: Math.max(3, ...rows.map(r => r.age.length)),
326
362
  tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
327
363
  project: Math.max(7, ...rows.map(r => r.project.length)),
328
364
  };
329
- console.log(`${'ID'.padEnd(w.id)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
365
+ console.log(`${'ID'.padEnd(w.id)} ${'PIN'.padEnd(w.pin)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
330
366
  for (const r of rows) {
331
- console.log(`${r.id.padEnd(w.id)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
332
- }
333
- if (limit !== null && totalMatched > limit) {
334
- console.log(`\n... ${totalMatched - limit} more. Use --limit <n> or --limit all to see them.`);
367
+ console.log(`${r.id.padEnd(w.id)} ${r.pin.padEnd(w.pin)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
335
368
  }
336
369
  return 0;
337
370
  }
@@ -396,6 +429,53 @@ async function runSessionPinCli(args) {
396
429
  } catch (e) { reportCliError(e); return 1; }
397
430
  }
398
431
 
432
+ async function runSessionPinsCli(args) {
433
+ const stickyOnly = args.includes('--sticky');
434
+ const asJson = args.includes('--json');
435
+ const pinsMap = await fetchPinsMap();
436
+ const items = Object.entries(pinsMap)
437
+ .filter(([, state]) => !stickyOnly || state === 'sticky')
438
+ .map(([id, state]) => ({ id, state }));
439
+ if (!items.length) {
440
+ if (asJson) console.log('[]'); else console.log('No pinned sessions.');
441
+ return 0;
442
+ }
443
+ let sessions;
444
+ try {
445
+ sessions = await fetchSessionsList(items.length, items.map(p => p.id));
446
+ } catch (e) { reportCliError(e); return 1; }
447
+ const byId = new Map(sessions.map(s => [s.id, s]));
448
+ let rows = items
449
+ .map(p => {
450
+ const s = byId.get(p.id) || {};
451
+ return {
452
+ id: p.id,
453
+ state: p.state,
454
+ status: s.id ? sessionStatus(s) : '-',
455
+ age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
456
+ project: path.basename(s.project || ''),
457
+ title: s.customTitle || s.name || s.slug || '',
458
+ };
459
+ })
460
+ .sort((a, b) => (a.state === b.state ? 0 : a.state === 'sticky' ? -1 : 1));
461
+ if (asJson) {
462
+ console.log(JSON.stringify(rows, null, 2));
463
+ return 0;
464
+ }
465
+ const w = {
466
+ id: 8,
467
+ state: Math.max(5, ...rows.map(r => r.state.length)),
468
+ status: Math.max(6, ...rows.map(r => r.status.length)),
469
+ age: Math.max(3, ...rows.map(r => r.age.length)),
470
+ project: Math.max(7, ...rows.map(r => r.project.length)),
471
+ };
472
+ console.log(`${'ID'.padEnd(w.id)} ${'STATE'.padEnd(w.state)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'PROJECT'.padEnd(w.project)} TITLE`);
473
+ for (const r of rows) {
474
+ console.log(`${r.id.slice(0, 8).padEnd(w.id)} ${r.state.padEnd(w.state)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.project.padEnd(w.project)} ${r.title}`);
475
+ }
476
+ return 0;
477
+ }
478
+
399
479
  async function runSessionViewCli(args) {
400
480
  const idArg = args.find(a => !a.startsWith('--'));
401
481
  if (!idArg) {
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.8.0",
3
+ "version": "3.10.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.4",
3
+ "version": "1.1.5",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -32,10 +32,18 @@ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
32
32
  claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
33
33
  ```
34
34
 
35
- State applies to every connected browser tab (broadcast via SSE) and persists in each tab's localStorage. With no tabs open the command is a no-op.
36
-
37
35
  Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
38
36
 
37
+ ## List pinned sessions
38
+
39
+ ```bash
40
+ claude-code-kanban session pins # all pinned/sticky sessions
41
+ claude-code-kanban session pins --sticky # sticky only
42
+ claude-code-kanban session pins --json # JSON output
43
+ ```
44
+
45
+ Trigger phrases: "show pinned sessions", "what's pinned", "list pins".
46
+
39
47
  ## Preview a file in kanban
40
48
 
41
49
  Opens a markdown file in the preview modal:
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 = '';
@@ -1322,6 +1367,16 @@ function savePinnedSessions() {
1322
1367
  localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1323
1368
  }
1324
1369
 
1370
+ // Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
1371
+ function offloadSessionPin(sessionId) {
1372
+ const state = getSessionPinState(sessionId);
1373
+ fetch('/api/session/pin', {
1374
+ method: 'POST',
1375
+ headers: { 'Content-Type': 'application/json' },
1376
+ body: JSON.stringify({ id: sessionId, state }),
1377
+ }).catch(() => {});
1378
+ }
1379
+
1325
1380
  function toggleSessionPin(sessionId) {
1326
1381
  if (pinnedSessionIds.has(sessionId)) {
1327
1382
  pinnedSessionIds.delete(sessionId);
@@ -1332,6 +1387,7 @@ function toggleSessionPin(sessionId) {
1332
1387
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1333
1388
  }
1334
1389
  savePinnedSessions();
1390
+ offloadSessionPin(sessionId);
1335
1391
  renderSessions();
1336
1392
  }
1337
1393
 
@@ -1346,6 +1402,7 @@ function toggleSessionSticky(sessionId) {
1346
1402
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1347
1403
  }
1348
1404
  savePinnedSessions();
1405
+ offloadSessionPin(sessionId);
1349
1406
  renderSessions();
1350
1407
  }
1351
1408
 
@@ -2227,7 +2284,7 @@ async function showAllTasks() {
2227
2284
  updateUrl();
2228
2285
  renderAllTasks();
2229
2286
  renderSessions();
2230
- renderLiveUpdatesFromCache();
2287
+ renderActivityChip();
2231
2288
  } catch (error) {
2232
2289
  console.error('Failed to fetch all tasks:', error);
2233
2290
  }
@@ -2301,7 +2358,11 @@ function renderSessions() {
2301
2358
  filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
2302
2359
  }
2303
2360
 
2304
- // 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
+
2305
2366
  if (searchQuery) {
2306
2367
  const taskMatchIds = new Set();
2307
2368
  for (const t of allTasksCache) {
@@ -2322,7 +2383,7 @@ function renderSessions() {
2322
2383
  filteredSessions = filteredSessions.filter(matchesSearch);
2323
2384
 
2324
2385
  // Re-add pinned/sticky sessions that match the query but were excluded by active filter
2325
- if (pinnedSessionIds.size > 0 || stickySessionIds.size > 0) {
2386
+ if (activityFilter.size === 0 && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2326
2387
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2327
2388
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
2328
2389
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
@@ -2330,7 +2391,8 @@ function renderSessions() {
2330
2391
  }
2331
2392
 
2332
2393
  // Include pinned/sticky sessions even if they don't match active/recent filter
2333
- 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)) {
2334
2396
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2335
2397
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
2336
2398
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
@@ -3534,7 +3596,6 @@ async function refreshCurrentView() {
3534
3596
  await showAllTasks();
3535
3597
  } else if (currentSessionId) {
3536
3598
  await fetchTasks(currentSessionId);
3537
- renderLiveUpdatesFromCache();
3538
3599
  } else {
3539
3600
  await fetchSessions();
3540
3601
  }
@@ -3985,7 +4046,6 @@ function _renderStorageLinkedDocs() {
3985
4046
  }
3986
4047
 
3987
4048
  function _storagePreviewLinkedDoc(path) {
3988
- closeStorageManager();
3989
4049
  openPreviewByPath(path);
3990
4050
  }
3991
4051
 
@@ -4293,6 +4353,33 @@ document.addEventListener('keydown', (e) => {
4293
4353
  hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
4294
4354
  return;
4295
4355
  }
4356
+ if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
4357
+ e.preventDefault();
4358
+ if (!contextSid || dismissedSessionIds.has(contextSid)) return;
4359
+ const prevIdx = selectedSessionIdx;
4360
+ dismissedSessionIds.add(contextSid);
4361
+ updateDismissBtnState();
4362
+ renderSessions();
4363
+ const newItems = getNavigableItems();
4364
+ const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
4365
+ // If the dismissed session is currently open, navigate to the previous one
4366
+ if (currentSessionId === contextSid || selectedSessionId === contextSid) {
4367
+ selectedSessionId = null;
4368
+ if (targetIdx >= 0) {
4369
+ const targetSid = newItems[targetIdx]?.dataset?.sessionId;
4370
+ if (targetSid) {
4371
+ fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
4372
+ } else {
4373
+ showAllTasks().then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
4374
+ }
4375
+ } else {
4376
+ showAllTasks();
4377
+ }
4378
+ } else if (targetIdx >= 0) {
4379
+ selectSessionByIndex(targetIdx, newItems);
4380
+ }
4381
+ return;
4382
+ }
4296
4383
  if (e.code === 'KeyC' && e.shiftKey) {
4297
4384
  e.preventDefault();
4298
4385
  if (!contextSid) {
@@ -4612,20 +4699,12 @@ function setupEventSource() {
4612
4699
  wasConnected = true;
4613
4700
  retryDelay = 1000;
4614
4701
  hideOffline();
4615
- connectionStatus.innerHTML = `
4616
- <span class="connection-dot live"></span>
4617
- <span>Connected</span>
4618
- `;
4619
4702
  };
4620
4703
 
4621
4704
  eventSource.onerror = () => {
4622
4705
  eventSource.close();
4623
4706
  failCount++;
4624
4707
  console.warn('[SSE] Connection lost, retrying in', retryDelay, 'ms');
4625
- connectionStatus.innerHTML = `
4626
- <span class="connection-dot error"></span>
4627
- <span>Reconnecting...</span>
4628
- `;
4629
4708
  if (failCount >= 2) showOffline();
4630
4709
  setTimeout(connect, retryDelay);
4631
4710
  retryDelay = Math.min(retryDelay * 2, 30000);
@@ -4655,7 +4734,7 @@ function setupEventSource() {
4655
4734
  if (viewMode === 'all') {
4656
4735
  currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
4657
4736
  renderAllTasks();
4658
- renderLiveUpdatesFromCache();
4737
+ renderActivityChip();
4659
4738
  } else if (viewMode === 'project' && currentProjectPath) {
4660
4739
  const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
4661
4740
  if (hasUpdate) fetchProjectView(currentProjectPath);
@@ -5084,8 +5163,10 @@ function getOwnerColor(name) {
5084
5163
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5085
5164
  function filterBySessions(value) {
5086
5165
  sessionFilter = value;
5166
+ if (value !== 'active') activityFilter.clear();
5087
5167
  updateUrl();
5088
5168
  renderSessions();
5169
+ renderActivityChip();
5089
5170
  }
5090
5171
 
5091
5172
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -5348,7 +5429,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
5348
5429
  });
5349
5430
 
5350
5431
  function onMove(e) {
5351
- const w = Math.min(900, Math.max(320, startWidth - (e.clientX - startX)));
5432
+ const w = Math.max(200, startWidth - (e.clientX - startX));
5352
5433
  panel.style.setProperty(cssVar, `${w}px`);
5353
5434
  }
5354
5435
 
@@ -5772,15 +5853,6 @@ function filterByOwner(value) {
5772
5853
 
5773
5854
  //#endregion
5774
5855
 
5775
- //#region LAYOUT_SYNC
5776
- const sidebarHeader = document.querySelector('.sidebar-header');
5777
- const viewHeader = document.querySelector('.view-header');
5778
- new ResizeObserver(() => {
5779
- sidebarHeader.style.height = `${viewHeader.offsetHeight}px`;
5780
- }).observe(viewHeader);
5781
-
5782
- //#endregion
5783
-
5784
5856
  //#region PWA
5785
5857
  if ('serviceWorker' in navigator) {
5786
5858
  navigator.serviceWorker.register('/sw.js');
@@ -5790,14 +5862,10 @@ if ('serviceWorker' in navigator) {
5790
5862
 
5791
5863
  //#region INIT
5792
5864
  loadTheme();
5793
- ['live-updates', 'sessions-filters'].forEach((id) => {
5794
- if (localStorage.getItem(`${id}Collapsed`) === 'true') {
5795
- document.getElementById(id).classList.add('collapsed');
5796
- document
5797
- .getElementById(id === 'live-updates' ? 'live-updates-chevron' : 'sessions-chevron')
5798
- .classList.add('rotated');
5799
- }
5800
- });
5865
+ if (localStorage.getItem('sessions-filtersCollapsed') === 'true') {
5866
+ document.getElementById('sessions-filters').classList.add('collapsed');
5867
+ document.getElementById('sessions-chevron').classList.add('rotated');
5868
+ }
5801
5869
 
5802
5870
  document.addEventListener('DOMContentLoaded', () => {
5803
5871
  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;
@@ -3455,10 +3422,6 @@ pre.mermaid svg {
3455
3422
  }
3456
3423
  }
3457
3424
 
3458
- .connection-dot.live {
3459
- animation: breathe 3s ease-in-out infinite;
3460
- }
3461
-
3462
3425
  /* Progress bar shimmer */
3463
3426
  @keyframes shimmer {
3464
3427
  0% {
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const express = require('express');
4
4
  const path = require('path');
5
5
  const fs = require('fs').promises;
6
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync } = require('fs');
6
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync } = require('fs');
7
7
  const readline = require('readline');
8
8
  const chokidar = require('chokidar');
9
9
  const os = require('os');
@@ -73,6 +73,27 @@ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
73
73
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
74
74
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
75
75
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
76
+ const PINS_FILE = path.join(CCK_DIR, 'pins.json');
77
+
78
+ // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
79
+ function readPins() {
80
+ try {
81
+ const obj = JSON.parse(readFileSync(PINS_FILE, 'utf8'));
82
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
83
+ } catch (_) {}
84
+ return {};
85
+ }
86
+
87
+ function writePins(pins) {
88
+ try {
89
+ mkdirSync(CCK_DIR, { recursive: true });
90
+ const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
92
+ renameSync(tmp, PINS_FILE);
93
+ } catch (e) {
94
+ console.error('Failed to write pins.json:', e.message);
95
+ }
96
+ }
76
97
 
77
98
  const PERMISSION_TTL_MS = 1800000;
78
99
  const AGENT_TTL_MS = 3600000;
@@ -1067,6 +1088,29 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1067
1088
  }
1068
1089
  }
1069
1090
  } catch (_) {}
1091
+ // Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
1092
+ // the parent will never read their output, so they're orphans. Match by agentId
1093
+ // when the digest already correlated tool_use→agent, else fall back to prompt text
1094
+ // (the agent-spy hook doesn't record the spawning tool_use_id).
1095
+ try {
1096
+ const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
1097
+ getSessionDigest(meta.jsonlPath);
1098
+ if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
1099
+ for (const agent of liveAgents) {
1100
+ if (agent.status !== 'active' && agent.status !== 'idle') continue;
1101
+ let reason = null;
1102
+ if (killedAgentIds.has(agent.agentId)) reason = 'killed-by-harness';
1103
+ else if (rejectedAgentIds.has(agent.agentId) || (agent.prompt && rejectedPrompts.has(agent.prompt))) {
1104
+ reason = 'orphaned-by-rejection';
1105
+ }
1106
+ if (!reason) continue;
1107
+ agent.status = 'stopped';
1108
+ agent.stoppedAt = agent.stoppedAt || new Date().toISOString();
1109
+ agent.stopReason = agent.stopReason || reason;
1110
+ persistAgent(agentDir, agent);
1111
+ }
1112
+ }
1113
+ } catch (_) {}
1070
1114
  }
1071
1115
 
1072
1116
  const dirty = new Set();
@@ -1169,6 +1213,31 @@ function subagentJsonlPath(meta, agentId) {
1169
1213
  );
1170
1214
  }
1171
1215
 
1216
+ // Claude Code can scatter a session's records across multiple project dirs
1217
+ // (e.g. main repo + worktree), so the subagent JSONL may live under a
1218
+ // different project dir than meta.jsonlPath. Fall back to scanning when the
1219
+ // derived path is missing.
1220
+ const subagentPathCache = new Map();
1221
+ function resolveSubagentJsonl(meta, sessionId, agentId) {
1222
+ const primary = subagentJsonlPath(meta, agentId);
1223
+ if (existsSync(primary)) return primary;
1224
+ const key = sessionId + '/' + agentId;
1225
+ if (subagentPathCache.has(key)) return subagentPathCache.get(key) || primary;
1226
+ let found = null;
1227
+ try {
1228
+ for (const entry of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1229
+ if (!entry.isDirectory()) continue;
1230
+ const candidate = path.join(
1231
+ PROJECTS_DIR, entry.name, sessionId,
1232
+ 'subagents', 'agent-' + agentId + '.jsonl'
1233
+ );
1234
+ if (existsSync(candidate)) { found = candidate; break; }
1235
+ }
1236
+ } catch (_) { /* projects dir missing */ }
1237
+ subagentPathCache.set(key, found);
1238
+ return found || primary;
1239
+ }
1240
+
1172
1241
  app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1173
1242
  const sessionId = resolveSessionId(req.params.sessionId);
1174
1243
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1176,7 +1245,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1176
1245
  const metadata = loadSessionMetadata();
1177
1246
  const meta = metadata[sessionId];
1178
1247
  if (!meta?.jsonlPath) return res.json({ messages: [], agentId });
1179
- const subagentJsonl = subagentJsonlPath(meta, agentId);
1248
+ const subagentJsonl = resolveSubagentJsonl(meta, sessionId, agentId);
1180
1249
  if (!existsSync(subagentJsonl)) return res.json({ messages: [], agentId });
1181
1250
  const messages = readRecentMessages(subagentJsonl, limit);
1182
1251
  res.json({ messages, agentId });
@@ -1191,7 +1260,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
1191
1260
  res.status(404).json({ error: 'Session not found' });
1192
1261
  return;
1193
1262
  }
1194
- const subagentJsonl = subagentJsonlPath(meta, agentId);
1263
+ const subagentJsonl = resolveSubagentJsonl(meta, sessionId, agentId);
1195
1264
 
1196
1265
  res.writeHead(200, {
1197
1266
  'Content-Type': 'text/event-stream',
@@ -1561,6 +1630,10 @@ app.post('/api/session/pin', async (req, res) => {
1561
1630
  if (!['none', 'pinned', 'sticky'].includes(state)) {
1562
1631
  return res.status(400).json({ error: 'state must be none|pinned|sticky' });
1563
1632
  }
1633
+ const pins = readPins();
1634
+ if (state === 'none') delete pins[id];
1635
+ else pins[id] = state;
1636
+ writePins(pins);
1564
1637
  broadcast({ type: 'session:pin', id, state });
1565
1638
  res.json({ success: true, id, state });
1566
1639
  } catch (error) {
@@ -1569,6 +1642,18 @@ app.post('/api/session/pin', async (req, res) => {
1569
1642
  }
1570
1643
  });
1571
1644
 
1645
+ app.get('/api/session/pins', (req, res) => {
1646
+ res.setHeader('Cache-Control', 'no-store');
1647
+ try {
1648
+ const pins = readPins();
1649
+ const items = Object.entries(pins).map(([id, state]) => ({ id, state }));
1650
+ res.json({ pins, items });
1651
+ } catch (error) {
1652
+ console.error('Error in GET /api/session/pins:', error);
1653
+ res.status(500).json({ error: error.message || 'Failed' });
1654
+ }
1655
+ });
1656
+
1572
1657
  app.get('/api/preview', async (req, res) => {
1573
1658
  try {
1574
1659
  const abs = resolvePreviewPath(req.query.path, req.query.base);