claude-code-kanban 5.3.0 → 6.0.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.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/cli.js +30 -108
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/dispatch-groups.js +9 -31
  6. package/lib/dispatch.js +19 -166
  7. package/lib/git-branch.js +62 -0
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/retention.js +5 -17
  11. package/lib/session-cache.js +1 -1
  12. package/lib/session-events.js +3 -2
  13. package/lib/terminal.js +30 -5
  14. package/package.json +1 -2
  15. package/plugin/.claude-plugin/marketplace.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  17. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  20. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  21. package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
  24. package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
  25. package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
  26. package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
  27. package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
  28. package/public/app.js +257 -74
  29. package/public/index.html +12 -2
  30. package/public/project-match.js +6 -1
  31. package/public/style.css +99 -0
  32. package/server.js +146 -147
  33. package/skill-guides/dispatch.md +16 -54
  34. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  35. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  36. package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/lib/dispatch.js CHANGED
@@ -1,179 +1,32 @@
1
- // Dispatch: one Claude Code session started by another through cck, and the report it
2
- // settles with. In memory on purpose: the PTYs die with the server, so nothing a
3
- // restart drops could still settle.
4
- //
5
- // The child holds a per-dispatch capability, never the terminal token, so it can
6
- // report its own outcome and nothing else. A stale or duplicate child is refused
7
- // because a record settles once.
1
+ // Dispatch: a Claude Code session one session started in cck's terminal. In memory on
2
+ // purpose: an entry lives as long as its PTY, which dies with the server.
8
3
 
