claude-code-kanban 3.2.4 → 3.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.2.4",
3
+ "version": "3.3.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.
package/public/app.js CHANGED
@@ -135,7 +135,11 @@ async function fetchSessions(includeTasks = true) {
135
135
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
136
136
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
137
137
  const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
138
- const sessionsPromise = fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`).then((r) => r.json());
138
+ const projectParam =
139
+ filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
140
+ const sessionsPromise = fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}`).then((r) =>
141
+ r.json(),
142
+ );
139
143
 
140
144
  let newSessions, newTasks;
141
145
  if (includeTasks) {
@@ -4036,6 +4040,219 @@ document.addEventListener('keydown', (e) => {
4036
4040
 
4037
4041
  //#endregion
4038
4042
 
4043
+ //#region MARKDOWN_PREVIEW
4044
+ const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
4045
+ let currentPreviewPath = null;
4046
+
4047
+ function getSessionPreviewPaths(sessionId) {
4048
+ if (!sessionId) return [];
4049
+ try {
4050
+ const raw = localStorage.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
4051
+ const arr = raw ? JSON.parse(raw) : [];
4052
+ return Array.isArray(arr) ? arr : [];
4053
+ } catch {
4054
+ return [];
4055
+ }
4056
+ }
4057
+
4058
+ function addSessionPreviewPath(sessionId, filePath) {
4059
+ if (!sessionId || !filePath) return;
4060
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
4061
+ paths.unshift(filePath);
4062
+ localStorage.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
4063
+ }
4064
+
4065
+ function removeSessionPreviewPath(sessionId, filePath) {
4066
+ if (!sessionId) return;
4067
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
4068
+ if (paths.length) localStorage.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
4069
+ else localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
4070
+ }
4071
+
4072
+ function splitFrontmatter(text) {
4073
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
4074
+ if (!m) return { fm: null, body: text };
4075
+ const fm = {};
4076
+ for (const line of m[1].split(/\r?\n/)) {
4077
+ const kv = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
4078
+ if (kv) fm[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
4079
+ }
4080
+ return { fm, body: m[2] };
4081
+ }
4082
+
4083
+ function renderFrontmatterBlock(fm) {
4084
+ const rows = Object.entries(fm)
4085
+ .map(
4086
+ ([k, v]) =>
4087
+ `<div class="fm-row"><span class="fm-k">${escapeHtml(k)}</span><span class="fm-v">${escapeHtml(String(v))}</span></div>`,
4088
+ )
4089
+ .join('');
4090
+ return `<details class="preview-fm" open><summary>frontmatter</summary><div class="fm-grid">${rows}</div></details>`;
4091
+ }
4092
+
4093
+ function openPreviewModal(filePath, content) {
4094
+ currentPreviewPath = filePath;
4095
+ document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
4096
+ const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
4097
+ document.getElementById('preview-modal-body').innerHTML =
4098
+ (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4099
+ document.getElementById('preview-modal-meta').textContent = filePath;
4100
+ document.getElementById('preview-modal').classList.add('visible');
4101
+ updatePreviewLinkBtn();
4102
+ }
4103
+
4104
+ function isPreviewLinkedToCurrentSession() {
4105
+ if (!currentPreviewPath || !currentSessionId) return false;
4106
+ return getSessionPreviewPaths(currentSessionId).includes(currentPreviewPath);
4107
+ }
4108
+
4109
+ function updatePreviewLinkBtn() {
4110
+ const btn = document.getElementById('preview-link-btn');
4111
+ if (!btn) return;
4112
+ if (!currentSessionId) {
4113
+ btn.style.display = 'none';
4114
+ return;
4115
+ }
4116
+ btn.style.display = '';
4117
+ const linked = isPreviewLinkedToCurrentSession();
4118
+ btn.title = linked ? 'Unlink from current session' : 'Link to current session';
4119
+ btn.style.color = linked ? 'var(--accent, #5b9a6b)' : '';
4120
+ }
4121
+
4122
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4123
+ function togglePreviewSessionLink() {
4124
+ if (!currentPreviewPath || !currentSessionId) {
4125
+ showToast('Select a session first');
4126
+ return;
4127
+ }
4128
+ if (isPreviewLinkedToCurrentSession()) {
4129
+ removeSessionPreviewPath(currentSessionId, currentPreviewPath);
4130
+ showToast('Unlinked from session');
4131
+ } else {
4132
+ addSessionPreviewPath(currentSessionId, currentPreviewPath);
4133
+ showToast('Linked to session');
4134
+ }
4135
+ updatePreviewLinkBtn();
4136
+ if (_infoModalSessionId === currentSessionId) {
4137
+ refreshInfoModalLinkedDocs();
4138
+ }
4139
+ }
4140
+
4141
+ function refreshInfoModalLinkedDocs() {
4142
+ const bodyEl = document.getElementById('team-modal-body');
4143
+ if (!bodyEl) return;
4144
+ const existing = bodyEl.querySelector('.linked-docs-section');
4145
+ const html = renderLinkedDocsHtml(_infoModalSessionId);
4146
+ if (!existing) {
4147
+ if (!html) return;
4148
+ const planCard = bodyEl.querySelector('[data-plan-card]');
4149
+ const wrap = document.createElement('div');
4150
+ wrap.innerHTML = html;
4151
+ const node = wrap.firstElementChild;
4152
+ if (planCard?.nextSibling) planCard.parentNode.insertBefore(node, planCard.nextSibling);
4153
+ else bodyEl.appendChild(node);
4154
+ bindLinkedDocsHandlers(node, _infoModalSessionId);
4155
+ return;
4156
+ }
4157
+ if (!html) {
4158
+ existing.remove();
4159
+ return;
4160
+ }
4161
+ const wrap = document.createElement('div');
4162
+ wrap.innerHTML = html;
4163
+ const node = wrap.firstElementChild;
4164
+ existing.replaceWith(node);
4165
+ bindLinkedDocsHandlers(node, _infoModalSessionId);
4166
+ }
4167
+
4168
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4169
+ function closePreviewModal() {
4170
+ resetModalFullscreen('preview-modal');
4171
+ currentPreviewPath = null;
4172
+ }
4173
+
4174
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4175
+ function openPreviewInEditor() {
4176
+ if (!currentPreviewPath) return;
4177
+ postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
4178
+ }
4179
+
4180
+ async function openPreviewByPath(filePath) {
4181
+ if (!filePath) return;
4182
+ try {
4183
+ const r = await fetch(`/api/preview?path=${encodeURIComponent(filePath)}`);
4184
+ if (!r.ok) {
4185
+ showToast('Preview file unavailable');
4186
+ return;
4187
+ }
4188
+ const data = await r.json();
4189
+ openPreviewModal(data.path, data.content);
4190
+ } catch {
4191
+ showToast('Failed to load preview');
4192
+ }
4193
+ }
4194
+
4195
+ function handleSessionOpenEvent(data) {
4196
+ const { id } = data;
4197
+ if (!id) return;
4198
+ const target = sessions.find((s) => s.id === id);
4199
+ if (!target) {
4200
+ showToast(`Session not found: ${id.slice(0, 8)}`);
4201
+ return;
4202
+ }
4203
+ if (sessionFilter !== 'active') {
4204
+ sessionFilter = 'active';
4205
+ const sel = document.getElementById('session-filter');
4206
+ if (sel) sel.value = 'active';
4207
+ updateUrl();
4208
+ }
4209
+ if (!isSessionActive(target)) {
4210
+ stickySessionIds.add(id);
4211
+ }
4212
+ fetchTasks(id);
4213
+ }
4214
+
4215
+ function handlePreviewOpenEvent(data) {
4216
+ const { path: filePath, content, sessionId } = data;
4217
+ if (sessionId && sessionId !== currentSessionId) {
4218
+ if (sessions.find((s) => s.id === sessionId)) {
4219
+ fetchTasks(sessionId);
4220
+ } else {
4221
+ showToast(`Preview received for unknown session ${sessionId.slice(0, 8)}`);
4222
+ }
4223
+ }
4224
+ openPreviewModal(filePath, content);
4225
+ }
4226
+
4227
+ function renderLinkedDocsHtml(sessionId) {
4228
+ const paths = getSessionPreviewPaths(sessionId);
4229
+ if (!paths.length) return '';
4230
+ const items = paths
4231
+ .map((p, i) => {
4232
+ const name = p.split(/[\\/]/).pop();
4233
+ return `<a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}" style="color:var(--accent-text);text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;">${escapeHtml(name)}</a>`;
4234
+ })
4235
+ .join(', ');
4236
+ return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
4237
+ <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">Linked documents</div>
4238
+ <div>${items}</div>
4239
+ </div>`;
4240
+ }
4241
+
4242
+ function bindLinkedDocsHandlers(container, sessionId) {
4243
+ if (!container) return;
4244
+ const links = container.querySelectorAll('.linked-doc-link');
4245
+ if (!links.length) return;
4246
+ const paths = getSessionPreviewPaths(sessionId);
4247
+ for (const link of links) {
4248
+ link.addEventListener('click', (e) => {
4249
+ e.preventDefault();
4250
+ openPreviewByPath(paths[+link.dataset.idx]);
4251
+ });
4252
+ }
4253
+ }
4254
+ //#endregion
4255
+
4039
4256
  //#region SSE
4040
4257
  function setupEventSource() {
4041
4258
  let retryDelay = 1000;
@@ -4149,6 +4366,15 @@ function setupEventSource() {
4149
4366
 
4150
4367
  if (data.type === 'context-update') {
4151
4368
  debouncedRefresh(data.sessionId, true);
4369
+ refreshRateLimits();
4370
+ }
4371
+
4372
+ if (data.type === 'preview:open') {
4373
+ handlePreviewOpenEvent(data);
4374
+ }
4375
+
4376
+ if (data.type === 'session:open') {
4377
+ handleSessionOpenEvent(data);
4152
4378
  }
4153
4379
 
4154
4380
  if (data.type === 'team-update') {
@@ -4590,7 +4816,7 @@ document.addEventListener('click', (e) => {
4590
4816
  function filterByProject(project) {
4591
4817
  filterProject = project || null;
4592
4818
  updateUrl();
4593
- renderSessions();
4819
+ fetchSessions(false);
4594
4820
  showAllTasks();
4595
4821
  }
4596
4822
 
@@ -4936,7 +5162,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4936
5162
  _pendingPlanContent = planContent;
4937
5163
  const titleMatch = planContent.match(/^#\s+(.+)$/m);
4938
5164
  const planTitle = titleMatch ? titleMatch[1].trim() : null;
4939
- html += `<div onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
5165
+ html += `<div data-plan-card="1" onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
4940
5166
  <span style="font-size: 14px;">📋</span>
4941
5167
  <div style="flex: 1; min-width: 0;">
4942
5168
  <div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
@@ -4946,6 +5172,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4946
5172
  </div>`;
4947
5173
  }
4948
5174
 
5175
+ html += renderLinkedDocsHtml(session.id);
5176
+
4949
5177
  // Team info section
4950
5178
  if (teamConfig) {
4951
5179
  const ownerCounts = {};
@@ -5000,6 +5228,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5000
5228
  }
5001
5229
 
5002
5230
  bodyEl.innerHTML = html;
5231
+ bindLinkedDocsHandlers(bodyEl, session.id);
5003
5232
  const alreadyVisible = modal.classList.contains('visible');
5004
5233
  _infoModalSessionId = session.id;
5005
5234
  updateStickyBtnState();
@@ -5298,10 +5527,76 @@ msgContentEl.addEventListener('wheel', function (e) {
5298
5527
  }
5299
5528
  });
5300
5529
 
5530
+ const footerState = { version: null, limitsKey: null, timer: null };
5531
+ function formatResetIn(epochSec) {
5532
+ if (!epochSec) return null;
5533
+ const ms = epochSec * 1000 - Date.now();
5534
+ if (ms <= 0) return 'now';
5535
+ const m = Math.round(ms / 60000);
5536
+ if (m < 60) return `${m}m`;
5537
+ const h = Math.floor(m / 60);
5538
+ const rm = m % 60;
5539
+ if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`;
5540
+ const d = Math.floor(h / 24);
5541
+ const rh = h % 24;
5542
+ return rh ? `${d}d ${rh}h` : `${d}d`;
5543
+ }
5544
+ function makeLimitCell(label, bucket) {
5545
+ const pct = bucket?.used_percentage;
5546
+ const cell = document.createElement('span');
5547
+ cell.className = 'footer-limit-cell';
5548
+ const reset = formatResetIn(bucket?.resets_at);
5549
+ if (reset) cell.title = `${label}: resets in ${reset}`;
5550
+ cell.append(document.createTextNode(`${label} `));
5551
+ const strong = document.createElement('strong');
5552
+ strong.textContent = pct == null ? '-%' : `${Math.ceil(pct)}%`;
5553
+ cell.appendChild(strong);
5554
+ return cell;
5555
+ }
5556
+ function makeLimitSpan(rl) {
5557
+ const span = document.createElement('span');
5558
+ span.className = 'footer-limits';
5559
+ span.append(makeLimitCell('5h', rl?.five_hour), document.createTextNode(' · '), makeLimitCell('7d', rl?.seven_day));
5560
+ return span;
5561
+ }
5562
+ function renderSidebarFooter(rateLimits) {
5563
+ const el = document.getElementById('sidebar-footer');
5564
+ if (!el) return;
5565
+ const fh = rateLimits?.five_hour?.used_percentage ?? null;
5566
+ const sd = rateLimits?.seven_day?.used_percentage ?? null;
5567
+ const children = [];
5568
+ if (footerState.version) {
5569
+ const v = document.createElement('span');
5570
+ v.textContent = `v${footerState.version}`;
5571
+ children.push(v);
5572
+ }
5573
+ if (fh != null || sd != null) children.push(makeLimitSpan(rateLimits));
5574
+ el.replaceChildren(...children);
5575
+ }
5576
+ function refreshRateLimits() {
5577
+ if (footerState.timer) return;
5578
+ footerState.timer = setTimeout(() => {
5579
+ footerState.timer = null;
5580
+ fetch('/api/context-status')
5581
+ .then((r) => r.json())
5582
+ .then((all) => {
5583
+ const rl = Object.values(all || {}).find((e) => e?.rate_limits)?.rate_limits || null;
5584
+ const fh = rl?.five_hour?.used_percentage ?? null;
5585
+ const sd = rl?.seven_day?.used_percentage ?? null;
5586
+ const key = `${fh}|${sd}`;
5587
+ if (key === footerState.limitsKey) return;
5588
+ footerState.limitsKey = key;
5589
+ renderSidebarFooter(rl);
5590
+ })
5591
+ .catch(() => {});
5592
+ }, 1500);
5593
+ }
5301
5594
  fetch('/api/version')
5302
5595
  .then((r) => r.json())
5303
5596
  .then((d) => {
5304
- document.getElementById('sidebar-footer').textContent = `v${d.version}`;
5597
+ footerState.version = d.version;
5598
+ renderSidebarFooter(null);
5599
+ refreshRateLimits();
5305
5600
  })
5306
5601
  .catch(() => {});
5307
5602
 
package/public/index.html CHANGED
@@ -310,6 +310,33 @@
310
310
  </div>
311
311
  </div>
312
312
 
313
+ <!-- Markdown Preview Modal -->
314
+ <div id="preview-modal" class="modal-overlay" style="z-index:10002;" onclick="closePreviewModal()">
315
+ <div class="modal preview-modal-dialog" onclick="event.stopPropagation()">
316
+ <div class="modal-header">
317
+ <h3 class="modal-title" id="preview-modal-title">Preview</h3>
318
+ <div style="display:flex;gap:4px;align-items:center;">
319
+ <button id="preview-link-btn" class="icon-btn" aria-label="Link to current session" title="Link to current session" onclick="togglePreviewSessionLink()">
320
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
321
+ </button>
322
+ <button class="icon-btn" aria-label="Open in editor" title="Open in editor" onclick="openPreviewInEditor()">
323
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
324
+ </button>
325
+ <button id="preview-modal-fullscreen-btn" class="icon-btn" aria-label="Toggle fullscreen" title="Toggle fullscreen" onclick="toggleModalFullscreen('preview-modal')">
326
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
327
+ </button>
328
+ <button class="modal-close" aria-label="Close" onclick="closePreviewModal()">
329
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
330
+ </button>
331
+ </div>
332
+ </div>
333
+ <div class="modal-body" style="flex: 0 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden;">
334
+ <div id="preview-modal-body" class="rendered-md" style="word-break: break-word; font-size: 13px; line-height: 1.6; overflow-y: auto; flex: 0 1 auto; min-height: 0; padding-right: 8px;"></div>
335
+ <div id="preview-modal-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted); flex-shrink: 0;"></div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+
313
340
  <!-- Help Modal -->
314
341
  <div id="help-modal" class="modal-overlay" onclick="closeHelpModal()">
315
342
  <div class="modal" onclick="event.stopPropagation()">
package/public/style.css CHANGED
@@ -739,6 +739,10 @@ body::before {
739
739
  background-position: top;
740
740
  font-size: 10px;
741
741
  color: var(--text-muted);
742
+ display: flex;
743
+ align-items: center;
744
+ justify-content: space-between;
745
+ gap: 8px;
742
746
  }
743
747
 
744
748
  .sidebar-footer a {
@@ -747,6 +751,11 @@ body::before {
747
751
  transition: color 0.15s;
748
752
  }
749
753
 
754
+ .sidebar-footer .footer-limits strong {
755
+ color: var(--text-secondary);
756
+ font-weight: 600;
757
+ }
758
+
750
759
  .sidebar-footer a:hover {
751
760
  color: var(--text-secondary);
752
761
  }
@@ -2689,6 +2698,68 @@ body.light .msg-assistant .msg-text {
2689
2698
  max-height: 92vh;
2690
2699
  }
2691
2700
 
2701
+ .modal.preview-modal-dialog {
2702
+ width: 90vw;
2703
+ max-width: 1200px;
2704
+ max-height: 90vh;
2705
+ display: flex;
2706
+ flex-direction: column;
2707
+ }
2708
+
2709
+ .modal.preview-modal-dialog.fullscreen {
2710
+ width: 90vw;
2711
+ max-width: 90vw;
2712
+ height: 90vh;
2713
+ max-height: 90vh;
2714
+ }
2715
+
2716
+ .preview-fm {
2717
+ margin: 0 0 14px;
2718
+ border: 1px solid var(--border);
2719
+ border-radius: 6px;
2720
+ background: var(--bg-elevated, rgba(127, 127, 127, 0.06));
2721
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
2722
+ font-size: 11px;
2723
+ }
2724
+
2725
+ .preview-fm > summary {
2726
+ cursor: pointer;
2727
+ padding: 6px 10px;
2728
+ color: var(--text-muted);
2729
+ text-transform: uppercase;
2730
+ letter-spacing: 0.06em;
2731
+ font-size: 10px;
2732
+ user-select: none;
2733
+ }
2734
+
2735
+ .preview-fm[open] > summary {
2736
+ border-bottom: 1px solid var(--border);
2737
+ }
2738
+
2739
+ .preview-fm .fm-grid {
2740
+ padding: 8px 10px;
2741
+ display: grid;
2742
+ gap: 4px 12px;
2743
+ }
2744
+
2745
+ .preview-fm .fm-row {
2746
+ display: grid;
2747
+ grid-template-columns: minmax(80px, 140px) 1fr;
2748
+ gap: 12px;
2749
+ align-items: baseline;
2750
+ }
2751
+
2752
+ .preview-fm .fm-k {
2753
+ color: var(--accent, #7aa2f7);
2754
+ font-weight: 600;
2755
+ }
2756
+
2757
+ .preview-fm .fm-v {
2758
+ color: var(--text, inherit);
2759
+ word-break: break-word;
2760
+ white-space: pre-wrap;
2761
+ }
2762
+
2692
2763
  .modal-sm {
2693
2764
  max-width: 440px;
2694
2765
  }
package/server.js CHANGED
@@ -20,14 +20,15 @@ const {
20
20
  extractPromptFromTranscript
21
21
  } = require('./lib/parsers');
22
22
 
23
- const isSetupCommand = process.argv.includes('--install') || process.argv.includes('--uninstall');
24
-
25
- if (isSetupCommand) {
26
- const { runInstall, runUninstall } = require('./install');
27
- (process.argv.includes('--install') ? runInstall() : runUninstall())
23
+ if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
24
+ const { runInstall, runUninstall } = require("./install");
25
+ (process.argv.includes("--install") ? runInstall() : runUninstall())
28
26
  .then(() => process.exit(0))
29
27
  .catch(e => { console.error(e.message); process.exit(1); });
28
+ return;
30
29
  }
30
+ if (require("./cli").runCli(process.argv)) return;
31
+
31
32
 
32
33
  const app = express();
33
34
  const PORT = process.env.PORT || 3456;
@@ -791,6 +792,12 @@ app.get('/api/sessions', async (req, res) => {
791
792
  let sessions = Array.from(sessionsMap.values());
792
793
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
793
794
 
795
+ // Apply project filter before limit so the limit is per-project
796
+ const projectFilter = req.query.project;
797
+ if (projectFilter) {
798
+ sessions = sessions.filter(s => s.project === projectFilter);
799
+ }
800
+
794
801
  // Apply limit if specified, but always include pinned sessions
795
802
  if (limit !== null && limit > 0) {
796
803
  const top = sessions.slice(0, limit);
@@ -1303,6 +1310,7 @@ app.get('/api/tasks/all', async (req, res) => {
1303
1310
  }
1304
1311
 
1305
1312
  const metadata = loadSessionMetadata();
1313
+ const { listToSessions } = loadAllTaskMaps();
1306
1314
  const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
1307
1315
  .filter(d => d.isDirectory());
1308
1316
 
@@ -1313,6 +1321,19 @@ app.get('/api/tasks/all', async (req, res) => {
1313
1321
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
1314
1322
  const meta = metadata[sessionDir.name] || {};
1315
1323
 
1324
+ // For custom task list directories (non-UUID dirs), resolve project from the
1325
+ // mapped sessions since those dirs don't have their own metadata entry.
1326
+ let project = meta.project || null;
1327
+ if (!project) {
1328
+ const mappedSessions = listToSessions[sessionDir.name];
1329
+ if (mappedSessions) {
1330
+ for (const [sid, info] of Object.entries(mappedSessions)) {
1331
+ project = metadata[sid]?.project || info.project || null;
1332
+ if (project) break;
1333
+ }
1334
+ }
1335
+ }
1336
+
1316
1337
  for (const file of taskFiles) {
1317
1338
  try {
1318
1339
  const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
@@ -1320,7 +1341,7 @@ app.get('/api/tasks/all', async (req, res) => {
1320
1341
  ...task,
1321
1342
  sessionId: sessionDir.name,
1322
1343
  sessionName: getSessionDisplayName(sessionDir.name, meta),
1323
- project: meta.project || null
1344
+ project
1324
1345
  });
1325
1346
  } catch (e) {
1326
1347
  // Skip invalid files
@@ -1431,6 +1452,91 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
1431
1452
  }
1432
1453
  });
1433
1454
 
1455
+ // API: Markdown preview — read file and broadcast to clients
1456
+ async function readMarkdownFile(absPath) {
1457
+ const ext = path.extname(absPath).toLowerCase();
1458
+ if (ext !== '.md' && ext !== '.markdown') {
1459
+ const err = new Error('Only .md/.markdown files are allowed');
1460
+ err.status = 400;
1461
+ throw err;
1462
+ }
1463
+ try {
1464
+ return await fs.readFile(absPath, 'utf8');
1465
+ } catch (e) {
1466
+ if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
1467
+ if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
1468
+ throw e;
1469
+ }
1470
+ }
1471
+
1472
+ function resolvePreviewPath(filePath) {
1473
+ if (!filePath || typeof filePath !== 'string') return null;
1474
+ return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
1475
+ }
1476
+
1477
+ app.post('/api/preview', async (req, res) => {
1478
+ try {
1479
+ const { path: filePath, sessionId } = req.body || {};
1480
+ const abs = resolvePreviewPath(filePath);
1481
+ if (!abs) return res.status(400).json({ error: 'path is required' });
1482
+ const content = await readMarkdownFile(abs);
1483
+ broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
1484
+ res.json({ success: true });
1485
+ } catch (error) {
1486
+ console.error('Error in /api/preview:', error);
1487
+ res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
1488
+ }
1489
+ });
1490
+
1491
+ app.get('/api/session/resolve', (req, res) => {
1492
+ try {
1493
+ const idArg = (req.query.id || '').toString();
1494
+ if (!idArg) return res.status(400).json({ error: 'id is required' });
1495
+ const metadata = loadSessionMetadata();
1496
+ const ids = Object.keys(metadata);
1497
+ if (Object.hasOwn(metadata, idArg)) {
1498
+ const m = metadata[idArg];
1499
+ return res.json({ id: idArg, customTitle: m?.customTitle || null });
1500
+ }
1501
+ const matches = ids.filter(id => id.startsWith(idArg));
1502
+ if (matches.length === 0) return res.status(404).json({ matches: [] });
1503
+ if (matches.length > 1) {
1504
+ return res.status(409).json({
1505
+ matches: matches.slice(0, 50).map(id => ({ id, customTitle: metadata[id]?.customTitle || null }))
1506
+ });
1507
+ }
1508
+ const id = matches[0];
1509
+ res.json({ id, customTitle: metadata[id]?.customTitle || null });
1510
+ } catch (error) {
1511
+ console.error('Error in /api/session/resolve:', error);
1512
+ res.status(500).json({ error: error.message || 'Failed' });
1513
+ }
1514
+ });
1515
+
1516
+ app.post('/api/session/open', async (req, res) => {
1517
+ try {
1518
+ const { id } = req.body || {};
1519
+ if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
1520
+ broadcast({ type: 'session:open', id });
1521
+ res.json({ success: true, id });
1522
+ } catch (error) {
1523
+ console.error('Error in /api/session/open:', error);
1524
+ res.status(500).json({ error: error.message || 'Failed' });
1525
+ }
1526
+ });
1527
+
1528
+ app.get('/api/preview', async (req, res) => {
1529
+ try {
1530
+ const abs = resolvePreviewPath(req.query.path);
1531
+ if (!abs) return res.status(400).json({ error: 'path is required' });
1532
+ const content = await readMarkdownFile(abs);
1533
+ res.json({ path: abs, content });
1534
+ } catch (error) {
1535
+ console.error('Error in GET /api/preview:', error);
1536
+ res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
1537
+ }
1538
+ });
1539
+
1434
1540
  // SSE endpoint for live updates
1435
1541
  app.get('/api/events', (req, res) => {
1436
1542
  res.setHeader('Content-Type', 'text/event-stream');
@@ -1468,9 +1574,6 @@ app.use('/api', (req, res) => {
1468
1574
  res.status(404).json({ error: 'Not found' });
1469
1575
  });
1470
1576
 
1471
- // File watchers and server startup (skip for --install/--uninstall)
1472
- if (!isSetupCommand) {
1473
-
1474
1577
  // Watch for file changes (chokidar handles non-existent paths)
1475
1578
  const watcher = chokidar.watch(TASKS_DIR, {
1476
1579
  persistent: true,
@@ -1719,4 +1822,3 @@ const server = app.listen(PORT, () => {
1719
1822
  }
1720
1823
  });
1721
1824
 
1722
- } // end if (!isSetupCommand)