claude-code-kanban 4.4.0 → 4.6.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
@@ -107,6 +107,15 @@ function parseJsonlLine(line) {
107
107
  };
108
108
  }
109
109
 
110
+ if (obj.type === 'queue-operation' && obj.operation === 'enqueue') {
111
+ return {
112
+ ...base,
113
+ role: 'user',
114
+ queued: true,
115
+ content: typeof obj.content === 'string' ? obj.content : null
116
+ };
117
+ }
118
+
110
119
  if (obj.type === 'progress') {
111
120
  return { ...base, cwd: obj.cwd || null, version: obj.version || null, slug: obj.slug || null };
112
121
  }
@@ -118,6 +127,25 @@ const TOOL_RESULT_MAX = 1500;
118
127
  const USER_TEXT_MAX = 500;
119
128
  const INTERRUPT_MARKER = '[Request interrupted by user]';
120
129
 
130
+ // Newer Claude Code no longer inlines pasted images as base64 blocks. It writes
131
+ // them to ~/.claude/image-cache/<sessionId>/<N>.<ext> and leaves only a text marker
132
+ // in the message — an inline "[Image #N]" placeholder and/or a standalone
133
+ // "[Image: source: ...\N.ext]" line. Pull out the N references (so the UI can serve
134
+ // the cached file) and strip the verbose source lines from the displayed text — the
135
+ // rendered preview replaces them; the short "[Image #N]" placeholders stay.
136
+ // Runs on the per-message scan hot path, so it skips the regexes entirely unless a
137
+ // marker is present. Returns { refs: [{ kind: 'cache', n }], text }.
138
+ function parseImageMarkers(text) {
139
+ const raw = text || '';
140
+ if (!raw.includes('[Image')) return { refs: [], text: raw.trim() };
141
+ const ns = new Set();
142
+ for (const m of raw.matchAll(/\[Image #(\d+)\]/g)) ns.add(Number(m[1]));
143
+ for (const m of raw.matchAll(/\[Image: source:[^\]]*?(\d+)\.[a-z0-9]+\]/gi)) ns.add(Number(m[1]));
144
+ const refs = [...ns].sort((a, b) => a - b).map((n) => ({ kind: 'cache', n }));
145
+ const cleaned = raw.replace(/\[Image: source:[^\]]*\]/gi, '').trim();
146
+ return { refs, text: cleaned };
147
+ }
148
+
121
149
  function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
122
150
  if (sysLabel === '__skip__') return;
123
151
  const safeText = text || '';
@@ -133,6 +161,7 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
133
161
  if (extras.uuid) msg.uuid = extras.uuid;
134
162
  if (extras.images && extras.images.length) msg.images = extras.images;
135
163
  if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
164
+ if (extras.queued) msg.queued = true;
136
165
  }
137
166
  messages.push(msg);
138
167
  }
@@ -222,12 +251,29 @@ function scrapeScalarFromBlob(blob, re) {
222
251
  const sessionInfoCache = new Map();
223
252
  const SESSION_INFO_CACHE_MAX = 2000;
224
253
 
254
+ // Session-scoped /goal: a goal_status attachment carries the active condition
255
+ // (latest-wins as the Stop hook re-evaluates). Only an unmet goal is "active" —
256
+ // a met goal auto-clears in Claude Code, so treat met as removal, same as a
257
+ // `/goal clear` command line. Returns the goal value to apply, or `undefined`
258
+ // when the line is not a goal event (caller keeps its current value).
259
+ function extractGoalFromLine(data) {
260
+ if (data.type === 'attachment' && data.attachment?.type === 'goal_status') {
261
+ return data.attachment.met ? null : { condition: data.attachment.condition || '' };
262
+ }
263
+ if (data.type === 'user' && typeof data.message?.content === 'string'
264
+ && data.message.content.includes('<command-name>/goal</command-name>')
265
+ && /<command-args>\s*clear/.test(data.message.content)) {
266
+ return null;
267
+ }
268
+ return undefined;
269
+ }
270
+
225
271
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
226
272
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
227
273
  // live branch must resolve it from cwd separately. Cache is reset on inode
228
274
  // change or truncation (size < scannedUpTo).
229
275
  function readSessionInfoFromJsonl(jsonlPath) {
230
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null };
276
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, goal: null };
231
277
  let stat;
232
278
  let fd;
