claude-code-kanban 4.1.0 → 4.3.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
@@ -142,19 +142,35 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
142
142
  const customTitleCache = new Map();
143
143
  const CUSTOM_TITLE_SCAN_SIZE = 1048576; // 1MB max scan on first read
144
144
 
145
+ // Returns the best title found, in priority order:
146
+ // custom-title (user override) > ai-title > agent-name
147
+ // Background-session JSONLs created by the "claude agents" feature only emit
148
+ // ai-title/agent-name records; older sessions emit custom-title.
145
149
  function extractCustomTitleFromText(text) {
146
- if (!text.includes('"custom-title"')) return null;
150
+ const hasCustom = text.includes('"custom-title"');
151
+ const hasAi = text.includes('"ai-title"');
152
+ const hasAgent = text.includes('"agent-name"');
153
+ if (!hasCustom && !hasAi && !hasAgent) return null;
147
154
  const lines = text.split('\n');
155
+ let customTitle = null;
156
+ let aiTitle = null;
157
+ let agentName = null;
148
158
  for (let i = lines.length - 1; i >= 0; i--) {
149
- if (!lines[i].includes('"custom-title"')) continue;
159
+ const line = lines[i];
160
+ if (!line.includes('"custom-title"') && !line.includes('"ai-title"') && !line.includes('"agent-name"')) continue;
150
161
  try {
151
- const data = JSON.parse(lines[i]);
152
- if (data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
153
- return data.customTitle;
162
+ const data = JSON.parse(line);
163
+ if (!customTitle && data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
164
+ customTitle = data.customTitle;
165
+ } else if (!aiTitle && data.type === 'ai-title' && data.aiTitle && !data.aiTitle.startsWith('<')) {
166
+ aiTitle = data.aiTitle;
167
+ } else if (!agentName && data.type === 'agent-name' && data.agentName && !data.agentName.startsWith('<')) {
168
+ agentName = data.agentName;
154
169
  }
170
+ if (customTitle) break;
155
171
  } catch (e) {}
156
172
  }
157
- return null;
173
+ return customTitle || aiTitle || agentName || null;
158
174
  }
159
175
 
160
176
  function readCustomTitle(jsonlPath, existingStat) {
@@ -206,99 +222,147 @@ function scrapeScalarFromBlob(blob, re) {
206
222
  const sessionInfoCache = new Map();
207
223
  const SESSION_INFO_CACHE_MAX = 2000;
208
224
 
225
+ // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
226
+ // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
227
+ // live branch must resolve it from cwd separately. Cache is reset on inode
228
+ // change or truncation (size < scannedUpTo).
209
229
  function readSessionInfoFromJsonl(jsonlPath) {
210
230
  const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
211
231
  let stat;
212
232
  let fd;
213
233
  try {
214
234
  stat = statSync(jsonlPath);
215
- const cached = sessionInfoCache.get(jsonlPath);
216
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
217
- return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
218
- }
219
235
  } catch (_) {
220
236
  return result;
221
237
  }
222
- // State shared across head-chunk parse, leftover flush, and tail parse.
223
- let lastCwdFromHead = null;
238
+
239
+ const cached = sessionInfoCache.get(jsonlPath);
240
+ const inodeMatch = cached && cached.ino === stat.ino;
241
+ const sizeOK = cached && stat.size >= cached.scannedUpTo;
242
+ const canIncrement = inodeMatch && sizeOK;
243
+
244
+ if (canIncrement && stat.size === cached.scannedUpTo) {
245
+ return {
246
+ slug: cached.slug,
247
+ projectPath: cached.projectPath,
248
+ cwd: cached.cwd,
249
+ gitBranch: cached.gitBranch,
250
+ customTitle: readCustomTitle(jsonlPath, stat)
251
+ };
252
+ }
253
+
254
+ if (canIncrement) {
255
+ result.slug = cached.slug;
256
+ result.projectPath = cached.projectPath;
257
+ result.cwd = cached.cwd;
258
+ result.gitBranch = cached.gitBranch;
259
+ }
260
+
261
+ let lastCwdSeen = result.cwd;
224
262
  const applyLine = (line) => {
225
263
  try {
226
264
  const data = JSON.parse(line);
227
- if (data.slug) result.slug = data.slug;
265
+ if (data.slug && !result.slug) result.slug = data.slug;
228
266
  if (data.cwd) {
229
267
  if (!result.projectPath) result.projectPath = data.cwd;
230
- lastCwdFromHead = data.cwd;
268
+ lastCwdSeen = data.cwd;
231
269
  }
232
270
  if (data.gitBranch) result.gitBranch = data.gitBranch;
233
271
  } catch (e) {}
234
272
  };
273
+
274
+ const CHUNK_SIZE = 16384;
275
+ const TAIL_SIZE = 16384;
276
+ const HEAD_MAX = 1048576;
277
+ let scannedUpTo = canIncrement ? cached.scannedUpTo : 0;
278
+
235
279
  try {
236
280
  fd = fs.openSync(jsonlPath, 'r');
237
- const CHUNK_SIZE = 16384;
238
- const TAIL_SIZE = 16384;
239
- // Stream head in chunks with StringDecoder to preserve multi-byte UTF-8
240
- // codepoints across chunk boundaries. Scan up to HEAD_MAX so the last cwd
241
- // in the window wins — sessions that change cwd mid-run must surface it.
242
- const HEAD_MAX = 1048576;
243
- const decoder = new StringDecoder('utf8');
244
- const buf = Buffer.alloc(CHUNK_SIZE);
245
- let leftover = '';
246
- let offset = 0;
247
- while (offset < stat.size && offset < HEAD_MAX) {
248
- const len = Math.min(CHUNK_SIZE, stat.size - offset);
249
- const n = fs.readSync(fd, buf, 0, len, offset);
250
- if (n === 0) break;
251
- offset += n;
252
- const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
253
- const lines = text.split('\n');
254
- leftover = lines.pop();
255
- for (const line of lines) applyLine(line);
256
- }
257
- leftover += decoder.end();
258
- if (leftover) applyLine(leftover);
259
- // Oversized first line (e.g. multi-MB inline image) left leftover unparsed
260
- // above; scrape scalars out of it so we don't fall through to the tail and
261
- // pick a mid-session cwd as projectPath.
262
- if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
263
- const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
264
- if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdFromHead = scrapedCwd; }
265
- if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
266
- if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
267
- }
268
281
 
269
- result.cwd = lastCwdFromHead;
270
-
271
- // Tail scan: always runs when the file extends past the head window, so
272
- // `cwd` reflects the latest value even in long sessions where a late cwd
273
- // switch sits past HEAD_MAX. Also backfills projectPath/slug/gitBranch
274
- // when the head couldn't find them. projectPath is NOT overwritten — it
275
- // stays anchored to the earliest cwd seen.
276
- if (stat.size > offset) {
277
- const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
278
- const tailBuf = Buffer.alloc(TAIL_SIZE);
279
- const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
280
- const lines = tailBuf.toString('utf8', 0, tn).split('\n');
281
- let latestTailCwd = null;
282
- for (let i = lines.length - 1; i >= 0; i--) {
283
- try {
284
- const data = JSON.parse(lines[i]);
285
- if (!result.slug && data.slug) result.slug = data.slug;
286
- if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
287
- if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
288
- if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
289
- if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
290
- } catch (e) {}
282
+ if (canIncrement) {
283
+ // Each conversational JSONL line carries `cwd`, so a mid-session `cd`
284
+ // surfaces in any tail window we don't need the whole delta.
285
+ const DELTA_MAX = 1048576;
286
+ const deltaLen = stat.size - cached.scannedUpTo;
287
+ if (deltaLen > 0) {
288
+ const readLen = Math.min(deltaLen, DELTA_MAX);
289
+ const readStart = stat.size - readLen;
290
+ const buf = Buffer.alloc(readLen);
291
+ const n = fs.readSync(fd, buf, 0, readLen, readStart);
292
+ if (n > 0) {
293
+ const text = buf.toString('utf8', 0, n);
294
+ const lastNl = text.lastIndexOf('\n');
295
+ const complete = lastNl >= 0 ? text.slice(0, lastNl) : '';
296
+ const lines = complete.split('\n');
297
+ for (const line of lines) if (line) applyLine(line);
298
+ }
299
+ scannedUpTo = stat.size;
300
+ }
301
+ } else {
302
+ const decoder = new StringDecoder('utf8');
303
+ const buf = Buffer.alloc(CHUNK_SIZE);
304
+ let leftover = '';
305
+ let offset = 0;
306
+ while (offset < stat.size && offset < HEAD_MAX) {
307
+ const len = Math.min(CHUNK_SIZE, stat.size - offset);
308
+ const n = fs.readSync(fd, buf, 0, len, offset);
309
+ if (n === 0) break;
310
+ offset += n;
311
+ const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
312
+ const lines = text.split('\n');
313
+ leftover = lines.pop();
314
+ for (const line of lines) applyLine(line);
315
+ }
316
+ leftover += decoder.end();
317
+ if (leftover) applyLine(leftover);
318
+ // Oversized first line (e.g. multi-MB inline image) — scrape scalars so
319
+ // we don't fall through and pick a mid-session cwd as projectPath.
320
+ if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
321
+ const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
322
+ if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdSeen = scrapedCwd; }
323
+ if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
324
+ if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
325
+ }
326
+
327
+ // Tail scan catches late cwd switches past HEAD_MAX. projectPath stays
328
+ // anchored to the earliest cwd — it is never overwritten here.
329
+ if (stat.size > offset) {
330
+ const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
331
+ const tailBuf = Buffer.alloc(TAIL_SIZE);
332
+ const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
333
+ const lines = tailBuf.toString('utf8', 0, tn).split('\n');
334
+ let latestTailCwd = null;
335
+ for (let i = lines.length - 1; i >= 0; i--) {
336
+ try {
337
+ const data = JSON.parse(lines[i]);
338
+ if (!result.slug && data.slug) result.slug = data.slug;
339
+ if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
340
+ if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
341
+ if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
342
+ if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
343
+ } catch (e) {}
344
+ }
345
+ if (latestTailCwd) lastCwdSeen = latestTailCwd;
291
346
  }
292
- if (latestTailCwd) result.cwd = latestTailCwd;
347
+ scannedUpTo = stat.size;
293
348
  }
294
349
  } catch (e) {
295
350
  } finally {
296
351
  if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
297
352
  }
298
353
 
354
+ result.cwd = lastCwdSeen;
355
+
299
356
  if (stat) {
300
- const { customTitle: _ct, ...rest } = result;
301
- sessionInfoCache.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, result: rest });
357
+ sessionInfoCache.set(jsonlPath, {
358
+ ino: stat.ino,
359
+ size: stat.size,
360
+ scannedUpTo,
361
+ slug: result.slug,
362
+ projectPath: result.projectPath,
363
+ gitBranch: result.gitBranch,
364
+ cwd: result.cwd
365
+ });
302
366
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
303
367
  const firstKey = sessionInfoCache.keys().next().value;
