claude-code-kanban 4.4.0 → 4.5.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
CHANGED
|
@@ -222,12 +222,29 @@ function scrapeScalarFromBlob(blob, re) {
|
|
|
222
222
|
const sessionInfoCache = new Map();
|
|
223
223
|
const SESSION_INFO_CACHE_MAX = 2000;
|
|
224
224
|
|
|
225
|
+
// Session-scoped /goal: a goal_status attachment carries the active condition
|
|
226
|
+
// (latest-wins as the Stop hook re-evaluates). Only an unmet goal is "active" —
|
|
227
|
+
// a met goal auto-clears in Claude Code, so treat met as removal, same as a
|
|
228
|
+
// `/goal clear` command line. Returns the goal value to apply, or `undefined`
|
|
229
|
+
// when the line is not a goal event (caller keeps its current value).
|
|
230
|
+
function extractGoalFromLine(data) {
|
|
231
|
+
if (data.type === 'attachment' && data.attachment?.type === 'goal_status') {
|
|
232
|
+
return data.attachment.met ? null : { condition: data.attachment.condition || '' };
|
|
233
|
+
}
|
|
234
|
+
if (data.type === 'user' && typeof data.message?.content === 'string'
|
|
235
|
+
&& data.message.content.includes('<command-name>/goal</command-name>')
|
|
236
|
+
&& /<command-args>\s*clear/.test(data.message.content)) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
225
242
|
// gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
|
|
226
243
|
// and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
|
|
227
244
|
// live branch must resolve it from cwd separately. Cache is reset on inode
|
|
228
245
|
// change or truncation (size < scannedUpTo).
|
|
229
246
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
230
|
-
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null };
|
|
247
|
+
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, goal: null };
|
|
231
248
|
let stat;
|
|
232
249
|
let fd;
|
|
233
250
|
try {
|
|
@@ -248,6 +265,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
248
265
|
cwd: cached.cwd,
|
|
249
266
|
gitBranch: cached.gitBranch,
|
|
250
267
|
logicalParentUuid: cached.logicalParentUuid || null,
|
|
268
|
+
goal: cached.goal || null,
|
|
251
269
|
customTitle: readCustomTitle(jsonlPath, stat)
|
|
252
270
|
};
|
|
253
271
|
}
|
|
@@ -258,6 +276,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
258
276
|
result.cwd = cached.cwd;
|
|
259
277
|
result.gitBranch = cached.gitBranch;
|
|
260
278
|
result.logicalParentUuid = cached.logicalParentUuid || null;
|
|
279
|
+
result.goal = cached.goal || null;
|
|
261
280
|
}
|
|
262
281
|
|
|
263
282
|
let lastCwdSeen = result.cwd;
|
|
@@ -273,6 +292,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
273
292
|
if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
|
|
274
293
|
result.logicalParentUuid = data.logicalParentUuid;
|
|
275
294
|
}
|
|
295
|
+
// Forward → last wins.
|
|
296
|
+
const goalUpdate = extractGoalFromLine(data);
|
|
297
|
+
if (goalUpdate !== undefined) result.goal = goalUpdate;
|
|
276
298
|
} catch (e) {}
|
|
277
299
|
};
|
|
278
300
|
|
|
@@ -337,6 +359,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
337
359
|
const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
|
|
338
360
|
const lines = tailBuf.toString('utf8', 0, tn).split('\n');
|
|
339
361
|
let latestTailCwd = null;
|
|
362
|
+
// undefined = no goal event seen in tail (keep head's); otherwise the
|
|
363
|
+
// most recent tail goal/clear supersedes head (tail bytes are later).
|
|
364
|
+
let tailGoal;
|
|
340
365
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
341
366
|
try {
|
|
342
367
|
const data = JSON.parse(lines[i]);
|
|
@@ -344,10 +369,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
344
369
|
if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
|
|
345
370
|
if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
|
|
346
371
|
if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
|
|
347
|
-
if (
|
|
372
|
+
if (tailGoal === undefined) tailGoal = extractGoalFromLine(data);
|
|
373
|
+
if (latestTailCwd && tailGoal !== undefined && result.slug && result.projectPath && result.gitBranch) break;
|
|
348
374
|
} catch (e) {}
|
|
349
375
|
}
|
|
350
376
|
if (latestTailCwd) lastCwdSeen = latestTailCwd;
|
|
377
|
+
if (tailGoal !== undefined) result.goal = tailGoal;
|
|
351
378
|
}
|
|
352
379
|
scannedUpTo = stat.size;
|
|
353
380
|
}
|
|
@@ -367,7 +394,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
367
394
|
projectPath: result.projectPath,
|
|
368
395
|
gitBranch: result.gitBranch,
|
|
369
396
|
cwd: result.cwd,
|
|
370
|
-
logicalParentUuid: result.logicalParentUuid
|
|
397
|
+
logicalParentUuid: result.logicalParentUuid,
|
|
398
|
+
goal: result.goal
|
|
371
399
|
});
|
|
372
400
|
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
373
401
|
const firstKey = sessionInfoCache.keys().next().value;
|
|
@@ -402,6 +430,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
402
430
|
fd = require('fs').openSync(jsonlPath, 'r');
|
|
403
431
|
const messages = [];
|
|
404
432
|
const toolResults = new Map();
|
|
433
|
+
const toolResultExtras = new Map();
|
|
405
434
|
let readSize = Math.min(65536, stat.size);
|
|
406
435
|
|
|
407
436
|
while (messages.length < limit) {
|
|
@@ -417,6 +446,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
417
446
|
|
|
418
447
|
messages.length = 0;
|
|
419
448
|
toolResults.clear();
|
|
449
|
+
toolResultExtras.clear();
|
|
420
450
|
for (const line of clean.split('\n')) {
|
|
421
451
|
if (!line.trim()) continue;
|
|
422
452
|
try {
|
|
@@ -478,11 +508,20 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
478
508
|
const params = {};
|
|
479
509
|
if (inp) {
|
|
480
510
|
if (block.name === 'Edit') {
|
|
511
|
+
if (inp.file_path) params.file_path = inp.file_path;
|
|
481
512
|
if (inp.old_string) params.old_string = inp.old_string;
|
|
482
513
|
if (inp.new_string) params.new_string = inp.new_string;
|
|
483
514
|
if (inp.replace_all) params.replace_all = true;
|
|
484
515
|
} else if (block.name === 'Write') {
|
|
485
|
-
if (inp.
|
|
516
|
+
if (inp.file_path) params.file_path = inp.file_path;
|
|
517
|
+
if (inp.content) {
|
|
518
|
+
if (inp.content.length > TOOL_RESULT_MAX) {
|
|
519
|
+
params.content = inp.content.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
|
|
520
|
+
params.contentFull = inp.content;
|
|
521
|
+
} else {
|
|
522
|
+
params.content = inp.content;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
486
525
|
} else if (block.name === 'Grep') {
|
|
487
526
|
if (inp.path) params.path = inp.path;
|
|
488
527
|
if (inp.glob) params.glob = inp.glob;
|
|
@@ -538,6 +577,19 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
538
577
|
if (inp.message && typeof inp.message === 'object') {
|
|
539
578
|
params.protocol = inp.message;
|
|
540
579
|
}
|
|
580
|
+
} else {
|
|
581
|
+
// Passthrough for unknown tools (e.g. MCP `mcp__...`) so the detail panel
|
|
582
|
+
// can render args instead of "No details". Truncate large strings to bound
|
|
583
|
+
// wire/cache size, matching the Write `content` cap above.
|
|
584
|
+
for (const [k, v] of Object.entries(inp)) {
|
|
585
|
+
if (k === 'description' || v == null) continue;
|
|
586
|
+
if (typeof v === 'string' && v.length > TOOL_RESULT_MAX) {
|
|
587
|
+
params[k] = v.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
|
|
588
|
+
params[k + 'Full'] = v;
|
|
589
|
+
} else {
|
|
590
|
+
params[k] = v;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
541
593
|
}
|
|
542
594
|
}
|
|
543
595
|
const msg = {
|
|
@@ -634,9 +686,25 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
634
686
|
if (resultText) {
|
|
635
687
|
toolResults.set(block.tool_use_id, resultText);
|
|
636
688
|
}
|
|
689
|
+
// AskUserQuestion (and similar) stash the structured
|
|
690
|
+
// {questions, answers} payload at the line-level
|
|
691
|
+
// obj.toolUseResult — the block.content string is just a
|
|
692
|
+
// short confirmation. Capture it so the renderer can show
|
|
693
|
+
// the actual answers + option descriptions.
|
|
694
|
+
const tur = obj.toolUseResult;
|
|
695
|
+
let answerPayload = null;
|
|
696
|
+
if (tur && typeof tur === 'object' && !Array.isArray(tur)
|
|
697
|
+
&& tur.answers && typeof tur.answers === 'object') {
|
|
698
|
+
answerPayload = {
|
|
699
|
+
answers: tur.answers,
|
|
700
|
+
questions: Array.isArray(tur.questions) ? tur.questions : null,
|
|
701
|
+
};
|
|
702
|
+
toolResultExtras.set(block.tool_use_id, { answerPayload });
|
|
703
|
+
}
|
|
637
704
|
toolResultRefs.push({
|
|
638
705
|
toolUseId: block.tool_use_id,
|
|
639
|
-
preview: resultText ? resultText.slice(0, 200) : ''
|
|
706
|
+
preview: resultText ? resultText.slice(0, 200) : '',
|
|
707
|
+
answerPayload,
|
|
640
708
|
});
|
|
641
709
|
}
|
|
642
710
|
});
|
|
@@ -662,15 +730,27 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
662
730
|
}
|
|
663
731
|
|
|
664
732
|
// Attach tool results to their corresponding tool_use messages.
|
|
665
|
-
//
|
|
666
|
-
//
|
|
733
|
+
// When truncated, ship the full text inline as toolResultFull so the
|
|
734
|
+
// modal expand toggle is instant. The lazy fetch at
|
|
735
|
+
// /api/sessions/:id/tool-result/:toolUseId remains a fallback for older
|
|
736
|
+
// cached payloads that may lack toolResultFull.
|
|
667
737
|
for (const msg of messages) {
|
|
668
738
|
if (msg.type === 'tool_use' && msg.toolUseId && toolResults.has(msg.toolUseId)) {
|
|
669
739
|
const full = toolResults.get(msg.toolUseId);
|
|
670
740
|
const truncated = full.length > TOOL_RESULT_MAX;
|
|
671
|
-
|
|
741
|
+
if (truncated) {
|
|
742
|
+
msg.toolResult = full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
|
|
743
|
+
msg.toolResultFull = full;
|
|
744
|
+
} else {
|
|
745
|
+
msg.toolResult = full;
|
|
746
|
+
}
|
|
672
747
|
msg.toolResultTruncated = truncated;
|
|
673
748
|
}
|
|
749
|
+
if (msg.type === 'tool_use' && msg.tool === 'AskUserQuestion'
|
|
750
|
+
&& msg.toolUseId && toolResultExtras.has(msg.toolUseId)) {
|
|
751
|
+
const extra = toolResultExtras.get(msg.toolUseId);
|
|
752
|
+
if (extra.answerPayload) msg.answerPayload = extra.answerPayload;
|
|
753
|
+
}
|
|
674
754
|
}
|
|
675
755
|
|
|
676
756
|
require('fs').closeSync(fd);
|
|
@@ -878,6 +958,13 @@ function buildSessionDigest(jsonlPath) {
|
|
|
878
958
|
for (const [key, entry] of Object.entries(map)) {
|
|
879
959
|
if (nameByToolUseId[key]) entry.name = nameByToolUseId[key];
|
|
880
960
|
if (descByToolUseId[key]) entry.description = descByToolUseId[key];
|
|
961
|
+
// Prefer the full prompt from the assistant's tool_use input — Claude Code's
|
|
962
|
+
// agent_progress system messages embed a truncated prompt that ends mid-sentence.
|
|
963
|
+
// Only override when entry already had a prompt (i.e. agent_progress path);
|
|
964
|
+
// bg/teammate paths intentionally keep prompt null per existing contract.
|
|
965
|
+
if (entry.prompt && promptByToolUseId[key] && promptByToolUseId[key].length > entry.prompt.length) {
|
|
966
|
+
entry.prompt = promptByToolUseId[key];
|
|
967
|
+
}
|
|
881
968
|
}
|
|
882
969
|
} catch (_) {}
|
|
883
970
|
const rejectedAgentIds = new Set();
|
|
@@ -977,10 +1064,10 @@ function extractPromptFromTranscript(jsonlPath) {
|
|
|
977
1064
|
const obj = JSON.parse(firstLine);
|
|
978
1065
|
if (obj.type === 'user') {
|
|
979
1066
|
const content = obj.message?.content;
|
|
980
|
-
if (typeof content === 'string') return content
|
|
1067
|
+
if (typeof content === 'string') return content;
|
|
981
1068
|
if (Array.isArray(content)) {
|
|
982
1069
|
for (const b of content) {
|
|
983
|
-
if (b.type === 'text' && b.text) return b.text
|
|
1070
|
+
if (b.type === 'text' && b.text) return b.text;
|
|
984
1071
|
}
|
|
985
1072
|
}
|
|
986
1073
|
}
|
package/package.json
CHANGED
|
@@ -58,7 +58,7 @@ if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$
|
|
|
58
58
|
status: "waiting",
|
|
59
59
|
kind: $kind,
|
|
60
60
|
toolName: (.tool_name // "unknown"),
|
|
61
|
-
toolInput: ((.tool_input | tostring)
|
|
61
|
+
toolInput: ((.tool_input | tostring) // ""),
|
|
62
62
|
timestamp: $ts
|
|
63
63
|
}' > "$DIR/_waiting.json"
|
|
64
64
|
exit 0
|
package/public/app.js
CHANGED
|
@@ -161,12 +161,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
161
161
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
162
162
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
163
163
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
164
|
+
// When server filters by activity, the focused session may not be active —
|
|
165
|
+
// include it in pinned so the server still returns it.
|
|
166
|
+
if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
164
167
|
const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
|
|
165
168
|
const projectParam =
|
|
166
169
|
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
|
|
171
|
+
const sessionsPromise = fetch(
|
|
172
|
+
`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
|
|
173
|
+
).then((r) => r.json());
|
|
170
174
|
|
|
171
175
|
let newSessions, newTasks;
|
|
172
176
|
if (includeTasks) {
|
|
@@ -582,7 +586,6 @@ async function fetchTasks(sessionId) {
|
|
|
582
586
|
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
583
587
|
const AGENT_LOG_MAX = 8;
|
|
584
588
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
585
|
-
const ACTIVE_PLAN_MS = 10 * 60 * 1000;
|
|
586
589
|
// #endregion
|
|
587
590
|
|
|
588
591
|
function resetAgentState() {
|
|
@@ -1144,42 +1147,80 @@ function toggleToolGroup(id) {
|
|
|
1144
1147
|
if (el) el.classList.toggle('show');
|
|
1145
1148
|
}
|
|
1146
1149
|
|
|
1147
|
-
const WAITING_PLAN_PREVIEW_CHARS = 120;
|
|
1148
|
-
const WAITING_PREVIEW_MAX_CHARS = 200;
|
|
1149
|
-
|
|
1150
1150
|
function getWaitingLabel(kind, tool) {
|
|
1151
1151
|
if (kind !== 'question') return `Awaiting permission: ${tool}`;
|
|
1152
1152
|
if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1153
1153
|
return 'Question pending';
|
|
1154
1154
|
}
|
|
1155
1155
|
|
|
1156
|
-
function
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1156
|
+
function getWaitingPill(kind, tool) {
|
|
1157
|
+
if (kind === 'question' && tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1158
|
+
if (kind === 'question') return 'Question pending';
|
|
1159
|
+
return 'Awaiting permission';
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function deriveWaitingDetail(tool, params) {
|
|
1163
|
+
if (!params) return '';
|
|
1164
|
+
const trunc = (s) => (s.length > 80 ? `${s.slice(0, 80)}...` : s);
|
|
1165
|
+
if (params.file_path) return params.file_path.replace(/^.*[/\\]/, '');
|
|
1166
|
+
if (params.command) return trunc(params.command);
|
|
1167
|
+
if (params.pattern) return trunc(params.pattern);
|
|
1168
|
+
if (params.query) return trunc(params.query);
|
|
1169
|
+
if (params.url) return trunc(params.url);
|
|
1170
|
+
if (params.skill) {
|
|
1171
|
+
const s = params.skill + (typeof params.args === 'string' ? ` ${params.args}` : '');
|
|
1172
|
+
return trunc(s);
|
|
1173
|
+
}
|
|
1174
|
+
if (tool === 'AskUserQuestion' && params.questions?.[0]?.question) return trunc(params.questions[0].question);
|
|
1175
|
+
if (tool === 'ExitPlanMode' && typeof params.plan === 'string') {
|
|
1176
|
+
const t = params.plan.match(/^#\s+(.+)/m);
|
|
1177
|
+
return trunc(t ? t[1] : params.plan);
|
|
1178
|
+
}
|
|
1179
|
+
if (params.description) return trunc(String(params.description));
|
|
1170
1180
|
return '';
|
|
1171
1181
|
}
|
|
1172
1182
|
|
|
1183
|
+
function renderWaitingBody(tool, params) {
|
|
1184
|
+
if (!params) return '';
|
|
1185
|
+
if (tool === 'AskUserQuestion' && Array.isArray(params.questions)) {
|
|
1186
|
+
const items = params.questions
|
|
1187
|
+
.map((q) => {
|
|
1188
|
+
const head = `<div style="font-weight:600">${escapeHtml(q.question || '')}</div>`;
|
|
1189
|
+
const opts = Array.isArray(q.options)
|
|
1190
|
+
? `<ul style="margin:2px 0 0 16px;padding:0">${q.options
|
|
1191
|
+
.map(
|
|
1192
|
+
(o) =>
|
|
1193
|
+
`<li><span style="font-weight:600">${escapeHtml(o.label || '')}</span>${o.description ? ` — <span style="color:var(--text-muted)">${escapeHtml(o.description)}</span>` : ''}</li>`,
|
|
1194
|
+
)
|
|
1195
|
+
.join('')}</ul>`
|
|
1196
|
+
: '';
|
|
1197
|
+
return `<div style="margin-top:6px">${head}${opts}</div>`;
|
|
1198
|
+
})
|
|
1199
|
+
.join('');
|
|
1200
|
+
return items;
|
|
1201
|
+
}
|
|
1202
|
+
return renderToolParamsHtml(params);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1173
1205
|
function renderWaitingEntry() {
|
|
1174
1206
|
if (!isWaitingFresh()) return '';
|
|
1175
1207
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1208
|
+
let params = null;
|
|
1209
|
+
if (currentWaiting.toolInput) {
|
|
1210
|
+
try {
|
|
1211
|
+
params = JSON.parse(currentWaiting.toolInput);
|
|
1212
|
+
} catch (_) {
|
|
1213
|
+
/* toolInput may be truncated/non-JSON */
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const pillText = getWaitingPill(currentWaiting.kind, tool);
|
|
1217
|
+
const detail = deriveWaitingDetail(tool, params);
|
|
1218
|
+
const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
|
|
1219
|
+
const bodyHtml = renderWaitingBody(tool, params);
|
|
1220
|
+
const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
|
|
1221
|
+
const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
|
|
1181
1222
|
const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
1182
|
-
return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${
|
|
1223
|
+
return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text">${pill} <span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}<div class="msg-time">waiting…</div></div>${discardBtn}</div>`;
|
|
1183
1224
|
}
|
|
1184
1225
|
|
|
1185
1226
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
@@ -1354,6 +1395,20 @@ function togglePin(msgIndex) {
|
|
|
1354
1395
|
currentPins.splice(idx, 1);
|
|
1355
1396
|
} else {
|
|
1356
1397
|
pinnedCollapsed = false;
|
|
1398
|
+
// Strip large server-truncated `*Full` payloads (Write contentFull,
|
|
1399
|
+
// MCP passthrough <k>Full) before stashing in localStorage — a few
|
|
1400
|
+
// pinned big writes can blow past the per-origin quota. On pin
|
|
1401
|
+
// expand, the modal falls back to the truncated string + the lazy
|
|
1402
|
+
// /api/sessions/:id/tool-result/:toolUseId endpoint.
|
|
1403
|
+
let paramsForPin = null;
|
|
1404
|
+
if (m.params) {
|
|
1405
|
+
paramsForPin = {};
|
|
1406
|
+
for (const [k, v] of Object.entries(m.params)) {
|
|
1407
|
+
if (k === 'contentFull') continue;
|
|
1408
|
+
if (k.endsWith('Full') && typeof v === 'string' && typeof m.params[k.slice(0, -4)] === 'string') continue;
|
|
1409
|
+
paramsForPin[k] = v;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1357
1412
|
currentPins.push({
|
|
1358
1413
|
id,
|
|
1359
1414
|
type: m.type,
|
|
@@ -1363,6 +1418,9 @@ function togglePin(msgIndex) {
|
|
|
1363
1418
|
toolUseId: m.toolUseId || null,
|
|
1364
1419
|
toolResult: m.toolResult || null,
|
|
1365
1420
|
toolResultTruncated: m.toolResultTruncated || false,
|
|
1421
|
+
toolResultFull: null,
|
|
1422
|
+
answerPayload: m.answerPayload || null,
|
|
1423
|
+
params: paramsForPin,
|
|
1366
1424
|
detail: m.detail || null,
|
|
1367
1425
|
fullDetail: m.fullDetail || null,
|
|
1368
1426
|
description: m.description || null,
|
|
@@ -1671,7 +1729,9 @@ function showMsgDetail(idx) {
|
|
|
1671
1729
|
} else {
|
|
1672
1730
|
mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
|
|
1673
1731
|
}
|
|
1674
|
-
|
|
1732
|
+
const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
|
|
1733
|
+
body.innerHTML =
|
|
1734
|
+
mainHtml + toolParamsHtml + answersHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
|
|
1675
1735
|
} else if (m.type === 'teammate') {
|
|
1676
1736
|
document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
|
|
1677
1737
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
@@ -1890,16 +1950,75 @@ function renderTaskResult(toolResult) {
|
|
|
1890
1950
|
return `${html}</div>`;
|
|
1891
1951
|
}
|
|
1892
1952
|
|
|
1953
|
+
function renderAnswerPayloadHtml(answerPayload) {
|
|
1954
|
+
if (!answerPayload?.answers || typeof answerPayload.answers !== 'object') return '';
|
|
1955
|
+
const qs = Array.isArray(answerPayload.questions) ? answerPayload.questions : [];
|
|
1956
|
+
const findOptionDesc = (qText, label) => {
|
|
1957
|
+
const q = qs.find((x) => x && x.question === qText);
|
|
1958
|
+
if (!q || !Array.isArray(q.options)) return null;
|
|
1959
|
+
const opt = q.options.find((o) => o && o.label === label);
|
|
1960
|
+
return opt?.description ? opt.description : null;
|
|
1961
|
+
};
|
|
1962
|
+
const rows = Object.entries(answerPayload.answers)
|
|
1963
|
+
.map(([q, a]) => {
|
|
1964
|
+
const ansList = Array.isArray(a) ? a : [a];
|
|
1965
|
+
const items = ansList
|
|
1966
|
+
.map((label) => {
|
|
1967
|
+
const desc = findOptionDesc(q, label);
|
|
1968
|
+
const descHtml = desc ? ` <span style="color:var(--text-muted)">— ${escapeHtml(desc)}</span>` : '';
|
|
1969
|
+
return `<li><span style="font-weight:600">${escapeHtml(String(label))}</span>${descHtml}</li>`;
|
|
1970
|
+
})
|
|
1971
|
+
.join('');
|
|
1972
|
+
return `<div style="margin-top:6px">
|
|
1973
|
+
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(q)}</div>
|
|
1974
|
+
<ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
|
|
1975
|
+
</div>`;
|
|
1976
|
+
})
|
|
1977
|
+
.join('');
|
|
1978
|
+
return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
|
|
1979
|
+
<div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:4px">Answers</div>
|
|
1980
|
+
${rows}
|
|
1981
|
+
</div>`;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1893
1984
|
function renderToolParamsHtml(params) {
|
|
1894
1985
|
if (!params) return '';
|
|
1895
|
-
const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'plan']);
|
|
1986
|
+
const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'contentFull', 'plan']);
|
|
1896
1987
|
const badges = [],
|
|
1897
|
-
blocks = []
|
|
1988
|
+
blocks = [],
|
|
1989
|
+
jsonBlocks = [];
|
|
1898
1990
|
for (const [k, v] of Object.entries(params)) {
|
|
1899
1991
|
if (BLOCK_KEYS.has(k)) continue;
|
|
1992
|
+
// Skip sibling `<k>Full` entries — they're used as expand targets, not
|
|
1993
|
+
// rendered as their own field. Only treat it as a sibling when the
|
|
1994
|
+
// trimmed key holds a server-truncated string (ends with the truncation
|
|
1995
|
+
// marker), otherwise a real param that happens to end in "Full" would
|
|
1996
|
+
// disappear.
|
|
1997
|
+
if (k.endsWith('Full')) {
|
|
1998
|
+
const baseKey = k.slice(0, -4);
|
|
1999
|
+
const base = params[baseKey];
|
|
2000
|
+
if (typeof base === 'string' && base.endsWith('... (truncated)') && typeof v === 'string') {
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
if (v !== null && typeof v === 'object') {
|
|
2005
|
+
let pretty;
|
|
2006
|
+
try {
|
|
2007
|
+
pretty = JSON.stringify(v, null, 2);
|
|
2008
|
+
} catch (_) {
|
|
2009
|
+
pretty = String(v);
|
|
2010
|
+
}
|
|
2011
|
+
if (pretty.length > CONTENT_TRUNCATE_MAX) {
|
|
2012
|
+
pretty = `${pretty.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`;
|
|
2013
|
+
}
|
|
2014
|
+
jsonBlocks.push({ k, pretty });
|
|
2015
|
+
continue;
|
|
2016
|
+
}
|
|
1900
2017
|
const display = typeof v === 'boolean' ? (v ? 'yes' : 'no') : String(v);
|
|
1901
2018
|
if (display.length > 60) {
|
|
1902
|
-
|
|
2019
|
+
const fullKey = `${k}Full`;
|
|
2020
|
+
const full = typeof params[fullKey] === 'string' ? params[fullKey] : null;
|
|
2021
|
+
blocks.push({ k, display, full });
|
|
1903
2022
|
} else {
|
|
1904
2023
|
badges.push(
|
|
1905
2024
|
`<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-secondary);font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> ${escapeHtml(display)}</span>`,
|
|
@@ -1908,8 +2027,19 @@ function renderToolParamsHtml(params) {
|
|
|
1908
2027
|
}
|
|
1909
2028
|
let html = '';
|
|
1910
2029
|
if (badges.length) html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">${badges.join('')}</div>`;
|
|
1911
|
-
for (const { k, display } of blocks) {
|
|
1912
|
-
|
|
2030
|
+
for (const { k, display, full } of blocks) {
|
|
2031
|
+
let suffix = '';
|
|
2032
|
+
if (full && full.length > display.length) {
|
|
2033
|
+
const toggle = makeExpandToggle(escapeHtml(display), escapeHtml(full), { fontSize: '0.75rem' });
|
|
2034
|
+
suffix = ` ${toggle.btn}${toggle.full}`;
|
|
2035
|
+
}
|
|
2036
|
+
html += `<div style="margin-top:6px;font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> <span style="word-break:break-all">${escapeHtml(display)}</span>${suffix}</div>`;
|
|
2037
|
+
}
|
|
2038
|
+
for (const { k, pretty } of jsonBlocks) {
|
|
2039
|
+
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
|
|
2040
|
+
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(k)}</div>
|
|
2041
|
+
<pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto;font-size:0.75rem">${escapeHtml(pretty)}</pre>
|
|
2042
|
+
</div>`;
|
|
1913
2043
|
}
|
|
1914
2044
|
if (params.old_string || params.new_string) {
|
|
1915
2045
|
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
|
|
@@ -1924,14 +2054,18 @@ function renderToolParamsHtml(params) {
|
|
|
1924
2054
|
html += `</div>`;
|
|
1925
2055
|
}
|
|
1926
2056
|
if (params.content) {
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2057
|
+
// params.contentFull is set by the server when the truncated `content`
|
|
2058
|
+
// ends with `... (truncated)`. Fall back to params.content otherwise so
|
|
2059
|
+
// small writes render as before.
|
|
2060
|
+
const fullContent = params.contentFull || params.content;
|
|
2061
|
+
const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
|
|
2062
|
+
const truncContent = isTruncated
|
|
2063
|
+
? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}${params.content.length > CONTENT_TRUNCATE_MAX ? '\n... (truncated)' : ''}`
|
|
1930
2064
|
: params.content;
|
|
1931
2065
|
let writeMoreBtn = '',
|
|
1932
2066
|
fullBlock = '';
|
|
1933
|
-
if (
|
|
1934
|
-
const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(
|
|
2067
|
+
if (isTruncated) {
|
|
2068
|
+
const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(fullContent), {
|
|
1935
2069
|
fontSize: '0.75rem',
|
|
1936
2070
|
maxHeight: '500px',
|
|
1937
2071
|
tinted: true,
|
|
@@ -2109,6 +2243,16 @@ async function postAndToast(url, body, label) {
|
|
|
2109
2243
|
async function openMsgInEditor() {
|
|
2110
2244
|
const m = getDetailMsg();
|
|
2111
2245
|
if (!m) return;
|
|
2246
|
+
// Write/Edit tool calls record the source path — open that directly instead
|
|
2247
|
+
// of dumping the rendered modal body into a temp buffer.
|
|
2248
|
+
const filePath =
|
|
2249
|
+
m.type === 'tool_use' && (m.tool === 'Write' || m.tool === 'Edit')
|
|
2250
|
+
? m.params?.file_path || m.fullDetail || null
|
|
2251
|
+
: null;
|
|
2252
|
+
if (filePath) {
|
|
2253
|
+
postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2112
2256
|
const title = m.type === 'tool_use' ? m.tool : m.compactSummary ? 'compact-summary' : m.type;
|
|
2113
2257
|
postAndToast('/api/open-in-editor', { content: getMessageDisplayContent(m), title }, 'in editor');
|
|
2114
2258
|
}
|
|
@@ -2463,7 +2607,6 @@ function renderSessions() {
|
|
|
2463
2607
|
// project filter → search filter → ensure pinned/sticky sessions are always included
|
|
2464
2608
|
let filteredSessions = sessions;
|
|
2465
2609
|
if (sessionFilter === 'active') {
|
|
2466
|
-
const now = Date.now();
|
|
2467
2610
|
const activeSessionIds = new Set();
|
|
2468
2611
|
filteredSessions = filteredSessions.filter((s) => {
|
|
2469
2612
|
if (dismissedSessionIds.has(s.id)) return false;
|
|
@@ -2472,8 +2615,7 @@ function renderSessions() {
|
|
|
2472
2615
|
((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
|
|
2473
2616
|
s.hasActiveAgents ||
|
|
2474
2617
|
s.hasWaitingForUser ||
|
|
2475
|
-
s.hasRecentLog
|
|
2476
|
-
(s.hasPlan && !s.planImplementationSessionId && now - new Date(s.modifiedAt).getTime() <= ACTIVE_PLAN_MS));
|
|
2618
|
+
s.hasRecentLog);
|
|
2477
2619
|
if (isActive) activeSessionIds.add(s.id);
|
|
2478
2620
|
return isActive;
|
|
2479
2621
|
});
|
|
@@ -2613,6 +2755,7 @@ function renderSessions() {
|
|
|
2613
2755
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
2614
2756
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
2615
2757
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
2758
|
+
${renderGoalSubtitle(session)}
|
|
2616
2759
|
<div class="session-progress">
|
|
2617
2760
|
<span class="session-indicators">
|
|
2618
2761
|
${isTeam ? `<span class="team-badge" title="${memberCount} team members"><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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
|
|
@@ -2715,6 +2858,7 @@ function renderSessions() {
|
|
|
2715
2858
|
const folderName = projectPath.split(/[/\\]/).pop();
|
|
2716
2859
|
const isCollapsed = collapsedProjectGroups.has(projectPath);
|
|
2717
2860
|
const escapedPath = escapeHtml(projectPath);
|
|
2861
|
+
const activeCount = projectSessions.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
2718
2862
|
const breadcrumbParts = projectPath
|
|
2719
2863
|
.replace(/^\/home\/[^/]+/, '~')
|
|
2720
2864
|
.split(/[/\\]/)
|
|
@@ -2727,7 +2871,7 @@ function renderSessions() {
|
|
|
2727
2871
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="${escapedPath}">
|
|
2728
2872
|
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
2729
2873
|
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
2730
|
-
<span class="group-count">${projectSessions.length}</span>
|
|
2874
|
+
<span class="group-count" title="${activeCount} active / ${projectSessions.length} total">${activeCount > 0 ? `<span class="group-count-active">${activeCount}</span><span class="group-count-sep">/</span>` : ''}${projectSessions.length}</span>
|
|
2731
2875
|
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
2732
2876
|
<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="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
2733
2877
|
</span>
|
|
@@ -2741,11 +2885,12 @@ function renderSessions() {
|
|
|
2741
2885
|
|
|
2742
2886
|
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
2743
2887
|
const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
|
|
2888
|
+
const ungroupedActive = ungrouped.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
2744
2889
|
html += `
|
|
2745
2890
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
|
|
2746
2891
|
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
2747
2892
|
<span class="group-name">Ungrouped</span>
|
|
2748
|
-
<span class="group-count">${ungrouped.length}</span>
|
|
2893
|
+
<span class="group-count" title="${ungroupedActive} active / ${ungrouped.length} total">${ungroupedActive > 0 ? `<span class="group-count-active">${ungroupedActive}</span><span class="group-count-sep">/</span>` : ''}${ungrouped.length}</span>
|
|
2749
2894
|
</div>
|
|
2750
2895
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
2751
2896
|
${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
|
|
@@ -5803,6 +5948,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
5803
5948
|
});
|
|
5804
5949
|
html += `</div>`;
|
|
5805
5950
|
|
|
5951
|
+
if (session.goal?.condition) {
|
|
5952
|
+
html += `<div class="info-goal-card">
|
|
5953
|
+
<div class="info-goal-head"><span class="info-goal-icon">◎</span>Goal</div>
|
|
5954
|
+
<div class="info-goal-text">${escapeHtml(session.goal.condition)}</div>
|
|
5955
|
+
</div>`;
|
|
5956
|
+
}
|
|
5957
|
+
|
|
5806
5958
|
if (session.contextStatus) {
|
|
5807
5959
|
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
5808
5960
|
html += renderContextDetail(session.contextStatus);
|
|
@@ -6044,6 +6196,16 @@ function renderLoopModalBody(data) {
|
|
|
6044
6196
|
body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
|
|
6045
6197
|
}
|
|
6046
6198
|
|
|
6199
|
+
function renderGoalSubtitle(session) {
|
|
6200
|
+
const g = session.goal;
|
|
6201
|
+
if (!g?.condition) return '';
|
|
6202
|
+
const short = g.condition.length > 70 ? `${g.condition.slice(0, 70)}…` : g.condition;
|
|
6203
|
+
// Only active (unmet) goals reach here — a met goal auto-clears. Clicking
|
|
6204
|
+
// opens the info modal (full text); stopPropagation so it doesn't also
|
|
6205
|
+
// trigger the card's fetchTasks.
|
|
6206
|
+
return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6047
6209
|
function renderLoopBadge(session) {
|
|
6048
6210
|
const li = session.loopInfo;
|
|
6049
6211
|
const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
|
|
@@ -6181,13 +6343,14 @@ function showToolStatsModal(sessionId) {
|
|
|
6181
6343
|
}
|
|
6182
6344
|
|
|
6183
6345
|
function renderToolStatsBody(data) {
|
|
6184
|
-
const { totalCalls, uniqueTools, totalFailed, tools } = data;
|
|
6346
|
+
const { totalCalls, uniqueTools, totalFailed, totalRejected, tools } = data;
|
|
6185
6347
|
|
|
6186
6348
|
const summary = `
|
|
6187
6349
|
<div class="tool-stats-summary">
|
|
6188
6350
|
<div class="tool-stats-chip"><span class="tool-stats-chip-val">${totalCalls}</span><span class="tool-stats-chip-lbl">Total calls</span></div>
|
|
6189
6351
|
<div class="tool-stats-chip"><span class="tool-stats-chip-val">${uniqueTools}</span><span class="tool-stats-chip-lbl">Unique tools</span></div>
|
|
6190
6352
|
<div class="tool-stats-chip"><span class="tool-stats-chip-val${totalFailed > 0 ? ' failed' : ''}">${totalFailed}</span><span class="tool-stats-chip-lbl">Failed</span></div>
|
|
6353
|
+
<div class="tool-stats-chip"><span class="tool-stats-chip-val${totalRejected > 0 ? ' rejected' : ''}">${totalRejected}</span><span class="tool-stats-chip-lbl">Rejected</span></div>
|
|
6191
6354
|
</div>`;
|
|
6192
6355
|
|
|
6193
6356
|
if (!tools?.length) {
|
|
@@ -6212,6 +6375,7 @@ function renderToolStatsBody(data) {
|
|
|
6212
6375
|
<th onclick="toolStatsSortBy('count')">Calls${arrow('count')}</th>
|
|
6213
6376
|
<th onclick="toolStatsSortBy('success')">✓ Success${arrow('success')}</th>
|
|
6214
6377
|
<th onclick="toolStatsSortBy('failed')">✗ Failed${arrow('failed')}</th>
|
|
6378
|
+
<th onclick="toolStatsSortBy('rejected')">⊘ Rejected${arrow('rejected')}</th>
|
|
6215
6379
|
<th onclick="toolStatsSortBy('impact')" title="Share of total tool output by character count">Impact${arrow('impact')}</th>
|
|
6216
6380
|
</tr></thead>
|
|
6217
6381
|
<tbody>${sorted
|
|
@@ -6221,6 +6385,7 @@ function renderToolStatsBody(data) {
|
|
|
6221
6385
|
<td>${t.count}</td>
|
|
6222
6386
|
<td>${t.success > 0 ? `<span class="badge-success">${t.success}</span>` : '—'}</td>
|
|
6223
6387
|
<td>${t.failed > 0 ? `<span class="badge-failed">${t.failed}</span>` : '—'}</td>
|
|
6388
|
+
<td>${t.rejected > 0 ? `<span class="badge-rejected">${t.rejected}</span>` : '—'}</td>
|
|
6224
6389
|
<td class="impact-cell">${
|
|
6225
6390
|
t.impact != null
|
|
6226
6391
|
? `<div class="impact-cell-inner"><div class="impact-bar-wrap"><div class="impact-bar-fill" style="width:${t.impact}%"></div></div><span class="impact-pct">${t.impact < 1 ? '<1' : t.impact}%</span></div>`
|
package/public/style.css
CHANGED
|
@@ -525,6 +525,24 @@ body::before {
|
|
|
525
525
|
text-overflow: ellipsis;
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
+
.session-goal {
|
|
529
|
+
font-size: 10px;
|
|
530
|
+
margin-top: 2px;
|
|
531
|
+
white-space: nowrap;
|
|
532
|
+
overflow: hidden;
|
|
533
|
+
text-overflow: ellipsis;
|
|
534
|
+
cursor: pointer;
|
|
535
|
+
color: var(--warning);
|
|
536
|
+
}
|
|
537
|
+
.session-goal .session-goal-icon {
|
|
538
|
+
margin-right: 3px;
|
|
539
|
+
font-weight: 700;
|
|
540
|
+
}
|
|
541
|
+
.session-goal:hover {
|
|
542
|
+
text-decoration: underline;
|
|
543
|
+
text-underline-offset: 2px;
|
|
544
|
+
}
|
|
545
|
+
|
|
528
546
|
.session-item.plan-reveal {
|
|
529
547
|
outline: 1.5px solid var(--plan);
|
|
530
548
|
background: var(--plan-dim);
|
|
@@ -1868,6 +1886,36 @@ body::before {
|
|
|
1868
1886
|
border-top: 1px solid var(--border);
|
|
1869
1887
|
}
|
|
1870
1888
|
|
|
1889
|
+
.info-goal-card {
|
|
1890
|
+
margin: 12px 0;
|
|
1891
|
+
padding: 10px 14px;
|
|
1892
|
+
background: var(--bg-elevated);
|
|
1893
|
+
border: 1px solid var(--border);
|
|
1894
|
+
border-left: 3px solid var(--warning);
|
|
1895
|
+
border-radius: 8px;
|
|
1896
|
+
}
|
|
1897
|
+
.info-goal-head {
|
|
1898
|
+
display: flex;
|
|
1899
|
+
align-items: center;
|
|
1900
|
+
gap: 6px;
|
|
1901
|
+
font-size: 11px;
|
|
1902
|
+
font-weight: 600;
|
|
1903
|
+
text-transform: uppercase;
|
|
1904
|
+
letter-spacing: 0.5px;
|
|
1905
|
+
color: var(--text-muted);
|
|
1906
|
+
}
|
|
1907
|
+
.info-goal-icon {
|
|
1908
|
+
color: var(--warning);
|
|
1909
|
+
}
|
|
1910
|
+
.info-goal-text {
|
|
1911
|
+
margin-top: 6px;
|
|
1912
|
+
font-size: 13px;
|
|
1913
|
+
line-height: 1.45;
|
|
1914
|
+
color: var(--text-primary);
|
|
1915
|
+
white-space: pre-wrap;
|
|
1916
|
+
word-break: break-word;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1871
1919
|
.info-grid {
|
|
1872
1920
|
display: grid;
|
|
1873
1921
|
grid-template-columns: auto 1fr auto;
|
|
@@ -2501,12 +2549,27 @@ body::before {
|
|
|
2501
2549
|
color: var(--danger, #e54d4d);
|
|
2502
2550
|
}
|
|
2503
2551
|
.msg-waiting .msg-text {
|
|
2552
|
+
font-weight: 500;
|
|
2553
|
+
}
|
|
2554
|
+
.msg-waiting-pill {
|
|
2555
|
+
display: inline-block;
|
|
2556
|
+
padding: 1px 6px;
|
|
2557
|
+
margin-right: 6px;
|
|
2558
|
+
border-radius: 3px;
|
|
2559
|
+
background: color-mix(in srgb, var(--warning, #f5a623) 22%, transparent);
|
|
2560
|
+
color: var(--warning, #f5a623);
|
|
2561
|
+
font-size: 10px;
|
|
2504
2562
|
font-weight: 600;
|
|
2563
|
+
text-transform: uppercase;
|
|
2564
|
+
letter-spacing: 0.3px;
|
|
2565
|
+
vertical-align: middle;
|
|
2505
2566
|
}
|
|
2506
|
-
.msg-waiting-
|
|
2507
|
-
font-size:
|
|
2508
|
-
|
|
2509
|
-
|
|
2567
|
+
.msg-waiting-body {
|
|
2568
|
+
font-size: 12px;
|
|
2569
|
+
margin-top: 4px;
|
|
2570
|
+
word-break: break-word;
|
|
2571
|
+
}
|
|
2572
|
+
.msg-waiting-body pre {
|
|
2510
2573
|
white-space: pre-wrap;
|
|
2511
2574
|
word-break: break-word;
|
|
2512
2575
|
}
|
|
@@ -3470,6 +3533,20 @@ select.form-input option:checked {
|
|
|
3470
3533
|
font-weight: 600;
|
|
3471
3534
|
}
|
|
3472
3535
|
|
|
3536
|
+
.badge-rejected {
|
|
3537
|
+
display: inline-block;
|
|
3538
|
+
background: rgba(245, 158, 11, 0.15);
|
|
3539
|
+
color: #f59e0b;
|
|
3540
|
+
border-radius: 4px;
|
|
3541
|
+
padding: 1px 6px;
|
|
3542
|
+
font-size: 12px;
|
|
3543
|
+
font-weight: 600;
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
.tool-stats-chip-val.rejected {
|
|
3547
|
+
color: #f59e0b;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3473
3550
|
.impact-bar-wrap {
|
|
3474
3551
|
width: 48px;
|
|
3475
3552
|
height: 6px;
|
|
@@ -3949,6 +4026,16 @@ pre.mermaid svg {
|
|
|
3949
4026
|
line-height: 1;
|
|
3950
4027
|
}
|
|
3951
4028
|
|
|
4029
|
+
.project-group-header .group-count-active {
|
|
4030
|
+
color: var(--success);
|
|
4031
|
+
font-weight: 500;
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
.project-group-header .group-count-sep {
|
|
4035
|
+
margin: 0 1px;
|
|
4036
|
+
opacity: 0.5;
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3952
4039
|
.project-group-header .group-path-toggle {
|
|
3953
4040
|
color: var(--text-muted);
|
|
3954
4041
|
cursor: pointer;
|
package/server.js
CHANGED
|
@@ -159,6 +159,10 @@ function isAgentFresh(agent) {
|
|
|
159
159
|
return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
function isAgentLive(agent) {
|
|
163
|
+
return agent.status === 'active' || agent.status === 'idle';
|
|
164
|
+
}
|
|
165
|
+
|
|
162
166
|
// Claude Code records gitBranch from the launch-time repo and never updates it
|
|
163
167
|
// when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
|
|
164
168
|
// the live cwd instead. Cached per-cwd with a short TTL so a list refresh
|
|
@@ -216,7 +220,7 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
|
216
220
|
for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
|
|
217
221
|
try {
|
|
218
222
|
const agent = readAgentJsonl(path.join(agentDir, file));
|
|
219
|
-
if (isTeam && (agent
|
|
223
|
+
if (isTeam && isAgentLive(agent)) {
|
|
220
224
|
result.hasActive = true;
|
|
221
225
|
if (agent.status === 'active') result.hasRunning = true;
|
|
222
226
|
} else if (isAgentFresh(agent)) {
|
|
@@ -456,6 +460,8 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
456
460
|
if (info.cwd) existing.cwd = info.cwd;
|
|
457
461
|
if (info.gitBranch) existing.gitBranch = info.gitBranch;
|
|
458
462
|
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
463
|
+
// Direct assign (not guarded) so a /goal clear propagates as null.
|
|
464
|
+
existing.goal = info.goal || null;
|
|
459
465
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
460
466
|
return true;
|
|
461
467
|
}
|
|
@@ -541,6 +547,7 @@ function loadSessionMetadata() {
|
|
|
541
547
|
cwd: sessionInfo.cwd || null,
|
|
542
548
|
gitBranch: sessionInfo.gitBranch || null,
|
|
543
549
|
customTitle: sessionInfo.customTitle || null,
|
|
550
|
+
goal: sessionInfo.goal || null,
|
|
544
551
|
jsonlPath: jsonlPath,
|
|
545
552
|
logicalParentUuid: sessionInfo.logicalParentUuid || null
|
|
546
553
|
};
|
|
@@ -690,6 +697,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
690
697
|
description: meta.description || null,
|
|
691
698
|
gitBranch: resolveSessionGitBranch(meta),
|
|
692
699
|
customTitle: meta.customTitle || null,
|
|
700
|
+
goal: meta.goal || null,
|
|
693
701
|
taskCount: 0,
|
|
694
702
|
completed: 0,
|
|
695
703
|
inProgress: 0,
|
|
@@ -729,6 +737,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
729
737
|
|
|
730
738
|
const pinnedParam = req.query.pinned;
|
|
731
739
|
const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
|
|
740
|
+
const activeFilter = req.query.filter === 'active';
|
|
732
741
|
|
|
733
742
|
const metadata = loadSessionMetadata();
|
|
734
743
|
const sessionsMap = new Map();
|
|
@@ -751,6 +760,24 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
751
760
|
const logAge = logMtime ? Date.now() - logMtime : Infinity;
|
|
752
761
|
const stale = logAge > AGENT_STALE_MS;
|
|
753
762
|
|
|
763
|
+
const isTeam = isTeamSession(entry.name);
|
|
764
|
+
const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
|
|
765
|
+
const resolvedAgentDir = path.join(AGENT_ACTIVITY_DIR, teamConfig?.leadSessionId || entry.name);
|
|
766
|
+
const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
|
|
767
|
+
|
|
768
|
+
// Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
|
|
769
|
+
// Mirrors the post-filter predicate using only signals already computed above.
|
|
770
|
+
if (activeFilter && !pinnedIds.has(entry.name)) {
|
|
771
|
+
const hasRecentLog = logAge <= SESSION_STALE_MS;
|
|
772
|
+
const cheaplyActive = logStat.hasMessages && (
|
|
773
|
+
hasRecentLog
|
|
774
|
+
|| agentStatus.hasActive
|
|
775
|
+
|| !!agentStatus.waitingForUser
|
|
776
|
+
|| (pending > 0 || inProgress > 0)
|
|
777
|
+
);
|
|
778
|
+
if (!cheaplyActive) continue;
|
|
779
|
+
}
|
|
780
|
+
|
|
754
781
|
// Use newest of: task file mtime, JSONL mtime, directory mtime
|
|
755
782
|
let modifiedAt = newestTaskMtime ? newestTaskMtime.toISOString() : stat.mtime.toISOString();
|
|
756
783
|
if (logMtime) {
|
|
@@ -758,17 +785,9 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
758
785
|
if (jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
|
|
759
786
|
}
|
|
760
787
|
|
|
761
|
-
const isTeam = isTeamSession(entry.name);
|
|
762
|
-
const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
|
|
763
788
|
const memberCount = teamConfig?.members?.length || 0;
|
|
764
789
|
const planInfo = getPlanInfo(meta.slug);
|
|
765
790
|
|
|
766
|
-
const resolvedAgentDir = (() => {
|
|
767
|
-
const rid = teamConfig?.leadSessionId || entry.name;
|
|
768
|
-
return path.join(AGENT_ACTIVITY_DIR, rid);
|
|
769
|
-
})();
|
|
770
|
-
const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
|
|
771
|
-
|
|
772
791
|
sessionsMap.set(entry.name, buildSessionObject(entry.name, meta, {
|
|
773
792
|
_logStat: logStat,
|
|
774
793
|
taskCount,
|
|
@@ -844,14 +863,24 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
844
863
|
const logMtime = logStat.mtime;
|
|
845
864
|
const logAge = logMtime ? Date.now() - logMtime : Infinity;
|
|
846
865
|
const stale = logAge > AGENT_STALE_MS;
|
|
866
|
+
const metaIsTeam = isTeamSession(sessionId);
|
|
867
|
+
const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
868
|
+
const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
|
|
869
|
+
|
|
870
|
+
// Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
|
|
871
|
+
if (activeFilter && !pinnedIds.has(sessionId)) {
|
|
872
|
+
const hasRecentLog = logAge <= SESSION_STALE_MS;
|
|
873
|
+
const cheaplyActive = logStat.hasMessages && (
|
|
874
|
+
hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
|
|
875
|
+
);
|
|
876
|
+
if (!cheaplyActive) continue;
|
|
877
|
+
}
|
|
878
|
+
|
|
847
879
|
let modifiedAt = meta.created || null;
|
|
848
880
|
if (logMtime) {
|
|
849
881
|
const jsonlMtime = new Date(logMtime).toISOString();
|
|
850
882
|
if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
|
|
851
883
|
}
|
|
852
|
-
const metaIsTeam = isTeamSession(sessionId);
|
|
853
|
-
const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
854
|
-
const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
|
|
855
884
|
sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
|
|
856
885
|
_logStat: logStat,
|
|
857
886
|
modifiedAt: modifiedAt || new Date(0).toISOString(),
|
|
@@ -900,16 +929,21 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
900
929
|
existing.memberCount = cfg.members?.length || 0;
|
|
901
930
|
existing.name = existing.name || cfg.name || dir.name;
|
|
902
931
|
teamLeaderIds.add(leaderId);
|
|
903
|
-
// Attach team-named task directory if present
|
|
932
|
+
// Attach team-named task directory if present.
|
|
933
|
+
// Prefer team task dir over an empty session-UUID task dir — when a team session has
|
|
934
|
+
// both a UUID-named dir (often empty: just .lock/.highwatermark) and a team-named dir
|
|
935
|
+
// holding the real tasks, the leader card otherwise shows 0/0.
|
|
904
936
|
const teamTaskDir = path.join(TASKS_DIR, dir.name);
|
|
905
|
-
if (
|
|
937
|
+
if (existsSync(teamTaskDir)) {
|
|
906
938
|
const counts = getTaskCounts(teamTaskDir);
|
|
907
|
-
existing.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
939
|
+
if (!existing.tasksDir || counts.taskCount > (existing.taskCount || 0)) {
|
|
940
|
+
existing.taskCount = counts.taskCount;
|
|
941
|
+
existing.completed = counts.completed;
|
|
942
|
+
existing.inProgress = counts.inProgress;
|
|
943
|
+
existing.pending = counts.pending;
|
|
944
|
+
existing.tasksDir = teamTaskDir;
|
|
945
|
+
existing.sharedTaskList = dir.name;
|
|
946
|
+
}
|
|
913
947
|
}
|
|
914
948
|
// Re-check agent status with isTeam=true
|
|
915
949
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
|
|
@@ -984,6 +1018,22 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
984
1018
|
}));
|
|
985
1019
|
}
|
|
986
1020
|
|
|
1021
|
+
// Server-side activity filter (mirrors the client predicate in public/app.js).
|
|
1022
|
+
// Pinned IDs bypass — they should always be in the response.
|
|
1023
|
+
if (activeFilter) {
|
|
1024
|
+
const isActive = (s) =>
|
|
1025
|
+
s.hasMessages && (
|
|
1026
|
+
(!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
|
|
1027
|
+
|| s.hasActiveAgents
|
|
1028
|
+
|| s.hasWaitingForUser
|
|
1029
|
+
|| s.hasRecentLog
|
|
1030
|
+
);
|
|
1031
|
+
for (const [id, s] of sessionsMap) {
|
|
1032
|
+
if (pinnedIds.has(id)) continue;
|
|
1033
|
+
if (!isActive(s)) sessionsMap.delete(id);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
987
1037
|
// Convert map to array and sort by most recently modified
|
|
988
1038
|
let sessions = Array.from(sessionsMap.values());
|
|
989
1039
|
sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
|
|
@@ -1242,7 +1292,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1242
1292
|
const agentTs = agent.updatedAt || agent.startedAt;
|
|
1243
1293
|
const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
|
|
1244
1294
|
if (!isAgentFresh(agent) || sessionStale || agentStale) {
|
|
1245
|
-
if (agent
|
|
1295
|
+
if (isAgentLive(agent)) {
|
|
1246
1296
|
const agentName = agentDisplayName(agent);
|
|
1247
1297
|
const isTeamMember = isTeam && agentName && teamMemberNames.has(agentName);
|
|
1248
1298
|
if (!isTeamMember) {
|
|
@@ -1254,7 +1304,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1254
1304
|
agents.push(agent);
|
|
1255
1305
|
} catch (e) { /* skip invalid */ }
|
|
1256
1306
|
}
|
|
1257
|
-
const liveAgents = agents.filter(
|
|
1307
|
+
const liveAgents = agents.filter(isAgentLive);
|
|
1258
1308
|
if (liveAgents.length && meta.jsonlPath) {
|
|
1259
1309
|
try {
|
|
1260
1310
|
const terminated = getTerminatedTeammates(meta.jsonlPath);
|
|
@@ -1280,7 +1330,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1280
1330
|
getSessionDigest(meta.jsonlPath);
|
|
1281
1331
|
if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
|
|
1282
1332
|
for (const agent of liveAgents) {
|
|
1283
|
-
if (agent
|
|
1333
|
+
if (!isAgentLive(agent)) continue;
|
|
1284
1334
|
let reason = null;
|
|
1285
1335
|
if (killedAgentIds.has(agent.agentId)) reason = 'killed-by-harness';
|
|
1286
1336
|
else if (rejectedAgentIds.has(agent.agentId) || (agent.prompt && rejectedPrompts.has(agent.prompt))) {
|
|
@@ -1298,13 +1348,16 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1298
1348
|
|
|
1299
1349
|
const dirty = new Set();
|
|
1300
1350
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1351
|
+
// Agents may be missing prompt/name/description because the parent's agent_progress
|
|
1352
|
+
// event or the subagent's own transcript hadn't been written yet at last poll. While
|
|
1353
|
+
// the agent is still active, keep retrying instead of latching *Unavailable permanently
|
|
1354
|
+
// (same pattern as agentsNeedingModel below). Each field shares the same resolve flow:
|
|
1355
|
+
// look up in progressMap by agentId, fall back to per-field extractor, persist only
|
|
1356
|
+
// on actual change.
|
|
1357
|
+
const byAgentId = {};
|
|
1358
|
+
const nameByAgentId = {};
|
|
1359
|
+
const descByAgentId = {};
|
|
1360
|
+
if (meta.jsonlPath) {
|
|
1308
1361
|
try {
|
|
1309
1362
|
const progressMap = getProgressMap(meta.jsonlPath);
|
|
1310
1363
|
for (const entry of Object.values(progressMap)) {
|
|
@@ -1313,22 +1366,34 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1313
1366
|
if (entry.description && !descByAgentId[entry.agentId]) descByAgentId[entry.agentId] = entry.description;
|
|
1314
1367
|
}
|
|
1315
1368
|
} catch (_) {}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1369
|
+
}
|
|
1370
|
+
const reconcileFields = [
|
|
1371
|
+
{
|
|
1372
|
+
field: 'prompt',
|
|
1373
|
+
flag: 'promptUnavailable',
|
|
1374
|
+
lookup: (a) => {
|
|
1375
|
+
if (byAgentId[a.agentId]) return byAgentId[a.agentId];
|
|
1376
|
+
try { return extractPromptFromTranscript(subagentJsonlPath(meta, a.agentId)); } catch (_) { return null; }
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
{ field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
|
|
1380
|
+
{ field: 'description', flag: 'descriptionUnavailable', lookup: (a) => descByAgentId[a.agentId] || null },
|
|
1381
|
+
];
|
|
1382
|
+
if (meta.jsonlPath) {
|
|
1383
|
+
for (const { field, flag, lookup } of reconcileFields) {
|
|
1384
|
+
for (const agent of agents) {
|
|
1385
|
+
if (agent[field]) continue;
|
|
1386
|
+
if (agent[flag] && !isAgentLive(agent)) continue;
|
|
1387
|
+
const value = lookup(agent);
|
|
1388
|
+
if (value) {
|
|
1389
|
+
agent[field] = value;
|
|
1390
|
+
delete agent[flag];
|
|
1391
|
+
dirty.add(agent);
|
|
1392
|
+
} else if (!isAgentLive(agent) && !agent[flag]) {
|
|
1393
|
+
agent[flag] = true;
|
|
1394
|
+
dirty.add(agent);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1332
1397
|
}
|
|
1333
1398
|
}
|
|
1334
1399
|
|
|
@@ -1758,7 +1823,7 @@ function buildToolStats(jsonlPath) {
|
|
|
1758
1823
|
if (!entry) continue;
|
|
1759
1824
|
const { displayName, isSkill } = entry;
|
|
1760
1825
|
seenResults.add(block.tool_use_id);
|
|
1761
|
-
if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, outputBytes: 0 };
|
|
1826
|
+
if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, rejected: 0, outputBytes: 0 };
|
|
1762
1827
|
toolMap[displayName].count++;
|
|
1763
1828
|
const raw = typeof block.content === 'string' ? block.content
|
|
1764
1829
|
: Array.isArray(block.content) ? block.content.map(b => b.text || '').join('\n') : '';
|
|
@@ -1771,13 +1836,17 @@ function buildToolStats(jsonlPath) {
|
|
|
1771
1836
|
skillPromptIds[promptId].push(displayName);
|
|
1772
1837
|
}
|
|
1773
1838
|
}
|
|
1774
|
-
const
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1839
|
+
const isRejected = typeof obj.toolUseResult === 'string' && /rejected/i.test(obj.toolUseResult);
|
|
1840
|
+
if (isRejected) toolMap[displayName].rejected++;
|
|
1841
|
+
else {
|
|
1842
|
+
const lower = raw.toLowerCase();
|
|
1843
|
+
const failed = /^error/i.test(raw.trimStart())
|
|
1844
|
+
|| /exit code [1-9]/.test(lower)
|
|
1845
|
+
|| lower.includes('command failed')
|
|
1846
|
+
|| (lower.includes('failed') && lower.includes('error'));
|
|
1847
|
+
if (failed) toolMap[displayName].failed++;
|
|
1848
|
+
else toolMap[displayName].success++;
|
|
1849
|
+
}
|
|
1781
1850
|
}
|
|
1782
1851
|
}
|
|
1783
1852
|
}
|
|
@@ -1785,7 +1854,7 @@ function buildToolStats(jsonlPath) {
|
|
|
1785
1854
|
// Count tool_use blocks that never got a tool_result
|
|
1786
1855
|
for (const [id, { displayName }] of Object.entries(toolUseById)) {
|
|
1787
1856
|
if (seenResults.has(id)) continue;
|
|
1788
|
-
if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, outputBytes: 0 };
|
|
1857
|
+
if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, rejected: 0, outputBytes: 0 };
|
|
1789
1858
|
toolMap[displayName].count++;
|
|
1790
1859
|
}
|
|
1791
1860
|
|
|
@@ -1797,10 +1866,11 @@ function buildToolStats(jsonlPath) {
|
|
|
1797
1866
|
}
|
|
1798
1867
|
}
|
|
1799
1868
|
|
|
1800
|
-
let totalCalls = 0, totalFailed = 0, totalOutputBytes = 0;
|
|
1869
|
+
let totalCalls = 0, totalFailed = 0, totalRejected = 0, totalOutputBytes = 0;
|
|
1801
1870
|
for (const s of Object.values(toolMap)) {
|
|
1802
1871
|
totalCalls += s.count;
|
|
1803
1872
|
totalFailed += s.failed;
|
|
1873
|
+
totalRejected += s.rejected;
|
|
1804
1874
|
totalOutputBytes += s.outputBytes || 0;
|
|
1805
1875
|
}
|
|
1806
1876
|
const uniqueTools = Object.keys(toolMap).length;
|
|
@@ -1809,10 +1879,10 @@ function buildToolStats(jsonlPath) {
|
|
|
1809
1879
|
for (const [name, stats] of Object.entries(toolMap)) {
|
|
1810
1880
|
const impact = totalOutputBytes > 0 ? Math.round((stats.outputBytes || 0) / totalOutputBytes * 100) : 0;
|
|
1811
1881
|
const displayName = name.startsWith('mcp__') ? name.split('__').slice(2).join('__') || name : name;
|
|
1812
|
-
tools.push({ name: displayName, count: stats.count, success: stats.success, failed: stats.failed, impact });
|
|
1882
|
+
tools.push({ name: displayName, count: stats.count, success: stats.success, failed: stats.failed, rejected: stats.rejected, impact });
|
|
1813
1883
|
}
|
|
1814
1884
|
|
|
1815
|
-
return { totalCalls, uniqueTools, totalFailed, tools };
|
|
1885
|
+
return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
|
|
1816
1886
|
}
|
|
1817
1887
|
|
|
1818
1888
|
app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
@@ -2402,13 +2472,38 @@ cleanupContextStatus();
|
|
|
2402
2472
|
setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
|
|
2403
2473
|
setInterval(cleanupContextStatus, 30 * 60 * 1000);
|
|
2404
2474
|
|
|
2405
|
-
|
|
2475
|
+
// Warm the metadata + loop-info caches in the background so the first user
|
|
2476
|
+
// request lands warm. The cheap-probe in /api/sessions skips per-session
|
|
2477
|
+
// enrichment for inactive sessions, so we no longer drive a full self-request
|
|
2478
|
+
// here — that was 690× wasted work for an active-filter first hit.
|
|
2479
|
+
// Yields to the event loop periodically so any inbound request isn't starved.
|
|
2480
|
+
async function prewarmCaches() {
|
|
2481
|
+
const t0 = Date.now();
|
|
2482
|
+
try {
|
|
2483
|
+
const metadata = loadSessionMetadata();
|
|
2484
|
+
|
|
2485
|
+
let i = 0;
|
|
2486
|
+
for (const meta of Object.values(metadata)) {
|
|
2487
|
+
if (meta?.jsonlPath) {
|
|
2488
|
+
try { refreshLoopInfoState(meta.jsonlPath); } catch {}
|
|
2489
|
+
}
|
|
2490
|
+
if (++i % 50 === 0) await new Promise(r => setImmediate(r));
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
|
|
2494
|
+
} catch (e) {
|
|
2495
|
+
console.warn('[prewarm] failed:', e.message);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const server = app.listen(PORT, () => {
|
|
2406
2500
|
const actualPort = server.address().port;
|
|
2407
2501
|
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
2408
2502
|
|
|
2409
2503
|
if (process.argv.includes('--open')) {
|
|
2410
2504
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
2411
2505
|
}
|
|
2506
|
+
setImmediate(prewarmCaches);
|
|
2412
2507
|
});
|
|
2413
2508
|
|
|
2414
2509
|
server.on('error', (err) => {
|
|
@@ -2421,6 +2516,7 @@ const server = app.listen(PORT, () => {
|
|
|
2421
2516
|
if (process.argv.includes('--open')) {
|
|
2422
2517
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
2423
2518
|
}
|
|
2519
|
+
setImmediate(prewarmCaches);
|
|
2424
2520
|
});
|
|
2425
2521
|
} else {
|
|
2426
2522
|
throw err;
|