233
279
  try {
@@ -248,6 +294,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
248
294
  cwd: cached.cwd,
249
295
  gitBranch: cached.gitBranch,
250
296
  logicalParentUuid: cached.logicalParentUuid || null,
297
+ goal: cached.goal || null,
251
298
  customTitle: readCustomTitle(jsonlPath, stat)
252
299
  };
253
300
  }
@@ -258,6 +305,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
258
305
  result.cwd = cached.cwd;
259
306
  result.gitBranch = cached.gitBranch;
260
307
  result.logicalParentUuid = cached.logicalParentUuid || null;
308
+ result.goal = cached.goal || null;
261
309
  }
262
310
 
263
311
  let lastCwdSeen = result.cwd;
@@ -273,6 +321,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
273
321
  if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
274
322
  result.logicalParentUuid = data.logicalParentUuid;
275
323
  }
324
+ // Forward → last wins.
325
+ const goalUpdate = extractGoalFromLine(data);
326
+ if (goalUpdate !== undefined) result.goal = goalUpdate;
276
327
  } catch (e) {}
277
328
  };
278
329
 
@@ -337,6 +388,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
337
388
  const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
338
389
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
339
390
  let latestTailCwd = null;
391
+ // undefined = no goal event seen in tail (keep head's); otherwise the
392
+ // most recent tail goal/clear supersedes head (tail bytes are later).
393
+ let tailGoal;
340
394
  for (let i = lines.length - 1; i >= 0; i--) {
341
395
  try {
342
396
  const data = JSON.parse(lines[i]);
@@ -344,10 +398,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
344
398
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
345
399
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
346
400
  if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
347
- if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
401
+ if (tailGoal === undefined) tailGoal = extractGoalFromLine(data);
402
+ if (latestTailCwd && tailGoal !== undefined && result.slug && result.projectPath && result.gitBranch) break;
348
403
  } catch (e) {}
349
404
  }
350
405
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
406
+ if (tailGoal !== undefined) result.goal = tailGoal;
351
407
  }
352
408
  scannedUpTo = stat.size;
353
409
  }
@@ -367,7 +423,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
367
423
  projectPath: result.projectPath,
368
424
  gitBranch: result.gitBranch,
369
425
  cwd: result.cwd,
370
- logicalParentUuid: result.logicalParentUuid
426
+ logicalParentUuid: result.logicalParentUuid,
427
+ goal: result.goal
371
428
  });
372
429
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
373
430
  const firstKey = sessionInfoCache.keys().next().value;
@@ -402,6 +459,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
402
459
  fd = require('fs').openSync(jsonlPath, 'r');
403
460
  const messages = [];
404
461
  const toolResults = new Map();
462
+ const toolResultExtras = new Map();
405
463
  let readSize = Math.min(65536, stat.size);
406
464
 
