claude-code-kanban 3.8.0 → 3.9.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 CHANGED
@@ -14,13 +14,14 @@ const COMMANDS = {
14
14
  summary: 'List or open Claude Code sessions',
15
15
  verbs: {
16
16
  list: {
17
- summary: 'List sessions',
18
- usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--json]',
17
+ summary: 'List sessions (pinned/sticky always included)',
18
+ usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
19
19
  flags: {
20
20
  '--active': 'Only sessions with recent activity (sidebar-style filter)',
21
21
  '--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
22
22
  '--project <name>': 'Filter by project name (substring match)',
23
23
  '--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
24
+ '--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
24
25
  '--json': 'Output JSON instead of a table',
25
26
  },
26
27
  run: runSessionListCli,
@@ -52,6 +53,15 @@ const COMMANDS = {
52
53
  },
53
54
  run: runSessionPinCli,
54
55
  },
56
+ pins: {
57
+ summary: 'List sessions pinned/stickied via the dashboard or CLI',
58
+ usage: 'claude-code-kanban session pins [--sticky] [--json]',
59
+ flags: {
60
+ '--sticky': 'Only sessions in sticky state',
61
+ '--json': 'Output JSON instead of a table',
62
+ },
63
+ run: runSessionPinsCli,
64
+ },
55
65
  peek: {
56
66
  summary: 'Show the last N messages from a session',
57
67
  usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
@@ -239,13 +249,23 @@ function parseLimit(args, { fallback, allowAll = false }) {
239
249
  return { ok: true, limit: n };
240
250
  }
241
251
 
242
- async function fetchSessionsList(limit) {
252
+ async function fetchSessionsList(limit, pinnedIds = []) {
243
253
  const q = limit === null ? 'all' : String(limit);
244
- const res = await cliFetch(`/api/sessions?limit=${q}`);
254
+ const pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
255
+ const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}`);
245
256
  if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
246
257
  return res.json();
247
258
  }
248
259
 
260
+ async function fetchPinsMap() {
261
+ try {
262
+ const res = await cliFetch('/api/session/pins');
263
+ if (!res.ok) return {};
264
+ const { pins = {} } = await res.json();
265
+ return pins;
266
+ } catch { return {}; }
267
+ }
268
+
249
269
  async function resolveSessionByIdOrPrefix(idArg) {
250
270
  let res;
251
271
  try {
@@ -273,6 +293,7 @@ async function resolveSessionByIdOrPrefix(idArg) {
273
293
 
274
294
  async function runSessionListCli(args) {
275
295
  const activeOnly = args.includes('--active');
296
+ const noPins = args.includes('--no-pins');
276
297
  const projectFilter = getArgValue(args, 'project');
277
298
  const daysArg = getArgValue(args, 'days');
278
299
  const days = daysArg !== null ? parseFloat(daysArg) : null;
@@ -284,27 +305,40 @@ async function runSessionListCli(args) {
284
305
  if (!parsed.ok) { console.error(parsed.error); return 1; }
285
306
  const limit = parsed.limit;
286
307
  const asJson = args.includes('--json');
308
+ const pinsMap = noPins ? {} : await fetchPinsMap();
309
+ const pinnedIds = Object.keys(pinsMap);
287
310
  const hasClientFilter = activeOnly || days !== null || projectFilter;
288
311
  let list;
289
312
  try {
290
- list = await fetchSessionsList(hasClientFilter ? null : limit);
313
+ list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds);
291
314
  } catch (e) {
292
315
  reportCliError(e);
293
316
  return 1;
294
317
  }
295
- if (activeOnly) list = list.filter(isSessionActive);
318
+ const pinOf = id => pinsMap[id] || null;
319
+ if (activeOnly) list = list.filter(s => pinOf(s.id) || isSessionActive(s));
296
320
  if (days !== null) {
297
321
  const cutoff = Date.now() - days * 86_400_000;
298
- list = list.filter(s => s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff);
322
+ list = list.filter(s => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
299
323
  }
300
324
  if (projectFilter) {
301
325
  const needle = projectFilter.toLowerCase();
302
326
  list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
303
327
  }
304
- const totalMatched = list.length;
305
- if (limit !== null && list.length > limit) list = list.slice(0, limit);
328
+ const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
329
+ list.sort((a, b) => {
330
+ const r = pinRank(a.id) - pinRank(b.id);
331
+ if (r !== 0) return r;
332
+ return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
333
+ });
334
+ if (limit !== null && list.length > limit) {
335
+ const top = list.slice(0, limit);
336
+ const topIds = new Set(top.map(s => s.id));
337
+ const extraPinned = list.filter(s => pinOf(s.id) && !topIds.has(s.id));
338
+ list = [...top, ...extraPinned];
339
+ }
306
340
  if (asJson) {
307
- console.log(JSON.stringify(list, null, 2));
341
+ console.log(JSON.stringify(list.map(s => ({ ...s, pinState: pinOf(s.id) })), null, 2));
308
342
  return 0;
309
343
  }
310
344
  if (!list.length) {
@@ -313,6 +347,7 @@ async function runSessionListCli(args) {
313
347
  }
314
348
  const rows = list.map(s => ({
315
349
  id: s.id.slice(0, 8),
350
+ pin: pinOf(s.id) || '',
316
351
  status: sessionStatus(s),
317
352
  age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
318
353
  tasks: `${s.completed}/${s.taskCount}`,
@@ -321,17 +356,15 @@ async function runSessionListCli(args) {
321
356
  }));
322
357
  const w = {
323
358
  id: 8,
359
+ pin: Math.max(3, ...rows.map(r => r.pin.length)),
324
360
  status: Math.max(6, ...rows.map(r => r.status.length)),
325
361
  age: Math.max(3, ...rows.map(r => r.age.length)),
326
362
  tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
327
363
  project: Math.max(7, ...rows.map(r => r.project.length)),
328
364
  };
329
- console.log(`${'ID'.padEnd(w.id)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
365
+ console.log(`${'ID'.padEnd(w.id)} ${'PIN'.padEnd(w.pin)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
330
366
  for (const r of rows) {
331
- 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}`);
332
- }
333
- if (limit !== null && totalMatched > limit) {
334
- console.log(`\n... ${totalMatched - limit} more. Use --limit <n> or --limit all to see them.`);
367
+ console.log(`${r.id.padEnd(w.id)} ${r.pin.padEnd(w.pin)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
335
368
  }
336
369
  return 0;
337
370
  }
@@ -396,6 +429,53 @@ async function runSessionPinCli(args) {
396
429
  } catch (e) { reportCliError(e); return 1; }
397
430
  }
398
431
 
432
+ async function runSessionPinsCli(args) {
433
+ const stickyOnly = args.includes('--sticky');
434
+ const asJson = args.includes('--json');
435
+ const pinsMap = await fetchPinsMap();
436
+ const items = Object.entries(pinsMap)
437
+ .filter(([, state]) => !stickyOnly || state === 'sticky')
438
+ .map(([id, state]) => ({ id, state }));
439
+ if (!items.length) {
440
+ if (asJson) console.log('[]'); else console.log('No pinned sessions.');
441
+ return 0;
442
+ }
443
+ let sessions;
444
+ try {
445
+ sessions = await fetchSessionsList(items.length, items.map(p => p.id));
446
+ } catch (e) { reportCliError(e); return 1; }
447
+ const byId = new Map(sessions.map(s => [s.id, s]));
448
+ let rows = items
449
+ .map(p => {
450
+ const s = byId.get(p.id) || {};
451
+ return {
452
+ id: p.id,
453
+ state: p.state,
454
+ status: s.id ? sessionStatus(s) : '-',
455
+ age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
456
+ project: path.basename(s.project || ''),
457
+ title: s.customTitle || s.name || s.slug || '',
458
+ };
459
+ })
460
+ .sort((a, b) => (a.state === b.state ? 0 : a.state === 'sticky' ? -1 : 1));
461
+ if (asJson) {
462
+ console.log(JSON.stringify(rows, null, 2));
463
+ return 0;
464
+ }
465
+ const w = {
466
+ id: 8,
467
+ state: Math.max(5, ...rows.map(r => r.state.length)),
468
+ status: Math.max(6, ...rows.map(r => r.status.length)),
469
+ age: Math.max(3, ...rows.map(r => r.age.length)),
470
+ project: Math.max(7, ...rows.map(r => r.project.length)),
471
+ };
472
+ console.log(`${'ID'.padEnd(w.id)} ${'STATE'.padEnd(w.state)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'PROJECT'.padEnd(w.project)} TITLE`);
473
+ for (const r of rows) {
474
+ console.log(`${r.id.slice(0, 8).padEnd(w.id)} ${r.state.padEnd(w.state)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.project.padEnd(w.project)} ${r.title}`);
475
+ }
476
+ return 0;
477
+ }
478
+
399
479
  async function runSessionViewCli(args) {
400
480
  const idArg = args.find(a => !a.startsWith('--'));
401
481
  if (!idArg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -32,10 +32,18 @@ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
32
32
  claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
33
33
  ```
34
34
 
35
- State applies to every connected browser tab (broadcast via SSE) and persists in each tab's localStorage. With no tabs open the command is a no-op.
36
-
37
35
  Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
38
36
 
37
+ ## List pinned sessions
38
+
39
+ ```bash
40
+ claude-code-kanban session pins # all pinned/sticky sessions
41
+ claude-code-kanban session pins --sticky # sticky only
42
+ claude-code-kanban session pins --json # JSON output
43
+ ```
44
+
45
+ Trigger phrases: "show pinned sessions", "what's pinned", "list pins".
46
+
39
47
  ## Preview a file in kanban
40
48
 
41
49
  Opens a markdown file in the preview modal:
package/public/app.js CHANGED
@@ -1322,6 +1322,16 @@ function savePinnedSessions() {
1322
1322
  localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1323
1323
  }
1324
1324
 
1325
+ // Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
1326
+ function offloadSessionPin(sessionId) {
1327
+ const state = getSessionPinState(sessionId);
1328
+ fetch('/api/session/pin', {
1329
+ method: 'POST',
1330
+ headers: { 'Content-Type': 'application/json' },
1331
+ body: JSON.stringify({ id: sessionId, state }),
1332
+ }).catch(() => {});
1333
+ }
1334
+
1325
1335
  function toggleSessionPin(sessionId) {
1326
1336
  if (pinnedSessionIds.has(sessionId)) {
1327
1337
  pinnedSessionIds.delete(sessionId);
@@ -1332,6 +1342,7 @@ function toggleSessionPin(sessionId) {
1332
1342
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1333
1343
  }
1334
1344
  savePinnedSessions();
1345
+ offloadSessionPin(sessionId);
1335
1346
  renderSessions();
1336
1347
  }
1337
1348
 
@@ -1346,6 +1357,7 @@ function toggleSessionSticky(sessionId) {
1346
1357
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1347
1358
  }
1348
1359
  savePinnedSessions();
1360
+ offloadSessionPin(sessionId);
1349
1361
  renderSessions();
1350
1362
  }
1351
1363
 
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const express = require('express');
4
4
  const path = require('path');
5
5
  const fs = require('fs').promises;
6
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync } = require('fs');
6
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync } = require('fs');
7
7
  const readline = require('readline');
8
8
  const chokidar = require('chokidar');
9
9
  const os = require('os');
@@ -73,6 +73,27 @@ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
73
73
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
74
74
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
75
75
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
76
+ const PINS_FILE = path.join(CCK_DIR, 'pins.json');
77
+
78
+ // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
79
+ function readPins() {
80
+ try {
81
+ const obj = JSON.parse(readFileSync(PINS_FILE, 'utf8'));
82
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
83
+ } catch (_) {}
84
+ return {};
85
+ }
86
+
87
+ function writePins(pins) {
88
+ try {
89
+ mkdirSync(CCK_DIR, { recursive: true });
90
+ const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
92
+ renameSync(tmp, PINS_FILE);
93
+ } catch (e) {
94
+ console.error('Failed to write pins.json:', e.message);
95
+ }
96
+ }
76
97
 
77
98
  const PERMISSION_TTL_MS = 1800000;
78
99
  const AGENT_TTL_MS = 3600000;
@@ -1561,6 +1582,10 @@ app.post('/api/session/pin', async (req, res) => {
1561
1582
  if (!['none', 'pinned', 'sticky'].includes(state)) {
1562
1583
  return res.status(400).json({ error: 'state must be none|pinned|sticky' });
1563
1584
  }
1585
+ const pins = readPins();
1586
+ if (state === 'none') delete pins[id];
1587
+ else pins[id] = state;
1588
+ writePins(pins);
1564
1589
  broadcast({ type: 'session:pin', id, state });
1565
1590
  res.json({ success: true, id, state });
1566
1591
  } catch (error) {
@@ -1569,6 +1594,18 @@ app.post('/api/session/pin', async (req, res) => {
1569
1594
  }
1570
1595
  });
1571
1596
 
1597
+ app.get('/api/session/pins', (req, res) => {
1598
+ res.setHeader('Cache-Control', 'no-store');
1599
+ try {
1600
+ const pins = readPins();
1601
+ const items = Object.entries(pins).map(([id, state]) => ({ id, state }));
1602
+ res.json({ pins, items });
1603
+ } catch (error) {
1604
+ console.error('Error in GET /api/session/pins:', error);
1605
+ res.status(500).json({ error: error.message || 'Failed' });
1606
+ }
1607
+ });
1608
+
1572
1609
  app.get('/api/preview', async (req, res) => {
1573
1610
  try {
1574
1611
  const abs = resolvePreviewPath(req.query.path, req.query.base);