claude-code-kanban 4.12.0 → 4.14.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/README.md CHANGED
@@ -8,9 +8,7 @@
8
8
 
9
9
  > Watch Claude Code work, in real time.
10
10
 
11
- ![Dark mode](assets/screenshot-dark-v2.png)
12
-
13
- ![Light mode](assets/screenshot-light-v2.png)
11
+ ![Kanban board with session log](assets/shot-session-log.png)
14
12
 
15
13
  ## Getting Started
16
14
 
@@ -22,11 +20,7 @@ Hooks enable subagent tracking, waiting-for-user detection, and session activity
22
20
  npx claude-code-kanban --install
23
21
  ```
24
22
 
25
- This will:
26
- - Copy `agent-spy.sh` to `~/.claude/hooks/` (requires `jq`)
27
- - Add `SubagentStart`, `SubagentStop`, `TeammateIdle`, and `PostToolUse` hooks to `~/.claude/settings.json`
28
-
29
- All changes are non-destructive — existing settings are preserved.
23
+ Non-destructive — existing settings in `~/.claude/settings.json` are preserved. Uninstall anytime with `npx claude-code-kanban --uninstall`.
30
24
 
31
25
  ### 2. Start the dashboard
32
26
 
@@ -34,72 +28,34 @@ All changes are non-destructive — existing settings are preserved.
34
28
  npx claude-code-kanban --open
35
29
  ```
36
30
 
37
- Open http://localhost:3541 (or use `--open` to auto-launch the browser).
38
-
39
31
  ### 3. Use Claude Code as usual
40
32
 
41
- Tasks, agents, and messages appear on the board automatically. No changes to your Claude Code workflow needed.
42
-
43
- > To uninstall hooks: `npx claude-code-kanban --uninstall`
33
+ Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. It never directs Claude's work.
44
34
 
45
35
  ## Features
46
36
 
47
37
  - **Real-time Kanban board** — Tasks move through Pending → In Progress → Completed as Claude works
48
- - **Agent log** — Live subagent tracking (start/stop/idle) with prompts, duration, and status
49
- - **Session message log** — Browse recent messages, tool calls, and model info (`Shift+L`)
38
+ - **Session log** — The full conversation timeline: prompts, replies, tool calls and results (`Shift+L`)
39
+ - **Agent log** — Live subagent tracking with prompts, duration, status, and idle detection
40
+ - **Task detail panel** — Full description, notes, blockedBy/blocks dependencies, inline editing
41
+ - **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
42
+ - **Tool stats & impact** — Per-session tool usage breakdown and file impact
50
43
  - **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
51
- - **Plan correlation** — Plan sessions linked to their implementation sessions
52
44
  - **Agent teams** — Color-coded team members, owner filtering, member count badges
53
- - **Task dependencies** — See blockedBy/blocks relationships and the critical path
54
- - **Live activity feed** — Stream of all in-progress tasks across every session
55
- - **Session management** — Fuzzy search, project/branch display, active/stale session styling
56
- - **Cleanup** — Delete tasks (with dependency checks) or bulk-delete entire sessions
57
- - **Keyboard shortcuts** — Press `?` for help; `Shift+M` for live message follow
58
-
59
- ## Configuration
60
-
61
- ```bash
62
- PORT=8080 npx claude-code-kanban # Custom port
63
- npx claude-code-kanban --open # Auto-open browser
64
- npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
65
- ```
66
-
67
- If port 3541 is in use, the server falls back to a random available port.
68
-
69
- ### Global install
70
-
71
- ```bash
72
- npm install -g claude-code-kanban
73
- claude-code-kanban --open
74
- ```
75
-
76
- ## How It Works
45
+ - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
46
+ - **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
47
+ - **Keyboard-first** — Press `?` for the full shortcut reference
77
48
 
