claude-code-kanban 4.14.0 → 4.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parsers.js CHANGED
@@ -1291,33 +1291,31 @@ function findTerminatedTeammates(jsonlPath) {
1291
1291
 
1292
1292
  function extractPromptFromTranscript(jsonlPath) {
1293
1293
  const { openSync, readSync, closeSync } = fs;
1294
- const MAX_READ = 65536;
1295
- const CHUNK = 4096;
1294
+ // statSync (outside try) throws on a missing path — callers rely on that.
1295
+ const stat = statSync(jsonlPath);
1296
+ // Workflow-spawned subagent prompts embed full task context and routinely
1297
+ // exceed 64 KB, so read the whole first line up to a generous cap. One buffered
1298
+ // read (vs. per-chunk decode) also avoids corrupting multi-byte chars at chunk
1299
+ // boundaries for large prompts.
1300
+ const readSize = Math.min(2097152, stat.size);
1296
1301
  const fd = openSync(jsonlPath, 'r');
1297
1302
  try {
1298
- let accumulated = '';
1299
- const buf = Buffer.alloc(CHUNK);
1300
- while (accumulated.length < MAX_READ) {
1301
- const bytesRead = readSync(fd, buf, 0, CHUNK, null);
1302
- if (bytesRead === 0) break;
1303
- accumulated += buf.toString('utf8', 0, bytesRead);
1304
- const nlIdx = accumulated.indexOf('\n');
1305
- if (nlIdx === -1) continue;
1306
- const firstLine = accumulated.slice(0, nlIdx);
1307
- try {
1308
- const obj = JSON.parse(firstLine);
1309
- if (obj.type === 'user') {
1310
- const content = obj.message?.content;
1311
- if (typeof content === 'string') return content;
1312
- if (Array.isArray(content)) {
1313
- for (const b of content) {
1314
- if (b.type === 'text' && b.text) return b.text;
1315
- }
1316
- }
1303
+ const buf = Buffer.alloc(readSize);
1304
+ readSync(fd, buf, 0, readSize, 0);
1305
+ // Decode only the first line, not the whole (possibly large) read window.
1306
+ const nlByte = buf.indexOf(0x0a);
1307
+ const firstLine = (nlByte === -1 ? buf : buf.subarray(0, nlByte)).toString('utf8');
1308
+ const obj = JSON.parse(firstLine);
1309
+ if (obj.type === 'user') {
1310
+ const content = obj.message?.content;
1311
+ if (typeof content === 'string') return content;
1312
+ if (Array.isArray(content)) {
1313
+ for (const b of content) {
1314
+ if (b.type === 'text' && b.text) return b.text;
1317
1315
  }
1318
- } catch (_) {}
1319
- break;
1316
+ }
1320
1317
  }
1318
+ } catch (_) {
1321
1319
  } finally {
1322
1320
  closeSync(fd);
1323
1321
  }
@@ -1354,6 +1352,82 @@ function extractModelFromTranscript(jsonlPath) {
1354
1352
  return null;
1355
1353
  }
1356
1354
 
1355
+ // Full-file scan of a subagent transcript for roster stats: model, summed
1356
+ // output tokens, and first/last timestamps (for duration). COLD PATH ONLY —
1357
+ // used by the workflow run view on demand, never from the session-scan hot
1358
+ // path (a full read per agent would blow the /api/sessions budget).
1359
+ function extractTranscriptStats(jsonlPath) {
1360
+ let content;
1361
+ try { content = readFileSync(jsonlPath, 'utf8'); } catch (_) { return null; }
1362
+ let model = null;
1363
+ let outputTokens = 0;
1364
+ let firstTs = null;
1365
+ let lastTs = null;
1366
+ for (const line of content.split('\n')) {
1367
+ if (!line.trim()) continue;
1368
+ let obj;
1369
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1370
+ if (obj.timestamp) {
1371
+ if (!firstTs) firstTs = obj.timestamp;
1372
+ lastTs = obj.timestamp;
1373
+ }
1374
+ const msg = obj.message;
1375
+ if (msg && typeof msg === 'object') {
1376
+ if (!model && msg.model) model = msg.model;
1377
+ const out = msg.usage?.output_tokens;
1378
+ if (typeof out === 'number') outputTokens += out;
1379
+ }
1380
+ }
1381
+ return { model, outputTokens, firstTs, lastTs };
1382
+ }
1383
+
1384
+ // Render a StructuredOutput tool input (arbitrary schema object) as markdown:
1385
+ // one section per field, prose kept verbatim, structured values as JSON blocks.
1386
+ function formatStructuredResult(input) {
1387
+ const parts = [];
1388
+ for (const [key, value] of Object.entries(input)) {
1389
+ if (value == null || value === '') continue;
1390
+ parts.push('### ' + key);
1391
+ parts.push(typeof value === 'string' ? value : '```json\n' + JSON.stringify(value, null, 2) + '\n```');
1392
+ }
1393
+ return parts.length ? parts.join('\n\n') : null;
1394
+ }
1395
+
1396
+ // Workflow-spawned subagents given a schema end their run on a forced
1397
+ // StructuredOutput tool call and never emit a text response — so their
1398
+ // lastMessage is empty. Tail-read the transcript, take the LAST StructuredOutput
1399
+ // tool_use, and format its input as the agent's result. Returns null when absent.
1400
+ function extractStructuredResultFromTranscript(jsonlPath) {
1401
+ const { openSync, readSync, closeSync } = fs;
1402
+ const stat = statSync(jsonlPath);
1403
+ const readSize = Math.min(1048576, stat.size);
1404
+ const start = stat.size - readSize;
1405
+ const fd = openSync(jsonlPath, 'r');
1406
+ try {
1407
+ const buf = Buffer.alloc(readSize);
1408
+ readSync(fd, buf, 0, readSize, start);
1409
+ const text = buf.toString('utf8');
1410
+ // Drop the leading partial line unless we read from the very start.
1411
+ const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
1412
+ if (!clean.includes('"StructuredOutput"')) return null;
1413
+ // Scan from the end for the last StructuredOutput tool call.
1414
+ const lines = clean.split('\n');
1415
+ for (let i = lines.length - 1; i >= 0; i--) {
1416
+ if (!lines[i].includes('"StructuredOutput"')) continue;
1417
+ let obj;
1418
+ try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1419
+ const content = obj?.message?.content;
1420
+ if (!Array.isArray(content)) continue;
1421
+ const block = content.find(b => b?.type === 'tool_use' && b.name === 'StructuredOutput' && b.input && typeof b.input === 'object');
1422
+ if (block) return formatStructuredResult(block.input);
1423
+ }
1424
+ } catch (_) {
1425
+ } finally {
1426
+ closeSync(fd);
1427
+ }
1428
+ return null;
1429
+ }
1430
+
1357
1431
  // Incremental loop-tool scanner. JSONL is append-only, so we keep per-path
1358
1432
  // state and on each call read ONLY the bytes appended since scannedOffset.
1359
1433
  // Avoids the only full-file read that ran inside the /api/sessions hot path.
@@ -1492,5 +1566,7 @@ module.exports = {
1492
1566
  readCompactSummaries,
1493
1567
  findTerminatedTeammates,
1494
1568
  extractPromptFromTranscript,
1495
- extractModelFromTranscript
1569
+ extractModelFromTranscript,
1570
+ extractStructuredResultFromTranscript,
1571
+ extractTranscriptStats
1496
1572
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.14.0",
3
+ "version": "4.14.1",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -2634,7 +2634,7 @@ function showAgentModal(agentId) {
2634
2634
  const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
2635
2635
  title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
2636
2636
 
2637
- const shortModel = agent.model ? agent.model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : null;
2637
+ const shortModel = shortModelName(agent.model);
2638
2638
  const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
2639
2639
  const chip = (label, value, opts = {}) => {
2640
2640
  const cls = opts.cls ? ` ${opts.cls}` : '';
@@ -2648,7 +2648,7 @@ function showAgentModal(agentId) {
2648
2648
  // toolUseResult), not on the agent-activity record — so read them from there.
2649
2649
  const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2650
2650
  const usageStats = agentMsg?.agentUsageStats || null;
2651
- const fmtTok = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`);
2651
+ const fmtTok = fmtTokens;
2652
2652
 
2653
2653
  const chips = [];
2654
2654
  if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
@@ -2796,7 +2796,7 @@ function renderSessions() {
2796
2796
  ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
2797
2797
  s.hasActiveAgents ||
2798
2798
  s.hasWaitingForUser ||
2799
- s.hasRecentLog);
2799
+ s.hasRecentActivity);
2800
2800
  if (isActive) activeSessionIds.add(s.id);
2801
2801
  return isActive;
2802
2802
  });
@@ -5707,8 +5707,12 @@ function filterBySessions(value) {
5707
5707
  sessionFilter = value;
5708
5708
  if (value !== 'active') activityFilter.clear();
5709
5709
  updateUrl();
5710
+ // Instant feedback from cached data, then refetch — the cached list was fetched
5711
+ // with the previous filter's server params (e.g. filter=active), so "All Sessions"
5712
+ // needs a server round-trip to actually include inactive sessions.
5710
5713
  renderSessions();
5711
5714
  renderActivityChip();
5715
+ fetchSessions(false);
5712
5716
  }
5713
5717
 
5714
5718
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -6502,6 +6506,7 @@ function renderWorkflowBadge(session) {
6502
6506
 
6503
6507
  let _workflowSessionId = null;
6504
6508
  let _workflowHeaderId = null;
6509
+ let _workflowList = [];
6505
6510
 
6506
6511
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6507
6512
  function showWorkflowModal(sessionId) {
@@ -6513,7 +6518,18 @@ function showWorkflowModal(sessionId) {
6513
6518
  fetch(`/api/sessions/${sessionId}/workflows`)
6514
6519
  .then((r) => (r.ok ? r.json() : { workflows: [] }))
6515
6520
  .catch(() => ({ workflows: [] }))
6516
- .then((data) => renderWorkflowList(data.workflows || []));
6521
+ .then((data) => {
6522
+ _workflowList = data.workflows || [];
6523
+ if (!_workflowList.length) {
6524
+ setWorkflowHeader('Workflows', null);
6525
+ body.innerHTML =
6526
+ '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
6527
+ } else if (_workflowList.length === 1) {
6528
+ showWorkflowRun(_workflowList[0].id);
6529
+ } else {
6530
+ renderWorkflowPicker(_workflowList);
6531
+ }
6532
+ });
6517
6533
  }
6518
6534
 
6519
6535
  function setWorkflowHeader(name, wfId) {
@@ -6527,44 +6543,118 @@ function setWorkflowHeader(name, wfId) {
6527
6543
  if (headerBtn) headerBtn.style.display = wfId ? '' : 'none';
6528
6544
  }
6529
6545
 
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.
6546
+ // Multiple workflows: pick one to drill into (we show a single run at a time).
6547
+ function renderWorkflowPicker(workflows) {
6551
6548
  setWorkflowHeader('Workflows', null);
6549
+ const body = document.getElementById('workflow-modal-body');
6552
6550
  body.innerHTML = workflows
6553
6551
  .map((w) => {
6554
6552
  const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6555
- return `<div class="workflow-row" onclick="viewWorkflowScript('${escapeHtml(w.id)}', this)">
6553
+ return `<div class="workflow-row" onclick="showWorkflowRun('${escapeHtml(w.id)}')">
6556
6554
  <div class="workflow-row-head">
6557
6555
  <span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
6558
6556
  <span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
6559
6557
  <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
6558
  </div>
6559
+ ${w.description ? `<div class="workflow-row-desc">${escapeHtml(w.description)}</div>` : ''}
6561
6560
  ${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
6562
- <div class="workflow-row-code"></div>
6563
6561
  </div>`;
6564
6562
  })
6565
6563
  .join('');
6566
6564
  }
6567
6565
 
6566
+ function showWorkflowRun(wfId) {
6567
+ const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
6568
+ setWorkflowHeader(name, wfId);
6569
+ const body = document.getElementById('workflow-modal-body');
6570
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6571
+ fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/run`)
6572
+ .then((r) => (r.ok ? r.json() : null))
6573
+ .catch(() => null)
6574
+ .then((run) => {
6575
+ if (!run) {
6576
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load run state.</div>';
6577
+ return;
6578
+ }
6579
+ renderWorkflowRun(run);
6580
+ });
6581
+ }
6582
+
6583
+ function fmtTokens(t) {
6584
+ return t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`;
6585
+ }
6586
+
6587
+ function shortModelName(model) {
6588
+ return model ? model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : '';
6589
+ }
6590
+
6591
+ function renderWorkflowRun(run) {
6592
+ const body = document.getElementById('workflow-modal-body');
6593
+ const allDone = run.startedCount > 0 && run.doneCount >= run.startedCount;
6594
+ const wall =
6595
+ run.startedAt && run.stoppedAt ? formatDuration(new Date(run.stoppedAt) - new Date(run.startedAt)) : null;
6596
+
6597
+ const back =
6598
+ _workflowList.length > 1
6599
+ ? `<div class="wf-back" onclick="renderWorkflowPicker(_workflowList)">‹ All workflows</div>`
6600
+ : '';
6601
+
6602
+ const stats = [`${run.doneCount}/${run.startedCount} agents`, wall ? escapeHtml(wall) : null]
6603
+ .filter(Boolean)
6604
+ .join(' · ');
6605
+
6606
+ const desc = run.description ? `<div class="wf-run-desc">${escapeHtml(run.description)}</div>` : '';
6607
+
6608
+ const phases = (run.phases || [])
6609
+ .map(
6610
+ (p) => `<div class="wf-phase">
6611
+ <span class="wf-phase-mark${allDone ? ' done' : ''}">${allDone ? '✓' : ''}</span>
6612
+ <span class="wf-phase-title">${escapeHtml(p.title)}</span>
6613
+ ${p.detail ? `<span class="wf-phase-detail">${escapeHtml(p.detail)}</span>` : ''}
6614
+ </div>`,
6615
+ )
6616
+ .join('');
6617
+ const phasesBlock = phases ? `<div class="wf-section-title">Phases</div><div class="wf-phases">${phases}</div>` : '';
6618
+
6619
+ const agents = (run.agents || [])
6620
+ .map((a) => {
6621
+ const dur = a.durationMs != null ? formatDuration(a.durationMs) : '';
6622
+ const running = a.status !== 'done';
6623
+ return `<div class="wf-agent" onclick="showAgentModal('${escapeHtml(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6624
+ <span class="wf-agent-type">${escapeHtml(a.type || 'agent')}</span>
6625
+ <span class="wf-agent-model">${escapeHtml(shortModelName(a.model))}</span>
6626
+ <span class="wf-agent-tok">${a.outputTokens ? `${fmtTokens(a.outputTokens)} tok` : ''}</span>
6627
+ <span class="wf-agent-dur">${escapeHtml(dur)}</span>
6628
+ <span class="wf-agent-status ${running ? 'running' : 'done'}">${running ? '●' : '✓'}</span>
6629
+ </div>`;
6630
+ })
6631
+ .join('');
6632
+ const agentsBlock = run.agents?.length
6633
+ ? `<div class="wf-section-title">Agents · ${run.agents.length}</div><div class="wf-agents">${agents}</div>`
6634
+ : '<div class="wf-section-title">Agents</div><div class="wf-empty">No agent runs recorded yet.</div>';
6635
+
6636
+ body.innerHTML = `<div class="workflow-run">
6637
+ ${back}
6638
+ ${desc}
6639
+ <div class="wf-run-stats">${stats} · <code>${escapeHtml(run.id)}</code></div>
6640
+ ${phasesBlock}
6641
+ ${agentsBlock}
6642
+ <details class="wf-source">
6643
+ <summary>Source</summary>
6644
+ <div class="workflow-row-code"></div>
6645
+ </details>
6646
+ </div>`;
6647
+
6648
+ const details = body.querySelector('.wf-source');
6649
+ details.addEventListener(
6650
+ 'toggle',
6651
+ () => {
6652
+ if (details.open) loadWorkflowCode(run.id, details.querySelector('.workflow-row-code'));
6653
+ },
6654
+ { once: true },
6655
+ );
6656
+ }
6657
+
6568
6658
  function loadWorkflowCode(wfId, codeEl) {
6569
6659
  if (!codeEl) return;
6570
6660
  codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Loading…</div>';
@@ -6586,19 +6676,6 @@ function loadWorkflowCode(wfId, codeEl) {
6586
6676
  });
6587
6677
  }
6588
6678
 
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
6679
  function openWorkflowInEditor(wfId) {
6603
6680
  postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/open`, {}, 'in editor');
6604
6681
  }
@@ -6611,6 +6688,7 @@ function openWorkflowHeaderScript() {
6611
6688
  function closeWorkflowModal() {
6612
6689
  document.getElementById('workflow-modal').classList.remove('visible');
6613
6690
  _workflowSessionId = null;
6691
+ _workflowList = [];
6614
6692
  setWorkflowHeader('Workflows', null);
6615
6693
  }
6616
6694
 
@@ -7111,6 +7189,9 @@ window.addEventListener('popstate', () => {
7111
7189
  ownerFilter = s.owner || '';
7112
7190
  searchQuery = s.search || '';
7113
7191
  loadPreferences();
7192
+ // fetchSessions derives query params from the globals set above — refetch so
7193
+ // back/forward across a filter change doesn't render a stale server-filtered list.
7194
+ fetchSessions(false);
7114
7195
  if (s.projectView) {
7115
7196
  try {
7116
7197
  fetchProjectView(atob(s.projectView));
package/public/style.css CHANGED
@@ -1723,6 +1723,13 @@ body::before {
1723
1723
  color: var(--text-secondary);
1724
1724
  }
1725
1725
 
1726
+ .workflow-row-desc {
1727
+ margin-top: 4px;
1728
+ font-size: 12px;
1729
+ line-height: 1.4;
1730
+ color: var(--text-secondary);
1731
+ }
1732
+
1726
1733
  .workflow-row-meta {
1727
1734
  margin-top: 2px;
1728
1735
  font-size: 11px;
@@ -1748,6 +1755,150 @@ body::before {
1748
1755
  border-radius: 6px;
1749
1756
  }
1750
1757
 
1758
+ /* Run state view */
1759
+ .wf-back {
1760
+ font-size: 12px;
1761
+ color: var(--text-secondary);
1762
+ cursor: pointer;
1763
+ margin-bottom: 10px;
1764
+ }
1765
+
1766
+ .wf-back:hover {
1767
+ color: var(--text-primary);
1768
+ }
1769
+
1770
+ .wf-run-desc {
1771
+ color: var(--text-secondary);
1772
+ font-size: 13px;
1773
+ line-height: 1.5;
1774
+ margin-bottom: 8px;
1775
+ }
1776
+
1777
+ .wf-run-stats {
1778
+ font-size: 12px;
1779
+ color: var(--text-secondary);
1780
+ margin-bottom: 4px;
1781
+ }
1782
+
1783
+ .wf-run-stats code {
1784
+ font-size: 11px;
1785
+ }
1786
+
1787
+ .wf-section-title {
1788
+ margin: 16px 0 8px;
1789
+ font-size: 11px;
1790
+ font-weight: 600;
1791
+ text-transform: uppercase;
1792
+ letter-spacing: 0.04em;
1793
+ color: var(--text-secondary);
1794
+ }
1795
+
1796
+ .wf-phases {
1797
+ display: flex;
1798
+ flex-direction: column;
1799
+ gap: 2px;
1800
+ }
1801
+
1802
+ .wf-phase {
1803
+ display: flex;
1804
+ align-items: baseline;
1805
+ gap: 8px;
1806
+ padding: 3px 0;
1807
+ font-size: 13px;
1808
+ }
1809
+
1810
+ .wf-phase-mark {
1811
+ flex-shrink: 0;
1812
+ width: 12px;
1813
+ color: var(--text-muted);
1814
+ }
1815
+
1816
+ .wf-phase-mark.done {
1817
+ color: var(--ok, #3fb950);
1818
+ }
1819
+
1820
+ .wf-phase-title {
1821
+ font-weight: 500;
1822
+ color: var(--text-primary);
1823
+ }
1824
+
1825
+ .wf-phase-detail {
1826
+ color: var(--text-muted);
1827
+ font-size: 12px;
1828
+ }
1829
+
1830
+ .wf-agents {
1831
+ display: flex;
1832
+ flex-direction: column;
1833
+ gap: 4px;
1834
+ }
1835
+
1836
+ .wf-agent {
1837
+ display: grid;
1838
+ grid-template-columns: 1fr auto auto auto 16px;
1839
+ align-items: center;
1840
+ gap: 12px;
1841
+ padding: 6px 10px;
1842
+ border: 1px solid var(--border);
1843
+ border-radius: 6px;
1844
+ cursor: pointer;
1845
+ font-size: 12px;
1846
+ transition: border-color 0.15s ease;
1847
+ }
1848
+
1849
+ .wf-agent:hover {
1850
+ border-color: var(--accent, var(--plan));
1851
+ }
1852
+
1853
+ .wf-agent-type {
1854
+ font-weight: 500;
1855
+ color: var(--text-primary);
1856
+ overflow: hidden;
1857
+ text-overflow: ellipsis;
1858
+ white-space: nowrap;
1859
+ }
1860
+
1861
+ .wf-agent-model,
1862
+ .wf-agent-tok,
1863
+ .wf-agent-dur {
1864
+ font-family: var(--font-mono, monospace);
1865
+ color: var(--text-secondary);
1866
+ white-space: nowrap;
1867
+ }
1868
+
1869
+ .wf-agent-status {
1870
+ text-align: center;
1871
+ }
1872
+
1873
+ .wf-agent-status.done {
1874
+ color: var(--ok, #3fb950);
1875
+ }
1876
+
1877
+ .wf-agent-status.running {
1878
+ color: var(--accent, var(--plan));
1879
+ }
1880
+
1881
+ .wf-empty {
1882
+ font-size: 12px;
1883
+ color: var(--text-muted);
1884
+ padding: 4px 0;
1885
+ }
1886
+
1887
+ .wf-source {
1888
+ margin-top: 16px;
1889
+ }
1890
+
1891
+ .wf-source > summary {
1892
+ cursor: pointer;
1893
+ font-size: 12px;
1894
+ color: var(--text-secondary);
1895
+ padding: 4px 0;
1896
+ }
1897
+
1898
+ .wf-source > summary:hover {
1899
+ color: var(--text-primary);
1900
+ }
1901
+
1751
1902
  /* #endregion */
1752
1903
 
1753
1904
  .loop-section-title {
package/server.js CHANGED
@@ -20,6 +20,8 @@ const {
20
20
  findTerminatedTeammates,
21
21
  extractPromptFromTranscript,
22
22
  extractModelFromTranscript,
23
+ extractStructuredResultFromTranscript,
24
+ extractTranscriptStats,
23
25
  readFullToolResult,
24
26
  readUserImage,
25
27
  readToolResultImage,
@@ -106,6 +108,10 @@ const PERMISSION_TTL_MS = 30 * 60 * 1000;
106
108
  const AGENT_TTL_MS = 60 * 60 * 1000;
107
109
  const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
108
110
  const SESSION_STALE_MS = 5 * 60 * 1000;
111
+ // Keep an idle session in the active list this long after its last log write, so it
112
+ // doesn't vanish the instant a turn ends. Ungated by registry-idle — visibility only,
113
+ // never the "active" status (which stays accurate via hasRecentLog).
114
+ const SESSION_GRACE_MS = 2 * 60 * 1000;
109
115
  const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
110
116
  const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
111
117
  const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
@@ -333,6 +339,13 @@ function hasRecentLogActivity(sessionId, logAge) {
333
339
  return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
334
340
  }
335
341
 
342
+ // Visibility-only recency: hasRecentLogActivity widened by the post-turn grace
343
+ // window (ungated by registry-idle). Drives whether a session appears in the
344
+ // active list — never the "active" status badges, which stay on hasRecentLog.
345
+ function hasVisibleLogActivity(sessionId, logAge) {
346
+ return logAge <= SESSION_GRACE_MS || hasRecentLogActivity(sessionId, logAge);
347
+ }
348
+
336
349
  // Given a self-team config, return the live interactive session id that owns it
337
350
  // (same cwd, startedAt within the boot window of the team's createdAt), or null.
338
351
  function resolveSelfTeamOwner(cfg) {
@@ -351,11 +364,20 @@ function resolveSelfTeamOwner(cfg) {
351
364
  return best;
352
365
  }
353
366
 
354
- // Attach a team-named task dir's counts to a session card, preferring it over an empty or
355
- // smaller task dir (a team session can also have a near-empty UUID-named dir). Caller passes
356
- // the already-computed counts.
367
+ // Attach a team-named task dir's counts to a session card, preferring the most recently written
368
+ // dir (see taskDirBeats — "latest wins"). A resumed session owns several team dirs: a stale
369
+ // prior-boot dir plus the current run's dir; the older "most tasks wins" rule attached the stale
370
+ // dir when it had more tasks than the live one. The incumbent's mtime comes from the path-cached
371
+ // counts (every card.tasksDir was already passed through getTaskCounts by the caller, so this is a
372
+ // cache hit, not disk I/O). Caller passes the already-computed candidate counts.
357
373
  function attachTeamTasks(card, teamTaskDir, teamName, counts) {
358
- if (!card.tasksDir || counts.taskCount > (card.taskCount || 0)) {
374
+ let curMtime = -1, curCount = -1;
375
+ if (card.tasksDir) {
376
+ const cur = getTaskCounts(card.tasksDir);
377
+ curMtime = taskDirMtime(cur);
378
+ curCount = cur.taskCount;
379
+ }
380
+ if (taskDirBeats(taskDirMtime(counts), counts.taskCount, curMtime, curCount)) {
359
381
  Object.assign(card, {
360
382
  taskCount: counts.taskCount,
361
383
  completed: counts.completed,
@@ -467,13 +489,14 @@ function getCustomTaskDir(sessionId) {
467
489
  // Check team-named task directory (teams store tasks under ~/.claude/tasks/<teamName>/).
468
490
  // Match either the recorded leadSessionId, or — for 2.1.x self-teams whose lead is a
469
491
  // team-lead agent id — the live interactive session that owns the team (see resolveSelfTeamOwner).
470
- // A live session can own several team dirs at once — its own (often empty) self-team plus a
471
- // resumed session's dir holding the real tasks — so pick the richest, mirroring attachTeamTasks'
472
- // "prefer more tasks" rule. Returning the first match (readdir order) can pick the empty dir,
473
- // making the board show 0 tasks while the session card counts the other dir.
492
+ // A live session can own several team dirs at once: a stale dir from a prior boot of the same
493
+ // session id, plus the current run's dir (a resume creates a fresh self-team). Pick the most
494
+ // recently written dir ("latest wins") — the old "most tasks wins" rule picked the stale dir
495
+ // whenever a completed prior run had accumulated more tasks than the live one. taskCount breaks
496
+ // ties; empty dirs (no task mtime) lose, so a real dir still beats an empty self-team.
474
497
  if (existsSync(TEAMS_DIR)) {
475
498
  try {
476
- let bestDir = null, bestCount = -1;
499
+ let bestDir = null, bestMtime = -1, bestCount = -1;
477
500
  for (const dir of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
478
501
  if (!dir.isDirectory()) continue;
479
502
  const cfg = loadTeamConfig(dir.name);
@@ -483,9 +506,11 @@ function getCustomTaskDir(sessionId) {
483
506
  if (!owns) continue;
484
507
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
485
508
  if (!existsSync(teamTaskDir)) continue;
486
- const count = getTaskCounts(teamTaskDir).taskCount;
487
- if (count > bestCount) {
488
- bestCount = count;
509
+ const counts = getTaskCounts(teamTaskDir);
510
+ const mtime = taskDirMtime(counts);
511
+ if (taskDirBeats(mtime, counts.taskCount, bestMtime, bestCount)) {
512
+ bestMtime = mtime;
513
+ bestCount = counts.taskCount;
489
514
  bestDir = teamTaskDir;
490
515
  }
491
516
  }
@@ -501,6 +526,11 @@ function getTaskCounts(sessionPath) {
501
526
 
502
527
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
503
528
  let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
529
+ // Directory mtime bumps when task files are added/removed, so it stays fresh even for an
530
+ // emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
531
+ // report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
532
+ let dirMtime = 0;
533
+ try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
504
534
 
505
535
  for (const file of taskFiles) {
506
536
  try {
@@ -518,11 +548,27 @@ function getTaskCounts(sessionPath) {
518
548
  }
519
549
 
520
550
  const taskCount = completed + inProgress + pending;
521
- const result = { taskCount, completed, inProgress, pending, newestTaskMtime };
551
+ const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
522
552
  taskCountsCache.set(sessionPath, result);
523
553
  return result;
524
554
  }
525
555
 
556
+ // Last-write recency of a getTaskCounts() result, in ms: the newer of the directory mtime (bumps
557
+ // on add/remove, so it survives an emptied dir) and the newest task-file mtime (bumps on in-place
558
+ // status edits, which don't touch the dir). An emptied current dir thus still ranks by when it was
559
+ // cleared, letting it win "latest wins" over a stale prior-boot dir instead of reporting 0.
560
+ function taskDirMtime(counts) {
561
+ const fileMtime = counts.newestTaskMtime ? counts.newestTaskMtime.getTime() : 0;
562
+ return Math.max(counts.dirMtime || 0, fileMtime);
563
+ }
564
+
565
+ // "Latest wins" ranking for two owned task dirs of the same session: more recently written wins,
566
+ // taskCount breaks ties. Callers seed the incumbent with mtime/count -1 so the first candidate
567
+ // always wins.
568
+ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
569
+ return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
570
+ }
571
+
526
572
  function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
527
573
  try {
528
574
  const cached = cache.get(cacheKey);
@@ -900,6 +946,7 @@ function buildSessionObject(id, meta, overrides = {}) {
900
946
  hasRunningAgents: false,
901
947
  hasWaitingForUser: false,
902
948
  hasRecentLog: hasRecentLogActivity(id, logAge),
949
+ hasRecentActivity: hasVisibleLogActivity(id, logAge),
903
950
  jsonlPath: meta.jsonlPath || null,
904
951
  tasksDir: null,
905
952
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
@@ -958,9 +1005,8 @@ app.get('/api/sessions', async (req, res) => {
958
1005
  // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
959
1006
  // Mirrors the post-filter predicate using only signals already computed above.
960
1007
  if (activeFilter && !pinnedIds.has(entry.name)) {
961
- const hasRecentLog = hasRecentLogActivity(entry.name, logAge);
962
1008
  const cheaplyActive = logStat.hasMessages && (
963
- hasRecentLog
1009
+ hasVisibleLogActivity(entry.name, logAge)
964
1010
  || agentStatus.hasActive
965
1011
  || !!agentStatus.waitingForUser
966
1012
  || (pending > 0 || inProgress > 0)
@@ -1059,9 +1105,8 @@ app.get('/api/sessions', async (req, res) => {
1059
1105
 
1060
1106
  // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
1061
1107
  if (activeFilter && !pinnedIds.has(sessionId)) {
1062
- const hasRecentLog = hasRecentLogActivity(sessionId, logAge);
1063
1108
  const cheaplyActive = logStat.hasMessages && (
1064
- hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1109
+ hasVisibleLogActivity(sessionId, logAge) || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1065
1110
  );
1066
1111
  if (!cheaplyActive) continue;
1067
1112
  }
@@ -1117,26 +1162,30 @@ app.get('/api/sessions', async (req, res) => {
1117
1162
  if (isAutoSelfTeam(cfg)) {
1118
1163
  // Self-teams are normally noise with an empty team-named task dir. But 2.1.x stores a
1119
1164
  // session's tasks in the self-team list (tasks/session-<id>/), so when it's non-empty
1120
- // the tasks would be silently orphaned. Recover them (gated on taskCount > 0 to keep
1121
- // the empty-self-team noise case suppressed): attach to the owning card — the recorded
1122
- // leadSessionId when it has one, else the live session continuing it (resolved from the
1123
- // session registry), else a freshly-built fallback lead card.
1165
+ // the tasks would be silently orphaned. Recover them: attach to the owning card — the
1166
+ // recorded leadSessionId when it has one, else the live session continuing it (resolved
1167
+ // from the session registry), else a freshly-built fallback lead card.
1124
1168
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
1125
1169
  if (!existsSync(teamTaskDir)) continue;
1126
1170
  const counts = getTaskCounts(teamTaskDir);
1127
- if (counts.taskCount === 0) continue;
1128
1171
 
1129
1172
  const ownerCard = sessionsMap.get(leaderId) || sessionsMap.get(resolveSelfTeamOwner(cfg));
1130
1173
  if (ownerCard) {
1174
+ // Attach even when empty: a resumed session's freshly-emptied current dir (task list
1175
+ // closed) must be allowed to win "latest wins" and suppress its stale prior-boot dir,
1176
+ // otherwise the card resurfaces the previous tasks. attachTeamTasks ranks by recency.
1131
1177
  attachTeamTasks(ownerCard, teamTaskDir, dir.name, counts);
1132
1178
  } else {
1179
+ // No owner card to attach to — don't fabricate one for an empty stale self-team
1180
+ // (that is the original noise case the skip suppressed).
1181
+ if (counts.taskCount === 0) continue;
1133
1182
  const meta = metadata[leaderId] || {};
1134
1183
  const logStat = getSessionLogStat(meta);
1135
1184
  const logMtime = logStat.mtime;
1136
1185
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
1137
1186
  const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
1138
1187
  const agentStatus = checkAgentStatus(agentDir, logAge > AGENT_STALE_MS, logMtime, false);
1139
- const taskMtime = counts.newestTaskMtime ? counts.newestTaskMtime.getTime() : 0;
1188
+ const taskMtime = taskDirMtime(counts);
1140
1189
  const card = buildSessionObject(leaderId, meta, {
1141
1190
  _logStat: logStat,
1142
1191
  name: getSessionDisplayName(leaderId, meta) || cfg.name || dir.name,
@@ -1246,7 +1295,7 @@ app.get('/api/sessions', async (req, res) => {
1246
1295
  (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1247
1296
  || s.hasActiveAgents
1248
1297
  || s.hasWaitingForUser
1249
- || s.hasRecentLog
1298
+ || s.hasRecentActivity
1250
1299
  );
1251
1300
  for (const [id, s] of sessionsMap) {
1252
1301
  if (pinnedIds.has(id)) continue;
@@ -1411,14 +1460,23 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1411
1460
  }
1412
1461
  });
1413
1462
 
1414
- // API: List workflow scripts for a session
1415
- app.get('/api/sessions/:sessionId/workflows', (req, res) => {
1463
+ // API: List workflow scripts for a session. Parses each script's meta for the
1464
+ // canonical name + description (cold path — only when the workflow modal opens).
1465
+ app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
1416
1466
  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
- }));
1467
+ const scripts = getWorkflowScripts(req.params.sessionId);
1468
+ const workflows = await Promise.all(
1469
+ scripts.map(async (w) => {
1470
+ let meta = {};
1471
+ try { meta = parseWorkflowMeta(await fs.readFile(w.path, 'utf8')); } catch (_) {}
1472
+ return {
1473
+ id: w.id,
1474
+ name: meta.name || w.name,
1475
+ description: meta.description || null,
1476
+ modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1477
+ };
1478
+ }),
1479
+ );
1422
1480
  res.json({ workflows });
1423
1481
  } catch (error) {
1424
1482
  console.error('Error listing workflows:', error);
@@ -1440,6 +1498,159 @@ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
1440
1498
  }
1441
1499
  });
1442
1500
 
1501
+ // meta.phases and meta.name/description are pure literals per the Workflow tool
1502
+ // contract, so pull them by regex rather than executing the untrusted script.
1503
+ // name/description take the first match (the meta block is at the top of the file).
1504
+ // Pull a quoted string value for `key` out of `src` (matches ', ", or `).
1505
+ function matchStr(src, key) {
1506
+ const m = src.match(new RegExp(`${key}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
1507
+ return m ? m[2] : null;
1508
+ }
1509
+
1510
+ function parseWorkflowMeta(source) {
1511
+ const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1512
+ const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
1513
+ if (phasesM) {
1514
+ const re = /\{[\s\S]*?\}/g;
1515
+ let m;
1516
+ while ((m = re.exec(phasesM[1]))) {
1517
+ const title = matchStr(m[0], 'title');
1518
+ if (title) meta.phases.push({ title, detail: matchStr(m[0], 'detail') });
1519
+ }
1520
+ }
1521
+ return meta;
1522
+ }
1523
+
1524
+ // journal.jsonl records {type:'started',agentId} then {type:'result',agentId,...}
1525
+ // per workflow agent. Map agentId → {started, done}; done count matches the
1526
+ // "N/M agents" ratio the /workflows viewer shows.
1527
+ function readWorkflowJournal(runDir) {
1528
+ const status = new Map();
1529
+ let content;
1530
+ try { content = readFileSync(path.join(runDir, 'journal.jsonl'), 'utf8'); } catch { return status; }
1531
+ for (const line of content.split('\n')) {
1532
+ if (!line.trim()) continue;
1533
+ let o;
1534
+ try { o = JSON.parse(line); } catch { continue; }
1535
+ if (!o.agentId) continue;
1536
+ const e = status.get(o.agentId) || { started: false, done: false };
1537
+ if (o.type === 'started') e.started = true;
1538
+ else if (o.type === 'result') e.done = true;
1539
+ status.set(o.agentId, e);
1540
+ }
1541
+ return status;
1542
+ }
1543
+
1544
+ // A workflow's run artifacts (journal + agent transcripts) live at
1545
+ // <sessionDir>/subagents/workflows/<wfId>/. The script itself sits under
1546
+ // <sessionDir>/workflows/scripts/, so the run dir is derivable from the script
1547
+ // path with no I/O — try that first. Fall back to the session dir from metadata,
1548
+ // then to scanning every project/session (the session dir can sit under a
1549
+ // different projEnc than the script when a workflow runs from another cwd). wfId
1550
+ // comes from the trusted script index (validated filename), so it can't
1551
+ // traverse. Cold path only.
1552
+ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1553
+ const rel = path.join('subagents', 'workflows', wfId);
1554
+ if (scriptPath) {
1555
+ const sessionDir = path.dirname(path.dirname(path.dirname(scriptPath)));
1556
+ const fromScript = path.join(sessionDir, rel);
1557
+ if (existsSync(fromScript)) return fromScript;
1558
+ }
1559
+ if (meta.jsonlPath) {
1560
+ const local = path.join(sessionDirFromMeta(meta), rel);
1561
+ if (existsSync(local)) return local;
1562
+ }
1563
+ try {
1564
+ for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1565
+ if (!proj.isDirectory()) continue;
1566
+ const projPath = path.join(PROJECTS_DIR, proj.name);
1567
+ let sessDirs;
1568
+ try { sessDirs = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
1569
+ for (const s of sessDirs) {
1570
+ if (!s.isDirectory()) continue;
1571
+ const cand = path.join(projPath, s.name, rel);
1572
+ if (existsSync(cand)) return cand;
1573
+ }
1574
+ }
1575
+ } catch {}
1576
+ return null;
1577
+ }
1578
+
1579
+ // API: Workflow run state — declared phases (from the script) plus the agent
1580
+ // roster (type/model/output-tokens/duration/status) reconstructed from the run
1581
+ // dir's journal + transcripts. Phase↔agent mapping and per-agent labels are
1582
+ // runtime-only and never persisted, so the roster is flat by design.
1583
+ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1584
+ try {
1585
+ const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1586
+ if (!wf) return res.status(404).json({ error: 'Workflow not found' });
1587
+ let source = '';
1588
+ try { source = await fs.readFile(wf.path, 'utf8'); } catch (_) {}
1589
+ const parsed = parseWorkflowMeta(source);
1590
+
1591
+ const sessionId = resolveSessionId(req.params.sessionId);
1592
+ const meta = loadSessionMetadata()[sessionId] || {};
1593
+ const runDir = resolveWorkflowRunDir(meta, wf.id, wf.path);
1594
+ const journal = runDir ? readWorkflowJournal(runDir) : new Map();
1595
+
1596
+ const agents = [];
1597
+ let stoppedAt = null;
1598
+ if (runDir) {
1599
+ let files = [];
1600
+ try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
1601
+ for (const f of files) {
1602
+ const agentId = f.slice('agent-'.length, -'.jsonl'.length);
1603
+ const stats = extractTranscriptStats(path.join(runDir, f)) || {};
1604
+ let type = null;
1605
+ try {
1606
+ type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
1607
+ } catch (_) {}
1608
+ const done = journal.get(agentId)?.done;
1609
+ const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
1610
+ if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
1611
+ agents.push({
1612
+ agentId,
1613
+ type,
1614
+ model: stats.model || null,
1615
+ outputTokens: stats.outputTokens || 0,
1616
+ durationMs,
1617
+ startedAt: stats.firstTs || null,
1618
+ status: done ? 'done' : 'running',
1619
+ });
1620
+ }
1621
+ agents.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
1622
+ }
1623
+ // Roster is start-ordered, so the earliest start is simply the first agent.
1624
+ const startedAt = agents[0]?.startedAt || null;
1625
+
1626
+ let startedCount = 0;
1627
+ let doneCount = 0;
1628
+ for (const e of journal.values()) {
1629
+ if (e.started) startedCount++;
1630
+ if (e.done) doneCount++;
1631
+ }
1632
+ if (!startedCount) {
1633
+ startedCount = agents.length;
1634
+ doneCount = agents.filter((a) => a.status === 'done').length;
1635
+ }
1636
+
1637
+ res.json({
1638
+ id: wf.id,
1639
+ name: parsed.name || wf.name,
1640
+ description: parsed.description,
1641
+ phases: parsed.phases,
1642
+ agents,
1643
+ startedCount,
1644
+ doneCount,
1645
+ startedAt,
1646
+ stoppedAt,
1647
+ });
1648
+ } catch (error) {
1649
+ console.error('Error building workflow run view:', error);
1650
+ res.status(500).json({ error: 'Failed to build workflow run view' });
1651
+ }
1652
+ });
1653
+
1443
1654
  function openInEditor(...targets) {
1444
1655
  const editor = process.env.EDITOR || 'code';
1445
1656
  spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
@@ -1680,6 +1891,27 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1680
1891
  }
1681
1892
  }
1682
1893
 
1894
+ // Workflow-spawned subagents given a schema end on a forced StructuredOutput
1895
+ // tool call and never emit a text lastMessage — surface that structured result
1896
+ // as the agent's response. Only stopped agents (complete transcript); latch
1897
+ // resultUnavailable so we tail-read at most once per agent. Workflow subagents
1898
+ // are exempt from the latch: the poll that flips them to "stopped" can beat the
1899
+ // transcript's final StructuredOutput line to disk, latching resultUnavailable
1900
+ // against an incomplete transcript — so always re-attempt for them, mirroring
1901
+ // the prompt/name/description reconcile above.
1902
+ const agentsNeedingResult = agents.filter(
1903
+ (a) => !a.lastMessage && !isAgentLive(a) && (!a.resultUnavailable || a.type === 'workflow-subagent'),
1904
+ );
1905
+ if (agentsNeedingResult.length && meta.jsonlPath) {
1906
+ for (const agent of agentsNeedingResult) {
1907
+ let result = null;
1908
+ try { result = extractStructuredResultFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
1909
+ if (result) agent.lastMessage = result;
1910
+ else agent.resultUnavailable = true;
1911
+ dirty.add(agent);
1912
+ }
1913
+ }
1914
+
1683
1915
  for (const agent of dirty) persistAgent(agentDir, agent);
1684
1916
  const teamColors = {};
1685
1917
  if (teamConfig?.members) {