claude-code-kanban 3.2.4 → 3.4.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/cli.js ADDED
@@ -0,0 +1,458 @@
1
+ const path = require('path');
2
+
3
+ // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
4
+ const COMMANDS = {
5
+ preview: {
6
+ summary: 'Open a markdown file in the preview modal on connected browser tabs',
7
+ usage: 'claude-code-kanban preview <file.md> [--session <id>]',
8
+ flags: {
9
+ '--session <id>': 'Switch focused session in the browser (does not link the file)',
10
+ },
11
+ run: runPreviewCli,
12
+ },
13
+ session: {
14
+ summary: 'List or open Claude Code sessions',
15
+ verbs: {
16
+ list: {
17
+ summary: 'List sessions',
18
+ usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--json]',
19
+ flags: {
20
+ '--active': 'Only sessions with recent activity (sidebar-style filter)',
21
+ '--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
22
+ '--project <name>': 'Filter by project name (substring match)',
23
+ '--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
24
+ '--json': 'Output JSON instead of a table',
25
+ },
26
+ run: runSessionListCli,
27
+ },
28
+ open: {
29
+ summary: 'Focus a session in the browser (Active tab)',
30
+ usage: 'claude-code-kanban session open <id>',
31
+ flags: {
32
+ '<id>': 'Full session id, or a unique prefix',
33
+ },
34
+ run: runSessionOpenCli,
35
+ },
36
+ view: {
37
+ summary: 'Show full session stats (metadata + context window + cost)',
38
+ usage: 'claude-code-kanban session view <id> [--json]',
39
+ flags: {
40
+ '<id>': 'Full session id, or a unique prefix',
41
+ '--json': 'Output JSON instead of formatted sections',
42
+ },
43
+ run: runSessionViewCli,
44
+ },
45
+ peek: {
46
+ summary: 'Show the last N messages from a session',
47
+ usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
48
+ flags: {
49
+ '<id>': 'Full session id, or a unique prefix',
50
+ '--limit <n>': 'Number of messages (default: 10, max: 50)',
51
+ '--json': 'Output JSON instead of formatted lines',
52
+ },
53
+ run: runSessionPeekCli,
54
+ },
55
+ },
56
+ },
57
+ };
58
+
59
+ function runCli(argv) {
60
+ if (argv.includes('--version') || argv.includes('-v')) {
61
+ console.log(require('./package.json').version);
62
+ process.exit(0);
63
+ }
64
+ const cli = resolveCliCommand(argv);
65
+ if (cli.kind === 'server') return false;
66
+ if (cli.kind === 'help') {
67
+ if (cli.target && Object.hasOwn(COMMANDS, cli.target)) printNounHelp(cli.target);
68
+ else printTopHelp();
69
+ process.exit(0);
70
+ }
71
+ if (cli.kind === 'unknown-noun') {
72
+ console.error(`Unknown command: ${cli.noun}\n`);
73
+ printTopHelp();
74
+ process.exit(1);
75
+ }
76
+ if (cli.kind === 'unknown-verb') {
77
+ console.error(`Unknown subcommand: ${cli.noun} ${cli.verb}\n`);
78
+ printNounHelp(cli.noun);
79
+ process.exit(1);
80
+ }
81
+ if (cli.kind === 'noun') {
82
+ printNounHelp(cli.noun);
83
+ process.exit(0);
84
+ }
85
+ if (cli.kind === 'leaf') {
86
+ if (cli.args.includes('--help') || cli.args.includes('-h')) {
87
+ printLeafHelp(cli.name, cli.entry);
88
+ process.exit(0);
89
+ }
90
+ cli.entry.run(cli.args)
91
+ .then(code => { process.exitCode = code; })
92
+ .catch(e => { console.error(e.message); process.exitCode = 1; });
93
+ return true;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ function resolveCliCommand(argv) {
99
+ const noun = argv[2] && !argv[2].startsWith('-') ? argv[2] : null;
100
+ const hasHelp = (a) => a.includes('--help') || a.includes('-h');
101
+ if (!noun) return hasHelp(argv) ? { kind: 'help' } : { kind: 'server' };
102
+ if (noun === 'help') return { kind: 'help', target: argv[3] };
103
+ if (!Object.hasOwn(COMMANDS, noun)) return { kind: 'unknown-noun', noun };
104
+ const entry = COMMANDS[noun];
105
+ if (!entry.verbs) return { kind: 'leaf', name: noun, entry, args: argv.slice(3) };
106
+ const verb = argv[3] && !argv[3].startsWith('-') ? argv[3] : null;
107
+ if (!verb) return { kind: 'noun', noun };
108
+ if (!Object.hasOwn(entry.verbs, verb)) return { kind: 'unknown-verb', noun, verb };
109
+ return { kind: 'leaf', name: `${noun} ${verb}`, entry: entry.verbs[verb], args: argv.slice(4) };
110
+ }
111
+
112
+ function printTopHelp() {
113
+ console.log('Usage: claude-code-kanban <command> [args] [--flags]\n');
114
+ console.log('Commands:');
115
+ for (const [name, cmd] of Object.entries(COMMANDS)) {
116
+ console.log(` ${name.padEnd(20)}${cmd.summary}`);
117
+ if (cmd.verbs) {
118
+ for (const [vName, v] of Object.entries(cmd.verbs)) {
119
+ console.log(` ${`${name} ${vName}`.padEnd(18)}${v.summary}`);
120
+ }
121
+ }
122
+ }
123
+ console.log(` ${'help'.padEnd(20)}Show help for a command (claude-code-kanban help <command>)`);
124
+ console.log('\nFlags:');
125
+ console.log(' --help, -h Show help (top-level, noun-level, or leaf-level)');
126
+ console.log(' --version, -v Print version and exit');
127
+ console.log('\nServer mode (no subcommand):');
128
+ console.log(' --port <n> Port to listen on (default 3456)');
129
+ console.log(' --dir <path> Override Claude config dir (default ~/.claude)');
130
+ console.log(' --open Open browser on start');
131
+ console.log(' --install, --uninstall Install or remove the agent-spy hook');
132
+ }
133
+
134
+ function printNounHelp(noun) {
135
+ const entry = COMMANDS[noun];
136
+ console.log(`${entry.summary}\n`);
137
+ if (entry.verbs) {
138
+ console.log(`Usage: claude-code-kanban ${noun} <subcommand> [args] [--flags]\n`);
139
+ console.log('Subcommands:');
140
+ for (const [vName, v] of Object.entries(entry.verbs)) {
141
+ console.log(` ${vName.padEnd(12)}${v.summary}`);
142
+ }
143
+ console.log(`\nRun \`claude-code-kanban ${noun} <subcommand> --help\` for details.`);
144
+ } else {
145
+ printLeafHelp(noun, entry);
146
+ }
147
+ }
148
+
149
+ function printLeafHelp(name, entry) {
150
+ console.log(`${entry.summary}\n`);
151
+ console.log(`Usage: ${entry.usage}`);
152
+ if (entry.flags && Object.keys(entry.flags).length) {
153
+ const pad = Math.max(...Object.keys(entry.flags).map(f => f.length));
154
+ console.log('\nFlags:');
155
+ for (const [flag, desc] of Object.entries(entry.flags)) {
156
+ console.log(` ${flag.padEnd(pad + 2)}${desc}`);
157
+ }
158
+ }
159
+ console.log('\n --help, -h Show this help');
160
+ }
161
+
162
+ function getArgValue(args, name) {
163
+ const idx = args.findIndex(a => a === `--${name}` || a.startsWith(`--${name}=`));
164
+ if (idx === -1) return null;
165
+ const arg = args[idx];
166
+ if (arg.includes('=')) return arg.split('=').slice(1).join('=');
167
+ return args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : null;
168
+ }
169
+
170
+ function cliPort() { return process.env.PORT || 3456; }
171
+ function unreachable() { return `Cannot reach cck server on port ${cliPort()}. Start it first with "claude-code-kanban".`; }
172
+
173
+ class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
174
+
175
+ async function cliFetch(urlPath, init) {
176
+ try {
177
+ return await fetch(`http://127.0.0.1:${cliPort()}${urlPath}`, init);
178
+ } catch (e) {
179
+ if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
180
+ throw e;
181
+ }
182
+ }
183
+
184
+ function reportCliError(e) {
185
+ console.error(e.code === 'unreachable' ? e.message : (e.message || String(e)));
186
+ }
187
+
188
+ async function runPreviewCli(args) {
189
+ const filePathArg = args.find(a => !a.startsWith('--'));
190
+ if (!filePathArg) {
191
+ printLeafHelp('preview', COMMANDS.preview);
192
+ return 1;
193
+ }
194
+ const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
195
+ const abs = path.resolve(filePathArg);
196
+ try {
197
+ const res = await cliFetch('/api/preview', {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ body: JSON.stringify({ path: abs, sessionId })
201
+ });
202
+ if (!res.ok) {
203
+ console.error(`Preview failed (${res.status}): ${await res.text()}`);
204
+ return 1;
205
+ }
206
+ console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId})` : ''}`);
207
+ return 0;
208
+ } catch (e) { reportCliError(e); return 1; }
209
+ }
210
+
211
+ // Mirror of `isSessionActive` in public/app.js — keep in sync (different runtimes, no shared module).
212
+ function isSessionActive(s) {
213
+ return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
214
+ }
215
+
216
+ function sessionStatus(s) {
217
+ if (!isSessionActive(s)) return 'idle';
218
+ if (s.hasWaitingForUser) return 'wait';
219
+ if (s.inProgress > 0) return 'busy';
220
+ return 'active';
221
+ }
222
+
223
+ function parseLimit(args, { fallback, allowAll = false }) {
224
+ const raw = getArgValue(args, 'limit');
225
+ if (raw === null) return { ok: true, limit: fallback };
226
+ if (allowAll && raw === 'all') return { ok: true, limit: null };
227
+ const n = parseInt(raw, 10);
228
+ if (Number.isNaN(n) || n <= 0) return { ok: false, error: `Invalid --limit value: ${raw}` };
229
+ return { ok: true, limit: n };
230
+ }
231
+
232
+ async function fetchSessionsList(limit) {
233
+ const q = limit === null ? 'all' : String(limit);
234
+ const res = await cliFetch(`/api/sessions?limit=${q}`);
235
+ if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
236
+ return res.json();
237
+ }
238
+
239
+ async function resolveSessionByIdOrPrefix(idArg) {
240
+ let res;
241
+ try {
242
+ res = await cliFetch(`/api/session/resolve?id=${encodeURIComponent(idArg)}`);
243
+ } catch (e) {
244
+ reportCliError(e);
245
+ return null;
246
+ }
247
+ if (res.status === 404) {
248
+ console.error(`No session matches: ${idArg}`);
249
+ return null;
250
+ }
251
+ if (res.status === 409) {
252
+ const { matches = [] } = await res.json().catch(() => ({}));
253
+ console.error(`Ambiguous prefix "${idArg}" matches ${matches.length} sessions:`);
254
+ for (const m of matches.slice(0, 10)) console.error(` ${m.id} ${m.customTitle || ''}`);
255
+ return null;
256
+ }
257
+ if (!res.ok) {
258
+ console.error(`Resolve failed (${res.status}): ${await res.text()}`);
259
+ return null;
260
+ }
261
+ return res.json();
262
+ }
263
+
264
+ async function runSessionListCli(args) {
265
+ const activeOnly = args.includes('--active');
266
+ const projectFilter = getArgValue(args, 'project');
267
+ const daysArg = getArgValue(args, 'days');
268
+ const days = daysArg !== null ? parseFloat(daysArg) : null;
269
+ if (daysArg !== null && (Number.isNaN(days) || days <= 0)) {
270
+ console.error(`Invalid --days value: ${daysArg}`);
271
+ return 1;
272
+ }
273
+ const parsed = parseLimit(args, { fallback: 10, allowAll: true });
274
+ if (!parsed.ok) { console.error(parsed.error); return 1; }
275
+ const limit = parsed.limit;
276
+ const asJson = args.includes('--json');
277
+ const hasClientFilter = activeOnly || days !== null || projectFilter;
278
+ let list;
279
+ try {
280
+ list = await fetchSessionsList(hasClientFilter ? null : limit);
281
+ } catch (e) {
282
+ reportCliError(e);
283
+ return 1;
284
+ }
285
+ if (activeOnly) list = list.filter(isSessionActive);
286
+ if (days !== null) {
287
+ const cutoff = Date.now() - days * 86_400_000;
288
+ list = list.filter(s => s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff);
289
+ }
290
+ if (projectFilter) {
291
+ const needle = projectFilter.toLowerCase();
292
+ list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
293
+ }
294
+ const totalMatched = list.length;
295
+ if (limit !== null && list.length > limit) list = list.slice(0, limit);
296
+ if (asJson) {
297
+ console.log(JSON.stringify(list, null, 2));
298
+ return 0;
299
+ }
300
+ if (!list.length) {
301
+ console.log('No sessions match.');
302
+ return 0;
303
+ }
304
+ const rows = list.map(s => ({
305
+ id: s.id.slice(0, 8),
306
+ status: sessionStatus(s),
307
+ age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
308
+ tasks: `${s.completed}/${s.taskCount}`,
309
+ project: path.basename(s.project || ''),
310
+ title: s.customTitle || s.name || s.slug || '',
311
+ }));
312
+ const w = {
313
+ id: 8,
314
+ status: Math.max(6, ...rows.map(r => r.status.length)),
315
+ age: Math.max(3, ...rows.map(r => r.age.length)),
316
+ tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
317
+ project: Math.max(7, ...rows.map(r => r.project.length)),
318
+ };
319
+ console.log(`${'ID'.padEnd(w.id)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
320
+ for (const r of rows) {
321
+ console.log(`${r.id.padEnd(w.id)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
322
+ }
323
+ if (limit !== null && totalMatched > limit) {
324
+ console.log(`\n... ${totalMatched - limit} more. Use --limit <n> or --limit all to see them.`);
325
+ }
326
+ return 0;
327
+ }
328
+
329
+ function formatAge(ms) {
330
+ if (ms < 0) return '0s';
331
+ const s = Math.floor(ms / 1000);
332
+ if (s < 60) return `${s}s`;
333
+ const m = Math.floor(s / 60);
334
+ if (m < 60) return `${m}m`;
335
+ const h = Math.floor(m / 60);
336
+ if (h < 24) return `${h}h`;
337
+ const d = Math.floor(h / 24);
338
+ return `${d}d`;
339
+ }
340
+
341
+ async function runSessionOpenCli(args) {
342
+ const idArg = args.find(a => !a.startsWith('--'));
343
+ if (!idArg) {
344
+ printLeafHelp('session open', COMMANDS.session.verbs.open);
345
+ return 1;
346
+ }
347
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
348
+ if (!resolved) return 1;
349
+ try {
350
+ const res = await cliFetch('/api/session/open', {
351
+ method: 'POST',
352
+ headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify({ id: resolved.id })
354
+ });
355
+ if (!res.ok) {
356
+ console.error(`Open failed (${res.status}): ${await res.text()}`);
357
+ return 1;
358
+ }
359
+ console.log(`Session opened: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
360
+ return 0;
361
+ } catch (e) { reportCliError(e); return 1; }
362
+ }
363
+
364
+ async function runSessionViewCli(args) {
365
+ const idArg = args.find(a => !a.startsWith('--'));
366
+ if (!idArg) {
367
+ printLeafHelp('session view', COMMANDS.session.verbs.view);
368
+ return 1;
369
+ }
370
+ const asJson = args.includes('--json');
371
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
372
+ if (!resolved) return 1;
373
+ let list;
374
+ try {
375
+ list = await fetchSessionsList(null);
376
+ } catch (e) { reportCliError(e); return 1; }
377
+ const s = list.find(x => x.id === resolved.id);
378
+ if (!s) {
379
+ console.error(`Session ${resolved.id} not found in /api/sessions response.`);
380
+ return 1;
381
+ }
382
+ if (asJson) {
383
+ console.log(JSON.stringify(s, null, 2));
384
+ return 0;
385
+ }
386
+ const status = sessionStatus(s);
387
+ const title = s.customTitle || s.name || s.slug || '';
388
+ const age = s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-';
389
+ const fmtTok = (n) => typeof n === 'number' ? (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) : '-';
390
+ const fmtCost = (n) => typeof n === 'number' ? `$${n.toFixed(2)}` : '-';
391
+ const lines = [];
392
+ lines.push(`${s.id.slice(0, 8)} — ${title} [${status}]`);
393
+ if (s.project) lines.push(` ${path.basename(s.project)}${s.gitBranch ? ` · ${s.gitBranch}` : ''} · modified ${age} ago`);
394
+ lines.push(` Tasks: ${s.completed}/${s.taskCount}${s.inProgress ? ` (${s.inProgress} in progress)` : ''}${s.pending ? ` · ${s.pending} pending` : ''}`);
395
+ const ctx = s.contextStatus;
396
+ if (ctx) {
397
+ const cw = ctx.context_window || {};
398
+ const cost = ctx.cost || {};
399
+ const rl = ctx.rate_limits || {};
400
+ const modelName = ctx.model?.display_name || ctx.model?.id || '-';
401
+ const modelExtras = [
402
+ ctx.effort?.level,
403
+ ctx.thinking?.enabled ? 'thinking' : null,
404
+ ctx.fast_mode ? 'fast' : null,
405
+ ].filter(Boolean).join(' · ');
406
+ lines.push(` Model: ${modelName}${modelExtras ? ` (${modelExtras})` : ''}`);
407
+ if (cw.used_percentage != null) {
408
+ lines.push(` Context: ${cw.used_percentage}% used · ${fmtTok(cw.total_input_tokens)} in / ${fmtTok(cw.total_output_tokens)} out · cache ${fmtTok(cw.current_usage?.cache_read_input_tokens)} read`);
409
+ }
410
+ lines.push(` Cost: ${fmtCost(cost.total_cost_usd)} · ${cost.total_api_duration_ms != null ? formatAge(cost.total_api_duration_ms) : '-'} api / ${cost.total_duration_ms != null ? formatAge(cost.total_duration_ms) : '-'} total · +${cost.total_lines_added || 0}/-${cost.total_lines_removed || 0}`);
411
+ if (rl.five_hour || rl.seven_day) {
412
+ lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
413
+ }
414
+ }
415
+ console.log(lines.join('\n'));
416
+ return 0;
417
+ }
418
+
419
+ async function runSessionPeekCli(args) {
420
+ const idArg = args.find(a => !a.startsWith('--'));
421
+ if (!idArg) {
422
+ printLeafHelp('session peek', COMMANDS.session.verbs.peek);
423
+ return 1;
424
+ }
425
+ const parsed = parseLimit(args, { fallback: 10 });
426
+ if (!parsed.ok) { console.error(parsed.error); return 1; }
427
+ const limit = parsed.limit;
428
+ const asJson = args.includes('--json');
429
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
430
+ if (!resolved) return 1;
431
+ try {
432
+ const res = await cliFetch(`/api/sessions/${resolved.id}/messages?limit=${Math.min(limit, 50)}`);
433
+ if (!res.ok) {
434
+ console.error(`Peek failed (${res.status}): ${await res.text()}`);
435
+ return 1;
436
+ }
437
+ const { messages } = await res.json();
438
+ const ordered = [...messages].reverse();
439
+ if (asJson) {
440
+ console.log(JSON.stringify(ordered, null, 2));
441
+ return 0;
442
+ }
443
+ if (!ordered.length) {
444
+ console.log(`No messages for session ${resolved.id.slice(0, 8)}.`);
445
+ return 0;
446
+ }
447
+ console.log(`Session ${resolved.id.slice(0, 8)}${resolved.customTitle ? ` — ${resolved.customTitle}` : ''}`);
448
+ for (const m of ordered) {
449
+ const ts = m.timestamp ? new Date(m.timestamp).toLocaleTimeString('en-GB', { hour12: false }) : '--:--:--';
450
+ const label = (m.type === 'tool_use' ? (m.tool || 'tool') : m.type).padEnd(10);
451
+ const body = (m.text || m.detail || m.description || '').replace(/\s+/g, ' ').trim();
452
+ console.log(`[${ts}] ${label} ${body.slice(0, 120)}${body.length > 120 ? '…' : ''}`);
453
+ }
454
+ return 0;
455
+ } catch (e) { reportCliError(e); return 1; }
456
+ }
457
+
458
+ module.exports = { runCli };
package/lib/parsers.js CHANGED
@@ -582,8 +582,9 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
582
582
  };
583
583
  }
584
584
 
585
- function buildAgentProgressMap(jsonlPath) {
585
+ function buildSessionDigest(jsonlPath) {
586
586
  const map = {};
587
+ const terminated = new Map();
587
588
  try {
588
589
  const content = readFileSync(jsonlPath, 'utf8');
589
590
  const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
@@ -596,6 +597,34 @@ function buildAgentProgressMap(jsonlPath) {
596
597
  const nameByToolUseId = {};
597
598
  const descByToolUseId = {};
598
599
  for (const line of content.split('\n')) {
600
+ // Terminated-teammate detection: check first since cheap substring guards
601
+ if (line.includes('teammate-message') &&
602
+ (line.includes('teammate_terminated') || line.includes('shutdown_response'))) {
603
+ try {
604
+ const obj = JSON.parse(line);
605
+ if (obj.type === 'user') {
606
+ const text = typeof obj.message?.content === 'string' ? obj.message.content : null;
607
+ if (text) {
608
+ const ts = obj.timestamp || null;
609
+ for (const tmMatch of text.matchAll(/<teammate-message\s+[^>]*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g)) {
610
+ try {
611
+ const tid = tmMatch[1];
612
+ const body = tmMatch[2].trim();
613
+ const protocol = JSON.parse(body);
614
+ if (protocol.type === 'teammate_terminated') {
615
+ const name = protocol.from || (protocol.message?.match(/^(\S+)\s/)?.[1]) || tid;
616
+ if (name !== 'system') terminated.set(name, ts);
617
+ } else if (protocol.type === 'shutdown_response' && protocol.approve) {
618
+ const name = protocol.from || tid;
619
+ if (name !== 'system') terminated.set(name, ts);
620
+ }
621
+ } catch (_) {}
622
+ }
623
+ }
624
+ }
625
+ } catch (_) {}
626
+ }
627
+
599
628
  if (line.includes('"agent_progress"')) {
600
629
  const agentMatch = re.exec(line);
601
630
  const parentMatch = parentRe.exec(line);
@@ -657,7 +686,11 @@ function buildAgentProgressMap(jsonlPath) {
657
686
  if (descByToolUseId[key]) entry.description = descByToolUseId[key];
658
687
  }
659
688
  } catch (_) {}
660
- return map;
689
+ return { progressMap: map, terminated };
690
+ }
691
+
692
+ function buildAgentProgressMap(jsonlPath) {
693
+ return buildSessionDigest(jsonlPath).progressMap;
661
694
  }
662
695
 
663
696
  function readCompactSummaries(jsonlPath) {
@@ -699,36 +732,7 @@ function readCompactSummaries(jsonlPath) {
699
732
  }
700
733
 
701
734
  function findTerminatedTeammates(jsonlPath) {
702
- const terminated = new Map();
703
- try {
704
- const content = readFileSync(jsonlPath, 'utf8');
705
- for (const line of content.split('\n')) {
706
- if (!line.includes('teammate-message')) continue;
707
- if (!line.includes('teammate_terminated') && !line.includes('shutdown_response')) continue;
708
- try {
709
- const obj = JSON.parse(line);
710
- if (obj.type !== 'user') continue;
711
- const text = typeof obj.message?.content === 'string' ? obj.message.content : null;
712
- if (!text) continue;
713
- const ts = obj.timestamp || null;
714
- for (const tmMatch of text.matchAll(/<teammate-message\s+[^>]*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g)) {
715
- try {
716
- const tid = tmMatch[1];
717
- const body = tmMatch[2].trim();
718
- const protocol = JSON.parse(body);
719
- if (protocol.type === 'teammate_terminated') {
720
- const name = protocol.from || (protocol.message?.match(/^(\S+)\s/)?.[1]) || tid;
721
- if (name !== 'system') terminated.set(name, ts);
722
- } else if (protocol.type === 'shutdown_response' && protocol.approve) {
723
- const name = protocol.from || tid;
724
- if (name !== 'system') terminated.set(name, ts);
725
- }
726
- } catch (_) {}
727
- }
728
- } catch (_) {}
729
- }
730
- } catch (_) {}
731
- return terminated;
735
+ return buildSessionDigest(jsonlPath).terminated;
732
736
  }
733
737
 
734
738
  function extractPromptFromTranscript(jsonlPath) {
@@ -766,6 +770,36 @@ function extractPromptFromTranscript(jsonlPath) {
766
770
  return null;
767
771
  }
768
772
 
773
+ function extractModelFromTranscript(jsonlPath) {
774
+ const { openSync, readSync, closeSync } = fs;
775
+ const MAX_READ = 65536;
776
+ const CHUNK = 4096;
777
+ const fd = openSync(jsonlPath, 'r');
778
+ try {
779
+ let accumulated = '';
780
+ const buf = Buffer.alloc(CHUNK);
781
+ while (accumulated.length < MAX_READ) {
782
+ const bytesRead = readSync(fd, buf, 0, CHUNK, null);
783
+ if (bytesRead === 0) break;
784
+ accumulated += buf.toString('utf8', 0, bytesRead);
785
+ let nlIdx;
786
+ while ((nlIdx = accumulated.indexOf('\n')) !== -1) {
787
+ const line = accumulated.slice(0, nlIdx);
788
+ accumulated = accumulated.slice(nlIdx + 1);
789
+ if (!line.trim()) continue;
790
+ try {
791
+ const obj = JSON.parse(line);
792
+ const model = obj.model || (obj.message && obj.message.model);
793
+ if (model) return model;
794
+ } catch (_) {}
795
+ }
796
+ }
797
+ } finally {
798
+ closeSync(fd);
799
+ }
800
+ return null;
801
+ }
802
+
769
803
  module.exports = {
770
804
  parseTask,
771
805
  parseAgent,
@@ -777,7 +811,9 @@ module.exports = {
777
811
  readRecentMessages,
778
812
  readMessagesPage,
779
813
  buildAgentProgressMap,
814
+ buildSessionDigest,
780
815
  readCompactSummaries,
781
816
  findTerminatedTeammates,
782
- extractPromptFromTranscript
817
+ extractPromptFromTranscript,
818
+ extractModelFromTranscript
783
819
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.2.4",
3
+ "version": "3.4.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": {
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "files": [
46
46
  "server.js",
47
+ "cli.js",
47
48
  "install.js",
48
49
  "plugin/**/*",
49
50
  "lib/**/*",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.0.1",
3
+ "version": "1.1.3",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: kanban
3
+ description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
4
+ compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3456).
5
+ ---
6
+
7
+ # Kanban Skill
8
+
9
+ Drive the kanban from this Claude session. Every command corresponds to something the user could do in the kanban dashboard.
10
+
11
+ The current Claude session id is available as `${CLAUDE_SESSION_ID}` (substituted when this skill loads), so the user never needs to look it up.
12
+
13
+ NOTE, sometimes user prefers `npx claude-code-kanban` to `claude-code-kanban` — both work, as long as the CLI is available. Use npx as fallback or when user instructed explicitly.
14
+
15
+ ## Open the current session in kanban
16
+
17
+ Primary use case. Pins the active Claude session in the kanban sidebar and switches to the Active tab.
18
+
19
+ ```bash
20
+ claude-code-kanban session open ${CLAUDE_SESSION_ID}
21
+ ```
22
+
23
+ Trigger phrases: "show this session in kanban", "focus current session", "open in kanban".
24
+
25
+ ## Preview a file in kanban
26
+
27
+ Opens a markdown file in the preview modal:
28
+
29
+ ```bash
30
+ claude-code-kanban preview <path-to-file.md> --session ${CLAUDE_SESSION_ID} # prefer this one
31
+ ```
32
+
33
+ Relative paths are fine — the server resolves to absolute.
34
+
35
+ ## Inspect sessions (read-only)
36
+
37
+ When the user asks "what's going on in kanban?" or wants stats:
38
+
39
+ ```bash
40
+ claude-code-kanban session list --active # recent active sessions
41
+ claude-code-kanban session list --project <name> # filter by project
42
+ claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats for current session
43
+ claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages
44
+ ```
45
+
46
+ Add `--json` to any list-style verb for machine-readable output.
47
+
48
+ ## Troubleshooting
49
+
50
+ - **"Cannot reach cck server on port 3456"** → ask the user to start it: `claude-code-kanban` (or `npm start` in the cck repo).
51
+ - **Different port** → set `PORT=<n>` env var when invoking the CLI.
52
+ - **Ambiguous session prefix (HTTP 409)** → use the full id. With `${CLAUDE_SESSION_ID}` this won't happen.