claude-code-kanban 4.14.0 → 4.16.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/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,
@@ -30,7 +32,8 @@ const {
30
32
 
31
33
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
32
34
  const { runInstall, runUninstall } = require("./install");
33
- (process.argv.includes("--install") ? runInstall() : runUninstall())
35
+ const pluginOnly = process.argv.includes("--plugin-only");
36
+ (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
34
37
  .then(() => process.exit(0))
35
38
  .catch(e => { console.error(e.message); process.exit(1); });
36
39
  return;
@@ -106,6 +109,10 @@ const PERMISSION_TTL_MS = 30 * 60 * 1000;
106
109
  const AGENT_TTL_MS = 60 * 60 * 1000;
107
110
  const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
108
111
  const SESSION_STALE_MS = 5 * 60 * 1000;
112
+ // Keep an idle session in the active list this long after its last log write, so it
113
+ // doesn't vanish the instant a turn ends. Ungated by registry-idle — visibility only,
114
+ // never the "active" status (which stays accurate via hasRecentLog).
115
+ const SESSION_GRACE_MS = 2 * 60 * 1000;
109
116
  const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
110
117
  const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
111
118
  const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
@@ -333,6 +340,13 @@ function hasRecentLogActivity(sessionId, logAge) {
333
340
  return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
334
341
  }
335
342
 
343
+ // Visibility-only recency: hasRecentLogActivity widened by the post-turn grace
344
+ // window (ungated by registry-idle). Drives whether a session appears in the
345
+ // active list — never the "active" status badges, which stay on hasRecentLog.
346
+ function hasVisibleLogActivity(sessionId, logAge) {
347
+ return logAge <= SESSION_GRACE_MS || hasRecentLogActivity(sessionId, logAge);
348
+ }
349
+
336
350
  // Given a self-team config, return the live interactive session id that owns it
337
351
  // (same cwd, startedAt within the boot window of the team's createdAt), or null.
338
352
  function resolveSelfTeamOwner(cfg) {
@@ -351,11 +365,20 @@ function resolveSelfTeamOwner(cfg) {
351
365
  return best;
352
366
  }
353
367
 
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.
368
+ // Attach a team-named task dir's counts to a session card, preferring the most recently written
369
+ // dir (see taskDirBeats — "latest wins"). A resumed session owns several team dirs: a stale
370
+ // prior-boot dir plus the current run's dir; the older "most tasks wins" rule attached the stale
371
+ // dir when it had more tasks than the live one. The incumbent's mtime comes from the path-cached
372
+ // counts (every card.tasksDir was already passed through getTaskCounts by the caller, so this is a
373
+ // cache hit, not disk I/O). Caller passes the already-computed candidate counts.
357
374
  function attachTeamTasks(card, teamTaskDir, teamName, counts) {
358
- if (!card.tasksDir || counts.taskCount > (card.taskCount || 0)) {
375
+ let curMtime = -1, curCount = -1;
376
+ if (card.tasksDir) {
377
+ const cur = getTaskCounts(card.tasksDir);
378
+ curMtime = taskDirMtime(cur);
379
+ curCount = cur.taskCount;
380
+ }
381
+ if (taskDirBeats(taskDirMtime(counts), counts.taskCount, curMtime, curCount)) {
359
382
  Object.assign(card, {
360
383
  taskCount: counts.taskCount,
361
384
  completed: counts.completed,
@@ -467,13 +490,14 @@ function getCustomTaskDir(sessionId) {
467
490
  // Check team-named task directory (teams store tasks under ~/.claude/tasks/<teamName>/).
468
491
  // Match either the recorded leadSessionId, or — for 2.1.x self-teams whose lead is a
469
492
  // 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.
493
+ // A live session can own several team dirs at once: a stale dir from a prior boot of the same
494
+ // session id, plus the current run's dir (a resume creates a fresh self-team). Pick the most
495
+ // recently written dir ("latest wins") — the old "most tasks wins" rule picked the stale dir
496
+ // whenever a completed prior run had accumulated more tasks than the live one. taskCount breaks
497
+ // ties; empty dirs (no task mtime) lose, so a real dir still beats an empty self-team.
474
498
  if (existsSync(TEAMS_DIR)) {
475
499
  try {
476
- let bestDir = null, bestCount = -1;
500
+ let bestDir = null, bestMtime = -1, bestCount = -1;
477
501
  for (const dir of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
478
502
  if (!dir.isDirectory()) continue;
479
503
  const cfg = loadTeamConfig(dir.name);
@@ -483,9 +507,11 @@ function getCustomTaskDir(sessionId) {
483
507
  if (!owns) continue;
484
508
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
485
509
  if (!existsSync(teamTaskDir)) continue;
486
- const count = getTaskCounts(teamTaskDir).taskCount;
487
- if (count > bestCount) {
488
- bestCount = count;
510
+ const counts = getTaskCounts(teamTaskDir);
511
+ const mtime = taskDirMtime(counts);
512
+ if (taskDirBeats(mtime, counts.taskCount, bestMtime, bestCount)) {
513
+ bestMtime = mtime;
514
+ bestCount = counts.taskCount;
489
515
  bestDir = teamTaskDir;
490
516
  }
491
517
  }
@@ -501,6 +527,11 @@ function getTaskCounts(sessionPath) {
501
527
 
502
528
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
503
529
  let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
530
+ // Directory mtime bumps when task files are added/removed, so it stays fresh even for an
531
+ // emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
532
+ // report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
533
+ let dirMtime = 0;
534
+ try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
504
535
 
505
536
  for (const file of taskFiles) {
506
537
  try {
@@ -518,11 +549,27 @@ function getTaskCounts(sessionPath) {
518
549
  }
519
550
 
520
551
  const taskCount = completed + inProgress + pending;
521
- const result = { taskCount, completed, inProgress, pending, newestTaskMtime };
552
+ const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
522
553
  taskCountsCache.set(sessionPath, result);
523
554
  return result;
524
555
  }
525
556
 
557
+ // Last-write recency of a getTaskCounts() result, in ms: the newer of the directory mtime (bumps
558
+ // on add/remove, so it survives an emptied dir) and the newest task-file mtime (bumps on in-place
559
+ // status edits, which don't touch the dir). An emptied current dir thus still ranks by when it was
560
+ // cleared, letting it win "latest wins" over a stale prior-boot dir instead of reporting 0.
561
+ function taskDirMtime(counts) {
562
+ const fileMtime = counts.newestTaskMtime ? counts.newestTaskMtime.getTime() : 0;
563
+ return Math.max(counts.dirMtime || 0, fileMtime);
564
+ }
565
+
566
+ // "Latest wins" ranking for two owned task dirs of the same session: more recently written wins,
567
+ // taskCount breaks ties. Callers seed the incumbent with mtime/count -1 so the first candidate
568
+ // always wins.
569
+ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
570
+ return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
571
+ }
572
+
526
573
  function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
527
574
  try {
528
575
  const cached = cache.get(cacheKey);
@@ -900,6 +947,7 @@ function buildSessionObject(id, meta, overrides = {}) {
900
947
  hasRunningAgents: false,
901
948
  hasWaitingForUser: false,
902
949
  hasRecentLog: hasRecentLogActivity(id, logAge),
950
+ hasRecentActivity: hasVisibleLogActivity(id, logAge),
903
951
  jsonlPath: meta.jsonlPath || null,
904
952
  tasksDir: null,
905
953
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
@@ -958,9 +1006,8 @@ app.get('/api/sessions', async (req, res) => {
958
1006
  // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
959
1007
  // Mirrors the post-filter predicate using only signals already computed above.
960
1008
  if (activeFilter && !pinnedIds.has(entry.name)) {
961
- const hasRecentLog = hasRecentLogActivity(entry.name, logAge);
962
1009
  const cheaplyActive = logStat.hasMessages && (
963
- hasRecentLog
1010
+ hasVisibleLogActivity(entry.name, logAge)
964
1011
  || agentStatus.hasActive
965
1012
  || !!agentStatus.waitingForUser
966
1013
  || (pending > 0 || inProgress > 0)
@@ -1059,9 +1106,8 @@ app.get('/api/sessions', async (req, res) => {
1059
1106
 
1060
1107
  // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
1061
1108
  if (activeFilter && !pinnedIds.has(sessionId)) {
1062
- const hasRecentLog = hasRecentLogActivity(sessionId, logAge);
1063
1109
  const cheaplyActive = logStat.hasMessages && (
1064
- hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1110
+ hasVisibleLogActivity(sessionId, logAge) || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1065
1111
  );
1066
1112
  if (!cheaplyActive) continue;
1067
1113
  }
@@ -1117,26 +1163,30 @@ app.get('/api/sessions', async (req, res) => {
1117
1163
  if (isAutoSelfTeam(cfg)) {
1118
1164
  // Self-teams are normally noise with an empty team-named task dir. But 2.1.x stores a
1119
1165
  // 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.
1166
+ // the tasks would be silently orphaned. Recover them: attach to the owning card — the
1167
+ // recorded leadSessionId when it has one, else the live session continuing it (resolved
1168
+ // from the session registry), else a freshly-built fallback lead card.
1124
1169
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
1125
1170
  if (!existsSync(teamTaskDir)) continue;
1126
1171
  const counts = getTaskCounts(teamTaskDir);
1127
- if (counts.taskCount === 0) continue;
1128
1172
 
1129
1173
  const ownerCard = sessionsMap.get(leaderId) || sessionsMap.get(resolveSelfTeamOwner(cfg));
1130
1174
  if (ownerCard) {
1175
+ // Attach even when empty: a resumed session's freshly-emptied current dir (task list
1176
+ // closed) must be allowed to win "latest wins" and suppress its stale prior-boot dir,
1177
+ // otherwise the card resurfaces the previous tasks. attachTeamTasks ranks by recency.
1131
1178
  attachTeamTasks(ownerCard, teamTaskDir, dir.name, counts);
1132
1179
  } else {
1180
+ // No owner card to attach to — don't fabricate one for an empty stale self-team
1181
+ // (that is the original noise case the skip suppressed).
1182
+ if (counts.taskCount === 0) continue;
1133
1183
  const meta = metadata[leaderId] || {};
1134
1184
  const logStat = getSessionLogStat(meta);
1135
1185
  const logMtime = logStat.mtime;
1136
1186
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
1137
1187
  const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
1138
1188
  const agentStatus = checkAgentStatus(agentDir, logAge > AGENT_STALE_MS, logMtime, false);
1139
- const taskMtime = counts.newestTaskMtime ? counts.newestTaskMtime.getTime() : 0;
1189
+ const taskMtime = taskDirMtime(counts);
1140
1190
  const card = buildSessionObject(leaderId, meta, {
1141
1191
  _logStat: logStat,
1142
1192
  name: getSessionDisplayName(leaderId, meta) || cfg.name || dir.name,
@@ -1246,7 +1296,7 @@ app.get('/api/sessions', async (req, res) => {
1246
1296
  (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1247
1297
  || s.hasActiveAgents
1248
1298
  || s.hasWaitingForUser
1249
- || s.hasRecentLog
1299
+ || s.hasRecentActivity
1250
1300
  );
1251
1301
  for (const [id, s] of sessionsMap) {
1252
1302
  if (pinnedIds.has(id)) continue;
@@ -1411,14 +1461,23 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1411
1461
  }
1412
1462
  });
1413
1463
 
1414
- // API: List workflow scripts for a session
1415
- app.get('/api/sessions/:sessionId/workflows', (req, res) => {
1464
+ // API: List workflow scripts for a session. Parses each script's meta for the
1465
+ // canonical name + description (cold path — only when the workflow modal opens).
1466
+ app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
1416
1467
  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
- }));
1468
+ const scripts = getWorkflowScripts(req.params.sessionId);
1469
+ const workflows = await Promise.all(
1470
+ scripts.map(async (w) => {
1471
+ let meta = {};
1472
+ try { meta = parseWorkflowMeta(await fs.readFile(w.path, 'utf8')); } catch (_) {}
1473
+ return {
1474
+ id: w.id,
1475
+ name: meta.name || w.name,
1476
+ description: meta.description || null,
1477
+ modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1478
+ };
1479
+ }),
1480
+ );
1422
1481
  res.json({ workflows });
1423
1482
  } catch (error) {
1424
1483
  console.error('Error listing workflows:', error);
@@ -1440,6 +1499,159 @@ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
1440
1499
  }
1441
1500
  });
1442
1501
 
1502
+ // meta.phases and meta.name/description are pure literals per the Workflow tool
1503
+ // contract, so pull them by regex rather than executing the untrusted script.
1504
+ // name/description take the first match (the meta block is at the top of the file).
1505
+ // Pull a quoted string value for `key` out of `src` (matches ', ", or `).
1506
+ function matchStr(src, key) {
1507
+ const m = src.match(new RegExp(`${key}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
1508
+ return m ? m[2] : null;
1509
+ }
1510
+
1511
+ function parseWorkflowMeta(source) {
1512
+ const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1513
+ const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
1514
+ if (phasesM) {
1515
+ const re = /\{[\s\S]*?\}/g;
1516
+ let m;
1517
+ while ((m = re.exec(phasesM[1]))) {
1518
+ const title = matchStr(m[0], 'title');
1519
+ if (title) meta.phases.push({ title, detail: matchStr(m[0], 'detail') });
1520
+ }
1521
+ }
1522
+ return meta;
1523
+ }
1524
+
1525
+ // journal.jsonl records {type:'started',agentId} then {type:'result',agentId,...}
1526
+ // per workflow agent. Map agentId → {started, done}; done count matches the
1527
+ // "N/M agents" ratio the /workflows viewer shows.
1528
+ function readWorkflowJournal(runDir) {
1529
+ const status = new Map();
1530
+ let content;
1531
+ try { content = readFileSync(path.join(runDir, 'journal.jsonl'), 'utf8'); } catch { return status; }
1532
+ for (const line of content.split('\n')) {
1533
+ if (!line.trim()) continue;
1534
+ let o;
1535
+ try { o = JSON.parse(line); } catch { continue; }
1536
+ if (!o.agentId) continue;
1537
+ const e = status.get(o.agentId) || { started: false, done: false };
1538
+ if (o.type === 'started') e.started = true;
1539
+ else if (o.type === 'result') e.done = true;
1540
+ status.set(o.agentId, e);
1541
+ }
1542
+ return status;
1543
+ }
1544
+
1545
+ // A workflow's run artifacts (journal + agent transcripts) live at
1546
+ // <sessionDir>/subagents/workflows/<wfId>/. The script itself sits under
1547
+ // <sessionDir>/workflows/scripts/, so the run dir is derivable from the script
1548
+ // path with no I/O — try that first. Fall back to the session dir from metadata,
1549
+ // then to scanning every project/session (the session dir can sit under a
1550
+ // different projEnc than the script when a workflow runs from another cwd). wfId
1551
+ // comes from the trusted script index (validated filename), so it can't
1552
+ // traverse. Cold path only.
1553
+ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1554
+ const rel = path.join('subagents', 'workflows', wfId);
1555
+ if (scriptPath) {
1556
+ const sessionDir = path.dirname(path.dirname(path.dirname(scriptPath)));
1557
+ const fromScript = path.join(sessionDir, rel);
1558
+ if (existsSync(fromScript)) return fromScript;
1559
+ }
1560
+ if (meta.jsonlPath) {
1561
+ const local = path.join(sessionDirFromMeta(meta), rel);
1562
+ if (existsSync(local)) return local;
1563
+ }
1564
+ try {
1565
+ for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1566
+ if (!proj.isDirectory()) continue;
1567
+ const projPath = path.join(PROJECTS_DIR, proj.name);
1568
+ let sessDirs;
1569
+ try { sessDirs = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
1570
+ for (const s of sessDirs) {
1571
+ if (!s.isDirectory()) continue;
1572
+ const cand = path.join(projPath, s.name, rel);
1573
+ if (existsSync(cand)) return cand;
1574
+ }
1575
+ }
1576
+ } catch {}
1577
+ return null;
1578
+ }
1579
+
1580
+ // API: Workflow run state — declared phases (from the script) plus the agent
1581
+ // roster (type/model/output-tokens/duration/status) reconstructed from the run
1582
+ // dir's journal + transcripts. Phase↔agent mapping and per-agent labels are
1583
+ // runtime-only and never persisted, so the roster is flat by design.
1584
+ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1585
+ try {
1586
+ const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1587
+ if (!wf) return res.status(404).json({ error: 'Workflow not found' });
1588
+ let source = '';
1589
+ try { source = await fs.readFile(wf.path, 'utf8'); } catch (_) {}
1590
+ const parsed = parseWorkflowMeta(source);
1591
+
1592
+ const sessionId = resolveSessionId(req.params.sessionId);
1593
+ const meta = loadSessionMetadata()[sessionId] || {};
1594
+ const runDir = resolveWorkflowRunDir(meta, wf.id, wf.path);
1595
+ const journal = runDir ? readWorkflowJournal(runDir) : new Map();
1596
+
1597
+ const agents = [];
1598
+ let stoppedAt = null;
1599
+ if (runDir) {
1600
+ let files = [];
1601
+ try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
1602
+ for (const f of files) {
1603
+ const agentId = f.slice('agent-'.length, -'.jsonl'.length);
1604
+ const stats = extractTranscriptStats(path.join(runDir, f)) || {};
1605
+ let type = null;
1606
+ try {
1607
+ type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
1608
+ } catch (_) {}
1609
+ const done = journal.get(agentId)?.done;
1610
+ const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
1611
+ if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
1612
+ agents.push({
1613
+ agentId,
1614
+ type,
1615
+ model: stats.model || null,
1616
+ outputTokens: stats.outputTokens || 0,
1617
+ durationMs,
1618
+ startedAt: stats.firstTs || null,
1619
+ status: done ? 'done' : 'running',
1620
+ });
1621
+ }
1622
+ agents.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
1623
+ }
1624
+ // Roster is start-ordered, so the earliest start is simply the first agent.
1625
+ const startedAt = agents[0]?.startedAt || null;
1626
+
1627
+ let startedCount = 0;
1628
+ let doneCount = 0;
1629
+ for (const e of journal.values()) {
1630
+ if (e.started) startedCount++;
1631
+ if (e.done) doneCount++;
1632
+ }
1633
+ if (!startedCount) {
1634
+ startedCount = agents.length;
1635
+ doneCount = agents.filter((a) => a.status === 'done').length;
1636
+ }
1637
+
1638
+ res.json({
1639
+ id: wf.id,
1640
+ name: parsed.name || wf.name,
1641
+ description: parsed.description,
1642
+ phases: parsed.phases,
1643
+ agents,
1644
+ startedCount,
1645
+ doneCount,
1646
+ startedAt,
1647
+ stoppedAt,
1648
+ });
1649
+ } catch (error) {
1650
+ console.error('Error building workflow run view:', error);
1651
+ res.status(500).json({ error: 'Failed to build workflow run view' });
1652
+ }
1653
+ });
1654
+
1443
1655
  function openInEditor(...targets) {
1444
1656
  const editor = process.env.EDITOR || 'code';
1445
1657
  spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
@@ -1680,6 +1892,27 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1680
1892
  }
1681
1893
  }
1682
1894
 
1895
+ // Workflow-spawned subagents given a schema end on a forced StructuredOutput
1896
+ // tool call and never emit a text lastMessage — surface that structured result
1897
+ // as the agent's response. Only stopped agents (complete transcript); latch
1898
+ // resultUnavailable so we tail-read at most once per agent. Workflow subagents
1899
+ // are exempt from the latch: the poll that flips them to "stopped" can beat the
1900
+ // transcript's final StructuredOutput line to disk, latching resultUnavailable
1901
+ // against an incomplete transcript — so always re-attempt for them, mirroring
1902
+ // the prompt/name/description reconcile above.
1903
+ const agentsNeedingResult = agents.filter(
1904
+ (a) => !a.lastMessage && !isAgentLive(a) && (!a.resultUnavailable || a.type === 'workflow-subagent'),
1905
+ );
1906
+ if (agentsNeedingResult.length && meta.jsonlPath) {
1907
+ for (const agent of agentsNeedingResult) {
1908
+ let result = null;
1909
+ try { result = extractStructuredResultFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
1910
+ if (result) agent.lastMessage = result;
1911
+ else agent.resultUnavailable = true;
1912
+ dirty.add(agent);
1913
+ }
1914
+ }
1915
+
1683
1916
  for (const agent of dirty) persistAgent(agentDir, agent);
1684
1917
  const teamColors = {};
1685
1918
  if (teamConfig?.members) {
@@ -2380,23 +2613,52 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2380
2613
  }
2381
2614
  });
2382
2615
 
2383
- // API: Markdown preview — read file and broadcast to clients
2384
- async function readMarkdownFile(absPath) {
2385
- const ext = path.extname(absPath).toLowerCase();
2386
- if (ext !== '.md' && ext !== '.markdown') {
2387
- const err = new Error('Only .md/.markdown files are allowed');
2388
- err.status = 400;
2389
- throw err;
2390
- }
2616
+ // API: File preview — read file and broadcast to clients
2617
+ const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
2618
+ // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
2619
+ const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
2620
+
2621
+ function previewError(status, message) {
2622
+ const err = new Error(message);
2623
+ err.status = status;
2624
+ return err;
2625
+ }
2626
+
2627
+ // Existence + kind of a file target, with the HTTP status codes both the preview and
2628
+ // the link-a-file endpoints report. `kind` is null for anything the previewer can't
2629
+ // render — the caller decides whether that disqualifies the path.
2630
+ async function statFileTarget(absPath) {
2391
2631
  try {
2392
- return await fs.readFile(absPath, 'utf8');
2632
+ const stats = await fs.stat(absPath);
2633
+ if (!stats.isFile()) throw previewError(400, 'Not a file');
2634
+ return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
2393
2635
  } catch (e) {
2394
- if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
2395
- if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
2636
+ if (e.status) throw e;
2637
+ if (e.code === 'ENOENT') throw previewError(404, 'File not found');
2638
+ if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
2396
2639
  throw e;
2397
2640
  }
2398
2641
  }
2399
2642
 
2643
+ // Checks the file is previewable without reading it — the broadcast path needs the
2644
+ // validation (and its status codes) but never the content.
2645
+ async function validatePreviewFile(absPath) {
2646
+ const { kind, size } = await statFileTarget(absPath);
2647
+ if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
2648
+ if (size > PREVIEW_MAX_BYTES) {
2649
+ throw previewError(
2650
+ 400,
2651
+ `Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
2652
+ );
2653
+ }
2654
+ return { kind, size };
2655
+ }
2656
+
2657
+ async function readPreviewFile(absPath) {
2658
+ const { kind } = await validatePreviewFile(absPath);
2659
+ return { content: await fs.readFile(absPath, 'utf8'), kind };
2660
+ }
2661
+
2400
2662
  function resolvePreviewPath(filePath, base) {
2401
2663
  if (!filePath || typeof filePath !== 'string') return null;
2402
2664
  if (path.isAbsolute(filePath)) return filePath;
@@ -2418,8 +2680,10 @@ app.post('/api/preview', async (req, res) => {
2418
2680
  const { path: filePath, sessionId, base } = req.body || {};
2419
2681
  const abs = resolvePreviewPath(filePath, base);
2420
2682
  if (!abs) return res.status(400).json({ error: 'path is required' });
2421
- const content = await readMarkdownFile(abs);
2422
- broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
2683
+ // Validate here so the CLI still gets 400/404, but broadcast the path only —
2684
+ // each tab fetches the document itself instead of it being fanned out over SSE.
2685
+ await validatePreviewFile(abs);
2686
+ broadcast({ type: 'preview:open', path: abs, sessionId: sessionId || null });
2423
2687
  res.json({ success: true });
2424
2688
  } catch (error) {
2425
2689
  console.error('Error in /api/preview:', error);
@@ -2427,6 +2691,24 @@ app.post('/api/preview', async (req, res) => {
2427
2691
  }
2428
2692
  });
2429
2693
 
2694
+ // API: Link a file to a session's sidebar docs without opening the preview modal.
2695
+ // Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
2696
+ // opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
2697
+ app.post('/api/document/link', async (req, res) => {
2698
+ try {
2699
+ const { path: filePath, sessionId, unlink } = req.body || {};
2700
+ if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
2701
+ const abs = resolvePreviewPath(filePath);
2702
+ if (!abs) return res.status(400).json({ error: 'path is required' });
2703
+ if (!unlink) await statFileTarget(abs);
2704
+ broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
2705
+ res.json({ success: true });
2706
+ } catch (error) {
2707
+ console.error('Error in /api/document/link:', error);
2708
+ res.status(error.status || 500).json({ error: error.message || 'Link failed' });
2709
+ }
2710
+ });
2711
+
2430
2712
  app.get('/api/session/resolve', (req, res) => {
2431
2713
  try {
2432
2714
  const idArg = (req.query.id || '').toString();
@@ -2499,14 +2781,29 @@ app.get('/api/preview', async (req, res) => {
2499
2781
  try {
2500
2782
  const abs = resolvePreviewPath(req.query.path, req.query.base);
2501
2783
  if (!abs) return res.status(400).json({ error: 'path is required' });
2502
- const content = await readMarkdownFile(abs);
2503
- res.json({ path: abs, content });
2784
+ const { content, kind } = await readPreviewFile(abs);
2785
+ res.json({ path: abs, content, kind });
2504
2786
  } catch (error) {
2505
2787
  console.error('Error in GET /api/preview:', error);
2506
2788
  res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
2507
2789
  }
2508
2790
  });
2509
2791
 
2792
+ // API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
2793
+ // extension-restricted — a linked file the previewer can't render (kind: null) is
2794
+ // still linkable, the client just opens it in the editor instead.
2795
+ app.get('/api/file/resolve', async (req, res) => {
2796
+ try {
2797
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
2798
+ if (!abs) return res.status(400).json({ error: 'path is required' });
2799
+ // No size cap here — an unpreviewable file of any size is still linkable.
2800
+ res.json({ path: abs, ...(await statFileTarget(abs)) });
2801
+ } catch (error) {
2802
+ console.error('Error in GET /api/file/resolve:', error);
2803
+ res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
2804
+ }
2805
+ });
2806
+
2510
2807
  // SSE endpoint for live updates
2511
2808
  app.get('/api/events', (req, res) => {
2512
2809
  res.setHeader('Content-Type', 'text/event-stream');