claude-code-kanban 3.5.1 → 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": "claude-code-kanban",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
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.3",
3
+ "version": "1.1.4",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -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
@@ -1335,6 +1335,19 @@ function toggleSessionSticky(sessionId) {
1335
1335
  renderSessions();
1336
1336
  }
1337
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
+
1338
1351
  function getSessionPinState(sessionId) {
1339
1352
  if (stickySessionIds.has(sessionId)) return 'sticky';
1340
1353
  if (pinnedSessionIds.has(sessionId)) return 'pinned';
@@ -4153,6 +4166,7 @@ document.addEventListener('keydown', (e) => {
4153
4166
  lastTasksHash = '';
4154
4167
  const refreshes = [fetchSessions()];
4155
4168
  if (currentSessionId) refreshes.push(fetchTasks(currentSessionId));
4169
+ refreshRateLimits();
4156
4170
  Promise.all(refreshes)
4157
4171
  .then(() => showToast('Data refreshed', 'success'))
4158
4172
  .finally(() => {
@@ -4515,6 +4529,10 @@ function setupEventSource() {
4515
4529
  handleSessionOpenEvent(data);
4516
4530
  }
4517
4531
 
4532
+ if (data.type === 'session:pin') {
4533
+ handleSessionPinEvent(data);
4534
+ }
4535
+
4518
4536
  if (data.type === 'team-update') {
4519
4537
  const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
4520
4538
  if (teamSession) {
@@ -5725,7 +5743,11 @@ function refreshRateLimits() {
5725
5743
  fetch('/api/context-status')
5726
5744
  .then((r) => r.json())
5727
5745
  .then((all) => {
5728
- const rl = Object.values(all || {}).find((e) => e?.rate_limits)?.rate_limits || null;
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;
5729
5751
  const fh = rl?.five_hour?.used_percentage ?? null;
5730
5752
  const sd = rl?.seven_day?.used_percentage ?? null;
5731
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 */ }