407
465
  while (messages.length < limit) {
@@ -417,6 +475,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
417
475
 
418
476
  messages.length = 0;
419
477
  toolResults.clear();
478
+ toolResultExtras.clear();
420
479
  for (const line of clean.split('\n')) {
421
480
  if (!line.trim()) continue;
422
481
  try {
@@ -478,11 +537,20 @@ function readRecentMessages(jsonlPath, limit = 10) {
478
537
  const params = {};
479
538
  if (inp) {
480
539
  if (block.name === 'Edit') {
540
+ if (inp.file_path) params.file_path = inp.file_path;
481
541
  if (inp.old_string) params.old_string = inp.old_string;
482
542
  if (inp.new_string) params.new_string = inp.new_string;
483
543
  if (inp.replace_all) params.replace_all = true;
484
544
  } 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;
545
+ if (inp.file_path) params.file_path = inp.file_path;
546
+ if (inp.content) {
547
+ if (inp.content.length > TOOL_RESULT_MAX) {
548
+ params.content = inp.content.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
549
+ params.contentFull = inp.content;
550
+ } else {
551
+ params.content = inp.content;
552
+ }
553
+ }
486
554
  } else if (block.name === 'Grep') {
487
555
  if (inp.path) params.path = inp.path;
488
556
  if (inp.glob) params.glob = inp.glob;
@@ -538,6 +606,19 @@ function readRecentMessages(jsonlPath, limit = 10) {
538
606
  if (inp.message && typeof inp.message === 'object') {
539
607
  params.protocol = inp.message;
540
608
  }
609
+ } else {
610
+ // Passthrough for unknown tools (e.g. MCP `mcp__...`) so the detail panel
611
+ // can render args instead of "No details". Truncate large strings to bound
612
+ // wire/cache size, matching the Write `content` cap above.
613
+ for (const [k, v] of Object.entries(inp)) {
614
+ if (k === 'description' || v == null) continue;
615
+ if (typeof v === 'string' && v.length > TOOL_RESULT_MAX) {
616
+ params[k] = v.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
617
+ params[k + 'Full'] = v;
618
+ } else {
619
+ params[k] = v;
620
+ }
621
+ }
541
622
  }
542
623
  }
543
624
  const msg = {
@@ -617,6 +698,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
617
698
  texts.push(block.text);
618
699
  } else if (block.type === 'image' && block.source && block.source.type === 'base64') {
619
700
  images.push({
701
+ kind: 'base64',
620
702
  blockIndex: idx,
621
703
  mediaType: block.source.media_type || 'image/png',
622
704
  dataLen: typeof block.source.data === 'string' ? block.source.data.length : 0
@@ -634,25 +716,54 @@ function readRecentMessages(jsonlPath, limit = 10) {
634
716
  if (resultText) {
635
717
  toolResults.set(block.tool_use_id, resultText);
636
718
  }
719
+ // AskUserQuestion (and similar) stash the structured
720
+ // {questions, answers} payload at the line-level
721
+ // obj.toolUseResult — the block.content string is just a
722
+ // short confirmation. Capture it so the renderer can show
723
+ // the actual answers + option descriptions.
724
+ const tur = obj.toolUseResult;
725
+ let answerPayload = null;
726
+ if (tur && typeof tur === 'object' && !Array.isArray(tur)
727
+ && tur.answers && typeof tur.answers === 'object') {
728
+ answerPayload = {
729
+ answers: tur.answers,
730
+ questions: Array.isArray(tur.questions) ? tur.questions : null,
731
+ };
732
+ toolResultExtras.set(block.tool_use_id, { answerPayload });
733
+ }
637
734
  toolResultRefs.push({
638
735
  toolUseId: block.tool_use_id,
639
- preview: resultText ? resultText.slice(0, 200) : ''
736
+ preview: resultText ? resultText.slice(0, 200) : '',
737
+ answerPayload,
640
738
  });
641
739
  }
642
740
  });
643
741
  const joined = texts.join('\n').trim();
644
- const hasText = joined && joined !== INTERRUPT_MARKER;
645
- const hasImages = images.length > 0;
742
+ // Prefer inline base64 blocks when present; otherwise fall back to
743
+ // file-cache references parsed from the text markers.
744
+ const { refs, text: displayText } = parseImageMarkers(joined);
745
+ const allImages = images.length ? images : refs;
746
+ const hasText = displayText && displayText !== INTERRUPT_MARKER;
747
+ const hasImages = allImages.length > 0;
646
748
  if (hasText || hasImages) {
647
749
  pushUserMessage(
648
750
  messages,
649
- joined,
751
+ displayText,
650
752
  obj.timestamp,
651
- getSystemMessageLabel(joined),
652
- { uuid: obj.uuid, images, toolResultRefs: hasText ? toolResultRefs : [] }
753
+ getSystemMessageLabel(displayText),
754
+ { uuid: obj.uuid, images: allImages, toolResultRefs: hasText ? toolResultRefs : [] }
653
755
  );
654
756
  }
655
757
  }
758
+ } else if (obj.type === 'queue-operation' && obj.operation === 'enqueue') {
759
+ // Queued messages are stored as top-level `content`, not under
760
+ // message.content, and never re-emitted as a type:'user' line.
761
+ // Surface them so the Session Log reflects what the user sent.
762
+ const qt = typeof obj.content === 'string' ? obj.content : '';
763
+ if (qt) {
764
+ const { refs, text } = parseImageMarkers(qt);
765
+ pushUserMessage(messages, text, obj.timestamp, null, { queued: true, images: refs });
766
+ }
656
767
  }
657
768
  } catch (e) { /* partial line */ }
658
769
  }
@@ -662,15 +773,27 @@ function readRecentMessages(jsonlPath, limit = 10) {
662
773
  }
663
774
 
664
775
  // 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.
776
+ // When truncated, ship the full text inline as toolResultFull so the
777
+ // modal expand toggle is instant. The lazy fetch at
778
+ // /api/sessions/:id/tool-result/:toolUseId remains a fallback for older
779
+ // cached payloads that may lack toolResultFull.
667
780
  for (const msg of messages) {
668
781
  if (msg.type === 'tool_use' && msg.toolUseId && toolResults.has(msg.toolUseId)) {
669
782
  const full = toolResults.get(msg.toolUseId);
670
783
  const truncated = full.length > TOOL_RESULT_MAX;
671
- msg.toolResult = truncated ? full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)' : full;
784
+ if (truncated) {
785
+ msg.toolResult = full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
786
+ msg.toolResultFull = full;
787
+ } else {
788
+ msg.toolResult = full;
789
+ }
672
790
  msg.toolResultTruncated = truncated;
673
791
  }
792
+ if (msg.type === 'tool_use' && msg.tool === 'AskUserQuestion'
793
+ && msg.toolUseId && toolResultExtras.has(msg.toolUseId)) {
794
+ const extra = toolResultExtras.get(msg.toolUseId);
795
+ if (extra.answerPayload) msg.answerPayload = extra.answerPayload;
796
+ }
674
797
  }
