claude-code-kanban 4.0.0 → 4.2.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
@@ -118,16 +118,23 @@ const TOOL_RESULT_MAX = 1500;
118
118
  const USER_TEXT_MAX = 500;
119
119
  const INTERRUPT_MARKER = '[Request interrupted by user]';
120
120
 
121
- function pushUserMessage(messages, text, timestamp, sysLabel) {
121
+ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
122
122
  if (sysLabel === '__skip__') return;
123
- const truncated = text.length > USER_TEXT_MAX;
124
- messages.push({
123
+ const safeText = text || '';
124
+ const truncated = safeText.length > USER_TEXT_MAX;
125
+ const msg = {
125
126
  type: 'user',
126
- text: truncated ? text.slice(0, USER_TEXT_MAX) + '...' : text,
127
- fullText: truncated ? text : null,
127
+ text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
128
+ fullText: truncated ? safeText : null,
128
129
  timestamp,
129
130
  ...(sysLabel && { systemLabel: sysLabel })
130
- });
131
+ };
132
+ if (extras) {
133
+ if (extras.uuid) msg.uuid = extras.uuid;
134
+ if (extras.images && extras.images.length) msg.images = extras.images;
135
+ if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
136
+ }
137
+ messages.push(msg);
131
138
  }
132
139
 
133
140
  // Cache: jsonlPath -> { scannedUpTo, customTitle }
@@ -135,19 +142,35 @@ function pushUserMessage(messages, text, timestamp, sysLabel) {
135
142
  const customTitleCache = new Map();
136
143
  const CUSTOM_TITLE_SCAN_SIZE = 1048576; // 1MB max scan on first read
137
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.
138
149
  function extractCustomTitleFromText(text) {
139
- 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;
140
154
  const lines = text.split('\n');
155
+ let customTitle = null;
156
+ let aiTitle = null;
157
+ let agentName = null;
141
158
  for (let i = lines.length - 1; i >= 0; i--) {
142
- 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;
143
161
  try {
144
- const data = JSON.parse(lines[i]);
145
- if (data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
146
- 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;
147
169
  }
170
+ if (customTitle) break;
148
171
  } catch (e) {}
149
172
  }
150
- return null;
173
+ return customTitle || aiTitle || agentName || null;
151
174
  }
152
175
 
153
176
  function readCustomTitle(jsonlPath, existingStat) {
@@ -199,99 +222,147 @@ function scrapeScalarFromBlob(blob, re) {
199
222
  const sessionInfoCache = new Map();
200
223
  const SESSION_INFO_CACHE_MAX = 2000;
201
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).
202
229
  function readSessionInfoFromJsonl(jsonlPath) {
203
230
  const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
204
231
  let stat;
205
232
  let fd;
206
233
  try {
207
234
  stat = statSync(jsonlPath);
208
- const cached = sessionInfoCache.get(jsonlPath);
209
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
210
- return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
211
- }
212
235
  } catch (_) {
213
236
  return result;
214
237
  }
215
- // State shared across head-chunk parse, leftover flush, and tail parse.
216
- 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;
217
262
  const applyLine = (line) => {
218
263
  try {
219
264
  const data = JSON.parse(line);
220
- if (data.slug) result.slug = data.slug;
265
+ if (data.slug && !result.slug) result.slug = data.slug;
221
266
  if (data.cwd) {
222
267
  if (!result.projectPath) result.projectPath = data.cwd;
223
- lastCwdFromHead = data.cwd;
268
+ lastCwdSeen = data.cwd;
224
269
  }
225
270
  if (data.gitBranch) result.gitBranch = data.gitBranch;
226
271
  } catch (e) {}
227
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
+
228
279
  try {
229
280
  fd = fs.openSync(jsonlPath, 'r');
230
- const CHUNK_SIZE = 16384;
231
- const TAIL_SIZE = 16384;
232
- // Stream head in chunks with StringDecoder to preserve multi-byte UTF-8
233
- // codepoints across chunk boundaries. Scan up to HEAD_MAX so the last cwd
234
- // in the window wins — sessions that change cwd mid-run must surface it.
235
- const HEAD_MAX = 1048576;
236
- const decoder = new StringDecoder('utf8');
237
- const buf = Buffer.alloc(CHUNK_SIZE);
238
- let leftover = '';
239
- let offset = 0;
240
- while (offset < stat.size && offset < HEAD_MAX) {
241
- const len = Math.min(CHUNK_SIZE, stat.size - offset);
242
- const n = fs.readSync(fd, buf, 0, len, offset);
243
- if (n === 0) break;
244
- offset += n;
245
- const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
246
- const lines = text.split('\n');
247
- leftover = lines.pop();
248
- for (const line of lines) applyLine(line);
249
- }
250
- leftover += decoder.end();
251
- if (leftover) applyLine(leftover);
252
- // Oversized first line (e.g. multi-MB inline image) left leftover unparsed
253
- // above; scrape scalars out of it so we don't fall through to the tail and
254
- // pick a mid-session cwd as projectPath.
255
- if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
256
- const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
257
- if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdFromHead = scrapedCwd; }
258
- if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
259
- if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
260
- }
261
281
 
262
- result.cwd = lastCwdFromHead;
263
-
264
- // Tail scan: always runs when the file extends past the head window, so
265
- // `cwd` reflects the latest value even in long sessions where a late cwd
266
- // switch sits past HEAD_MAX. Also backfills projectPath/slug/gitBranch
267
- // when the head couldn't find them. projectPath is NOT overwritten — it
268
- // stays anchored to the earliest cwd seen.
269
- if (stat.size > offset) {
270
- const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
271
- const tailBuf = Buffer.alloc(TAIL_SIZE);
272
- const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
273
- const lines = tailBuf.toString('utf8', 0, tn).split('\n');
274
- let latestTailCwd = null;
275
- for (let i = lines.length - 1; i >= 0; i--) {
276
- try {
277
- const data = JSON.parse(lines[i]);
278
- if (!result.slug && data.slug) result.slug = data.slug;
279
- if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
280
- if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
281
- if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
282
- if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
283
- } 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;
284
346
  }
285
- if (latestTailCwd) result.cwd = latestTailCwd;
347
+ scannedUpTo = stat.size;
286
348
  }
287
349
  } catch (e) {
288
350
  } finally {
289
351
  if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
290
352
  }
291
353
 
354
+ result.cwd = lastCwdSeen;
355
+
292
356
  if (stat) {
293
- const { customTitle: _ct, ...rest } = result;
294
- 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
+ });
295
366
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
296
367
  const firstKey = sessionInfoCache.keys().next().value;
