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/lib/parsers.js +100 -24
- package/package.json +1 -1
- package/public/app.js +222 -4
- package/public/index.html +24 -0
- package/public/style.css +244 -3
- package/server.js +412 -42
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
|
|
355
|
-
//
|
|
356
|
-
// the
|
|
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
|
-
|
|
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
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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,
|
|
1670
|
-
if (
|
|
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,
|
|
1676
|
-
if (
|
|
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
|
-
|
|
1682
|
-
|
|
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,
|
|
1691
|
-
if (
|
|
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 ||
|
|
2073
|
+
return found || subagentJsonlPath(meta, agentId);
|
|
1704
2074
|
}
|
|
1705
2075
|
|
|
1706
2076
|
// Claude Code creates child sessions in two ways:
|