675
798
 
676
799
  require('fs').closeSync(fd);
@@ -741,6 +864,31 @@ function readUserImage(jsonlPath, msgUuid, blockIndex) {
741
864
  return null;
742
865
  }
743
866
 
867
+ const CACHED_IMAGE_MIME = {
868
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
869
+ };
870
+
871
+ // Serve pasted image N from ~/.claude/image-cache/<sessionId>/. The cached file may
872
+ // be any image format, so resolve it by index rather than assuming .png. sessionId
873
+ // is validated to a bare id to prevent path traversal.
874
+ function readCachedImage(sessionId, n) {
875
+ const idx = Number(n);
876
+ if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) return null;
877
+ if (!Number.isInteger(idx) || idx < 0) return null;
878
+ const dir = path.join(require('os').homedir(), '.claude', 'image-cache', sessionId);
879
+ try {
880
+ const match = readdirSync(dir).find((f) => {
881
+ const dot = f.lastIndexOf('.');
882
+ return dot > 0 && f.slice(0, dot) === String(idx) && CACHED_IMAGE_MIME[f.slice(dot + 1).toLowerCase()];
883
+ });
884
+ if (!match) return null;
885
+ const ext = match.slice(match.lastIndexOf('.') + 1).toLowerCase();
886
+ return { mediaType: CACHED_IMAGE_MIME[ext], buffer: readFileSync(path.join(dir, match)) };
887
+ } catch (_) {
888
+ return null;
889
+ }
890
+ }
891
+
744
892
  function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
745
893
  const fetchLimit = limit + 1;
746
894
  const applyFilter = beforeTimestamp
@@ -878,6 +1026,13 @@ function buildSessionDigest(jsonlPath) {
878
1026
  for (const [key, entry] of Object.entries(map)) {
879
1027
  if (nameByToolUseId[key]) entry.name = nameByToolUseId[key];
880
1028
  if (descByToolUseId[key]) entry.description = descByToolUseId[key];
1029
+ // Prefer the full prompt from the assistant's tool_use input — Claude Code's
1030
+ // agent_progress system messages embed a truncated prompt that ends mid-sentence.
1031
+ // Only override when entry already had a prompt (i.e. agent_progress path);
1032
+ // bg/teammate paths intentionally keep prompt null per existing contract.
1033
+ if (entry.prompt && promptByToolUseId[key] && promptByToolUseId[key].length > entry.prompt.length) {
1034
+ entry.prompt = promptByToolUseId[key];
1035
+ }
881
1036
  }
882
1037
  } catch (_) {}
883
1038
  const rejectedAgentIds = new Set();
@@ -977,10 +1132,10 @@ function extractPromptFromTranscript(jsonlPath) {
977
1132
  const obj = JSON.parse(firstLine);
978
1133
  if (obj.type === 'user') {
979
1134
  const content = obj.message?.content;
980
- if (typeof content === 'string') return content.slice(0, 500);
1135
+ if (typeof content === 'string') return content;
981
1136
  if (Array.isArray(content)) {
982
1137
  for (const b of content) {
983
- if (b.type === 'text' && b.text) return b.text.slice(0, 500);
1138
+ if (b.type === 'text' && b.text) return b.text;
984
1139
  }
985
1140
  }
986
1141
  }
@@ -1150,6 +1305,7 @@ module.exports = {
1150
1305
  readMessagesPage,
1151
1306
  readFullToolResult,
1152
1307
  readUserImage,
1308
+ readCachedImage,
1153
1309
  updateLoopInfo,
1154
1310
  buildLoopInfoFromState,
1155
1311
  buildAgentProgressMap,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.4.0",
3
+ "version": "4.6.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