78
- Claude Code writes task files to `~/.claude/tasks/` and conversation logs to `~/.claude/projects/`. The dashboard watches these directories with [chokidar](https://github.com/paulmillr/chokidar) and pushes updates to the browser via Server-Sent Events (SSE). Nothing is modified — the dashboard is read-only.
49
+ ![Session info](assets/shot-session-info.png)
79
50
 
80
- **Tasks** are picked up automatically from Claude Code's native task system (TodoWrite). No hooks needed.
51
+ ![Subagent preview](assets/shot-subagent-preview.png)
81
52
 
82
- **Hooks** extend observability beyond tasks. When installed, lightweight shell scripts fire on Claude Code lifecycle events and write JSON markers that the dashboard picks up:
53
+ ![Theme picker](assets/shot-theme-picker.png)
83
54
 
84
- | Hook event | What it enables |
85
- |------------|----------------|
86
- | `SubagentStart` / `SubagentStop` | Agent log — see subagent spawns, durations, prompts |
87
- | `TeammateIdle` | Idle detection for team member agents |
88
- | `PostToolUse` | Waiting-for-user detection (permission prompts, AskUserQuestion) |
89
55
 
90
56
  ## Context Window Monitoring
91
57
 
92
- Track real-time context window usage for each Claude Code session directly in the dashboard sidebar and detail panel.
93
-
94
- ### Setup
95
-
96
- The installer copies `context-status.sh` alongside the agent hooks:
97
-
98
- ```bash
99
- npx claude-code-kanban --install
100
- ```
101
-
102
- Then add it to your statusline in `~/.claude/settings.json`:
58
+ Per-session context usage bars, token/cost breakdowns, and model info in the sidebar and detail panel. The installer copies `context-status.sh` — wire it into your statusline in `~/.claude/settings.json`:
103
59
 
104
60
  ```json
105
61
  {
@@ -111,27 +67,17 @@ Then add it to your statusline in `~/.claude/settings.json`:
111
67
  }
112
68
  ```
113
69
 
114
- The script pipes through — your existing statusline still works. It just writes a snapshot to `~/.claude/context-status/{sessionId}.json` on each update.
115
-
116
- ### What you get
70
+ The script pipes through, so your existing statusline keeps working.
117
71
 
118
- - **Sidebar bar** — compact context usage bar per session with color thresholds (green → yellow → orange → red) and a 200K token marker
119
- - **Detail panel** — input/output token breakdown, cache read tokens, cost, duration, API time, lines added/removed, and model name
120
- - Only shown for active or pinned sessions
121
-
122
- ## FAQ
123
-
124
- **Does this control Claude?**
125
- No. The viewer only observes — it never writes to task files or directs Claude's work.
126
-
127
- **Does it work with agent teams?**
128
- Yes. Team sessions are auto-detected with color-coded members and owner filtering.
72
+ ## Configuration
129
73
 
130
- **Does it require Claude Code to be running?**
131
- No. You can browse past sessions anytime. Live updates resume when Claude starts working again.
74
+ ```bash
75
+ PORT=8080 npx claude-code-kanban # Custom port (falls back if busy)
76
+ npx claude-code-kanban --open # Auto-open browser
77
+ npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
78
+ ```
132
79
 
133
- **What happens without hooks?**
134
- The Kanban board still shows tasks, but you won't see the agent log, waiting-for-user indicators, or live session activity. Run `npx claude-code-kanban --install` for the full experience.
80
+ Global install: `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
135
81
 
136
82
  ## License
137
83
 
package/lib/parsers.js CHANGED
@@ -1029,14 +1029,15 @@ const CACHED_IMAGE_MIME = {
1029
1029
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
1030
1030
  };
1031
1031
 
1032
- // Serve pasted image N from ~/.claude/image-cache/<sessionId>/. The cached file may
1032
+ // Serve pasted image N from <claudeDir>/image-cache/<sessionId>/. The cached file may
1033
1033
  // be any image format, so resolve it by index rather than assuming .png. sessionId
1034
1034
  // is validated to a bare id to prevent path traversal.
1035
- function readCachedImage(sessionId, n) {
1035
+ function readCachedImage(sessionId, n, claudeDir) {
1036
1036
  const idx = Number(n);
1037
1037
  if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) return null;
1038
1038
  if (!Number.isInteger(idx) || idx < 0) return null;
1039
- const dir = path.join(require('os').homedir(), '.claude', 'image-cache', sessionId);
1039
+ const baseDir = claudeDir || path.join(require('os').homedir(), '.claude');
1040
+ const dir = path.join(baseDir, 'image-cache', sessionId);
1040
1041
  try {
1041
1042
  const match = readdirSync(dir).find((f) => {
1042
1043
  const dot = f.lastIndexOf('.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.12.0",
3
+ "version": "4.14.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": {
package/public/app.js CHANGED
@@ -2944,6 +2944,7 @@ function renderSessions() {
2944
2944
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
2945
2945
  ${session.hasPlan && !session.planSourceSessionId ? `<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>` : ''}
2946
2946
  ${renderLoopBadge(session)}
2947
+ ${renderWorkflowBadge(session)}
2947
2948
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2948
2949
  ${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>` : ''}
