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 +458 -0
- package/lib/parsers.js +69 -33
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +52 -0
- package/public/app.js +419 -13
- package/public/index.html +29 -1
- package/public/style.css +93 -0
- package/server.js +151 -43
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
|
|
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
|
-
|
|
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.
|
|
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/**/*",
|
|
@@ -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.
|