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/cli.js +54 -32
- package/install.js +37 -7
- package/lib/parsers.js +100 -24
- package/package.json +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +26 -25
- package/public/app.js +352 -127
- package/public/index.html +4 -4
- package/public/style.css +364 -74
- package/server.js +344 -47
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
|
-
|
|
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
|
|
355
|
-
//
|
|
356
|
-
// the
|
|
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
|
-
|
|
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
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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:
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
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
|
-
|
|
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.
|
|
2395
|
-
if (e.code === '
|
|
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
|
-
|
|
2422
|
-
|
|
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
|
|
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');
|