claude-code-kanban 4.13.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/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);
@@ -743,6 +789,72 @@ function loadSessionMetadata() {
743
789
  return metadata;
744
790
  }
745
791
 
792
+ // Workflow (Workflow tool) scripts are persisted at
793
+ // projects/<projEnc>/<sessionId>/workflows/scripts/<name>-<wf_id>.js
794
+ // The script's projEnc can differ from the session's own JSONL dir (the workflow
795
+ // may run from a different cwd), so we scan every project dir into a TTL-cached
796
+ // index rather than deriving the path from meta.jsonlPath. Isolated from the
797
+ // session-scan hot path: a single Map lookup per buildSessionObject call.
798
+ const WORKFLOW_INDEX_TTL_MS = 5000;
799
+ let workflowIndexCache = null; // Map<sessionId, Array<{id,name,path,mtimeMs}>>
800
+ let workflowIndexBuiltAt = 0;
801
+
802
+ function buildWorkflowIndex() {
803
+ const index = new Map();
804
+ if (!existsSync(PROJECTS_DIR)) return index;
805
+ let projs;
806
+ try { projs = readdirSync(PROJECTS_DIR, { withFileTypes: true }); } catch { return index; }
807
+ for (const proj of projs) {
808
+ if (!proj.isDirectory()) continue;
809
+ const projPath = path.join(PROJECTS_DIR, proj.name);
810
+ let sessDirs;
811
+ try { sessDirs = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
812
+ for (const sess of sessDirs) {
813
+ if (!sess.isDirectory() || !isUUID(sess.name)) continue;
814
+ const scriptsDir = path.join(projPath, sess.name, 'workflows', 'scripts');
815
+ let scripts;
816
+ try { scripts = readdirSync(scriptsDir); } catch { continue; } // ENOENT for most — skip fast
817
+ for (const f of scripts) {
818
+ if (!f.endsWith('.js')) continue;
819
+ const full = path.join(scriptsDir, f);
820
+ let mtimeMs = 0;
821
+ try { mtimeMs = statSync(full).mtimeMs; } catch {}
822
+ const base = f.slice(0, -3);
823
+ const m = base.match(/^(.*)-(wf_[a-z0-9-]+)$/i);
824
+ const entry = { id: m ? m[2] : base, name: m ? m[1] : base, path: full, mtimeMs };
825
+ if (!index.has(sess.name)) index.set(sess.name, []);
826
+ index.get(sess.name).push(entry);
827
+ }
828
+ }
829
+ }
830
+ return index;
831
+ }
832
+
833
+ function getWorkflowIndex() {
834
+ const now = Date.now();
835
+ if (workflowIndexCache && now - workflowIndexBuiltAt < WORKFLOW_INDEX_TTL_MS) return workflowIndexCache;
836
+ workflowIndexCache = buildWorkflowIndex();
837
+ workflowIndexBuiltAt = now;
838
+ return workflowIndexCache;
839
+ }
840
+
841
+ // Resolve a session's workflow scripts, newest first. Tries the raw id then the
842
+ // team-lead resolution (a team session's scripts live under the lead's dir).
843
+ function getWorkflowScripts(sessionId) {
844
+ const idx = getWorkflowIndex();
845
+ let list = idx.get(sessionId);
846
+ if (!list || !list.length) {
847
+ const alt = resolveSessionId(sessionId);
848
+ if (alt && alt !== sessionId) list = idx.get(alt);
849
+ }
850
+ return list ? [...list].sort((a, b) => b.mtimeMs - a.mtimeMs) : [];
851
+ }
852
+
853
+ function getWorkflowInfoSummary(sessionId) {
854
+ const list = getWorkflowIndex().get(sessionId);
855
+ return { hasWorkflow: !!(list && list.length), workflowCount: list ? list.length : 0 };
856
+ }
857
+
746
858
  function getPlanInfo(slug) {
747
859
  if (!slug) return { hasPlan: false, planTitle: null, planPath: null };
748
860
  const planPath = path.join(PLANS_DIR, `${slug}.md`);
@@ -834,11 +946,13 @@ function buildSessionObject(id, meta, overrides = {}) {
834
946
  hasRunningAgents: false,
835
947
  hasWaitingForUser: false,
836
948
  hasRecentLog: hasRecentLogActivity(id, logAge),
949
+ hasRecentActivity: hasVisibleLogActivity(id, logAge),
837
950
  jsonlPath: meta.jsonlPath || null,
838
951
  tasksDir: null,
839
952
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
840
953
  contextStatus: getContextStatus(id, meta),
841
954
  ...getPlanInfo(meta.slug),
955
+ ...getWorkflowInfoSummary(id),
842
956
  loopInfo: getLoopInfoSummary(meta),
843
957
  ...overrides,
844
958
  // Remove internal-only field
@@ -891,9 +1005,8 @@ app.get('/api/sessions', async (req, res) => {
891
1005
  // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
892
1006
  // Mirrors the post-filter predicate using only signals already computed above.
893
1007
  if (activeFilter && !pinnedIds.has(entry.name)) {
894
- const hasRecentLog = hasRecentLogActivity(entry.name, logAge);
895
1008
  const cheaplyActive = logStat.hasMessages && (
896
- hasRecentLog
1009
+ hasVisibleLogActivity(entry.name, logAge)
897
1010
  || agentStatus.hasActive
898
1011
  || !!agentStatus.waitingForUser
899
1012
  || (pending > 0 || inProgress > 0)
@@ -992,9 +1105,8 @@ app.get('/api/sessions', async (req, res) => {
992
1105
 
993
1106
  // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
994
1107
  if (activeFilter && !pinnedIds.has(sessionId)) {
995
- const hasRecentLog = hasRecentLogActivity(sessionId, logAge);
996
1108
  const cheaplyActive = logStat.hasMessages && (
997
- hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1109
+ hasVisibleLogActivity(sessionId, logAge) || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
998
1110
  );
999
1111
  if (!cheaplyActive) continue;
1000
1112
  }
@@ -1050,26 +1162,30 @@ app.get('/api/sessions', async (req, res) => {
1050
1162
  if (isAutoSelfTeam(cfg)) {
1051
1163
  // Self-teams are normally noise with an empty team-named task dir. But 2.1.x stores a
1052
1164
  // session's tasks in the self-team list (tasks/session-<id>/), so when it's non-empty
1053
- // the tasks would be silently orphaned. Recover them (gated on taskCount > 0 to keep
1054
- // the empty-self-team noise case suppressed): attach to the owning card — the recorded
1055
- // leadSessionId when it has one, else the live session continuing it (resolved from the
1056
- // 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.
1057
1168
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
1058
1169
  if (!existsSync(teamTaskDir)) continue;
1059
1170
  const counts = getTaskCounts(teamTaskDir);
1060
- if (counts.taskCount === 0) continue;
1061
1171
 
1062
1172
  const ownerCard = sessionsMap.get(leaderId) || sessionsMap.get(resolveSelfTeamOwner(cfg));
1063
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.
1064
1177
  attachTeamTasks(ownerCard, teamTaskDir, dir.name, counts);
1065
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;
1066
1182
  const meta = metadata[leaderId] || {};
1067
1183
  const logStat = getSessionLogStat(meta);
1068
1184
  const logMtime = logStat.mtime;
1069
1185
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
1070
1186
  const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
1071
1187
  const agentStatus = checkAgentStatus(agentDir, logAge > AGENT_STALE_MS, logMtime, false);
1072
- const taskMtime = counts.newestTaskMtime ? counts.newestTaskMtime.getTime() : 0;
1188
+ const taskMtime = taskDirMtime(counts);
1073
1189
  const card = buildSessionObject(leaderId, meta, {
1074
1190
  _logStat: logStat,
1075
1191
  name: getSessionDisplayName(leaderId, meta) || cfg.name || dir.name,
@@ -1179,7 +1295,7 @@ app.get('/api/sessions', async (req, res) => {
1179
1295
  (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1180
1296
  || s.hasActiveAgents
1181
1297
  || s.hasWaitingForUser
1182
- || s.hasRecentLog
1298
+ || s.hasRecentActivity
1183
1299
  );
1184
1300
  for (const [id, s] of sessionsMap) {
1185
1301
  if (pinnedIds.has(id)) continue;
@@ -1344,11 +1460,215 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1344
1460
  }
1345
1461
  });
1346
1462
 
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) => {
1466
+ try {
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
+ );
1480
+ res.json({ workflows });
1481
+ } catch (error) {
1482
+ console.error('Error listing workflows:', error);
1483
+ res.status(500).json({ error: 'Failed to list workflows' });
1484
+ }
1485
+ });
1486
+
1487
+ // API: Get a single workflow script's source. Looked up by id from the session's
1488
+ // own script list (never built from the param), so the id can't traverse paths.
1489
+ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
1490
+ try {
1491
+ const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1492
+ if (!wf) return res.status(404).json({ error: 'Workflow script not found' });
1493
+ const content = await fs.readFile(wf.path, 'utf8');
1494
+ res.json({ id: wf.id, name: wf.name, content });
1495
+ } catch (error) {
1496
+ console.error('Error reading workflow script:', error);
1497
+ res.status(500).json({ error: 'Failed to read workflow script' });
1498
+ }
1499
+ });
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
+
1347
1654
  function openInEditor(...targets) {
1348
1655
  const editor = process.env.EDITOR || 'code';
1349
1656
  spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
1350
1657
  }
1351
1658
 
1659
+ // API: Open a workflow script in VS Code
1660
+ app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
1661
+ try {
1662
+ const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1663
+ if (!wf) return res.status(404).json({ error: 'Workflow script not found' });
1664
+ openInEditor(wf.path);
1665
+ res.json({ success: true });
1666
+ } catch (error) {
1667
+ console.error('Error opening workflow script in editor:', error);
1668
+ res.status(500).json({ error: 'Failed to open workflow script' });
1669
+ }
1670
+ });
1671
+
1352
1672
  // API: Open session plan in VS Code
1353
1673
  app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
1354
1674
  try {
@@ -1526,7 +1846,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1526
1846
  flag: 'promptUnavailable',
1527
1847
  lookup: (a) => {
1528
1848
  if (byAgentId[a.agentId]) return byAgentId[a.agentId];
1529
- try { return extractPromptFromTranscript(subagentJsonlPath(meta, a.agentId)); } catch (_) { return null; }
1849
+ try { return extractPromptFromTranscript(subagentJsonlForExtraction(meta, a.agentId)); } catch (_) { return null; }
1530
1850
  },
1531
1851
  },
1532
1852
  { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
@@ -1536,7 +1856,10 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1536
1856
  for (const { field, flag, lookup } of reconcileFields) {
1537
1857
  for (const agent of agents) {
1538
1858
  if (agent[field]) continue;
1539
- if (agent[flag] && !isAgentLive(agent)) continue;
1859
+ // Workflow subagents nest their transcript under subagents/workflows/;
1860
+ // pre-fix builds latched *Unavailable using the flat path, so always
1861
+ // re-attempt for them (the extractor resolves the nested path now).
1862
+ if (agent[flag] && !isAgentLive(agent) && agent.type !== 'workflow-subagent') continue;
1540
1863
  const value = lookup(agent);
1541
1864
  if (value) {
1542
1865
  agent[field] = value;
@@ -1556,7 +1879,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1556
1879
  if (agentsNeedingModel.length && meta.jsonlPath) {
1557
1880
  for (const agent of agentsNeedingModel) {
1558
1881
  let model = null;
1559
- try { model = extractModelFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) {}
1882
+ try { model = extractModelFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
1560
1883
  if (model) {
1561
1884
  agent.model = model;
1562
1885
  delete agent.modelUnavailable;
@@ -1568,6 +1891,27 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1568
1891
  }
1569
1892
  }
1570
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
+
1571
1915
  for (const agent of dirty) persistAgent(agentDir, agent);
1572
1916
  const teamColors = {};
1573
1917
  if (teamConfig?.members) {
@@ -1651,13 +1995,38 @@ function sanitizeAgentId(raw) {
1651
1995
  return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
1652
1996
  }
1653
1997
 
1998
+ function sessionDirFromMeta(meta) {
1999
+ return path.join(path.dirname(meta.jsonlPath), path.basename(meta.jsonlPath, '.jsonl'));
2000
+ }
2001
+
1654
2002
  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
- );
2003
+ return path.join(sessionDirFromMeta(meta), 'subagents', 'agent-' + agentId + '.jsonl');
2004
+ }
2005
+
2006
+ // A subagent transcript is normally at <sessionDir>/subagents/agent-<id>.jsonl.
2007
+ // Workflow-tool subagents nest one level deeper, under
2008
+ // <sessionDir>/subagents/workflows/<wf_id>/agent-<id>.jsonl. Return whichever
2009
+ // exists (flat first), else null.
2010
+ function subagentJsonlInDir(sessionDir, agentId) {
2011
+ const flat = path.join(sessionDir, 'subagents', 'agent-' + agentId + '.jsonl');
2012
+ if (existsSync(flat)) return flat;
2013
+ const wfRoot = path.join(sessionDir, 'subagents', 'workflows');
2014
+ let wfDirs;
2015
+ try { wfDirs = readdirSync(wfRoot, { withFileTypes: true }); } catch { return null; }
2016
+ for (const wf of wfDirs) {
2017
+ if (!wf.isDirectory()) continue;
2018
+ const nested = path.join(wfRoot, wf.name, 'agent-' + agentId + '.jsonl');
2019
+ if (existsSync(nested)) return nested;
2020
+ }
2021
+ return null;
2022
+ }
2023
+
2024
+ // Path for prompt/model extraction in agent enrichment: prefer the nested
2025
+ // workflow-subagent transcript when the flat path is absent. Falls back to the
2026
+ // flat path (which extract* handle gracefully when missing).
2027
+ function subagentJsonlForExtraction(meta, agentId) {
2028
+ const resolved = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
2029
+ return resolved || subagentJsonlPath(meta, agentId);
1661
2030
  }
1662
2031
 
1663
2032
  // Claude Code can scatter a session's records across multiple project dirs
@@ -1666,20 +2035,21 @@ function subagentJsonlPath(meta, agentId) {
1666
2035
  // parent sessionId. Fall back to scanning when the derived path is missing.
1667
2036
  const subagentPathCache = new Map();
1668
2037
  function findSubagentJsonlInProject(projPath, sessionId, agentId) {
1669
- const sameSid = path.join(projPath, sessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1670
- if (existsSync(sameSid)) return sameSid;
2038
+ const sameSid = subagentJsonlInDir(path.join(projPath, sessionId), agentId);
2039
+ if (sameSid) return sameSid;
1671
2040
  let sessions;
1672
2041
  try { sessions = readdirSync(projPath, { withFileTypes: true }); } catch { return null; }
1673
2042
  for (const sess of sessions) {
1674
2043
  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;
2044
+ const candidate = subagentJsonlInDir(path.join(projPath, sess.name), agentId);
2045
+ if (candidate) return candidate;
1677
2046
  }
1678
2047
  return null;
1679
2048
  }
1680
2049
  function resolveSubagentJsonl(meta, sessionId, agentId) {
1681
- const primary = subagentJsonlPath(meta, agentId);
1682
- if (existsSync(primary)) return primary;
2050
+ // Same session dir: flat path or nested under subagents/workflows/ (both checked here).
2051
+ const local = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
2052
+ if (local) return local;
1683
2053
  const key = sessionId + '/' + agentId;
1684
2054
  const cached = subagentPathCache.get(key);
1685
2055
  if (cached) return cached;
@@ -1687,8 +2057,8 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
1687
2057
  const parent = lookupParentSession(sessionId);
1688
2058
  if (parent.parentSessionId && parent.parentJsonlPath) {
1689
2059
  const projDir = path.dirname(parent.parentJsonlPath);
1690
- const candidate = path.join(projDir, parent.parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1691
- if (existsSync(candidate)) found = candidate;
2060
+ const candidate = subagentJsonlInDir(path.join(projDir, parent.parentSessionId), agentId);
2061
+ if (candidate) found = candidate;
1692
2062
  }
1693
2063
  if (!found) {
1694
2064
  try {
@@ -1700,7 +2070,7 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
1700
2070
  } catch (_) { /* projects dir missing */ }
1701
2071
  }
1702
2072
  if (found) subagentPathCache.set(key, found);
1703
- return found || primary;
2073
+ return found || subagentJsonlPath(meta, agentId);
1704
2074
  }
1705
2075
 
1706
2076
  // Claude Code creates child sessions in two ways: