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 (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
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.content) params.content = inp.content.length > TOOL_RESULT_MAX ? inp.content.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)' : inp.content;
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
- // For perf, we never ship the full text in the messages payload — when
666
- // truncated, the client lazy-fetches via /api/sessions/:id/tool-result/:toolUseId.
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
- msg.toolResult = truncated ? full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)' : full;
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.slice(0, 500);
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.slice(0, 500);
1070
+ if (b.type === 'text' && b.text) return b.text;
984
1071
  }
985
1072
  }
986
1073
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -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)[0:200] // ""),
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 sessionsPromise = fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}`).then((r) =>
168
- r.json(),
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 getWaitingPreview(toolInput) {
1157
- if (!toolInput) return '';
1158
- try {
1159
- const parsed = JSON.parse(toolInput);
1160
- if (parsed.questions?.[0]?.question) return parsed.questions[0].question;
1161
- if (parsed.plan) {
1162
- const t = parsed.plan.match(/^#\s+(.+)/m);
1163
- return t ? t[1] : parsed.plan.slice(0, WAITING_PLAN_PREVIEW_CHARS);
1164
- }
1165
- if (parsed.command) return parsed.command;
1166
- if (parsed.file_path) return parsed.file_path;
1167
- } catch (_) {
1168
- /* toolInput may be truncated/non-JSON */
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
- const label = getWaitingLabel(currentWaiting.kind, tool);
1177
- const preview = getWaitingPreview(currentWaiting.toolInput);
1178
- const previewHtml = preview
1179
- ? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
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()">${ICON_CHAT}<div class="msg-body"><div class="msg-text">${escapeHtml(label)}</div>${previewHtml}<div class="msg-time">waiting…</div></div>${discardBtn}</div>`;
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
- body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
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
- blocks.push({ k, display });
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
- 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></div>`;
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
- const contentTruncated = params.content.length > CONTENT_TRUNCATE_MAX;
1928
- const truncContent = contentTruncated
1929
- ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`
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 (contentTruncated) {
1934
- const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
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-preview {
2507
- font-size: 11px;
2508
- opacity: 0.85;
2509
- margin-top: 2px;
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.status === 'active' || agent.status === 'idle')) {
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 (!existing.tasksDir && existsSync(teamTaskDir)) {
937
+ if (existsSync(teamTaskDir)) {
906
938
  const counts = getTaskCounts(teamTaskDir);
907
- existing.taskCount = counts.taskCount;
908
- existing.completed = counts.completed;
909
- existing.inProgress = counts.inProgress;
910
- existing.pending = counts.pending;
911
- existing.tasksDir = teamTaskDir;
912
- existing.sharedTaskList = dir.name;
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.status === 'active' || agent.status === 'idle') {
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(a => a.status === 'active' || a.status === 'idle');
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.status !== 'active' && agent.status !== 'idle') continue;
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
- const agentsNeedingPrompt = agents.filter(a => !a.prompt && !a.promptUnavailable);
1302
- const agentsNeedingName = agents.filter(a => !a.agentName && !a.agentNameUnavailable);
1303
- const agentsNeedingDesc = agents.filter(a => !a.description && !a.descriptionUnavailable);
1304
- if ((agentsNeedingPrompt.length || agentsNeedingName.length || agentsNeedingDesc.length) && meta.jsonlPath) {
1305
- let byAgentId = {};
1306
- let nameByAgentId = {};
1307
- let descByAgentId = {};
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
- for (const agent of agentsNeedingPrompt) {
1317
- const prompt = byAgentId[agent.agentId]
1318
- || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
1319
- if (prompt) agent.prompt = prompt;
1320
- else agent.promptUnavailable = true;
1321
- dirty.add(agent);
1322
- }
1323
- for (const agent of agentsNeedingName) {
1324
- if (nameByAgentId[agent.agentId]) agent.agentName = nameByAgentId[agent.agentId];
1325
- else agent.agentNameUnavailable = true;
1326
- dirty.add(agent);
1327
- }
1328
- for (const agent of agentsNeedingDesc) {
1329
- if (descByAgentId[agent.agentId]) agent.description = descByAgentId[agent.agentId];
1330
- else agent.descriptionUnavailable = true;
1331
- dirty.add(agent);
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 lower = raw.toLowerCase();
1775
- const failed = /^error/i.test(raw.trimStart())
1776
- || /exit code [1-9]/.test(lower)
1777
- || lower.includes('command failed')
1778
- || (lower.includes('failed') && lower.includes('error'));
1779
- if (failed) toolMap[displayName].failed++;
1780
- else toolMap[displayName].success++;
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
- const server = app.listen(PORT, () => {
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;