9
- const crypto = require('node:crypto');
10
- const { tokenMatches } = require('./terminal');
11
- const { clampWait, EVENT_PREFIX } = require('./session-events');
4
+ function createDispatchRegistry({ now = Date.now } = {}) {
5
+ const entries = new Map();
12
6
 
13
- const OUTCOMES = new Set(['succeeded', 'failed']);
14
- const MAX_SUMMARY = 4000;
15
- // Settled records outlive their session long enough for a parent to collect them.
16
- const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
17
- const ID_RE = /^d_[0-9a-f]{12}$/;
18
- // The peer name lands verbatim in the started session's prompt.
19
- const PEER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
20
-
21
- const isPeerName = (v) => typeof v === 'string' && PEER_RE.test(v);
22
-
23
- // With neither --report nor --peer the started session gets the task alone, like one the user typed.
24
- function formatPreamble(record, cli = 'claude-code-kanban') {
25
- if (!record.report && !record.peer) return record.spec;
26
- const lines = [`[cck dispatch ${record.id}] Another Claude Code session started you through claude-code-kanban to do the task below.`];
27
- if (record.peer) {
28
- lines.push(
29
- `That session is the peer "${record.peer}". Ask it, not the user: when you need a decision, or find something that changes the task, send a short message with the SendMessage tool to "${record.peer}".`,
30
- 'Keep working on what does not depend on the answer. The reply arrives as a new message.',
31
- );
32
- }
33
- if (record.report) {
34
- lines.push(
35
- 'When the task is done, or you cannot finish it, report exactly once with this command, then stop:',
36
- `${cli} dispatch done ${record.id} --cap ${record.cap} --outcome succeeded --summary "<what changed, what you found, what remains>"`,
37
- record.peer
38
- ? 'Use --outcome failed when the task cannot be finished. If the command refuses because the dispatch already ended, send the summary to the peer instead.'
39
- : 'Use --outcome failed when the task is not done. Do not ask the user questions the other session must answer; report failed with the question instead.',
40
- );
41
- }
42
- lines.push('', 'Task:', record.spec);
43
- return lines.join('\n');
44
- }
45
-
46
- // The line reaches the parent through the postman at hook trust level; the summary is
47
- // last so no summary text can pose as a further field.
48
- const DISPATCH_OUTCOME = {
49
- succeeded: 'reported success',
50
- failed: 'reported failure',
51
- exited: 'ended without a report',
52
- };
53
-
54
- function formatDispatchLine(r) {
55
- const outcome = DISPATCH_OUTCOME[r.status] || r.status;
56
- const head = `${EVENT_PREFIX} Dispatch ${r.id} (session ${r.session}) ${outcome}.`;
57
- return r.summary ? `${head} Summary: ${r.summary}` : head;
58
- }
59
-
60
- function publicView(r) {
61
- const { cap: _cap, ...rest } = r;
62
- return rest;
63
- }
64
-
65
- function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
66
- const records = new Map();
67
- const waiters = new Set();
68
-
69
- function prune() {
70
- const cutoff = now() - KEEP_SETTLED_MS;
71
- for (const [id, r] of records) if (r.settledAt && r.settledAt < cutoff) records.delete(id);
72
- }
73
-
74
- function create({ parent, spec, name, report, peer, group, worktree }) {
75
- prune();
76
- const r = {
77
- id: `d_${crypto.randomBytes(6).toString('hex')}`,
78
- cap: crypto.randomBytes(16).toString('hex'),
7
+ function add({ session, parent, cwd, name, group, worktree }) {
8
+ entries.set(session, {
9
+ session,
79
10
  parent: parent || null,
80
- session: null,
81
- cwd: null,
11
+ cwd,
82
12
  name: name || null,
83
- report: !!report,
84
- peer: peer || null,
85
13
  group: group || null,
86
14
  worktree: worktree || null,
87
- spec,
88
- status: 'running',
89
- summary: null,
90
15
  startedAt: now(),
91
- settledAt: null,
92
- };
93
- records.set(r.id, r);
94
- return r;
95
- }
96
-
97
- // The preamble carries the id, so the record exists before its session does.
98
- function attach(id, { session, cwd }) {
99
- const r = records.get(id);
100
- if (r) Object.assign(r, { session, cwd });
101
- }
102
-
103
- function discard(id) {
104
- records.delete(id);
105
- }
106
-
107
- function finish(r, status, summary) {
108
- r.status = status;
109
- r.summary = summary;
110
- r.settledAt = now();
111
- onSettle?.(r);
112
- for (const wake of [...waiters]) wake();
113
- }
114
-
115
- // Returns null when settled, or {status, error} naming the refusal.
116
- function settle(id, cap, outcome, summary) {
117
- const r = typeof id === 'string' && ID_RE.test(id) ? records.get(id) : null;
118
- if (!r) return { status: 404, error: 'no such dispatch' };
119
- if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
120
- if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
121
- if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
122
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
123
- const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
124
- finish(r, outcome, text || null);
125
- return null;
126
- }
127
-
128
- // The PTY ended with no report: the parent must not wait on it forever.
129
- function sessionExited(sessionId) {
130
- for (const r of records.values()) if (r.session === sessionId && r.status === 'running') finish(r, 'exited', null);
131
- }
132
-
133
- function select({ ids, parent } = {}) {
134
- prune();
135
- let out = [...records.values()];
136
- if (ids?.length) out = out.filter((r) => ids.includes(r.id));
137
- else if (parent) out = out.filter((r) => r.parent === parent);
138
- return out;
139
- }
140
-
141
- function list(filter) {
142
- return select(filter).map(publicView);
16
+ });
143
17
  }
144
18
 
145
- // Resolves once any selected record is settled, or after `waitSec`. Stateless for the
146
- // caller: it passes the ids still running on the next call. `onClose(stop)` returns an
147
- // unsubscribe, called once the wait ends.
148
- function wait(filter, waitSec, onClose) {
149
- const sec = clampWait(waitSec);
150
- const snapshot = () => {
151
- const rows = select(filter);
152
- return {
153
- settled: rows.filter((r) => r.status !== 'running').map(publicView),
154
- running: rows.filter((r) => r.status === 'running').map(publicView),
155
- };
156
- };
157
- const first = snapshot();
158
- if (first.settled.length || !first.running.length || !sec) return Promise.resolve({ ...first, timeout: false });
159
- return new Promise((resolve) => {
160
- let unsubscribe;
161
- const done = (timeout) => {
162
- if (!waiters.delete(wake)) return;
163
- clearTimeout(timer);
164
- unsubscribe?.();
165
- resolve({ ...snapshot(), timeout });
166
- };
167
- const wake = () => {
168
- if (select(filter).some((r) => r.status !== 'running')) done(false);
169
- };
170
- const timer = setTimeout(() => done(true), sec * 1000);
171
- waiters.add(wake);
172
- unsubscribe = onClose?.(() => done(true));
173
- });
19
+ function list({ parent } = {}) {
20
+ const out = [...entries.values()];
21
+ return parent ? out.filter((e) => e.parent === parent) : out;
174
22
  }
175
23
 
176
- return { create, attach, discard, settle, sessionExited, list, wait };
24
+ return {
25
+ add,
26
+ list,
27
+ has: (session) => entries.has(session),
28
+ remove: (session) => entries.delete(session),
29
+ };
177
30
  }
178
31
 
179
- module.exports = { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME };
32
+ module.exports = { createDispatchRegistry };
@@ -0,0 +1,62 @@
1
+ // Read from HEAD rather than by spawning git: a sync spawn on the session-list path blocks the
2
+ // event loop for up to half a second per cwd on Windows, and terminal input waits behind it.
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const REF_RE = /^ref:\s*refs\/heads\/(.+)$/;
8
+ const GITDIR_RE = /^gitdir:\s*(.+)$/;
9
+
10
+ // `.git` is a directory in an ordinary checkout and a `gitdir:` pointer file in a linked
11
+ // worktree or a submodule.
12
+ function headFile(dir, readFile) {
13
+ const dotGit = path.join(dir, '.git');
14
+ let text;
15
+ try {
16
+ text = readFile(dotGit);
17
+ } catch (e) {
18
+ if (e.code === 'EISDIR') return path.join(dotGit, 'HEAD');
19
+ return null;
20
+ }
21
+ const m = GITDIR_RE.exec(text.trim());
22
+ return m ? path.join(path.resolve(dir, m[1]), 'HEAD') : null;
23
+ }
24
+
25
+ /**
26
+ * @param {string} cwd
27
+ * @param {(file: string) => string} [readFile]
28
+ * @returns {string|null} null outside a repo and on a detached HEAD
29
+ */
30
+ function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
31
+ for (let dir = path.resolve(cwd); ; ) {
32
+ const head = headFile(dir, readFile);
33
+ if (head) {
34
+ try {
35
+ const m = REF_RE.exec(readFile(head).trim());
36
+ return m ? m[1] : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+ const up = path.dirname(dir);
42
+ if (up === dir) return null;
43
+ dir = up;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Reads HEAD only where the transcript's `gitBranch` is wrong: cwd moved away from the launch
49
+ * project, or the project is a worktree (a `claude -w` session records the main checkout's
50
+ * branch on every line).
51
+ * @param {{cwd?: string, project?: string, gitBranch?: string}} meta
52
+ * @param {boolean} isWorktree whether `meta.project` is a linked worktree
53
+ * @param {(cwd: string) => string|null} branchAt
54
+ */
55
+ function sessionGitBranch(meta, isWorktree, branchAt) {
56
+ if (meta.cwd && meta.project && (meta.cwd !== meta.project || isWorktree)) {
57
+ return branchAt(meta.cwd) || meta.gitBranch || null;
58
+ }
59
+ return meta.gitBranch || null;
60
+ }
61
+
62
+ module.exports = { readGitBranch, sessionGitBranch };
package/lib/parsers.js CHANGED
@@ -4,6 +4,21 @@ const path = require('node:path');
4
4
  const os = require('node:os');
5
5
  const { StringDecoder } = require('node:string_decoder');
6
6
 
7
+ // Full-transcript scans stream the file instead of reading it whole: a transcript can
8
+ // reach hundreds of megabytes, and the event loop it would block also carries the
9
+ // embedded terminal's keystrokes. Yields the same lines as content.split('\n').
10
+ async function* readLines(file) {
11
+ const decoder = new StringDecoder('utf8');
12
+ let rest = '';
13
+ for await (const chunk of fs.createReadStream(file, { highWaterMark: 1 << 20 })) {
14
+ const parts = decoder.write(chunk).split('\n');
15
+ parts[0] = rest + parts[0];
16
+ rest = parts.pop();
17
+ yield* parts;
18
+ }
19
+ yield rest + decoder.end();
20
+ }
21
+
7
22
  function parseTask(raw) {
8
23
  const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
9
24
  return {
@@ -347,12 +362,24 @@ function scrapeScalarFromBlob(blob, re) {
347
362
  const sessionInfoCache = new Map();
348
363
  const SESSION_INFO_CACHE_MAX = 2000;
349
364
 
365
+ // A prompt line carries the mode it was sent in. After a Shift+Tab, Claude Code writes an
366
+ // auto_mode / auto_mode_exit attachment with the next tool result, and later rewrites a
367
+ // permission-mode line with the live mode, often tens of seconds after.
368
+ const modeOf = (data) => {
369
+ if (data.type === 'attachment') {
370
+ const t = data.attachment?.type;
371
+ return t === 'auto_mode' ? 'auto' : t === 'auto_mode_exit' ? 'default' : null;
372
+ }
373
+ return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
374
+ };
375
+
350
376
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
351
377
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
352
378
  // live branch must resolve it from cwd separately. Cache is reset on inode
353
379
  // change or truncation (size < scannedUpTo).
380
+
354
381
  function readSessionInfoFromJsonl(jsonlPath) {
355
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null };
382
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null };
356
383
  let stat;
357
384
  let fd;
358
385
  try {
@@ -372,6 +399,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
372
399
  gitBranch: cached.gitBranch,
373
400
  logicalParentUuid: cached.logicalParentUuid || null,
374
401
  compactBoundaryUuid: cached.compactBoundaryUuid || null,
402
+ permissionMode: cached.permissionMode || null,
375
403
  customTitle: readCustomTitle(jsonlPath, stat)
376
404
  };
377
405
  }
@@ -383,6 +411,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
383
411
  result.gitBranch = cached.gitBranch;
384
412
  result.logicalParentUuid = cached.logicalParentUuid || null;
385
413
  result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
414
+ result.permissionMode = cached.permissionMode || null;
386
415
  }
387
416
 
388
417
  let lastCwdSeen = result.cwd;
@@ -395,6 +424,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
395
424
  lastCwdSeen = data.cwd;
396
425
  }
397
426
  if (data.gitBranch) result.gitBranch = data.gitBranch;
427
+ const mode = modeOf(data);
428
+ if (mode) result.permissionMode = mode;
398
429
  if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
399
430
  result.logicalParentUuid = data.logicalParentUuid;
400
431
  // The boundary record's own uuid: a fork copies it verbatim from the
@@ -466,6 +497,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
466
497
  const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
467
498
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
468
499
  let latestTailCwd = null;
500
+ let latestTailMode = null;
469
501
  for (let i = lines.length - 1; i >= 0; i--) {
470
502
  try {
471
503
  const data = JSON.parse(lines[i]);
@@ -473,10 +505,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
473
505
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
474
506
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
475
507
  if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
476
- if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
508
+ if (!latestTailMode) latestTailMode = modeOf(data);
509
+ if (latestTailCwd && latestTailMode && result.slug && result.projectPath && result.gitBranch) break;
477
510
  } catch {}
478
511
  }
479
512
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
513
+ if (latestTailMode) result.permissionMode = latestTailMode;
480
514
  }
481
515
  scannedUpTo = stat.size;
482
516
  }
@@ -498,7 +532,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
498
532
  gitBranch: result.gitBranch,
499
533
  cwd: result.cwd,
500
534
  logicalParentUuid: result.logicalParentUuid,
501
- compactBoundaryUuid: result.compactBoundaryUuid
535
+ compactBoundaryUuid: result.compactBoundaryUuid,
536
+ permissionMode: result.permissionMode
502
537
  });
