claude-code-kanban 4.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.13.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
@@ -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: