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