304
368
  sessionInfoCache.delete(firstKey);
@@ -953,6 +1017,121 @@ function extractModelFromTranscript(jsonlPath) {
953
1017
  return null;
954
1018
  }
955
1019
 
1020
+ // Incremental loop-tool scanner. JSONL is append-only, so we keep per-path
1021
+ // state and on each call read ONLY the bytes appended since scannedOffset.
1022
+ // Avoids the only full-file read that ran inside the /api/sessions hot path.
1023
+ //
1024
+ // State shape: { mtimeMs, size, scannedOffset, wakeups[], crons[],
1025
+ // taskIdByToolUseId<Map>, deletedTaskIds<Set> }
1026
+ const EMPTY_LOOP_STATE = () => ({
1027
+ mtimeMs: 0, size: 0, scannedOffset: 0,
1028
+ wakeups: [], crons: [],
1029
+ taskIdByToolUseId: new Map(), deletedTaskIds: new Set()
1030
+ });
1031
+
1032
+ function processLoopLine(line, state) {
1033
+ if (!line) return;
1034
+ const hasToolResult = line.includes('"tool_use_id"');
1035
+ const hasLoopTool = line.includes('"ScheduleWakeup"')
1036
+ || line.includes('"CronCreate"')
1037
+ || line.includes('"CronDelete"');
1038
+ if (!hasToolResult && !hasLoopTool) return;
1039
+ let obj;
1040
+ try { obj = JSON.parse(line); } catch (_) { return; }
1041
+ const content = obj?.message?.content;
1042
+ if (!Array.isArray(content)) return;
1043
+ for (const b of content) {
1044
+ if (!b) continue;
1045
+ if (b.type === 'tool_result' && b.tool_use_id) {
1046
+ const tid = obj.toolUseResult?.id;
1047
+ if (tid) state.taskIdByToolUseId.set(b.tool_use_id, tid);
1048
+ } else if (b.type === 'tool_use') {
1049
+ const inp = b.input || {};
1050
+ if (b.name === 'ScheduleWakeup') {
1051
+ state.wakeups.push({
1052
+ id: b.id || null,
1053
+ timestamp: obj.timestamp || null,
1054
+ delaySeconds: typeof inp.delaySeconds === 'number' ? inp.delaySeconds : null,
1055
+ reason: inp.reason || null,
1056
+ prompt: inp.prompt || null
1057
+ });
1058
+ } else if (b.name === 'CronCreate') {
1059
+ state.crons.push({
1060
+ id: b.id || null,
1061
+ taskId: null,
1062
+ timestamp: obj.timestamp || null,
1063
+ cron: inp.cron || inp.cronExpression || null,
1064
+ prompt: inp.prompt || null,
1065
+ description: inp.description || inp.reason || null
1066
+ });
1067
+ } else if (b.name === 'CronDelete') {
1068
+ const ids = inp.id ? [inp.id] : (Array.isArray(inp.ids) ? inp.ids : []);
1069
+ for (const i of ids) state.deletedTaskIds.add(i);
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+
1075
+ // Reads bytes appended to jsonlPath since prev.scannedOffset and merges into
1076
+ // state. Pass `null` (or undefined) for prev to do a one-time full scan.
1077
+ function updateLoopInfo(jsonlPath, prev) {
1078
+ let stat;
1079
+ try { stat = statSync(jsonlPath); } catch (_) { return prev || EMPTY_LOOP_STATE(); }
1080
+
1081
+ let state = prev;
1082
+ // Cold start OR file shrank (truncate/replace) → rescan from beginning.
1083
+ if (!state || stat.size < state.size) {
1084
+ state = EMPTY_LOOP_STATE();
1085
+ } else if (state.mtimeMs === stat.mtimeMs && state.size === stat.size) {
1086
+ return state; // unchanged
1087
+ }
1088
+
1089
+ if (stat.size <= state.scannedOffset) {
1090
+ state.mtimeMs = stat.mtimeMs;
1091
+ state.size = stat.size;
1092
+ return state;
1093
+ }
1094
+
1095
+ let fd;
1096
+ try {
1097
+ fd = fs.openSync(jsonlPath, 'r');
1098
+ const len = stat.size - state.scannedOffset;
1099
+ const buf = Buffer.alloc(len);
1100
+ const n = fs.readSync(fd, buf, 0, len, state.scannedOffset);
1101
+ const text = buf.toString('utf8', 0, n);
1102
+ const lastNl = text.lastIndexOf('\n');
1103
+ if (lastNl < 0) {
1104
+ // No complete line yet — leave scannedOffset alone, update mtime only.
1105
+ state.mtimeMs = stat.mtimeMs;
1106
+ return state;
1107
+ }
1108
+ const complete = text.slice(0, lastNl);
1109
+ for (const line of complete.split('\n')) processLoopLine(line, state);
1110
+ state.scannedOffset += Buffer.byteLength(complete, 'utf8') + 1;
1111
+ state.mtimeMs = stat.mtimeMs;
1112
+ state.size = stat.size;
1113
+ } catch (_) {
1114
+ // leave state as-is
1115
+ } finally {
1116
+ if (fd != null) { try { fs.closeSync(fd); } catch (_) {} }
1117
+ }
1118
+ return state;
1119
+ }
1120
+
1121
+ function buildLoopInfoFromState(state) {
1122
+ if (!state) return { wakeups: [], crons: [] };
1123
+ // Resolve pending taskIds in place — taskIdByToolUseId is monotonic, so
1124
+ // once resolved an entry stays resolved and we skip the spread-copy on
1125
+ // every subsequent call.
1126
+ for (const c of state.crons) {
1127
+ if (!c.taskId) c.taskId = state.taskIdByToolUseId.get(c.id) || null;
1128
+ }
1129
+ const crons = state.deletedTaskIds.size
1130
+ ? state.crons.filter(c => !c.taskId || !state.deletedTaskIds.has(c.taskId))
1131
+ : state.crons;
1132
+ return { wakeups: state.wakeups, crons };
1133
+ }
1134
+
956
1135
  module.exports = {
957
1136
  parseTask,
958
1137
  parseAgent,
@@ -965,6 +1144,8 @@ module.exports = {
965
1144
  readMessagesPage,
966
1145
  readFullToolResult,
967
1146
  readUserImage,
1147
+ updateLoopInfo,
1148
+ buildLoopInfoFromState,
968
1149
  buildAgentProgressMap,
969
1150
  buildSessionDigest,
970
1151
  readCompactSummaries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.1.0",
3
+ "version": "4.3.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": {
package/public/app.js CHANGED
@@ -604,6 +604,7 @@ async function fetchAgents(sessionId) {
604
604
  if (waitHash !== lastWaitingHash) {
605
605
  lastWaitingHash = waitHash;
606
606
  if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
607
+ maybeFollowLatest();
607
608
  }
608
609
  } catch (e) {
609
610
  console.error('[fetchAgents]', e);
@@ -1159,16 +1160,33 @@ function getWaitingPreview(toolInput) {
1159
1160
  }
1160
1161
 
1161
1162
  function renderWaitingEntry() {
1162
- if (!currentWaiting?.timestamp) return '';
1163
- const age = Date.now() - new Date(currentWaiting.timestamp).getTime();
1164
- if (age >= WAITING_TTL_MS) return '';
1163
+ if (!isWaitingFresh()) return '';
1165
1164
  const tool = currentWaiting.toolName || 'unknown';
1166
1165
  const label = getWaitingLabel(currentWaiting.kind, tool);
1167
1166
  const preview = getWaitingPreview(currentWaiting.toolInput);
1168
1167
  const previewHtml = preview
1169
1168
  ? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
1170
1169
  : '';
1171
- return `<div class="msg-item msg-waiting">${ICON_CHAT}<div class="msg-body"><div class="msg-text">${escapeHtml(label)}</div>${previewHtml}<div class="msg-time">waiting…</div></div></div>`;
1170
+ 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>`;
1171
+ 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>`;
1172
+ }
1173
+
1174
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
1175
+ async function discardWaiting() {
1176
+ if (!currentSessionId) return;
1177
+ try {
1178
+ const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
1179
+ method: 'POST',
1180
+ });
1181
+ if (res.ok) {
1182
+ currentWaiting = null;
1183
+ if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) closeMsgDetailModal();
1184
+ renderMessages(currentMessages);
1185
+ renderAgentFooter();
1186
+ }
1187
+ } catch (e) {
1188
+ console.error('[discardWaiting]', e);
1189
+ }
1172
1190
  }
1173
1191
 
1174
1192
  function renderMessages(messages) {
@@ -1199,6 +1217,7 @@ function renderMessages(messages) {
1199
1217
 
1200
1218
  let currentMsgDetailIdx = null;
1201
1219
  let msgDetailFollowLatest = false;
1220
+ const MSG_DETAIL_WAITING_IDX = -2;
1202
1221
  let currentPins = [];
1203
1222
  let pinnedCollapsed = false;
1204
1223
 
@@ -2137,7 +2156,7 @@ function renderAgentFooter() {
2137
2156
  )
2138
2157
  .slice(0, AGENT_LOG_MAX);
2139
2158
 
2140
- const permFresh = currentWaiting?.timestamp && now - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
2159
+ const permFresh = isWaitingFresh();
2141
2160
 
2142
2161
  if (visible.length === 0 && !permFresh) {
2143
2162
  footer.classList.remove('visible');
@@ -2490,16 +2509,25 @@ function renderSessions() {
2490
2509
  filteredSessions = filteredSessions.filter(matchesSearch);
2491
2510
 
2492
2511
  // Re-add pinned/sticky sessions that match the query but were excluded by active filter
2493
- if (activityFilter.size === 0 && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2512
+ if (
2513
+ sessionFilter === 'active' &&
2514
+ activityFilter.size === 0 &&
2515
+ (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)
2516
+ ) {
2494
2517
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2495
2518
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
2496
2519
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
2497
2520
  }
2498
2521
  }
2499
2522
 
2500
- // Include pinned/sticky sessions even if they don't match active/recent filter
2501
- // (skipped when an activity chip filter is on user explicitly asked for a slice)
2502
- if (activityFilter.size === 0 && !searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2523
+ // Include pinned/sticky sessions even if they don't match the active filter.
2524
+ // Only in the "active" view the "all" view is a plain list with no pin prioritization.
2525
+ if (
2526
+ sessionFilter === 'active' &&
2527
+ activityFilter.size === 0 &&
2528
+ !searchQuery &&
2529
+ (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)
2530
+ ) {
2503
2531
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2504
2532
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
2505
2533
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
@@ -2580,6 +2608,7 @@ function renderSessions() {
2580
2608
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2581
2609
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2582
2610
  ${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
2611
+ ${renderLoopBadge(session)}
2583
2612
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2584
2613
  ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
2585
2614
  ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
@@ -2717,31 +2746,7 @@ function renderSessions() {
2717
2746
 
2718
2747
  sessionsList.innerHTML = html;
2719
2748
  } else {
2720
- const sticky = filteredSessions.filter((s) => isPlacedSticky(s.id));
2721
- const idlePinned = filteredSessions.filter((s) => isPlacedPinned(s.id) && !isSessionActive(s));
2722
- const rest = filteredSessions.filter(
2723
- (s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
2724
- );
2725
- let html = '';
2726
- if (sticky.length > 0) {
2727
- html += sticky.map(renderSessionCard).join('');
2728
- }
2729
- const isCollapsed = collapsedProjectGroups.has('__pinned__');
2730
- const hasPinned = pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id));
2731
- if (idlePinned.length > 0 || (hasPinned && isCollapsed)) {
2732
- html += `
2733
- <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__pinned__">
2734
- <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>
2735
- <span class="group-name">Pinned</span>
2736
- <span class="group-count">${idlePinned.length}</span>
2737
- </div>
2738
- <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
2739
- ${idlePinned.map(renderSessionCard).join('')}
2740
- </div>
2741
- `;
2742
- }
2743
- html += rest.map(renderSessionCard).join('');
2744
- sessionsList.innerHTML = html;
2749
+ sessionsList.innerHTML = filteredSessions.map(renderSessionCard).join('');
2745
2750
  }
2746
2751
 
2747
2752
  const navItems = getNavigableItems();
@@ -4251,7 +4256,7 @@ function matchKey(e, ...keys) {
4251
4256
  return keys.some((k) => e.key === k || e.code === k);
4252
4257
  }
4253
4258
 
4254
- const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
4259
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal', 'loop-modal'];
4255
4260
  const MODAL_CLOSERS = {
4256
4261
  'preview-modal': () => closePreviewModal(),
4257
4262
  'msg-detail-modal': () => {
@@ -4259,6 +4264,7 @@ const MODAL_CLOSERS = {
4259
4264
  msgDetailFollowLatest = false;
4260
4265
  },
4261
4266
  'plan-modal': () => closePlanModal(),
4267
+ 'loop-modal': () => closeLoopModal(),
4262
4268
  'team-modal': () => closeTeamModal(),
4263
4269
  'agent-modal': () => closeAgentModal(),
4264
4270
  'help-modal': () => closeHelpModal(),
@@ -4293,7 +4299,13 @@ document.addEventListener('keydown', (e) => {
4293
4299
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
4294
4300
  if (matchKey(e, 'ArrowDown', 'KeyJ')) {
4295
4301
  e.preventDefault();
4296
- if (currentMsgDetailIdx < currentMessages.length - 1) {
4302
+ if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
4303
+ msgDetailFollowLatest = true;
4304
+ showWaitingDetail();
4305
+ } else if (currentMsgDetailIdx === currentMessages.length - 1 && isWaitingFresh()) {
4306
+ msgDetailFollowLatest = false;
4307
+ showWaitingDetail();
4308
+ } else if (currentMsgDetailIdx < currentMessages.length - 1) {
4297
4309
  msgDetailFollowLatest = false;
4298
4310
  showMsgDetail(currentMsgDetailIdx + 1);
4299
4311
  } else if (currentMsgDetailIdx === currentMessages.length - 1) {
@@ -4302,7 +4314,12 @@ document.addEventListener('keydown', (e) => {
4302
4314
  }
4303
4315
  } else if (matchKey(e, 'ArrowUp', 'KeyK')) {
4304
4316
  e.preventDefault();
4305
- if (currentMsgDetailIdx > 0) {
4317
+ if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
4318
+ if (currentMessages.length) {
4319
+ msgDetailFollowLatest = false;
4320
+ showMsgDetail(currentMessages.length - 1);
4321
+ }
4322
+ } else if (currentMsgDetailIdx > 0) {
4306
4323
  msgDetailFollowLatest = false;
4307
4324
  showMsgDetail(currentMsgDetailIdx - 1);
4308
4325
  }
@@ -4327,6 +4344,9 @@ document.addEventListener('keydown', (e) => {
4327
4344
  const msgDetailModal = document.getElementById('msg-detail-modal');
4328
4345
  if (msgDetailModal.classList.contains('visible')) {
4329
4346
  closeMsgDetailModal();
4347
+ } else if (isWaitingFresh()) {
4348
+ msgDetailFollowLatest = true;
4349
+ showWaitingDetail();
4330
4350
  } else if (currentMessages.length) {
4331
4351
  msgDetailFollowLatest = true;
4332
4352
  showMsgDetail(currentMessages.length - 1);
@@ -5091,11 +5111,50 @@ function renderContextDetail(raw) {
5091
5111
 
5092
5112
  //#region UTILS
5093
5113
  function maybeFollowLatest() {
5094
- if (msgDetailFollowLatest && currentMessages.length) {
5114
+ if (!msgDetailFollowLatest) return;
5115
+ if (isWaitingFresh()) {
5116
+ showWaitingDetail();
5117
+ } else if (currentMessages.length) {
5095
5118
  showMsgDetail(currentMessages.length - 1);
5096
5119
  }
5097
5120
  }
5098
5121
 
5122
+ function isWaitingFresh() {
5123
+ if (!currentWaiting?.timestamp) return false;
5124
+ return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
5125
+ }
5126
+
5127
+ function showWaitingDetail() {
5128
+ if (!isWaitingFresh()) return;
5129
+ currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
5130
+ const tool = currentWaiting.toolName || 'unknown';
5131
+ const label = getWaitingLabel(currentWaiting.kind, tool);
5132
+ const body = document.getElementById('msg-detail-body');
5133
+ let inputHtml = '';
5134
+ if (currentWaiting.toolInput) {
5135
+ let pretty = currentWaiting.toolInput;
5136
+ try {
5137
+ pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
5138
+ } catch (_) {
5139
+ /* keep raw */
5140
+ }
5141
+ inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
5142
+ }
5143
+ body.innerHTML = inputHtml;
5144
+ document.getElementById('msg-detail-title').textContent = label;
5145
+ document.getElementById('msg-detail-agent-btn').style.display = 'none';
5146
+ const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
5147
+ autoSizeModal(modal, body);
5148
+ modal.classList.toggle('live', msgDetailFollowLatest);
5149
+ const overlay = document.getElementById('msg-detail-modal');
5150
+ overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
5151
+ const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
5152
+ document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
5153
+ currentPinDetailId = null;
5154
+ updateMsgDetailPinState();
5155
+ overlay.classList.add('visible');
5156
+ }
5157
+
5099
5158
  function isSessionActive(s) {
5100
5159
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
5101
5160
  }
@@ -5884,6 +5943,110 @@ function refreshOpenPlan() {
5884
5943
  .catch(() => {});
5885
5944
  }
5886
5945
 
5946
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5947
+ function showLoopModal(sessionId) {
5948
+ const body = document.getElementById('loop-modal-body');
5949
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
5950
+ document.getElementById('loop-modal').classList.add('visible');
5951
+ fetch(`/api/sessions/${sessionId}/loop`)
5952
+ .then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
5953
+ .catch(() => ({ wakeups: [], crons: [] }))
5954
+ .then((data) => {
5955
+ renderLoopModalBody(data);
5956
+ });
5957
+ }
5958
+
5959
+ function fmtLoopDelay(s) {
5960
+ if (s == null) return '';
5961
+ if (s < 60) return `${s}s`;
5962
+ if (s < 3600) return `${Math.round(s / 60)}m`;
5963
+ return `${(s / 3600).toFixed(1)}h`;
5964
+ }
5965
+
5966
+ function fmtLoopFireTime(timestamp, delaySeconds) {
5967
+ if (!timestamp || delaySeconds == null) return { abs: '', rel: '', status: '' };
5968
+ const fireMs = new Date(timestamp).getTime() + delaySeconds * 1000;
5969
+ const fireDate = new Date(fireMs);
5970
+ const diff = fireMs - Date.now();
5971
+ const abs = fireDate.toLocaleString(undefined, {
5972
+ month: 'short',
5973
+ day: 'numeric',
5974
+ hour: '2-digit',
5975
+ minute: '2-digit',
5976
+ });
5977
+ const absSec = Math.abs(Math.round(diff / 1000));
5978
+ const rel =
5979
+ absSec < 60 ? `${absSec}s` : absSec < 3600 ? `${Math.round(absSec / 60)}m` : `${(absSec / 3600).toFixed(1)}h`;
5980
+ if (diff > 0) return { abs, rel: `in ${rel}`, status: 'pending' };
5981
+ return { abs, rel: `${rel} ago`, status: 'fired' };
5982
+ }
5983
+
5984
+ const LOOP_CLOCK_SVG =
5985
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
5986
+ const LOOP_CRON_SVG =
5987
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="10"/></svg>';
5988
+
5989
+ function loopField(label, value, mono = false) {
5990
+ if (!value) return '';
5991
+ const inner = mono ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value);
5992
+ return `<div class="loop-field"><div class="loop-field-label">${label}</div><div class="loop-field-val">${inner}</div></div>`;
5993
+ }
5994
+
5995
+ function renderLoopRow(item, kind) {
5996
+ const when = item.timestamp ? formatDate(item.timestamp) : '';
5997
+ let headline = '';
5998
+ let footer = '';
5999
+ let fields = '';
6000
+ if (kind === 'wakeup') {
6001
+ const fire = fmtLoopFireTime(item.timestamp, item.delaySeconds);
6002
+ const delayLbl = item.delaySeconds != null ? `delay ${fmtLoopDelay(item.delaySeconds)}` : '';
6003
+ if (fire.abs) {
6004
+ headline = `<div class="loop-headline loop-fire-${fire.status}">${LOOP_CLOCK_SVG}<span class="loop-headline-rel">${fire.status === 'pending' ? 'Fires' : 'Fired'} ${escapeHtml(fire.rel)}</span><span class="loop-headline-abs">${escapeHtml(fire.abs)}</span></div>`;
6005
+ }
6006
+ fields = loopField('Reason', item.reason) + loopField('Prompt', item.prompt, true);
6007
+ footer = `<div class="loop-foot">scheduled ${escapeHtml(when)}${delayLbl ? ` · ${delayLbl}` : ''}</div>`;
6008
+ } else {
6009
+ if (item.cron) {
6010
+ headline = `<div class="loop-headline">${LOOP_CRON_SVG}<span class="loop-headline-rel"><code>${escapeHtml(item.cron)}</code></span></div>`;
6011
+ }
6012
+ fields = loopField('Description', item.description) + loopField('Prompt', item.prompt, true);
6013
+ footer = `<div class="loop-foot">created ${escapeHtml(when)}</div>`;
6014
+ }
6015
+ return `<div class="loop-row">${headline}${fields}${footer}</div>`;
6016
+ }
6017
+
6018
+ function renderLoopModalBody(data) {
6019
+ const body = document.getElementById('loop-modal-body');
6020
+ const wakeups = data.wakeups || [];
6021
+ const crons = data.crons || [];
6022
+ if (!wakeups.length && !crons.length) {
6023
+ body.innerHTML =
6024
+ '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No scheduled wakeups or cron jobs.</div>';
6025
+ return;
6026
+ }
6027
+ const section = (title, items, kind) =>
6028
+ items.length
6029
+ ? `<h4 class="loop-section-title">${title} <span class="loop-count">${items.length}</span></h4>${items.map((i) => renderLoopRow(i, kind)).join('')}`
6030
+ : '';
6031
+ body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
6032
+ }
6033
+
6034
+ function renderLoopBadge(session) {
6035
+ const li = session.loopInfo;
6036
+ const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
6037
+ if (total === 0) return '';
6038
+ let tip = `${li.wakeupCount} wakeup${li.wakeupCount === 1 ? '' : 's'}, ${li.cronCount} cron${li.cronCount === 1 ? '' : 's'}`;
6039
+ if (li.latest?.timestamp && li.latest.delaySeconds != null) {
6040
+ const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
6041
+ if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
6042
+ }
6043
+ return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${session.id}')" title="${escapeHtml(tip)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
6044
+ }
6045
+
6046
+ function closeLoopModal() {
6047
+ document.getElementById('loop-modal').classList.remove('visible');
6048
+ }
6049
+
5887
6050
  function openPlanForSession(sid) {
5888
6051
  fetch(`/api/sessions/${sid}/plan`)
5889
6052
  .then((r) => (r.ok ? r.json() : null))
package/public/index.html CHANGED
@@ -604,6 +604,25 @@
604
604
  </div>
605
605
  </div>
606
606
 
607
+ <!-- Loop Wakeups Modal -->
608
+ <div id="loop-modal" class="modal-overlay" onclick="closeLoopModal()">
609
+ <div class="modal plan-modal" onclick="event.stopPropagation()">
610
+ <div class="modal-header">
611
+ <h3 id="loop-modal-title" class="modal-title" style="display:flex;align-items:center;gap:8px;">
612
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
613
+ Loop activity
614
+ </h3>
615
+ <button class="modal-close" aria-label="Close dialog" onclick="closeLoopModal()">
616
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
617
+ </button>
618
+ </div>
619
+ <div id="loop-modal-body" class="modal-body" style="overflow-y:auto;flex:0 1 auto;min-height:0;"></div>
620
+ <div class="modal-footer">
621
+ <button class="btn btn-primary" onclick="closeLoopModal()">Close</button>
622
+ </div>
623
+ </div>
624
+ </div>
625
+
607
626
  <div id="agent-modal" class="modal-overlay plan-modal-overlay" onclick="closeAgentModal()">
608
627
  <div class="modal plan-modal" onclick="event.stopPropagation()">
609
628
  <div class="modal-header">
package/public/style.css CHANGED
@@ -153,14 +153,13 @@ body::before {
153
153
  }
154
154
 
155
155
  .activity-chip.activity-filter-on {
156
- background: color-mix(in srgb, var(--accent) 14%, var(--bg-deep));
157
- border-color: var(--accent);
158
- box-shadow: inset 0 0 0 1px var(--accent);
156
+ --chip-color: var(--accent);
157
+ background: color-mix(in srgb, var(--chip-color) 9%, var(--bg-deep));
158
+ border-color: color-mix(in srgb, var(--chip-color) 50%, var(--border));
159
+ color: color-mix(in srgb, var(--chip-color) 80%, var(--text-secondary));
159
160
  }
160
161
  .activity-chip.activity-waiting.activity-filter-on {
161
- border-color: var(--warning);
162
- box-shadow: inset 0 0 0 1px var(--warning);
163
- background: color-mix(in srgb, var(--warning) 14%, var(--bg-deep));
162
+ --chip-color: var(--warning);
164
163
  }
165
164
 
166
165
  .activity-dot {
@@ -188,22 +187,12 @@ body::before {
188
187
  opacity: 1;
189
188
  }
190
189
 
191
- .activity-chip.activity-waiting {
192
- color: var(--warning);
193
- border-color: color-mix(in srgb, var(--warning) 40%, var(--border));
194
- }
195
190
  .activity-chip.activity-waiting .activity-dot {
196
191
  background: var(--warning);
197
- box-shadow: 0 0 8px color-mix(in srgb, var(--warning) 60%, transparent);
198
192
  }
199
193
 
200
- .activity-chip.activity-active {
201
- color: color-mix(in srgb, var(--accent) 70%, var(--text-secondary));
202
- border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
203
- }
204
194
  .activity-chip.activity-active .activity-dot {
205
- background: color-mix(in srgb, var(--accent) 75%, var(--text-secondary));
206
- box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 30%, transparent);
195
+ background: color-mix(in srgb, var(--accent) 60%, var(--text-muted));
207
196
  animation: pulse 2.5s ease-in-out infinite;
208
197
  }
209
198
 
@@ -1615,6 +1604,136 @@ body::before {
1615
1604
  border-color: var(--plan);
1616
1605
  }
1617
1606
 
1607
+ .loop-badge {
1608
+ width: 24px;
1609
+ height: 24px;
1610
+ display: inline-flex;
1611
+ align-items: center;
1612
+ justify-content: center;
1613
+ background: var(--bg-deep);
1614
+ border: 1px solid transparent;
1615
+ border-radius: 4px;
1616
+ color: var(--text-secondary);
1617
+ cursor: pointer;
1618
+ flex-shrink: 0;
1619
+ transition: all 0.15s ease;
1620
+ }
1621
+
1622
+ .loop-badge:hover {
1623
+ border-color: var(--accent, var(--plan));
1624
+ color: var(--accent, var(--plan));
1625
+ }
1626
+
1627
+ .loop-section-title {
1628
+ margin: 14px 0 8px;
1629
+ font-size: 12px;
1630
+ font-weight: 600;
1631
+ text-transform: uppercase;
1632
+ letter-spacing: 0.04em;
1633
+ color: var(--text-secondary);
1634
+ display: flex;
1635
+ align-items: center;
1636
+ gap: 8px;
1637
+ }
1638
+
1639
+ .loop-section-title:first-child {
1640
+ margin-top: 4px;
1641
+ }
1642
+
1643
+ .loop-count {
1644
+ font-size: 11px;
1645
+ font-weight: 500;
1646
+ padding: 1px 6px;
1647
+ border-radius: 10px;
1648
+ background: var(--bg-elevated);
1649
+ color: var(--text-secondary);
1650
+ text-transform: none;
1651
+ letter-spacing: 0;
1652
+ }
1653
+
1654
+ .loop-row {
1655
+ padding: 12px 14px;
1656
+ border: 1px solid var(--border);
1657
+ border-left: 3px solid var(--border);
1658
+ border-radius: 6px;
1659
+ background: var(--bg-elevated);
1660
+ margin-bottom: 10px;
1661
+ display: flex;
1662
+ flex-direction: column;
1663
+ gap: 10px;
1664
+ }
1665
+
1666
+ .loop-row:has(.loop-fire-pending) {
1667
+ border-left-color: var(--accent, var(--plan));
1668
+ }
1669
+
1670
+ .loop-headline {
1671
+ display: flex;
1672
+ align-items: center;
1673
+ gap: 10px;
1674
+ flex-wrap: wrap;
1675
+ font-variant-numeric: tabular-nums;
1676
+ }
1677
+
1678
+ .loop-headline-rel {
1679
+ font-size: 15px;
1680
+ font-weight: 600;
1681
+ }
1682
+
1683
+ .loop-headline-abs {
1684
+ font-size: 12px;
1685
+ color: var(--text-secondary);
1686
+ margin-left: auto;
1687
+ }
1688
+
1689
+ .loop-fire-pending {
1690
+ color: var(--accent, var(--plan));
1691
+ }
1692
+ .loop-fire-fired {
1693
+ color: var(--text-secondary);
1694
+ }
1695
+ .loop-fire-fired .loop-headline-rel {
1696
+ font-weight: 500;
1697
+ }
1698
+
1699
+ .loop-field {
1700
+ display: grid;
1701
+ grid-template-columns: 80px 1fr;
1702
+ gap: 10px;
1703
+ align-items: start;
1704
+ font-size: 13px;
1705
+ line-height: 1.45;
1706
+ }
1707
+
1708
+ .loop-field-label {
1709
+ font-size: 10px;
1710
+ text-transform: uppercase;
1711
+ letter-spacing: 0.06em;
1712
+ color: var(--text-secondary);
1713
+ padding-top: 3px;
1714
+ }
1715
+
1716
+ .loop-field-val {
1717
+ color: var(--text-primary);
1718
+ word-break: break-word;
1719
+ }
1720
+
1721
+ .loop-field-val code {
1722
+ background: var(--bg);
1723
+ padding: 2px 6px;
1724
+ border-radius: 4px;
1725
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1726
+ font-size: 12px;
1727
+ }
1728
+
1729
+ .loop-foot {
1730
+ font-size: 11px;
1731
+ color: var(--text-secondary);
1732
+ padding-top: 6px;
1733
+ border-top: 1px dashed var(--border);
1734
+ font-variant-numeric: tabular-nums;
1735
+ }
1736
+
1618
1737
  /* #endregion */
1619
1738
 
1620
1739
  /* #region OWNER_BADGE */
@@ -2358,6 +2477,28 @@ body::before {
2358
2477
  border-left: 3px solid var(--warning, #f5a623);
2359
2478
  background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
2360
2479
  animation: msg-waiting-pulse 2s ease-in-out infinite;
2480
+ cursor: pointer;
2481
+ }
2482
+ .msg-waiting-discard {
2483
+ flex-shrink: 0;
2484
+ background: none;
2485
+ border: none;
2486
+ color: var(--text-muted);
2487
+ cursor: pointer;
2488
+ padding: 2px;
2489
+ opacity: 0;
2490
+ transition: opacity 0.15s;
2491
+ margin-left: auto;
2492
+ align-self: flex-start;
2493
+ line-height: 0;
2494
+ }
2495
+ .msg-item.msg-waiting:hover .msg-waiting-discard {
2496
+ opacity: 0.6;
2497
+ }
2498
+ .msg-waiting-discard:hover {
2499
+ /* biome-ignore lint/complexity/noImportantStyles: override parent hover opacity */
2500
+ opacity: 1 !important;
2501
+ color: var(--danger, #e54d4d);
2361
2502
  }
2362
2503
  .msg-waiting .msg-text {
2363
2504
  font-weight: 600;
package/server.js CHANGED
@@ -8,7 +8,7 @@ const readline = require('readline');
8
8
  const chokidar = require('chokidar');
9
9
  const os = require('os');
10
10
  const crypto = require('crypto');
11
- const { spawn } = require('child_process');
11
+ const { spawn, spawnSync } = require('child_process');
12
12
 
13
13
  const {
14
14
  readRecentMessages: _readRecentMessagesUncached,
@@ -21,7 +21,9 @@ const {
21
21
  extractPromptFromTranscript,
22
22
  extractModelFromTranscript,
23
23
  readFullToolResult,
24
- readUserImage
24
+ readUserImage,
25
+ updateLoopInfo,
26
+ buildLoopInfoFromState
25
27
  } = require('./lib/parsers');
26
28
 
27
29
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -157,6 +159,45 @@ function isAgentFresh(agent) {
157
159
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
158
160
  }
159
161
 
162
+ // Claude Code records gitBranch from the launch-time repo and never updates it
163
+ // when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
164
+ // the live cwd instead. Cached per-cwd with a short TTL so a list refresh
165
+ // across N sessions sharing one cwd spawns git at most once per TTL window.
166
+ const gitBranchCache = new Map();
167
+ const GIT_BRANCH_TTL_MS = 30000;
168
+ const GIT_BRANCH_CACHE_MAX = 500;
169
+ function getGitBranch(cwd) {
170
+ if (!cwd) return null;
171
+ const now = Date.now();
172
+ const cached = gitBranchCache.get(cwd);
173
+ if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
174
+ let branch = null;
175
+ try {
176
+ const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
177
+ encoding: 'utf8', timeout: 500, windowsHide: true
178
+ });
179
+ if (r.status === 0) {
180
+ const out = (r.stdout || '').trim();
181
+ if (out && out !== 'HEAD') branch = out;
182
+ }
183
+ } catch (_) {}
184
+ gitBranchCache.set(cwd, { branch, ts: now });
185
+ if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
186
+ const firstKey = gitBranchCache.keys().next().value;
187
+ gitBranchCache.delete(firstKey);
188
+ }
189
+ return branch;
190
+ }
191
+
192
+ // Only spawn git when cwd has diverged from the launch project — that's the
193
+ // only case the JSONL value is wrong. Saves N spawns on a typical list build.
194
+ function resolveSessionGitBranch(meta) {
195
+ if (meta.cwd && meta.project && meta.cwd !== meta.project) {
196
+ return getGitBranch(meta.cwd) || meta.gitBranch || null;
197
+ }
198
+ return meta.gitBranch || null;
199
+ }
200
+
160
201
  function getSessionLogStat(meta) {
161
202
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
162
203
  try {
@@ -221,6 +262,11 @@ const clients = new Set();
221
262
  let sessionMetadataCache = {};
222
263
  let lastMetadataRefresh = 0;
223
264
  const METADATA_CACHE_TTL = 10000; // 10 seconds
265
+ // Watcher-driven invalidation. `change` events (append to existing jsonl) only
266
+ // dirty the one path so we can do a targeted refresh; `add` / `unlink` events
267
+ // are structural and force a full rescan.
268
+ const dirtyMetadataPaths = new Set();
269
+ let metadataNeedsFullScan = true;
224
270
 
225
271
  const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
226
272
  function isSafeId(id) {
@@ -386,10 +432,50 @@ function readRecentMessages(jsonlPath, limit = 10) {
386
432
  /**
387
433
  * Scan all project directories to find session JSONL files and extract slugs
388
434
  */
435
+ // Returns false when sessionId is unknown — caller must promote to full scan.
436
+ function refreshSessionMetadataPath(jsonlPath) {
437
+ const sessionId = path.basename(jsonlPath, '.jsonl');
438
+ if (!isSafeId(sessionId)) return false;
439
+ const existing = sessionMetadataCache[sessionId];
440
+ if (!existing) return false;
441
+ let info;
442
+ try {
443
+ info = readSessionInfoFromJsonl(jsonlPath);
444
+ } catch (_) {
445
+ return false;
446
+ }
447
+ // Shadow JSONLs (continued from a worktree) hold only custom-title / agent-
448
+ // name records — no projectPath. Don't let a shadow clobber the real entry.
449
+ const shadow = existing.project && !info.projectPath;
450
+ if (shadow) {
451
+ if (!existing.slug && info.slug) existing.slug = info.slug;
452
+ if (!existing.customTitle && info.customTitle) existing.customTitle = info.customTitle;
453
+ return true;
454
+ }
455
+ if (info.slug) existing.slug = info.slug;
456
+ if (info.cwd) existing.cwd = info.cwd;
457
+ if (info.gitBranch) existing.gitBranch = info.gitBranch;
458
+ if (info.customTitle) existing.customTitle = info.customTitle;
459
+ return true;
460
+ }
461
+
389
462
  function loadSessionMetadata() {
390
463
  const now = Date.now();
391
- if (now - lastMetadataRefresh < METADATA_CACHE_TTL) {
392
- return sessionMetadataCache;
464
+
465
+ if (!metadataNeedsFullScan && now - lastMetadataRefresh < METADATA_CACHE_TTL) {
466
+ if (dirtyMetadataPaths.size > 0) {
467
+ for (const p of dirtyMetadataPaths) {
468
+ if (!refreshSessionMetadataPath(p)) {
469
+ // Unknown sessionId — structural change snuck in. Promote to full.
470
+ metadataNeedsFullScan = true;
471
+ break;
472
+ }
473
+ }
474
+ dirtyMetadataPaths.clear();
475
+ if (!metadataNeedsFullScan) return sessionMetadataCache;
476
+ } else {
477
+ return sessionMetadataCache;
478
+ }
393
479
  }
394
480
 
395
481
  const metadata = {};
@@ -520,6 +606,8 @@ function loadSessionMetadata() {
520
606
 
521
607
  sessionMetadataCache = metadata;
522
608
  lastMetadataRefresh = now;
609
+ metadataNeedsFullScan = false;
610
+ dirtyMetadataPaths.clear();
523
611
  return metadata;
524
612
  }
525
613
 
@@ -536,6 +624,51 @@ function getPlanInfo(slug) {
536
624
  }
537
625
  }
538
626
 
627
+ // Hide wakeups whose fire time is more than this far in the past — long /loop
628
+ // sessions otherwise produce dozens of stale entries that drown the badge.
629
+ const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
630
+
631
+ function isWakeupActive(w, now = Date.now()) {
632
+ if (!w || !w.timestamp || w.delaySeconds == null) return true;
633
+ const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
634
+ return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
635
+ }
636
+
637
+ function filterActiveLoopInfo(info) {
638
+ const now = Date.now();
639
+ return {
640
+ wakeups: info.wakeups.filter(w => isWakeupActive(w, now)),
641
+ crons: info.crons
642
+ };
643
+ }
644
+
645
+ // Per-path incremental scan state. Populated lazily on first access and
646
+ // updated in place; the projectsWatcher event handler keeps entries warm so
647
+ // the request path does O(1) work in steady state.
648
+ const loopInfoStateByPath = new Map();
649
+
650
+ function refreshLoopInfoState(jsonlPath) {
651
+ if (!jsonlPath) return null;
652
+ const prev = loopInfoStateByPath.get(jsonlPath);
653
+ const next = updateLoopInfo(jsonlPath, prev);
654
+ if (next) loopInfoStateByPath.set(jsonlPath, next);
655
+ return next;
656
+ }
657
+
658
+ function getLoopInfoSummary(meta) {
659
+ const empty = { wakeupCount: 0, cronCount: 0, latest: null };
660
+ if (!meta?.jsonlPath) return empty;
661
+ try {
662
+ const state = refreshLoopInfoState(meta.jsonlPath);
663
+ const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
664
+ return {
665
+ wakeupCount: filtered.wakeups.length,
666
+ cronCount: filtered.crons.length,
667
+ latest: filtered.wakeups[filtered.wakeups.length - 1] || filtered.crons[filtered.crons.length - 1] || null
668
+ };
669
+ } catch (_) { return empty; }
670
+ }
671
+
539
672
  function getSessionDisplayName(sessionId, meta) {
540
673
  if (meta?.customTitle) return meta.customTitle;
541
674
  if (meta?.slug) return meta.slug;
@@ -553,7 +686,7 @@ function buildSessionObject(id, meta, overrides = {}) {
553
686
  project: meta.project || null,
554
687
  cwd: meta.cwd || null,
555
688
  description: meta.description || null,
556
- gitBranch: meta.gitBranch || null,
689
+ gitBranch: resolveSessionGitBranch(meta),
557
690
  customTitle: meta.customTitle || null,
558
691
  taskCount: 0,
559
692
  completed: 0,
@@ -573,6 +706,7 @@ function buildSessionObject(id, meta, overrides = {}) {
573
706
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
574
707
  contextStatus: getContextStatus(id, meta),
575
708
  ...getPlanInfo(meta.slug),
709
+ loopInfo: getLoopInfoSummary(meta),
576
710
  ...overrides,
577
711
  // Remove internal-only field
578
712
  _logStat: undefined,
@@ -974,6 +1108,23 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
974
1108
  }
975
1109
  });
976
1110
 
1111
+ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1112
+ try {
1113
+ const metadata = loadSessionMetadata();
1114
+ const meta = metadata[req.params.sessionId];
1115
+ if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
1116
+ const state = refreshLoopInfoState(meta.jsonlPath);
1117
+ const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
1118
+ res.json({
1119
+ wakeups: [...filtered.wakeups].reverse(),
1120
+ crons: [...filtered.crons].reverse()
1121
+ });
1122
+ } catch (error) {
1123
+ console.error('Error reading loop info:', error);
1124
+ res.status(500).json({ error: 'Failed to read loop info' });
1125
+ }
1126
+ });
1127
+
977
1128
  function openInEditor(...targets) {
978
1129
  const editor = process.env.EDITOR || 'code';
979
1130
  spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
@@ -1223,6 +1374,20 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1223
1374
  }
1224
1375
  });
1225
1376
 
1377
+ function clearWaitingFile(sessionId) {
1378
+ try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json')); }
1379
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
1380
+ }
1381
+
1382
+ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
1383
+ try {
1384
+ clearWaitingFile(resolveSessionId(req.params.sessionId));
1385
+ res.json({ ok: true });
1386
+ } catch (e) {
1387
+ res.status(500).json({ error: 'Failed to discard waiting' });
1388
+ }
1389
+ });
1390
+
1226
1391
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
1227
1392
  const sessionId = resolveSessionId(req.params.sessionId);
1228
1393
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1234,9 +1399,7 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
1234
1399
  agent.stoppedAt = new Date().toISOString();
1235
1400
  const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
1236
1401
  writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
1237
- // Also remove waiting state if present
1238
- const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
1239
- if (existsSync(waitingFile)) unlinkSync(waitingFile);
1402
+ clearWaitingFile(sessionId);
1240
1403
  res.json({ ok: true });
1241
1404
  } catch (e) {
1242
1405
  res.status(500).json({ error: 'Failed to stop agent' });
@@ -1928,13 +2091,27 @@ console.log(`Watching for team changes in: ${TEAMS_DIR}`);
1928
2091
  const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
1929
2092
  persistent: true,
1930
2093
  ignoreInitial: true,
1931
- depth: 2
2094
+ depth: 2,
2095
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
1932
2096
  });
1933
2097
 
1934
2098
  projectsWatcher.on('all', (event, filePath) => {
1935
- if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.jsonl')) {
1936
- // Invalidate cache on any change
1937
- lastMetadataRefresh = 0;
2099
+ if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
2100
+ if (filePath.endsWith('.jsonl')) {
2101
+ if (event === 'unlink') {
2102
+ loopInfoStateByPath.delete(filePath);
2103
+ } else {
2104
+ // Warm the incremental scan state so the next request does no IO.
2105
+ try { refreshLoopInfoState(filePath); } catch (_) {}
2106
+ }
2107
+ // add/unlink reshape the session set — promote to full rescan.
2108
+ if (event === 'change') dirtyMetadataPaths.add(filePath);
2109
+ else metadataNeedsFullScan = true;
2110
+ broadcast({ type: 'metadata-update' });
2111
+ } else if (path.basename(filePath) === 'sessions-index.json') {
2112
+ // Index holds description / created / customTitle that the targeted
2113
+ // refresh doesn't touch — promote to full rescan.
2114
+ metadataNeedsFullScan = true;
1938
2115
  broadcast({ type: 'metadata-update' });
1939
2116
  }
1940
2117
  });
@@ -1947,7 +2124,9 @@ const plansWatcher = chokidar.watch(PLANS_DIR, {
1947
2124
 
1948
2125
  plansWatcher.on('all', (event, filePath) => {
1949
2126
  if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.md')) {
1950
- lastMetadataRefresh = 0;
2127
+ // Plan files don't affect cached session metadata — getPlanInfo is called
2128
+ // fresh from buildSessionObject on every list build. The broadcast alone
2129
+ // is enough to trigger a client refetch.
1951
2130
  broadcast({ type: 'metadata-update' });
1952
2131
  if (event === 'change') {
1953
2132
  const slug = path.basename(filePath, '.md');