503
538
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
504
539
  const firstKey = sessionInfoCache.keys().next().value;
@@ -1064,12 +1099,10 @@ function readRecentMessages(jsonlPath, limit = 10) {
1064
1099
  }
1065
1100
  }
1066
1101
 
1067
- function readFullToolResult(jsonlPath, toolUseId) {
1102
+ async function readFullToolResult(jsonlPath, toolUseId) {
1068
1103
  if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
1069
1104
  try {
1070
- const content = readFileSync(jsonlPath, 'utf8');
1071
- const lines = content.split('\n');
1072
- for (const line of lines) {
1105
+ for await (const line of readLines(jsonlPath)) {
1073
1106
  if (!line || line.indexOf(toolUseId) === -1) continue;
1074
1107
  try {
1075
1108
  const obj = JSON.parse(line);
@@ -1092,14 +1125,12 @@ function readFullToolResult(jsonlPath, toolUseId) {
1092
1125
  return null;
1093
1126
  }
1094
1127
 
1095
- function readUserImage(jsonlPath, msgUuid, blockIndex) {
1128
+ async function readUserImage(jsonlPath, msgUuid, blockIndex) {
1096
1129
  if (!msgUuid || !jsonlPath) return null;
1097
1130
  const idx = Number(blockIndex);
1098
1131
  if (!Number.isInteger(idx) || idx < 0) return null;
1099
1132
  try {
1100
- const content = readFileSync(jsonlPath, 'utf8');
1101
- const lines = content.split('\n');
1102
- for (const line of lines) {
1133
+ for await (const line of readLines(jsonlPath)) {
1103
1134
  if (!line || line.indexOf(msgUuid) === -1) continue;
1104
1135
  try {
1105
1136
  const obj = JSON.parse(line);
@@ -1119,14 +1150,12 @@ function readUserImage(jsonlPath, msgUuid, blockIndex) {
1119
1150
 
1120
1151
  // Read the Nth base64 image embedded in a tool_result block (e.g. the output of
1121
1152
  // a Read on an image file). Scans for the tool_result matching toolUseId.
1122
- function readToolResultImage(jsonlPath, toolUseId, n) {
1153
+ async function readToolResultImage(jsonlPath, toolUseId, n) {
1123
1154
  if (!toolUseId || !jsonlPath) return null;
1124
1155
  const idx = Number(n);
1125
1156
  if (!Number.isInteger(idx) || idx < 0) return null;
1126
1157
  try {
1127
- const content = readFileSync(jsonlPath, 'utf8');
1128
- const lines = content.split('\n');
1129
- for (const line of lines) {
1158
+ for await (const line of readLines(jsonlPath)) {
1130
1159
  if (!line || line.indexOf(toolUseId) === -1) continue;
1131
1160
  let obj;
1132
1161
  try { obj = JSON.parse(line); } catch (_) { continue; }
@@ -1196,14 +1225,13 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
1196
1225
  };
1197
1226
  }
1198
1227
 
1199
- function buildSessionDigest(jsonlPath) {
1228
+ async function buildSessionDigest(jsonlPath) {
1200
1229
  const map = {};
1201
1230
  const terminated = new Map();
1202
1231
  const rejectedToolUseIds = new Set();
1203
1232
  const promptByToolUseId = {};
1204
1233
  const killedAgentIds = new Set();
1205
1234
  try {
1206
- const content = readFileSync(jsonlPath, 'utf8');
1207
1235
  const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
1208
1236
  const parentRe = /"parentToolUseID":"([^"]+)"/;
1209
1237
  const promptRe = /"prompt":"((?:[^"\\]|\\.)*)"/;
@@ -1215,7 +1243,7 @@ function buildSessionDigest(jsonlPath) {
1215
1243
  const nameByToolUseId = {};
1216
1244
  const descByToolUseId = {};
1217
1245
  const usageByToolUseId = {};
1218
- for (const line of content.split('\n')) {
1246
+ for await (const line of readLines(jsonlPath)) {
1219
1247
  // Terminated-teammate detection: check first since cheap substring guards
1220
1248
  if (line.includes('teammate-message') &&
1221
1249
  (line.includes('teammate_terminated') || line.includes('shutdown_response'))) {
@@ -1346,8 +1374,8 @@ function buildSessionDigest(jsonlPath) {
1346
1374
  return { progressMap: map, terminated, rejectedAgentIds, rejectedPrompts, killedAgentIds };
1347
1375
  }
1348
1376
 
1349
- function buildAgentProgressMap(jsonlPath) {
1350
- return buildSessionDigest(jsonlPath).progressMap;
1377
+ async function buildAgentProgressMap(jsonlPath) {
1378
+ return (await buildSessionDigest(jsonlPath)).progressMap;
1351
1379
  }
1352
1380
 
1353
1381
  const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
@@ -1365,7 +1393,7 @@ function toolResultText(content) {
1365
1393
  // only when its tool_use_id belongs to a publish call — an artifact URL quoted
1366
1394
  // back by a grep or a file read would otherwise register another session's page as
1367
1395
  // this session's own, and a `read` of someone else's artifact is not a publish.
1368
- function readArtifactLinks(jsonlPath) {
1396
+ async function readArtifactLinks(jsonlPath) {
1369
1397
  const byUrl = new Map();
1370
1398
  const publishedFileByToolUseId = new Map();
1371
1399
  const upsert = (url, ts) => {
@@ -1378,8 +1406,7 @@ function readArtifactLinks(jsonlPath) {
1378
1406
  return a;
1379
1407
  };
1380
1408
  try {
1381
- const content = readFileSync(jsonlPath, 'utf8');
1382
- for (const line of content.split('\n')) {
1409
+ for await (const line of readLines(jsonlPath)) {
1383
1410
  if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
1384
1411
  continue;
1385
1412
  let obj;
@@ -1426,11 +1453,10 @@ const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
1426
1453
  // the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
1427
1454
  // stands in because it is what the manifest stores as its own `name`; the path is not
1428
1455
  // recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
1429
- function readScratchpadCreations(jsonlPath) {
1456
+ async function readScratchpadCreations(jsonlPath) {
1430
1457
  const calls = new Map();
1431
1458
  try {
1432
- const content = readFileSync(jsonlPath, 'utf8');
1433
- for (const line of content.split('\n')) {
1459
+ for await (const line of readLines(jsonlPath)) {
1434
1460
  // Substring reject before JSON.parse: transcripts reach tens of megabytes and
1435
1461
  // only a handful of lines can ever match.
1436
1462
  const isCall = line.includes('scratch new');
@@ -1456,13 +1482,12 @@ function readScratchpadCreations(jsonlPath) {
1456
1482
  return [...calls.values()];
1457
1483
  }
1458
1484
 
1459
- function readCompactSummaries(jsonlPath) {
1485
+ async function readCompactSummaries(jsonlPath) {
1460
1486
  const results = [];
1461
1487
  // Inline format: newer Claude Code stores the summary directly in the parent
1462
1488
  // session JSONL as a user message with isCompactSummary: true (no subagent file).
1463
1489
  try {
1464
- const content = readFileSync(jsonlPath, 'utf8');
1465
- for (const line of content.split('\n')) {
1490
+ for await (const line of readLines(jsonlPath)) {
1466
1491
  if (!line.trim() || !line.includes('isCompactSummary')) continue;
1467
1492
  try {
1468
1493
  const obj = JSON.parse(line);
@@ -1515,8 +1540,8 @@ function readCompactSummaries(jsonlPath) {
1515
1540
  return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1516
1541
  }
1517
1542
 
1518
- function findTerminatedTeammates(jsonlPath) {
1519
- return buildSessionDigest(jsonlPath).terminated;
1543
+ async function findTerminatedTeammates(jsonlPath) {
1544
+ return (await buildSessionDigest(jsonlPath)).terminated;
1520
1545
  }
1521
1546
 
1522
1547
  function extractPromptFromTranscript(jsonlPath) {
@@ -1585,28 +1610,28 @@ function extractModelFromTranscript(jsonlPath) {
1585
1610
  // output tokens, and first/last timestamps (for duration). COLD PATH ONLY —
1586
1611
  // used by the workflow run view on demand, never from the session-scan hot
1587
1612
  // path (a full read per agent would blow the /api/sessions budget).
1588
- function extractTranscriptStats(jsonlPath) {
1589
- let content;
1590
- try { content = readFileSync(jsonlPath, 'utf8'); } catch (_) { return null; }
1613
+ async function extractTranscriptStats(jsonlPath) {
1591
1614
  let model = null;
1592
1615
  let outputTokens = 0;
1593
1616
  let firstTs = null;
1594
1617
  let lastTs = null;
1595
- for (const line of content.split('\n')) {
1596
- if (!line.trim()) continue;
1597
- let obj;
1598
- try { obj = JSON.parse(line); } catch (_) { continue; }
1599
- if (obj.timestamp) {
1600
- if (!firstTs) firstTs = obj.timestamp;
1601
- lastTs = obj.timestamp;
1602
- }
1603
- const msg = obj.message;
1604
- if (msg && typeof msg === 'object') {
1605
- if (!model && msg.model) model = msg.model;
1606
- const out = msg.usage?.output_tokens;
1607
- if (typeof out === 'number') outputTokens += out;
1618
+ try {
1619
+ for await (const line of readLines(jsonlPath)) {
1620
+ if (!line.trim()) continue;
1621
+ let obj;
1622
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1623
+ if (obj.timestamp) {
1624
+ if (!firstTs) firstTs = obj.timestamp;
1625
+ lastTs = obj.timestamp;
1626
+ }
1627
+ const msg = obj.message;
1628
+ if (msg && typeof msg === 'object') {
1629
+ if (!model && msg.model) model = msg.model;
1630
+ const out = msg.usage?.output_tokens;
1631
+ if (typeof out === 'number') outputTokens += out;
1632
+ }
1608
1633
  }
1609
- }
1634
+ } catch (_) { return null; }
1610
1635
  return { model, outputTokens, firstTs, lastTs };
1611
1636
  }
1612
1637
 
@@ -1625,7 +1650,9 @@ function formatStructuredResult(input) {
1625
1650
  // Some subagents end their run on a tool call and never emit a text response, so
1626
1651
  // their lastMessage is empty: workflow subagents given a schema call StructuredOutput,
1627
1652
  // background subagents report through SubagentHandback. Tail-read the transcript and
1628
- // take the LAST such tool_use as the agent's result. Returns null when absent.
1653
+ // take the LAST such tool_use as the agent's result. Without one, take the last
1654
+ // assistant text: the plugin's mod records no lastMessage, because Claude Code's
1655
+ // turn.complete carries an empty answer for a subagent. Returns null when neither exists.
1629
1656
  const RESULT_TOOLS = {
1630
1657
  StructuredOutput: formatStructuredResult,
1631
1658
  SubagentHandback: (input) => (typeof input.message === 'string' && input.message) || null,
@@ -1649,16 +1676,25 @@ function extractAgentResultFromTranscript(jsonlPath) {
1649
1676
  const text = buf.toString('utf8');
1650
1677
  // Drop the leading partial line unless we read from the very start.
1651
1678
  const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
1652
- if (!RESULT_TOOL_TEXT.some((t) => clean.includes(t))) return null;
1653
1679
  const lines = clean.split('\n');
1654
- for (let i = lines.length - 1; i >= 0; i--) {
1655
- if (!RESULT_TOOL_TEXT.some((t) => lines[i].includes(t))) continue;
1656
- let obj;
1657
- try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1658
- const content = obj?.message?.content;
1659
- const result = Array.isArray(content) && content.map(resultFromBlock).findLast(Boolean);
1660
- if (result) return result;
1661
- }
1680
+ const lastAssistant = (needle, pick) => {
1681
+ for (let i = lines.length - 1; i >= 0; i--) {
1682
+ if (!needle(lines[i])) continue;
1683
+ let obj;
1684
+ try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1685
+ const content = obj?.message?.content;
1686
+ const found = obj?.type === 'assistant' && Array.isArray(content) && pick(content);
1687
+ if (found) return found;
1688
+ }
1689
+ return null;
1690
+ };
1691
+ return lastAssistant(
1692
+ (line) => RESULT_TOOL_TEXT.some((t) => line.includes(t)),
1693
+ (content) => content.map(resultFromBlock).findLast(Boolean),
1694
+ ) ?? lastAssistant(
1695
+ (line) => line.includes('"type":"assistant"'),
1696
+ (content) => toolResultText(content).trim(),
1697
+ );
1662
1698
  } catch (_) {
1663
1699
  } finally {
1664
1700
  closeSync(fd);
@@ -1831,5 +1867,6 @@ module.exports = {
1831
1867
  extractPromptFromTranscript,
1832
1868
  extractModelFromTranscript,
1833
1869
  extractAgentResultFromTranscript,
1834
- extractTranscriptStats
1870
+ extractTranscriptStats,
1871
+ readLines
1835
1872
  };
@@ -31,7 +31,7 @@ function pluginStatus(claudeDir, bundledManifest = BUNDLED_MANIFEST) {
31
31
 
32
32
  // --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
33
33
  function installCommand(claudeDir) {
34
- const base = 'claude-code-kanban --install --plugin-only';
34
+ const base = 'claude-code-kanban --install --yes';
35
35
  return isDefaultClaudeDir(claudeDir) ? base : `${base} --dir "${claudeDir}"`;
36
36
  }
37
37