claude-code-kanban 3.5.0 → 3.5.3
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
|
@@ -42,6 +42,16 @@ const COMMANDS = {
|
|
|
42
42
|
},
|
|
43
43
|
run: runSessionViewCli,
|
|
44
44
|
},
|
|
45
|
+
pin: {
|
|
46
|
+
summary: 'Pin (or unpin) a session in the sidebar of connected browser tabs',
|
|
47
|
+
usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
|
|
48
|
+
flags: {
|
|
49
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
50
|
+
'--sticky': 'Set sticky state (always shown, top of list)',
|
|
51
|
+
'--unpin': 'Clear pin/sticky state',
|
|
52
|
+
},
|
|
53
|
+
run: runSessionPinCli,
|
|
54
|
+
},
|
|
45
55
|
peek: {
|
|
46
56
|
summary: 'Show the last N messages from a session',
|
|
47
57
|
usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
|
|
@@ -361,6 +371,31 @@ async function runSessionOpenCli(args) {
|
|
|
361
371
|
} catch (e) { reportCliError(e); return 1; }
|
|
362
372
|
}
|
|
363
373
|
|
|
374
|
+
async function runSessionPinCli(args) {
|
|
375
|
+
const idArg = args.find(a => !a.startsWith('--'));
|
|
376
|
+
if (!idArg) {
|
|
377
|
+
printLeafHelp('session pin', COMMANDS.session.verbs.pin);
|
|
378
|
+
return 1;
|
|
379
|
+
}
|
|
380
|
+
const state = args.includes('--unpin') ? 'none' : args.includes('--sticky') ? 'sticky' : 'pinned';
|
|
381
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
382
|
+
if (!resolved) return 1;
|
|
383
|
+
try {
|
|
384
|
+
const res = await cliFetch('/api/session/pin', {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: { 'Content-Type': 'application/json' },
|
|
387
|
+
body: JSON.stringify({ id: resolved.id, state })
|
|
388
|
+
});
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
console.error(`Pin failed (${res.status}): ${await res.text()}`);
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
const label = state === 'none' ? 'unpinned' : state;
|
|
394
|
+
console.log(`Session ${label}: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
|
|
395
|
+
return 0;
|
|
396
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
397
|
+
}
|
|
398
|
+
|
|
364
399
|
async function runSessionViewCli(args) {
|
|
365
400
|
const idArg = args.find(a => !a.startsWith('--'));
|
|
366
401
|
if (!idArg) {
|
package/lib/parsers.js
CHANGED
|
@@ -182,10 +182,22 @@ function scrapeScalarFromBlob(blob, re) {
|
|
|
182
182
|
try { return JSON.parse(`"${m[1]}"`); } catch (e) { return null; }
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
const sessionInfoCache = new Map();
|
|
186
|
+
const SESSION_INFO_CACHE_MAX = 2000;
|
|
187
|
+
|
|
185
188
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
186
189
|
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
|
|
187
190
|
let stat;
|
|
188
191
|
let fd;
|
|
192
|
+
try {
|
|
193
|
+
stat = statSync(jsonlPath);
|
|
194
|
+
const cached = sessionInfoCache.get(jsonlPath);
|
|
195
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
196
|
+
return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
|
|
197
|
+
}
|
|
198
|
+
} catch (_) {
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
189
201
|
// State shared across head-chunk parse, leftover flush, and tail parse.
|
|
190
202
|
let lastCwdFromHead = null;
|
|
191
203
|
const applyLine = (line) => {
|
|
@@ -200,7 +212,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
200
212
|
} catch (e) {}
|
|
201
213
|
};
|
|
202
214
|
try {
|
|
203
|
-
stat = statSync(jsonlPath);
|
|
204
215
|
fd = fs.openSync(jsonlPath, 'r');
|
|
205
216
|
const CHUNK_SIZE = 16384;
|
|
206
217
|
const TAIL_SIZE = 16384;
|
|
@@ -264,6 +275,14 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
264
275
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
|
265
276
|
}
|
|
266
277
|
|
|
278
|
+
if (stat) {
|
|
279
|
+
const { customTitle: _ct, ...rest } = result;
|
|
280
|
+
sessionInfoCache.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, result: rest });
|
|
281
|
+
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
282
|
+
const firstKey = sessionInfoCache.keys().next().value;
|
|
283
|
+
sessionInfoCache.delete(firstKey);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
267
286
|
result.customTitle = readCustomTitle(jsonlPath, stat);
|
|
268
287
|
return result;
|
|
269
288
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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.
|
|
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", "pin/unpin a session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
|
|
4
4
|
compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3456).
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -22,6 +22,20 @@ claude-code-kanban session open ${CLAUDE_SESSION_ID}
|
|
|
22
22
|
|
|
23
23
|
Trigger phrases: "show this session in kanban", "focus current session", "open in kanban".
|
|
24
24
|
|
|
25
|
+
## Pin the current session in kanban
|
|
26
|
+
|
|
27
|
+
Pins the active Claude session in the sidebar so it stays visible regardless of filters. Three states: `pinned` (default), `sticky` (always at the top), or cleared via `--unpin`.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
|
|
31
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
|
|
32
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
|
|
33
|
+
```
|
|
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
|
+
Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
|
|
38
|
+
|
|
25
39
|
## Preview a file in kanban
|
|
26
40
|
|
|
27
41
|
Opens a markdown file in the preview modal:
|
package/public/app.js
CHANGED
|
@@ -504,6 +504,7 @@ async function fetchTasks(sessionId) {
|
|
|
504
504
|
resetAgentState();
|
|
505
505
|
updateUrl();
|
|
506
506
|
renderSession();
|
|
507
|
+
renderSessions();
|
|
507
508
|
fetchAgents(sessionId);
|
|
508
509
|
if (!agentLogMode) fetchMessages(sessionId);
|
|
509
510
|
} catch (error) {
|
|
@@ -1334,6 +1335,19 @@ function toggleSessionSticky(sessionId) {
|
|
|
1334
1335
|
renderSessions();
|
|
1335
1336
|
}
|
|
1336
1337
|
|
|
1338
|
+
function handleSessionPinEvent({ id, state }) {
|
|
1339
|
+
if (!id) return;
|
|
1340
|
+
pinnedSessionIds.delete(id);
|
|
1341
|
+
stickySessionIds.delete(id);
|
|
1342
|
+
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
1343
|
+
if (state === 'sticky') {
|
|
1344
|
+
pinnedSessionIds.add(id);
|
|
1345
|
+
stickySessionIds.add(id);
|
|
1346
|
+
}
|
|
1347
|
+
savePinnedSessions();
|
|
1348
|
+
renderSessions();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1337
1351
|
function getSessionPinState(sessionId) {
|
|
1338
1352
|
if (stickySessionIds.has(sessionId)) return 'sticky';
|
|
1339
1353
|
if (pinnedSessionIds.has(sessionId)) return 'pinned';
|
|
@@ -4148,8 +4162,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
4148
4162
|
e.preventDefault();
|
|
4149
4163
|
if (_manualRefreshing) return;
|
|
4150
4164
|
_manualRefreshing = true;
|
|
4165
|
+
lastSessionsHash = '';
|
|
4166
|
+
lastTasksHash = '';
|
|
4151
4167
|
const refreshes = [fetchSessions()];
|
|
4152
4168
|
if (currentSessionId) refreshes.push(fetchTasks(currentSessionId));
|
|
4169
|
+
refreshRateLimits();
|
|
4153
4170
|
Promise.all(refreshes)
|
|
4154
4171
|
.then(() => showToast('Data refreshed', 'success'))
|
|
4155
4172
|
.finally(() => {
|
|
@@ -4512,6 +4529,10 @@ function setupEventSource() {
|
|
|
4512
4529
|
handleSessionOpenEvent(data);
|
|
4513
4530
|
}
|
|
4514
4531
|
|
|
4532
|
+
if (data.type === 'session:pin') {
|
|
4533
|
+
handleSessionPinEvent(data);
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4515
4536
|
if (data.type === 'team-update') {
|
|
4516
4537
|
const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
|
|
4517
4538
|
if (teamSession) {
|
|
@@ -4979,11 +5000,18 @@ async function updateProjectDropdown() {
|
|
|
4979
5000
|
projectsCacheDirty = false;
|
|
4980
5001
|
|
|
4981
5002
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
5003
|
+
const prevRecent = recentProjects;
|
|
4982
5004
|
recentProjects = new Set(
|
|
4983
5005
|
projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
|
|
4984
5006
|
);
|
|
4985
5007
|
|
|
4986
5008
|
renderProjectDropdown(dropdown, projects);
|
|
5009
|
+
|
|
5010
|
+
// recentProjects was empty before — sidebar rendered with __recent__ filter
|
|
5011
|
+
// dropping every session. Re-render now that we know which projects qualify.
|
|
5012
|
+
if (filterProject === '__recent__' && prevRecent.size === 0 && recentProjects.size > 0) {
|
|
5013
|
+
renderSessions();
|
|
5014
|
+
}
|
|
4987
5015
|
}
|
|
4988
5016
|
|
|
4989
5017
|
function renderProjectDropdown(dropdown, projects) {
|
|
@@ -5715,7 +5743,11 @@ function refreshRateLimits() {
|
|
|
5715
5743
|
fetch('/api/context-status')
|
|
5716
5744
|
.then((r) => r.json())
|
|
5717
5745
|
.then((all) => {
|
|
5718
|
-
|
|
5746
|
+
let freshest = null;
|
|
5747
|
+
for (const e of Object.values(all || {})) {
|
|
5748
|
+
if (e?.rate_limits && (!freshest || (e._updatedAt || 0) > (freshest._updatedAt || 0))) freshest = e;
|
|
5749
|
+
}
|
|
5750
|
+
const rl = freshest?.rate_limits || null;
|
|
5719
5751
|
const fh = rl?.five_hour?.used_percentage ?? null;
|
|
5720
5752
|
const sd = rl?.seven_day?.used_percentage ?? null;
|
|
5721
5753
|
const key = `${fh}|${sd}`;
|
package/server.js
CHANGED
|
@@ -1531,6 +1531,21 @@ app.post('/api/session/open', async (req, res) => {
|
|
|
1531
1531
|
}
|
|
1532
1532
|
});
|
|
1533
1533
|
|
|
1534
|
+
app.post('/api/session/pin', async (req, res) => {
|
|
1535
|
+
try {
|
|
1536
|
+
const { id, state } = req.body || {};
|
|
1537
|
+
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
|
|
1538
|
+
if (!['none', 'pinned', 'sticky'].includes(state)) {
|
|
1539
|
+
return res.status(400).json({ error: 'state must be none|pinned|sticky' });
|
|
1540
|
+
}
|
|
1541
|
+
broadcast({ type: 'session:pin', id, state });
|
|
1542
|
+
res.json({ success: true, id, state });
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
console.error('Error in /api/session/pin:', error);
|
|
1545
|
+
res.status(500).json({ error: error.message || 'Failed' });
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1534
1549
|
app.get('/api/preview', async (req, res) => {
|
|
1535
1550
|
try {
|
|
1536
1551
|
const abs = resolvePreviewPath(req.query.path);
|
|
@@ -1746,6 +1761,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
1746
1761
|
if (event === 'add' || event === 'change') {
|
|
1747
1762
|
try {
|
|
1748
1763
|
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
1764
|
+
try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
|
|
1749
1765
|
contextStatusCache.set(sessionId, data);
|
|
1750
1766
|
evictStaleCache(contextStatusCache);
|
|
1751
1767
|
} catch (e) { /* ignore malformed */ }
|