297
368
  sessionInfoCache.delete(firstKey);
@@ -532,16 +603,19 @@ function readRecentMessages(jsonlPath, limit = 10) {
532
603
  }
533
604
  pushUserMessage(messages, t, obj.timestamp, getSystemMessageLabel(t));
534
605
  } else if (Array.isArray(obj.message.content)) {
535
- const joined = obj.message.content
536
- .filter(b => b.type === 'text' && typeof b.text === 'string' && b.text)
537
- .map(b => b.text)
538
- .join('\n')
539
- .trim();
540
- if (joined && joined !== INTERRUPT_MARKER) {
541
- pushUserMessage(messages, joined, obj.timestamp, getSystemMessageLabel(joined));
542
- }
543
- for (const block of obj.message.content) {
544
- if (block.type === 'tool_result' && block.tool_use_id) {
606
+ const texts = [];
607
+ const images = [];
608
+ const toolResultRefs = [];
609
+ obj.message.content.forEach((block, idx) => {
610
+ if (block.type === 'text' && typeof block.text === 'string' && block.text) {
611
+ texts.push(block.text);
612
+ } else if (block.type === 'image' && block.source && block.source.type === 'base64') {
613
+ images.push({
614
+ blockIndex: idx,
615
+ mediaType: block.source.media_type || 'image/png',
616
+ dataLen: typeof block.source.data === 'string' ? block.source.data.length : 0
617
+ });
618
+ } else if (block.type === 'tool_result' && block.tool_use_id) {
545
619
  let resultText = '';
546
620
  if (typeof block.content === 'string') {
547
621
  resultText = block.content;
@@ -554,7 +628,23 @@ function readRecentMessages(jsonlPath, limit = 10) {
554
628
  if (resultText) {
555
629
  toolResults.set(block.tool_use_id, resultText);
556
630
  }
631
+ toolResultRefs.push({
632
+ toolUseId: block.tool_use_id,
633
+ preview: resultText ? resultText.slice(0, 200) : ''
634
+ });
557
635
  }
636
+ });
637
+ const joined = texts.join('\n').trim();
638
+ const hasText = joined && joined !== INTERRUPT_MARKER;
639
+ const hasImages = images.length > 0;
640
+ if (hasText || hasImages) {
641
+ pushUserMessage(
642
+ messages,
643
+ joined,
644
+ obj.timestamp,
645
+ getSystemMessageLabel(joined),
646
+ { uuid: obj.uuid, images, toolResultRefs: hasText ? toolResultRefs : [] }
647
+ );
558
648
  }
559
649
  }
560
650
  }
@@ -620,6 +710,31 @@ function readFullToolResult(jsonlPath, toolUseId) {
620
710
  return null;
621
711
  }
622
712
 
713
+ function readUserImage(jsonlPath, msgUuid, blockIndex) {
714
+ if (!msgUuid || !jsonlPath) return null;
715
+ const idx = Number(blockIndex);
716
+ if (!Number.isInteger(idx) || idx < 0) return null;
717
+ try {
718
+ const content = readFileSync(jsonlPath, 'utf8');
719
+ const lines = content.split('\n');
720
+ for (const line of lines) {
721
+ if (!line || line.indexOf(msgUuid) === -1) continue;
722
+ try {
723
+ const obj = JSON.parse(line);
724
+ if (obj?.uuid !== msgUuid) continue;
725
+ if (!Array.isArray(obj?.message?.content)) continue;
726
+ const block = obj.message.content[idx];
727
+ if (!block || block.type !== 'image' || !block.source || block.source.type !== 'base64') return null;
728
+ return {
729
+ mediaType: block.source.media_type || 'image/png',
730
+ data: block.source.data
731
+ };
732
+ } catch (_) {}
733
+ }
734
+ } catch (_) {}
735
+ return null;
736
+ }
737
+
623
738
  function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
624
739
  const fetchLimit = limit + 1;
625
740
  const applyFilter = beforeTimestamp
@@ -902,6 +1017,121 @@ function extractModelFromTranscript(jsonlPath) {
902
1017
  return null;
903
1018
  }
904
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
+
905
1135
  module.exports = {
906
1136
  parseTask,
907
1137
  parseAgent,
@@ -913,6 +1143,9 @@ module.exports = {
913
1143
  readRecentMessages,
914
1144
  readMessagesPage,
915
1145
  readFullToolResult,
1146
+ readUserImage,
1147
+ updateLoopInfo,
1148
+ buildLoopInfoFromState,
916
1149
  buildAgentProgressMap,
917
1150
  buildSessionDigest,
918
1151
  readCompactSummaries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.0.0",
3
+ "version": "4.2.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.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -70,6 +70,16 @@
70
70
  "timeout": 5
71
71
  }
72
72
  ]
73
+ },
74
+ {
75
+ "matcher": "ExitPlanMode",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh",
80
+ "timeout": 5
81
+ }
82
+ ]
73
83
  }
