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": "claude-code-kanban",
3
- "version": "3.5.0",
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
@@ -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
- 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;
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 */ }