2949
2950
  ${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>` : ''}
@@ -4589,7 +4590,14 @@ function matchKey(e, ...keys) {
4589
4590
  return keys.some((k) => e.key === k || e.code === k);
4590
4591
  }
4591
4592
 
4592
- const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'tool-stats-modal', 'plan-modal', 'loop-modal'];
4593
+ const MODAL_ESC_PRIORITY = [
4594
+ 'preview-modal',
4595
+ 'msg-detail-modal',
4596
+ 'tool-stats-modal',
4597
+ 'plan-modal',
4598
+ 'loop-modal',
4599
+ 'workflow-modal',
4600
+ ];
4593
4601
  const MODAL_CLOSERS = {
4594
4602
  'preview-modal': () => closePreviewModal(),
4595
4603
  'msg-detail-modal': () => {
@@ -4599,6 +4607,7 @@ const MODAL_CLOSERS = {
4599
4607
  'tool-stats-modal': () => closeToolStatsModal(),
4600
4608
  'plan-modal': () => closePlanModal(),
4601
4609
  'loop-modal': () => closeLoopModal(),
4610
+ 'workflow-modal': () => closeWorkflowModal(),
4602
4611
  'team-modal': () => closeTeamModal(),
4603
4612
  'agent-modal': () => closeAgentModal(),
4604
4613
  'help-modal': () => closeHelpModal(),
@@ -6477,6 +6486,134 @@ function closeLoopModal() {
6477
6486
  document.getElementById('loop-modal').classList.remove('visible');
6478
6487
  }
6479
6488
 
6489
+ const WORKFLOW_GEAR_SVG =
6490
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
6491
+
6492
+ // Standard "open in editor" glyph — matches the message-detail / plan modals.
6493
+ const ICON_OPEN_EDITOR =
6494
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><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>';
6495
+
6496
+ function renderWorkflowBadge(session) {
6497
+ if (!session.hasWorkflow) return '';
6498
+ const n = session.workflowCount || 0;
6499
+ const tip = `${n} workflow${n === 1 ? '' : 's'}`;
6500
+ return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${session.id}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
6501
+ }
6502
+
6503
+ let _workflowSessionId = null;
6504
+ let _workflowHeaderId = null;
6505
+
6506
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6507
+ function showWorkflowModal(sessionId) {
6508
+ _workflowSessionId = sessionId;
6509
+ setWorkflowHeader('Workflows', null);
6510
+ const body = document.getElementById('workflow-modal-body');
6511
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6512
+ document.getElementById('workflow-modal').classList.add('visible');
6513
+ fetch(`/api/sessions/${sessionId}/workflows`)
6514
+ .then((r) => (r.ok ? r.json() : { workflows: [] }))
6515
+ .catch(() => ({ workflows: [] }))
6516
+ .then((data) => renderWorkflowList(data.workflows || []));
6517
+ }
6518
+
6519
+ function setWorkflowHeader(name, wfId) {
6520
+ const titleText = document.getElementById('workflow-modal-title-text');
6521
+ const headerBtn = document.getElementById('workflow-modal-open-btn');
6522
+ _workflowHeaderId = wfId || null;
6523
+ if (titleText) {
6524
+ titleText.textContent = name;
6525
+ titleText.title = wfId ? name : '';
6526
+ }
6527
+ if (headerBtn) headerBtn.style.display = wfId ? '' : 'none';
6528
+ }
6529
+
6530
+ function renderWorkflowList(workflows) {
6531
+ const body = document.getElementById('workflow-modal-body');
6532
+ if (!workflows.length) {
6533
+ setWorkflowHeader('Workflows', null);
6534
+ body.innerHTML =
6535
+ '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
6536
+ return;
6537
+ }
6538
+ // Single workflow: promote its name to the header and open-button next to the close (X).
6539
+ if (workflows.length === 1) {
6540
+ const w = workflows[0];
6541
+ setWorkflowHeader(w.name || w.id, w.id);
6542
+ const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6543
+ body.innerHTML = `<div class="workflow-single">
6544
+ <div class="workflow-row-meta"><code>${escapeHtml(w.id)}</code>${when ? ` · ${escapeHtml(when)}` : ''}</div>
6545
+ <div class="workflow-row-code"></div>
6546
+ </div>`;
6547
+ loadWorkflowCode(w.id, body.querySelector('.workflow-row-code'));
6548
+ return;
6549
+ }
6550
+ // Multiple workflows: generic header, collapsible list with per-row open icons.
6551
+ setWorkflowHeader('Workflows', null);
6552
+ body.innerHTML = workflows
6553
+ .map((w) => {
6554
+ const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6555
+ return `<div class="workflow-row" onclick="viewWorkflowScript('${escapeHtml(w.id)}', this)">
6556
+ <div class="workflow-row-head">
6557
+ <span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
6558
+ <span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
6559
+ <button class="icon-btn workflow-open-icon" aria-label="Open in editor" title="Open in editor" onclick="event.stopPropagation(); openWorkflowInEditor('${escapeHtml(w.id)}')">${ICON_OPEN_EDITOR}</button>
6560
+ </div>
6561
+ ${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
6562
+ <div class="workflow-row-code"></div>
6563
+ </div>`;
6564
+ })
6565
+ .join('');
6566
+ }
6567
+
6568
+ function loadWorkflowCode(wfId, codeEl) {
6569
+ if (!codeEl) return;
6570
+ codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Loading…</div>';
6571
+ fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}`)
6572
+ .then((r) => (r.ok ? r.json() : null))
6573
+ .catch(() => null)
6574
+ .then((data) => {
6575
+ if (!data?.content) {
6576
+ codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Failed to load script.</div>';
6577
+ return;
6578
+ }
6579
+ let highlighted;
6580
+ if (typeof hljs !== 'undefined' && hljs.getLanguage('javascript')) {
6581
+ highlighted = hljs.highlight(data.content, { language: 'javascript' }).value;
6582
+ } else {
6583
+ highlighted = escapeHtml(data.content);
6584
+ }
6585
+ codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
6586
+ });
6587
+ }
6588
+
6589
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6590
+ function viewWorkflowScript(wfId, rowEl) {
6591
+ const codeEl = rowEl.querySelector('.workflow-row-code');
6592
+ if (!codeEl) return;
6593
+ if (rowEl.classList.contains('expanded')) {
6594
+ rowEl.classList.remove('expanded');
6595
+ codeEl.innerHTML = '';
6596
+ return;
6597
+ }
6598
+ rowEl.classList.add('expanded');
6599
+ loadWorkflowCode(wfId, codeEl);
6600
+ }
6601
+
6602
+ function openWorkflowInEditor(wfId) {
6603
+ postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/open`, {}, 'in editor');
6604
+ }
6605
+
6606
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6607
+ function openWorkflowHeaderScript() {
6608
+ if (_workflowHeaderId) openWorkflowInEditor(_workflowHeaderId);
6609
+ }
6610
+
6611
+ function closeWorkflowModal() {
6612
+ document.getElementById('workflow-modal').classList.remove('visible');
6613
+ _workflowSessionId = null;
6614
+ setWorkflowHeader('Workflows', null);
6615
+ }
6616
+
6480
6617
  function openPlanForSession(sid) {
6481
6618
  fetch(`/api/sessions/${sid}/plan`)
6482
6619
  .then((r) => (r.ok ? r.json() : null))
package/public/index.html CHANGED
@@ -604,6 +604,30 @@
604
604
  </div>
605
605
  </div>
606
606
 
607
+ <!-- Workflow Scripts Modal -->
608
+ <div id="workflow-modal" class="modal-overlay" onclick="closeWorkflowModal()">
609
+ <div class="modal plan-modal" onclick="event.stopPropagation()">
610
+ <div class="modal-header">
611
+ <h3 id="workflow-modal-title" class="modal-title" style="display:flex;align-items:center;gap:8px;min-width:0;">
612
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;flex-shrink:0;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
613
+ <span id="workflow-modal-title-text" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Workflows</span>
614
+ </h3>
615
+ <div style="display:flex;align-items:center;gap:4px;">
616
+ <button id="workflow-modal-open-btn" class="icon-btn" aria-label="Open in editor" title="Open in editor" style="display:none;" onclick="openWorkflowHeaderScript()">
617
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><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>
618
+ </button>
619
+ <button class="modal-close" aria-label="Close dialog" onclick="closeWorkflowModal()">
620
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
621
+ </button>
622
+ </div>
623
+ </div>
624
+ <div id="workflow-modal-body" class="modal-body" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
625
+ <div class="modal-footer">
626
+ <button class="btn btn-secondary" onclick="closeWorkflowModal()">Close</button>
627
+ </div>
628
+ </div>
629
+ </div>
630
+
607
631
  <div id="agent-modal" class="modal-overlay plan-modal-overlay" onclick="closeAgentModal()">
608
632
  <div class="modal plan-modal" onclick="event.stopPropagation()">
609
633
  <div class="modal-header">
package/public/style.css CHANGED
@@ -1573,7 +1573,7 @@ body::before {
1573
1573
  .session-indicators {
1574
1574
  display: flex;
1575
1575
  align-items: center;
1576
- gap: 6px;
1576
+ gap: 2px;
1577
1577
  flex-shrink: 0;
1578
1578
  }
1579
1579
 
@@ -1640,26 +1640,116 @@ body::before {
1640
1640
  border-color: var(--plan);
1641
1641
  }
1642
1642
 
1643
+ /* Match .team-info-btn so the wakeups clock blends with the other session-card icons. */
1643
1644
  .loop-badge {
1644
1645
  width: 24px;
1645
1646
  height: 24px;
1646
1647
  display: inline-flex;
1647
1648
  align-items: center;
1648
1649
  justify-content: center;
1649
- background: var(--bg-deep);
1650
+ background: transparent;
1650
1651
  border: 1px solid transparent;
1651
1652
  border-radius: 4px;
1652
- color: var(--text-secondary);
1653
+ color: var(--text-muted);
1653
1654
  cursor: pointer;
1654
1655
  flex-shrink: 0;
1655
1656
  transition: all 0.15s ease;
1656
1657
  }
1657
1658
 
1658
1659
  .loop-badge:hover {
1660
+ background: var(--bg-hover);
1661
+ border-color: var(--border);
1662
+ color: var(--text-secondary);
1663
+ }
1664
+
1665
+ /* #region WORKFLOW */
1666
+ /* Match .team-info-btn so the gear blends with the other session-card icons. */
1667
+ .workflow-badge {
1668
+ width: 24px;
1669
+ height: 24px;
1670
+ display: inline-flex;
1671
+ align-items: center;
1672
+ justify-content: center;
1673
+ background: transparent;
1674
+ border: 1px solid transparent;
1675
+ border-radius: 4px;
1676
+ color: var(--text-muted);
1677
+ cursor: pointer;
1678
+ flex-shrink: 0;
1679
+ transition: all 0.15s ease;
1680
+ }
1681
+
1682
+ .workflow-badge:hover {
1683
+ background: var(--bg-hover);
1684
+ border-color: var(--border);
1685
+ color: var(--text-secondary);
1686
+ }
1687
+
1688
+ .workflow-row {
1689
+ border: 1px solid var(--border);
1690
+ border-radius: 6px;
1691
+ padding: 10px 12px;
1692
+ margin-bottom: 8px;
1693
+ cursor: pointer;
1694
+ transition: border-color 0.15s ease;
1695
+ }
1696
+
1697
+ .workflow-row:hover {
1659
1698
  border-color: var(--accent, var(--plan));
1660
- color: var(--accent, var(--plan));
1661
1699
  }
1662
1700
 
1701
+ .workflow-row.expanded {
1702
+ cursor: default;
1703
+ }
1704
+
1705
+ .workflow-row-head {
1706
+ display: flex;
1707
+ align-items: center;
1708
+ gap: 8px;
1709
+ }
1710
+
1711
+ .workflow-open-icon {
1712
+ margin-left: auto;
1713
+ flex-shrink: 0;
1714
+ }
1715
+
1716
+ .workflow-row-name {
1717
+ font-weight: 600;
1718
+ color: var(--text-primary);
1719
+ }
1720
+
1721
+ .workflow-row-id code {
1722
+ font-size: 11px;
1723
+ color: var(--text-secondary);
1724
+ }
1725
+
1726
+ .workflow-row-meta {
1727
+ margin-top: 2px;
1728
+ font-size: 11px;
1729
+ color: var(--text-secondary);
1730
+ }
1731
+
1732
+ .workflow-single .workflow-row-meta {
1733
+ margin: 0 0 10px;
1734
+ }
1735
+
1736
+ .workflow-row-code:not(:empty) {
1737
+ margin-top: 10px;
1738
+ }
1739
+
1740
+ .workflow-single .workflow-row-code:not(:empty) {
1741
+ margin-top: 0;
1742
+ }
1743
+
1744
+ .workflow-row-code pre {
1745
+ margin: 0;
1746
+ max-height: 60vh;
1747
+ overflow: auto;
1748
+ border-radius: 6px;
1749
+ }
1750
+
1751
+ /* #endregion */
1752
+
1663
1753
  .loop-section-title {
1664
1754
  margin: 14px 0 8px;
1665
1755
  font-size: 12px;
package/server.js CHANGED
@@ -54,7 +54,7 @@ function getClaudeDir() {
54
54
  return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
55
55
  }
56
56
  }
57
- return process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
57
+ return process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
58
58
  }
59
59
 
60
60
  function getArgUrl(argName, envName) {
@@ -743,6 +743,72 @@ function loadSessionMetadata() {
743
743
  return metadata;
744
744
  }
745
745
 
746
+ // Workflow (Workflow tool) scripts are persisted at
747
+ // projects/<projEnc>/<sessionId>/workflows/scripts/<name>-<wf_id>.js
748
+ // The script's projEnc can differ from the session's own JSONL dir (the workflow
749
+ // may run from a different cwd), so we scan every project dir into a TTL-cached
750
+ // index rather than deriving the path from meta.jsonlPath. Isolated from the
751
+ // session-scan hot path: a single Map lookup per buildSessionObject call.
752
+ const WORKFLOW_INDEX_TTL_MS = 5000;
753
+ let workflowIndexCache = null; // Map<sessionId, Array<{id,name,path,mtimeMs}>>
754
+ let workflowIndexBuiltAt = 0;
755
+
756
+ function buildWorkflowIndex() {
757
+ const index = new Map();
758
+ if (!existsSync(PROJECTS_DIR)) return index;
759
+ let projs;
760
+ try { projs = readdirSync(PROJECTS_DIR, { withFileTypes: true }); } catch { return index; }
761
+ for (const proj of projs) {
762
+ if (!proj.isDirectory()) continue;
763
+ const projPath = path.join(PROJECTS_DIR, proj.name);
764
+ let sessDirs;
765
+ try { sessDirs = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
766
+ for (const sess of sessDirs) {
767
+ if (!sess.isDirectory() || !isUUID(sess.name)) continue;
768
+ const scriptsDir = path.join(projPath, sess.name, 'workflows', 'scripts');
769
+ let scripts;
770
+ try { scripts = readdirSync(scriptsDir); } catch { continue; } // ENOENT for most — skip fast
771
+ for (const f of scripts) {
772
+ if (!f.endsWith('.js')) continue;
773
+ const full = path.join(scriptsDir, f);
774
+ let mtimeMs = 0;
775
+ try { mtimeMs = statSync(full).mtimeMs; } catch {}
776
+ const base = f.slice(0, -3);
777
+ const m = base.match(/^(.*)-(wf_[a-z0-9-]+)$/i);
778
+ const entry = { id: m ? m[2] : base, name: m ? m[1] : base, path: full, mtimeMs };
779
+ if (!index.has(sess.name)) index.set(sess.name, []);
780
+ index.get(sess.name).push(entry);
781
+ }
782
+ }
783
+ }
784
+ return index;
785
+ }
786
+
787
+ function getWorkflowIndex() {
788
+ const now = Date.now();
789
+ if (workflowIndexCache && now - workflowIndexBuiltAt < WORKFLOW_INDEX_TTL_MS) return workflowIndexCache;
790
+ workflowIndexCache = buildWorkflowIndex();
791
+ workflowIndexBuiltAt = now;
792
+ return workflowIndexCache;
793
+ }
794
+
795
+ // Resolve a session's workflow scripts, newest first. Tries the raw id then the
796
+ // team-lead resolution (a team session's scripts live under the lead's dir).
797
+ function getWorkflowScripts(sessionId) {
798
+ const idx = getWorkflowIndex();
799
+ let list = idx.get(sessionId);
800
+ if (!list || !list.length) {
801
+ const alt = resolveSessionId(sessionId);
802
+ if (alt && alt !== sessionId) list = idx.get(alt);
803
+ }
804
+ return list ? [...list].sort((a, b) => b.mtimeMs - a.mtimeMs) : [];
805
+ }
806
+
807
+ function getWorkflowInfoSummary(sessionId) {
808
+ const list = getWorkflowIndex().get(sessionId);
809
+ return { hasWorkflow: !!(list && list.length), workflowCount: list ? list.length : 0 };
810
+ }
811
+
746
812
  function getPlanInfo(slug) {
747
813
  if (!slug) return { hasPlan: false, planTitle: null, planPath: null };
748
814
  const planPath = path.join(PLANS_DIR, `${slug}.md`);
@@ -839,6 +905,7 @@ function buildSessionObject(id, meta, overrides = {}) {
839
905
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
840
906
  contextStatus: getContextStatus(id, meta),
841
907
  ...getPlanInfo(meta.slug),
908
+ ...getWorkflowInfoSummary(id),
842
909
  loopInfo: getLoopInfoSummary(meta),
843
910
  ...overrides,
844
911
  // Remove internal-only field
@@ -1344,11 +1411,53 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1344
1411
  }
1345
1412
  });
1346
1413
 
1414
+ // API: List workflow scripts for a session
1415
+ app.get('/api/sessions/:sessionId/workflows', (req, res) => {
1416
+ try {
1417
+ const workflows = getWorkflowScripts(req.params.sessionId).map((w) => ({
1418
+ id: w.id,
1419
+ name: w.name,
1420
+ modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1421
+ }));
1422
+ res.json({ workflows });
1423
+ } catch (error) {
1424
+ console.error('Error listing workflows:', error);
1425
+ res.status(500).json({ error: 'Failed to list workflows' });
1426
+ }
1427
+ });
1428
+
1429
+ // API: Get a single workflow script's source. Looked up by id from the session's
1430
+ // own script list (never built from the param), so the id can't traverse paths.
1431
+ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
1432
+ try {
1433
+ const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1434
+ if (!wf) return res.status(404).json({ error: 'Workflow script not found' });
1435
+ const content = await fs.readFile(wf.path, 'utf8');
1436
+ res.json({ id: wf.id, name: wf.name, content });
1437
+ } catch (error) {
1438
+ console.error('Error reading workflow script:', error);
1439
+ res.status(500).json({ error: 'Failed to read workflow script' });
1440
+ }
1441
+ });
1442
+
1347
1443
  function openInEditor(...targets) {
1348
1444
  const editor = process.env.EDITOR || 'code';
1349
1445
  spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
1350
1446
  }
1351
1447
 
1448
+ // API: Open a workflow script in VS Code
1449
+ app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
1450
+ try {
1451
+ const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1452
+ if (!wf) return res.status(404).json({ error: 'Workflow script not found' });
1453
+ openInEditor(wf.path);
1454
+ res.json({ success: true });
1455
+ } catch (error) {
1456
+ console.error('Error opening workflow script in editor:', error);
1457
+ res.status(500).json({ error: 'Failed to open workflow script' });
1458
+ }
1459
+ });
1460
+
1352
1461
  // API: Open session plan in VS Code
1353
1462
  app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
1354
1463
  try {
@@ -1526,7 +1635,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1526
1635
  flag: 'promptUnavailable',
1527
1636
  lookup: (a) => {
1528
1637
  if (byAgentId[a.agentId]) return byAgentId[a.agentId];
1529
- try { return extractPromptFromTranscript(subagentJsonlPath(meta, a.agentId)); } catch (_) { return null; }
1638
+ try { return extractPromptFromTranscript(subagentJsonlForExtraction(meta, a.agentId)); } catch (_) { return null; }
1530
1639
  },
1531
1640
  },
1532
1641
  { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
@@ -1536,7 +1645,10 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1536
1645
  for (const { field, flag, lookup } of reconcileFields) {
1537
1646
  for (const agent of agents) {
1538
1647
  if (agent[field]) continue;
1539
- if (agent[flag] && !isAgentLive(agent)) continue;
1648
+ // Workflow subagents nest their transcript under subagents/workflows/;
1649
+ // pre-fix builds latched *Unavailable using the flat path, so always
1650
+ // re-attempt for them (the extractor resolves the nested path now).
1651
+ if (agent[flag] && !isAgentLive(agent) && agent.type !== 'workflow-subagent') continue;
1540
1652
  const value = lookup(agent);
1541
1653
  if (value) {
1542
1654
  agent[field] = value;
@@ -1556,7 +1668,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1556
1668
  if (agentsNeedingModel.length && meta.jsonlPath) {
1557
1669
  for (const agent of agentsNeedingModel) {
1558
1670
  let model = null;
1559
- try { model = extractModelFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) {}
1671
+ try { model = extractModelFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
1560
1672
  if (model) {
1561
1673
  agent.model = model;
1562
1674
  delete agent.modelUnavailable;
@@ -1651,13 +1763,38 @@ function sanitizeAgentId(raw) {
1651
1763
  return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
1652
1764
  }
1653
1765
 
1766
+ function sessionDirFromMeta(meta) {
1767
+ return path.join(path.dirname(meta.jsonlPath), path.basename(meta.jsonlPath, '.jsonl'));
1768
+ }
1769
+
1654
1770
  function subagentJsonlPath(meta, agentId) {
1655
- return path.join(
1656
- path.dirname(meta.jsonlPath),
1657
- path.basename(meta.jsonlPath, '.jsonl'),
1658
- 'subagents',
1659
- 'agent-' + agentId + '.jsonl'
1660
- );
1771
+ return path.join(sessionDirFromMeta(meta), 'subagents', 'agent-' + agentId + '.jsonl');
1772
+ }
1773
+
1774
+ // A subagent transcript is normally at <sessionDir>/subagents/agent-<id>.jsonl.
1775
+ // Workflow-tool subagents nest one level deeper, under
1776
+ // <sessionDir>/subagents/workflows/<wf_id>/agent-<id>.jsonl. Return whichever
1777
+ // exists (flat first), else null.
1778
+ function subagentJsonlInDir(sessionDir, agentId) {
1779
+ const flat = path.join(sessionDir, 'subagents', 'agent-' + agentId + '.jsonl');
1780
+ if (existsSync(flat)) return flat;
1781
+ const wfRoot = path.join(sessionDir, 'subagents', 'workflows');
1782
+ let wfDirs;
1783
+ try { wfDirs = readdirSync(wfRoot, { withFileTypes: true }); } catch { return null; }
1784
+ for (const wf of wfDirs) {
1785
+ if (!wf.isDirectory()) continue;
1786
+ const nested = path.join(wfRoot, wf.name, 'agent-' + agentId + '.jsonl');
1787
+ if (existsSync(nested)) return nested;
1788
+ }
1789
+ return null;
1790
+ }
1791
+
1792
+ // Path for prompt/model extraction in agent enrichment: prefer the nested
1793
+ // workflow-subagent transcript when the flat path is absent. Falls back to the
1794
+ // flat path (which extract* handle gracefully when missing).
1795
+ function subagentJsonlForExtraction(meta, agentId) {
1796
+ const resolved = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
1797
+ return resolved || subagentJsonlPath(meta, agentId);
1661
1798
  }
1662
1799
 
1663
1800
  // Claude Code can scatter a session's records across multiple project dirs
@@ -1666,20 +1803,21 @@ function subagentJsonlPath(meta, agentId) {
1666
1803
  // parent sessionId. Fall back to scanning when the derived path is missing.
1667
1804
  const subagentPathCache = new Map();
1668
1805
  function findSubagentJsonlInProject(projPath, sessionId, agentId) {
1669
- const sameSid = path.join(projPath, sessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1670
- if (existsSync(sameSid)) return sameSid;
1806
+ const sameSid = subagentJsonlInDir(path.join(projPath, sessionId), agentId);
1807
+ if (sameSid) return sameSid;
1671
1808
  let sessions;
1672
1809
  try { sessions = readdirSync(projPath, { withFileTypes: true }); } catch { return null; }
1673
1810
  for (const sess of sessions) {
1674
1811
  if (!sess.isDirectory() || sess.name === sessionId) continue;
1675
- const candidate = path.join(projPath, sess.name, 'subagents', 'agent-' + agentId + '.jsonl');
1676
- if (existsSync(candidate)) return candidate;
1812
+ const candidate = subagentJsonlInDir(path.join(projPath, sess.name), agentId);
1813
+ if (candidate) return candidate;
1677
1814
  }
1678
1815
  return null;
1679
1816
  }
1680
1817
  function resolveSubagentJsonl(meta, sessionId, agentId) {
1681
- const primary = subagentJsonlPath(meta, agentId);
1682
- if (existsSync(primary)) return primary;
1818
+ // Same session dir: flat path or nested under subagents/workflows/ (both checked here).
1819
+ const local = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
1820
+ if (local) return local;
1683
1821
  const key = sessionId + '/' + agentId;
1684
1822
  const cached = subagentPathCache.get(key);
1685
1823
  if (cached) return cached;
@@ -1687,8 +1825,8 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
1687
1825
  const parent = lookupParentSession(sessionId);
1688
1826
  if (parent.parentSessionId && parent.parentJsonlPath) {
1689
1827
  const projDir = path.dirname(parent.parentJsonlPath);
1690
- const candidate = path.join(projDir, parent.parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1691
- if (existsSync(candidate)) found = candidate;
1828
+ const candidate = subagentJsonlInDir(path.join(projDir, parent.parentSessionId), agentId);
1829
+ if (candidate) found = candidate;
1692
1830
  }
1693
1831
  if (!found) {
1694
1832
  try {
@@ -1700,7 +1838,7 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
1700
1838
  } catch (_) { /* projects dir missing */ }
1701
1839
  }
1702
1840
  if (found) subagentPathCache.set(key, found);
1703
- return found || primary;
1841
+ return found || subagentJsonlPath(meta, agentId);
1704
1842
  }
1705
1843
 
1706
1844
  // Claude Code creates child sessions in two ways:
@@ -2110,7 +2248,7 @@ app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', (req, res) =
2110
2248
  });
2111
2249
 
2112
2250
  app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
2113
- const img = readCachedImage(req.params.sessionId, req.params.n);
2251
+ const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
2114
2252
  if (!img) return res.status(404).end();
2115
2253
  res.setHeader('Content-Type', img.mediaType);
2116
2254
  res.setHeader('Cache-Control', 'no-store');