claude-code-kanban 4.7.1 → 4.8.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/lib/parsers.js +103 -3
- package/package.json +1 -1
- package/public/app.js +203 -4
- package/public/style.css +67 -1
- package/server.js +40 -1
package/lib/parsers.js
CHANGED
|
@@ -146,17 +146,49 @@ function parseImageMarkers(text) {
|
|
|
146
146
|
return { refs, text: cleaned };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Compact usage suffix for a task-notification chip: " · 22.3k tok · 6 tools · 119s".
|
|
150
|
+
function formatTaskUsage(usage) {
|
|
151
|
+
if (!usage) return '';
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (usage.subagentTokens != null) {
|
|
154
|
+
const t = usage.subagentTokens;
|
|
155
|
+
const tok = t >= 1000 ? (t / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(t);
|
|
156
|
+
parts.push(tok + ' tok');
|
|
157
|
+
}
|
|
158
|
+
if (usage.toolUses != null) parts.push(usage.toolUses + (usage.toolUses === 1 ? ' tool' : ' tools'));
|
|
159
|
+
if (usage.durationMs != null) parts.push(Math.round(usage.durationMs / 1000) + 's');
|
|
160
|
+
return parts.length ? ' · ' + parts.join(' · ') : '';
|
|
161
|
+
}
|
|
162
|
+
|
|
149
163
|
function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
150
164
|
if (sysLabel === '__skip__') return;
|
|
151
|
-
|
|
165
|
+
let safeText = text || '';
|
|
166
|
+
let label = sysLabel;
|
|
167
|
+
// Background-task completion notifications are harness events, not user input.
|
|
168
|
+
// Whichever path delivered them (normal type:'user' or a queued enqueue), show
|
|
169
|
+
// a rich summary+usage chip and surface the agent's actual result as the body —
|
|
170
|
+
// never the raw <task-notification> envelope under a person icon.
|
|
171
|
+
const notif = parseTaskNotification(safeText);
|
|
172
|
+
if (notif) {
|
|
173
|
+
const base = notif.summary || (notif.status ? `Background task ${notif.status}` : 'Background task notification');
|
|
174
|
+
label = base + formatTaskUsage(notif.usage);
|
|
175
|
+
safeText = notif.result || base;
|
|
176
|
+
}
|
|
152
177
|
const truncated = safeText.length > USER_TEXT_MAX;
|
|
153
178
|
const msg = {
|
|
154
179
|
type: 'user',
|
|
155
180
|
text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
|
|
156
181
|
fullText: truncated ? safeText : null,
|
|
157
182
|
timestamp,
|
|
158
|
-
...(
|
|
183
|
+
...(label && { systemLabel: label })
|
|
159
184
|
};
|
|
185
|
+
// Tag task-notifications so the client can group the duplicate enqueue+delivered
|
|
186
|
+
// pair (same taskId) and join the agent type from the agents list.
|
|
187
|
+
if (notif) {
|
|
188
|
+
msg.taskNotification = true;
|
|
189
|
+
if (notif.taskId) msg.taskId = notif.taskId;
|
|
190
|
+
if (notif.status) msg.taskStatus = notif.status;
|
|
191
|
+
}
|
|
160
192
|
if (extras) {
|
|
161
193
|
if (extras.uuid) msg.uuid = extras.uuid;
|
|
162
194
|
if (extras.images && extras.images.length) msg.images = extras.images;
|
|
@@ -435,6 +467,58 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
435
467
|
return result;
|
|
436
468
|
}
|
|
437
469
|
|
|
470
|
+
// Background-task completion records arrive as a user message whose content is a
|
|
471
|
+
// <task-notification> envelope. Rendering the raw text leaks the tag values as a
|
|
472
|
+
// run-on line (task-id + tool-use-id + output-file path) plus the <usage> numbers
|
|
473
|
+
// mashed together (subagent_tokens|tool_uses|duration_ms with tags stripped).
|
|
474
|
+
// Parse the envelope into structured fields so the UI can show just the agent's
|
|
475
|
+
// result and a clean metadata line instead of the raw wrapper. Returns null when
|
|
476
|
+
// the text is not a task-notification.
|
|
477
|
+
function parseTaskNotification(text) {
|
|
478
|
+
if (typeof text !== 'string' || !text.includes('<task-notification>')) return null;
|
|
479
|
+
// Metadata fields are machine-generated single-line values that appear before
|
|
480
|
+
// the free-form <result>, so first-match non-greedy is safe for them.
|
|
481
|
+
const pick = (tag) => {
|
|
482
|
+
const m = text.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
|
|
483
|
+
return m ? m[1].trim() : null;
|
|
484
|
+
};
|
|
485
|
+
// <result> and <usage> carry UNescaped agent text that can itself contain
|
|
486
|
+
// literal "</result>" / "<usage>" (e.g. an agent describing this very format).
|
|
487
|
+
// The real closing tags are always the LAST occurrence, so match greedily and
|
|
488
|
+
// take the last <usage> block to avoid truncating on embedded markers.
|
|
489
|
+
const lastBlock = (tag) => {
|
|
490
|
+
const m = text.match(new RegExp(`<${tag}>([\\s\\S]*)<\\/${tag}>`));
|
|
491
|
+
return m ? m[1] : null;
|
|
492
|
+
};
|
|
493
|
+
let usage = null;
|
|
494
|
+
const usageRaw = lastBlock('usage');
|
|
495
|
+
if (usageRaw != null) {
|
|
496
|
+
const uNum = (tag) => {
|
|
497
|
+
// Take the LAST match: the real <usage> wins over any example block an agent
|
|
498
|
+
// embedded in its result.
|
|
499
|
+
const last = [...usageRaw.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'))].at(-1);
|
|
500
|
+
if (!last) return null;
|
|
501
|
+
const n = Number(last[1]);
|
|
502
|
+
return Number.isFinite(n) ? n : null;
|
|
503
|
+
};
|
|
504
|
+
usage = {
|
|
505
|
+
subagentTokens: uNum('subagent_tokens'),
|
|
506
|
+
toolUses: uNum('tool_uses'),
|
|
507
|
+
durationMs: uNum('duration_ms')
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const resultRaw = lastBlock('result');
|
|
511
|
+
return {
|
|
512
|
+
taskId: pick('task-id'),
|
|
513
|
+
toolUseId: pick('tool-use-id'),
|
|
514
|
+
outputFile: pick('output-file'),
|
|
515
|
+
status: pick('status'),
|
|
516
|
+
summary: pick('summary'),
|
|
517
|
+
result: resultRaw != null ? resultRaw.trim() : null,
|
|
518
|
+
usage
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
438
522
|
function getSystemMessageLabel(text) {
|
|
439
523
|
const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
|
|
440
524
|
if (taskMatch) return taskMatch[1].trim();
|
|
@@ -1044,11 +1128,25 @@ function buildSessionDigest(jsonlPath) {
|
|
|
1044
1128
|
const obj = JSON.parse(line);
|
|
1045
1129
|
const tur = obj.toolUseResult;
|
|
1046
1130
|
if (tur?.agentId) {
|
|
1131
|
+
// Foreground agents report their cost on the completion toolUseResult
|
|
1132
|
+
// (totalTokens/totalToolUseCount/totalDurationMs) — the same numbers a
|
|
1133
|
+
// background agent surfaces via its <task-notification> <usage> block. Keep
|
|
1134
|
+
// the raw numbers (for the agent modal's per-stat chips) and a formatted
|
|
1135
|
+
// one-liner via the shared formatter (for the inline message row):
|
|
1136
|
+
// " · 31.5k tok · 5 tools · 34s".
|
|
1137
|
+
const usage = {};
|
|
1138
|
+
if (tur.totalTokens != null) usage.tokens = tur.totalTokens;
|
|
1139
|
+
if (tur.totalToolUseCount != null) usage.tools = tur.totalToolUseCount;
|
|
1140
|
+
if (tur.totalDurationMs != null) usage.durationMs = tur.totalDurationMs;
|
|
1141
|
+
const hasUsage = Object.keys(usage).length > 0;
|
|
1142
|
+
const usageText = hasUsage
|
|
1143
|
+
? formatTaskUsage({ subagentTokens: usage.tokens, toolUses: usage.tools, durationMs: usage.durationMs })
|
|
1144
|
+
: null;
|
|
1047
1145
|
const blocks = obj.message?.content;
|
|
1048
1146
|
if (Array.isArray(blocks)) {
|
|
1049
1147
|
for (const b of blocks) {
|
|
1050
1148
|
if (b.type === 'tool_result' && b.tool_use_id && !map[b.tool_use_id]) {
|
|
1051
|
-
map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null };
|
|
1149
|
+
map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage: hasUsage ? usage : null, usageText };
|
|
1052
1150
|
}
|
|
1053
1151
|
}
|
|
1054
1152
|
}
|
|
@@ -1333,6 +1431,8 @@ module.exports = {
|
|
|
1333
1431
|
parseTeamConfig,
|
|
1334
1432
|
parseSessionsIndex,
|
|
1335
1433
|
parseJsonlLine,
|
|
1434
|
+
parseTaskNotification,
|
|
1435
|
+
getSystemMessageLabel,
|
|
1336
1436
|
readSessionInfoFromJsonl,
|
|
1337
1437
|
readRecentMessages,
|
|
1338
1438
|
readMessagesPage,
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -945,6 +945,130 @@ function cleanMessageText(text) {
|
|
|
945
945
|
.trim();
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
+
// A GFM table delimiter row (|---|:--:|): only pipes/dashes/colons/spaces, with
|
|
949
|
+
// at least one dash. Pairs with a preceding line that has cells to mark a table.
|
|
950
|
+
function isTableDelimiter(line) {
|
|
951
|
+
const l = (line || '').trim();
|
|
952
|
+
return l.includes('-') && /^\|?[ :|-]+\|?$/.test(l);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Cut a string to at most `max` chars at a word boundary (no mid-word chops).
|
|
956
|
+
function truncateAtWord(s, max) {
|
|
957
|
+
if (s.length <= max) return s;
|
|
958
|
+
const cut = s.slice(0, max);
|
|
959
|
+
const sp = cut.lastIndexOf(' ');
|
|
960
|
+
return (sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Build a markdown preview for an assistant message: render real markdown but
|
|
964
|
+
// truncate on clean structural boundaries so the feed stays compact and the
|
|
965
|
+
// output is always valid markdown (never cut mid code-fence or mid table-row).
|
|
966
|
+
// Prose is bounded by a line count AND a char budget (long single paragraphs are
|
|
967
|
+
// cut at a word boundary). Returns { md, remainder } where remainder is a short
|
|
968
|
+
// label describing what was cut (e.g. "+12 lines"), or '' when nothing was cut.
|
|
969
|
+
function buildAssistantPreview(text) {
|
|
970
|
+
const lines = stripAnsi(text || '')
|
|
971
|
+
.replace(/\r/g, '')
|
|
972
|
+
.split('\n');
|
|
973
|
+
const MAX_LINES = 5; // non-blank content lines kept before truncating
|
|
974
|
+
const MAX_CHARS = 280; // total prose/code/table chars kept
|
|
975
|
+
const MAX_ROWS = 3; // table body rows
|
|
976
|
+
const MAX_CODE_LINES = 8; // lines kept inside a fenced code block
|
|
977
|
+
const countRest = (idx) => {
|
|
978
|
+
let c = 0;
|
|
979
|
+
for (let j = idx; j < lines.length; j++) if (lines[j].trim()) c++;
|
|
980
|
+
return c;
|
|
981
|
+
};
|
|
982
|
+
const pluralize = (n, word) => `+${n} ${word}${n > 1 ? 's' : ''}`;
|
|
983
|
+
const out = [];
|
|
984
|
+
let remainder = '';
|
|
985
|
+
let content = 0;
|
|
986
|
+
let chars = 0;
|
|
987
|
+
let i = 0;
|
|
988
|
+
|
|
989
|
+
for (; i < lines.length; i++) {
|
|
990
|
+
const line = lines[i];
|
|
991
|
+
const trimmed = line.trim();
|
|
992
|
+
if (content >= MAX_LINES || chars >= MAX_CHARS) {
|
|
993
|
+
const rest = countRest(i);
|
|
994
|
+
if (rest) remainder = pluralize(rest, 'line');
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Fenced code block — copy verbatim (capped), never break the fence open.
|
|
999
|
+
const fence = trimmed.match(/^(```|~~~)/);
|
|
1000
|
+
if (fence) {
|
|
1001
|
+
const marker = fence[1];
|
|
1002
|
+
out.push(line);
|
|
1003
|
+
let codeLines = 0;
|
|
1004
|
+
let dropped = 0;
|
|
1005
|
+
// Advance to the real closing fence, keeping at most MAX_CODE_LINES lines,
|
|
1006
|
+
// so leftover code isn't re-parsed as top-level markdown after the cap.
|
|
1007
|
+
for (i++; i < lines.length && !lines[i].trim().startsWith(marker); i++) {
|
|
1008
|
+
if (codeLines < MAX_CODE_LINES) {
|
|
1009
|
+
out.push(lines[i]);
|
|
1010
|
+
codeLines++;
|
|
1011
|
+
chars += lines[i].length;
|
|
1012
|
+
} else {
|
|
1013
|
+
dropped++;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
out.push(marker); // close the fence (covers capped / unterminated blocks)
|
|
1017
|
+
content++;
|
|
1018
|
+
if (dropped) {
|
|
1019
|
+
// Stop at the truncated code block rather than resuming after the gap.
|
|
1020
|
+
remainder = pluralize(dropped, 'code line');
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Table — keep header + separator + up to MAX_ROWS body rows.
|
|
1027
|
+
if (line.includes('|') && isTableDelimiter(lines[i + 1])) {
|
|
1028
|
+
out.push(line, lines[i + 1]);
|
|
1029
|
+
content++;
|
|
1030
|
+
chars += line.length;
|
|
1031
|
+
i++;
|
|
1032
|
+
let rows = 0;
|
|
1033
|
+
let droppedRows = 0;
|
|
1034
|
+
while (lines[i + 1]?.includes('|')) {
|
|
1035
|
+
if (rows >= MAX_ROWS) {
|
|
1036
|
+
// Skip remaining rows so they aren't re-rendered as prose lines.
|
|
1037
|
+
droppedRows++;
|
|
1038
|
+
i++;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
out.push(lines[++i]);
|
|
1042
|
+
rows++;
|
|
1043
|
+
}
|
|
1044
|
+
if (droppedRows) {
|
|
1045
|
+
// Stop at the truncated table — don't resume with later content across
|
|
1046
|
+
// the gap; the "+N rows" chip signals the table continues.
|
|
1047
|
+
remainder = pluralize(droppedRows, 'row');
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Prose / list / heading — bound by the remaining char budget, cutting a
|
|
1054
|
+
// long line at a word boundary rather than rendering a giant paragraph.
|
|
1055
|
+
const remaining = MAX_CHARS - chars;
|
|
1056
|
+
if (trimmed && line.length > remaining) {
|
|
1057
|
+
out.push(truncateAtWord(line, remaining));
|
|
1058
|
+
// Mid-line word cut — line counts would be misleading; count only the
|
|
1059
|
+
// additional full lines that follow, else just signal "more".
|
|
1060
|
+
const rest = countRest(i + 1);
|
|
1061
|
+
remainder = rest ? pluralize(rest, 'line') : 'more';
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
out.push(line);
|
|
1065
|
+
chars += line.length;
|
|
1066
|
+
if (trimmed) content++;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return { md: out.join('\n').trim(), remainder };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
948
1072
|
function renderMsgPinBtn(m, i) {
|
|
949
1073
|
const pinned = isPinned(m);
|
|
950
1074
|
return `<button class="msg-pin-btn${pinned ? ' pinned' : ''}" onclick="event.stopPropagation();togglePin(${i})" title="${pinned ? 'Unpin' : 'Pin'} message">${PIN_SVG}</button>`;
|
|
@@ -1019,10 +1143,17 @@ function toolGroupKey(m) {
|
|
|
1019
1143
|
|
|
1020
1144
|
function renderToolItem(m, i, compact) {
|
|
1021
1145
|
const toolDetail = getToolDetail(m.tool, m.params, m.detail);
|
|
1146
|
+
// m.agentId is resolved server-side even while the agent is still running (correlated
|
|
1147
|
+
// from the live agent-activity files), so the ⇗ link, agent-log button, and modal
|
|
1148
|
+
// click all light up DURING the run, identical to post-completion.
|
|
1022
1149
|
const agentLink =
|
|
1023
1150
|
m.tool === 'Agent' && m.agentId
|
|
1024
1151
|
? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
|
|
1025
1152
|
: '';
|
|
1153
|
+
// Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
|
|
1154
|
+
// background agent shows, sourced here from the foreground toolUseResult.
|
|
1155
|
+
const agentUsage =
|
|
1156
|
+
m.tool === 'Agent' && m.agentUsage ? `<span class="msg-agent-usage">${escapeHtml(m.agentUsage)}</span>` : '';
|
|
1026
1157
|
const agentLogBtn = resolveAgentLogBtn(m);
|
|
1027
1158
|
const recipientColor = m.tool === 'SendMessage' && m.params?.to ? resolveNamedColor(teamColorMap[m.params.to]) : null;
|
|
1028
1159
|
const borderStyle = recipientColor ? `border-left:3px solid ${recipientColor.color};` : '';
|
|
@@ -1035,7 +1166,7 @@ function renderToolItem(m, i, compact) {
|
|
|
1035
1166
|
const pinBtn = renderMsgPinBtn(m, i);
|
|
1036
1167
|
return `<div class="msg-item msg-tool${compactClass}" data-msg-idx="${i}" ${itemClickAttr}>
|
|
1037
1168
|
${getToolIcon(m.tool)}
|
|
1038
|
-
<div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
|
|
1169
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}${agentUsage}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
|
|
1039
1170
|
</div>`;
|
|
1040
1171
|
}
|
|
1041
1172
|
|
|
@@ -1075,6 +1206,53 @@ function renderMessageList(messages) {
|
|
|
1075
1206
|
continue;
|
|
1076
1207
|
}
|
|
1077
1208
|
|
|
1209
|
+
// Background task-notifications: the harness writes each one twice (an enqueue
|
|
1210
|
+
// record + the delivered type:'user' record, same taskId). Collapse a run of
|
|
1211
|
+
// same-taskId notifications into one ×N group (like tool groups), prefixed with
|
|
1212
|
+
// the agent type joined from the loaded agents list.
|
|
1213
|
+
if (m.taskNotification) {
|
|
1214
|
+
let runEnd = i + 1;
|
|
1215
|
+
while (runEnd < messages.length && messages[runEnd].taskNotification && messages[runEnd].taskId === m.taskId)
|
|
1216
|
+
runEnd++;
|
|
1217
|
+
const count = runEnd - i;
|
|
1218
|
+
const agentType = m.taskId ? currentAgents.find((a) => a.agentId === m.taskId)?.type : null;
|
|
1219
|
+
const typePrefix = agentType ? `${escapeHtml(agentType)} · ` : '';
|
|
1220
|
+
const labelHtml = `${typePrefix}<code>${escapeHtml(m.systemLabel || 'Background task')}</code>`;
|
|
1221
|
+
// Shared header row for both the single notification and the ×N group header.
|
|
1222
|
+
const headerRow = (extraClass, onclick, badge, queuedTag) =>
|
|
1223
|
+
`<div class="msg-item msg-system${extraClass}" ${onclick} style="cursor:pointer">
|
|
1224
|
+
${ICON_AGENT}
|
|
1225
|
+
<div class="msg-body"><div class="msg-text">${labelHtml}${badge}</div><div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${renderMsgPinBtn(m, i)}
|
|
1226
|
+
</div>`;
|
|
1227
|
+
|
|
1228
|
+
if (count >= 2) {
|
|
1229
|
+
const gid = `task-group-${i}`;
|
|
1230
|
+
const items = Array.from({ length: count }, (_, j) => {
|
|
1231
|
+
const r = messages[i + j];
|
|
1232
|
+
const idx = i + j;
|
|
1233
|
+
return `<div class="msg-item msg-system msg-tool-grouped" data-msg-idx="${idx}" onclick="msgDetailFollowLatest=false;showMsgDetail(${idx})" style="cursor:pointer">
|
|
1234
|
+
${ICON_AGENT}
|
|
1235
|
+
<div class="msg-body"><div class="msg-text">${r.queued ? 'queued' : 'delivered'}</div><div class="msg-time">${formatDate(r.timestamp)}</div></div>
|
|
1236
|
+
</div>`;
|
|
1237
|
+
}).join('');
|
|
1238
|
+
parts.push(`<div class="msg-tool-group">
|
|
1239
|
+
${headerRow(' msg-tool-group-header', `onclick="toggleToolGroup('${gid}')"`, `<span class="tool-count-badge">×${count}</span>`, '')}
|
|
1240
|
+
<div class="msg-tool-group-items" id="${gid}">${items}</div>
|
|
1241
|
+
</div>`);
|
|
1242
|
+
i = runEnd;
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Not collapsed (the enqueue/delivered pair wasn't consecutive): tag the queued
|
|
1247
|
+
// record with the same golden marker used for other queued messages.
|
|
1248
|
+
const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
|
|
1249
|
+
parts.push(
|
|
1250
|
+
headerRow('', `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})"`, '', queuedTag),
|
|
1251
|
+
);
|
|
1252
|
+
i++;
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1078
1256
|
const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
|
|
1079
1257
|
const pinBtn = renderMsgPinBtn(m, i);
|
|
1080
1258
|
if (m.type === 'user') {
|
|
@@ -1108,9 +1286,16 @@ function renderMessageList(messages) {
|
|
|
1108
1286
|
</div>`);
|
|
1109
1287
|
}
|
|
1110
1288
|
} else if (m.type === 'assistant') {
|
|
1289
|
+
const preview = buildAssistantPreview(m.fullText || m.text);
|
|
1290
|
+
const moreChip = preview.remainder ? `<div class="msg-md-more">${escapeHtml(preview.remainder)}</div>` : '';
|
|
1291
|
+
const bodyHtml = preview.md
|
|
1292
|
+
? `<div class="msg-text msg-text-md">
|
|
1293
|
+
<div class="msg-md-content rendered-md${preview.remainder ? ' is-truncated' : ''}">${renderMarkdown(preview.md)}</div>${moreChip}
|
|
1294
|
+
</div>`
|
|
1295
|
+
: `<div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div>`;
|
|
1111
1296
|
parts.push(`<div class="msg-item msg-assistant" ${clickable}>
|
|
1112
1297
|
${MSG_ICON_ASSISTANT}
|
|
1113
|
-
<div class="msg-body"
|
|
1298
|
+
<div class="msg-body">${bodyHtml}<div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
|
|
1114
1299
|
</div>`);
|
|
1115
1300
|
} else if (m.type === 'teammate') {
|
|
1116
1301
|
if (m.teammateId && m.color && !teamColorMap[m.teammateId]) teamColorMap[m.teammateId] = m.color;
|
|
@@ -1309,6 +1494,8 @@ const ICON_CHECKMARK =
|
|
|
1309
1494
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
|
|
1310
1495
|
const ICON_AGENT_WAITING =
|
|
1311
1496
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
|
1497
|
+
const ICON_AGENT_ACTIVE =
|
|
1498
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
|
|
1312
1499
|
const ICON_CHAT =
|
|
1313
1500
|
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
|
1314
1501
|
const TOOL_ICONS = {
|
|
@@ -2519,10 +2706,18 @@ function showAgentModal(agentId) {
|
|
|
2519
2706
|
return `<span class="agent-chip${cls}"${style}${title}>${labelHtml}<span class="agent-chip-val">${value}</span></span>`;
|
|
2520
2707
|
};
|
|
2521
2708
|
|
|
2709
|
+
// Agent cost stats live on the launch row's message (resolved from the completion
|
|
2710
|
+
// toolUseResult), not on the agent-activity record — so read them from there.
|
|
2711
|
+
const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
|
|
2712
|
+
const usageStats = agentMsg?.agentUsageStats || null;
|
|
2713
|
+
const fmtTok = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`);
|
|
2714
|
+
|
|
2522
2715
|
const chips = [];
|
|
2523
2716
|
if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
|
|
2524
2717
|
chips.push(chip('', escapeHtml(agent.status), { cls: `agent-chip-status agent-chip-${agent.status}` }));
|
|
2525
2718
|
chips.push(chip('⏱', formatDuration(elapsed)));
|
|
2719
|
+
if (usageStats?.tokens != null) chips.push(chip('tokens', fmtTok(usageStats.tokens), { cls: 'agent-chip-mono' }));
|
|
2720
|
+
if (usageStats?.tools != null) chips.push(chip('tools', String(usageStats.tools), { cls: 'agent-chip-mono' }));
|
|
2526
2721
|
if (shortModel) chips.push(chip('model', escapeHtml(shortModel), { cls: 'agent-chip-mono' }));
|
|
2527
2722
|
if (agent.agentName) {
|
|
2528
2723
|
const c = getOwnerColor(agent.agentName);
|
|
@@ -2535,8 +2730,6 @@ function showAgentModal(agentId) {
|
|
|
2535
2730
|
if (started) chips.push(chip('started', started.toLocaleTimeString()));
|
|
2536
2731
|
if (stopped) chips.push(chip('stopped', stopped.toLocaleTimeString()));
|
|
2537
2732
|
|
|
2538
|
-
const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
|
|
2539
|
-
|
|
2540
2733
|
let html = `<div class="agent-chips">${chips.join('')}</div>`;
|
|
2541
2734
|
|
|
2542
2735
|
const promptText = stripTeammateWrapper(agentMsg?.agentPrompt || agent.prompt || null);
|
|
@@ -2818,6 +3011,7 @@ function renderSessions() {
|
|
|
2818
3011
|
${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
|
|
2819
3012
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
|
|
2820
3013
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3014
|
+
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
2821
3015
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
2822
3016
|
</span>
|
|
2823
3017
|
<div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
|
|
@@ -5162,6 +5356,11 @@ function setupEventSource() {
|
|
|
5162
5356
|
refreshProjectAgents();
|
|
5163
5357
|
} else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
|
|
5164
5358
|
fetchAgents(currentSessionId);
|
|
5359
|
+
// An agent starting/stopping adds its Agent tool_use + completion rows to
|
|
5360
|
+
// the transcript. Refresh the message log on the same signal so they appear
|
|
5361
|
+
// at start, not only when an unrelated metadata/context refresh coincides
|
|
5362
|
+
// with completion.
|
|
5363
|
+
if (!agentLogMode) fetchMessages(currentSessionId);
|
|
5165
5364
|
}
|
|
5166
5365
|
pendingAgentSessionIds.clear();
|
|
5167
5366
|
}, 500);
|
package/public/style.css
CHANGED
|
@@ -2101,7 +2101,63 @@ body::before {
|
|
|
2101
2101
|
overflow: hidden;
|
|
2102
2102
|
}
|
|
2103
2103
|
.msg-assistant .msg-text {
|
|
2104
|
-
color: var(--
|
|
2104
|
+
color: var(--text-primary);
|
|
2105
|
+
}
|
|
2106
|
+
.msg-text-md {
|
|
2107
|
+
display: block; /* override .msg-text -webkit-box clamp */
|
|
2108
|
+
-webkit-line-clamp: none;
|
|
2109
|
+
white-space: normal; /* override .msg-text pre-wrap */
|
|
2110
|
+
font-size: 12px;
|
|
2111
|
+
}
|
|
2112
|
+
/* Element spacing/borders come from .rendered-md (also on .msg-md-content);
|
|
2113
|
+
these are only the compact-feed deltas: smaller type + code/quote chrome. */
|
|
2114
|
+
.msg-text-md table,
|
|
2115
|
+
.msg-text-md pre,
|
|
2116
|
+
.msg-text-md code {
|
|
2117
|
+
font-size: 11px;
|
|
2118
|
+
}
|
|
2119
|
+
.msg-text-md h1,
|
|
2120
|
+
.msg-text-md h2,
|
|
2121
|
+
.msg-text-md h3,
|
|
2122
|
+
.msg-text-md h4,
|
|
2123
|
+
.msg-text-md h5,
|
|
2124
|
+
.msg-text-md h6 {
|
|
2125
|
+
font-size: 12px;
|
|
2126
|
+
font-weight: 600;
|
|
2127
|
+
}
|
|
2128
|
+
.msg-text-md pre {
|
|
2129
|
+
padding: 6px 8px;
|
|
2130
|
+
background: var(--bg-hover);
|
|
2131
|
+
border-radius: 4px;
|
|
2132
|
+
overflow-x: auto;
|
|
2133
|
+
}
|
|
2134
|
+
.msg-text-md blockquote {
|
|
2135
|
+
padding-left: 8px;
|
|
2136
|
+
border-left: 2px solid var(--border);
|
|
2137
|
+
color: var(--text-secondary);
|
|
2138
|
+
}
|
|
2139
|
+
.msg-md-content > :first-child {
|
|
2140
|
+
margin-top: 0;
|
|
2141
|
+
}
|
|
2142
|
+
.msg-md-content > :last-child {
|
|
2143
|
+
margin-bottom: 0;
|
|
2144
|
+
}
|
|
2145
|
+
/* Soften just the bottom edge of a truncated preview so it reads as "continues"
|
|
2146
|
+
without obscuring the last line of text. */
|
|
2147
|
+
.msg-md-content.is-truncated {
|
|
2148
|
+
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 0.85em), transparent);
|
|
2149
|
+
mask-image: linear-gradient(to bottom, #000 calc(100% - 0.85em), transparent);
|
|
2150
|
+
}
|
|
2151
|
+
.msg-md-more {
|
|
2152
|
+
display: block;
|
|
2153
|
+
width: fit-content;
|
|
2154
|
+
margin: 3px auto 0 0;
|
|
2155
|
+
padding: 1px 8px;
|
|
2156
|
+
font-size: 10px;
|
|
2157
|
+
font-weight: 500;
|
|
2158
|
+
color: var(--text-secondary);
|
|
2159
|
+
background: var(--bg-hover);
|
|
2160
|
+
border-radius: 9px;
|
|
2105
2161
|
}
|
|
2106
2162
|
.msg-tool .msg-text {
|
|
2107
2163
|
color: var(--text-secondary);
|
|
@@ -2147,6 +2203,12 @@ body::before {
|
|
|
2147
2203
|
.msg-agent-link:hover {
|
|
2148
2204
|
opacity: 1;
|
|
2149
2205
|
}
|
|
2206
|
+
.msg-agent-usage {
|
|
2207
|
+
color: var(--text-muted);
|
|
2208
|
+
font-size: 10px;
|
|
2209
|
+
opacity: 0.55;
|
|
2210
|
+
margin-left: 6px;
|
|
2211
|
+
}
|
|
2150
2212
|
.msg-pin-btn {
|
|
2151
2213
|
flex-shrink: 0;
|
|
2152
2214
|
background: none;
|
|
@@ -2912,6 +2974,10 @@ body::before {
|
|
|
2912
2974
|
color: var(--warning);
|
|
2913
2975
|
animation: agent-badge-pulse 1.6s ease-in-out infinite;
|
|
2914
2976
|
}
|
|
2977
|
+
.agent-badge-active {
|
|
2978
|
+
color: var(--accent, var(--text-secondary));
|
|
2979
|
+
animation: agent-badge-pulse 1.6s ease-in-out infinite;
|
|
2980
|
+
}
|
|
2915
2981
|
@keyframes agent-badge-pulse {
|
|
2916
2982
|
0%,
|
|
2917
2983
|
100% {
|
package/server.js
CHANGED
|
@@ -120,6 +120,16 @@ function readAgentJsonl(filePath) {
|
|
|
120
120
|
return merged;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Agent-activity record files in a session dir, excluding the `_`-prefixed sidecars
|
|
124
|
+
// (_waiting.json, _name-*). Returns [] if the dir is missing/unreadable.
|
|
125
|
+
function listAgentFiles(agentDir) {
|
|
126
|
+
try {
|
|
127
|
+
return readdirSync(agentDir).filter((f) => f.endsWith('.jsonl') && !f.startsWith('_'));
|
|
128
|
+
} catch (_) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
function persistAgent(dir, agent) {
|
|
124
134
|
const file = path.join(dir, agent.agentId + '.jsonl');
|
|
125
135
|
fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
|
|
@@ -1284,7 +1294,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1284
1294
|
const isTeam = !!teamConfig;
|
|
1285
1295
|
const teamMemberNames = isTeam ? new Set(teamConfig.members.map(m => m.name)) : null;
|
|
1286
1296
|
|
|
1287
|
-
const files =
|
|
1297
|
+
const files = listAgentFiles(agentDir);
|
|
1288
1298
|
const agents = [];
|
|
1289
1299
|
for (const file of files) {
|
|
1290
1300
|
try {
|
|
@@ -1738,6 +1748,8 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
1738
1748
|
if (entry) {
|
|
1739
1749
|
msg.agentId = entry.agentId;
|
|
1740
1750
|
if (entry.description) msg.agentDescription = entry.description;
|
|
1751
|
+
if (entry.usageText) msg.agentUsage = entry.usageText;
|
|
1752
|
+
if (entry.usage) msg.agentUsageStats = entry.usage;
|
|
1741
1753
|
if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
|
|
1742
1754
|
try {
|
|
1743
1755
|
const agentFile = path.join(agentDir, entry.agentId + '.jsonl');
|
|
@@ -1752,6 +1764,33 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
1752
1764
|
} catch (_) {}
|
|
1753
1765
|
}
|
|
1754
1766
|
}
|
|
1767
|
+
// Mid-run fallback: the progressMap only carries agentId once the agent completes
|
|
1768
|
+
// (this build writes no agent_progress lines), so a still-running subagent's launch
|
|
1769
|
+
// row has no agentId yet — leaving its ⇗ link / agent modal inert until completion.
|
|
1770
|
+
// Correlate by PROMPT against the live agent-activity files (which know the agentId
|
|
1771
|
+
// from launch): the activity prompt is the launch prompt plus appended harness
|
|
1772
|
+
// boilerplate, so the tool_use prompt is a prefix. Resolving agentId here makes the
|
|
1773
|
+
// row behave identically while running as it does after it finishes.
|
|
1774
|
+
const unresolved = agentMessages.filter(m => !m.agentId && m.agentPrompt);
|
|
1775
|
+
if (unresolved.length && existsSync(agentDir)) {
|
|
1776
|
+
const activity = listAgentFiles(agentDir)
|
|
1777
|
+
.map((f) => { try { return readAgentJsonl(path.join(agentDir, f)); } catch (_) { return null; } })
|
|
1778
|
+
.filter((a) => a && a.agentId && a.prompt);
|
|
1779
|
+
const usedIds = new Set(agentMessages.map(m => m.agentId).filter(Boolean));
|
|
1780
|
+
for (const msg of unresolved) {
|
|
1781
|
+
const key = msg.agentPrompt.slice(0, 200);
|
|
1782
|
+
const match = activity.find(a =>
|
|
1783
|
+
!usedIds.has(a.agentId) &&
|
|
1784
|
+
(!msg.agentType || a.type === msg.agentType) &&
|
|
1785
|
+
a.prompt.startsWith(key)
|
|
1786
|
+
);
|
|
1787
|
+
if (match) {
|
|
1788
|
+
msg.agentId = match.agentId;
|
|
1789
|
+
usedIds.add(match.agentId);
|
|
1790
|
+
if (match.lastMessage) msg.agentLastMessage = match.lastMessage;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1755
1794
|
}
|
|
1756
1795
|
const cachedCompact = compactSummaryCache.get(jsonlPath);
|
|
1757
1796
|
let compactSummaries;
|