74
84
  ],
75
85
  "PostToolUse": [
@@ -38,17 +38,17 @@ if [ "$EVENT" = "SessionStart" ]; then
38
38
  fi
39
39
 
40
40
  # PostToolUse / non-waiting PreToolUse: clear waiting state
41
- if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ]; }; then
41
+ if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
42
42
  WFILE="$CCK_ACTIVITY/$SESSION_ID/_waiting.json"
43
43
  rm -f "$WFILE"
44
44
  [ "$EVENT" = "PostToolUse" ] && exit 0
45
45
  fi
46
46
 
47
- # Plan mode tools don't fire PostToolUse — skip to avoid stale markers
48
- [ "$TOOL_NAME" = "EnterPlanMode" ] || [ "$TOOL_NAME" = "ExitPlanMode" ] && exit 0
47
+ # EnterPlanMode has no waiting semantics — skip
48
+ [ "$TOOL_NAME" = "EnterPlanMode" ] && exit 0
49
49
 
50
50
  # Waiting-for-user events → write _waiting.json marker
51
- if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" = "AskUserQuestion" ]; }; then
51
+ if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; }; then
52
52
  DIR="$CCK_ACTIVITY/$SESSION_ID"
53
53
  mkdir -p "$DIR"
54
54
  KIND="permission"