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
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
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
|
@@ -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);
|