claude-code-kanban 3.2.1 → 3.2.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/lib/parsers.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const { readFileSync, existsSync, readdirSync, statSync } = fs;
3
3
  const path = require('path');
4
+ const { StringDecoder } = require('string_decoder');
4
5
 
5
6
  function parseTask(raw) {
6
7
  const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
@@ -171,52 +172,97 @@ function readCustomTitle(jsonlPath, existingStat) {
171
172
  }
172
173
  }
173
174
 
175
+ const SCRAPE_CWD_RE = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/;
176
+ const SCRAPE_SLUG_RE = /"slug"\s*:\s*"((?:[^"\\]|\\.)*)"/;
177
+ const SCRAPE_GITBRANCH_RE = /"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/;
178
+
179
+ function scrapeScalarFromBlob(blob, re) {
180
+ const m = blob.match(re);
181
+ if (!m) return null;
182
+ try { return JSON.parse(`"${m[1]}"`); } catch (e) { return null; }
183
+ }
184
+
174
185
  function readSessionInfoFromJsonl(jsonlPath) {
175
186
  const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
176
187
  let stat;
188
+ let fd;
189
+ // State shared across head-chunk parse, leftover flush, and tail parse.
190
+ let lastCwdFromHead = null;
191
+ const applyLine = (line) => {
192
+ try {
193
+ const data = JSON.parse(line);
194
+ if (data.slug) result.slug = data.slug;
195
+ if (data.cwd) {
196
+ if (!result.projectPath) result.projectPath = data.cwd;
197
+ lastCwdFromHead = data.cwd;
198
+ }
199
+ if (data.gitBranch) result.gitBranch = data.gitBranch;
200
+ } catch (e) {}
201
+ };
177
202
  try {
178
203
  stat = statSync(jsonlPath);
179
- const fd = fs.openSync(jsonlPath, 'r');
180
- const HEAD_SIZE = 16384;
204
+ fd = fs.openSync(jsonlPath, 'r');
205
+ const CHUNK_SIZE = 16384;
181
206
  const TAIL_SIZE = 16384;
182
-
183
- const headBuf = Buffer.alloc(Math.min(HEAD_SIZE, stat.size));
184
- const hn = fs.readSync(fd, headBuf, 0, headBuf.length, 0);
185
- let lastCwdFromHead = null;
186
- for (const line of headBuf.toString('utf8', 0, hn).split('\n')) {
187
- try {
188
- const data = JSON.parse(line);
189
- if (data.slug) result.slug = data.slug;
190
- if (data.cwd) {
191
- if (!result.projectPath) result.projectPath = data.cwd;
192
- lastCwdFromHead = data.cwd;
193
- }
194
- if (data.gitBranch) result.gitBranch = data.gitBranch;
195
- if (result.slug && result.projectPath && result.gitBranch) break;
196
- } catch (e) {}
207
+ // Stream head in chunks with StringDecoder to preserve multi-byte UTF-8
208
+ // codepoints across chunk boundaries. Scan up to HEAD_MAX so the last cwd
209
+ // in the window wins sessions that change cwd mid-run must surface it.
210
+ const HEAD_MAX = 1048576;
211
+ const decoder = new StringDecoder('utf8');
212
+ const buf = Buffer.alloc(CHUNK_SIZE);
213
+ let leftover = '';
214
+ let offset = 0;
215
+ while (offset < stat.size && offset < HEAD_MAX) {
216
+ const len = Math.min(CHUNK_SIZE, stat.size - offset);
217
+ const n = fs.readSync(fd, buf, 0, len, offset);
218
+ if (n === 0) break;
219
+ offset += n;
220
+ const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
221
+ const lines = text.split('\n');
222
+ leftover = lines.pop();
223
+ for (const line of lines) applyLine(line);
224
+ }
225
+ leftover += decoder.end();
226
+ if (leftover) applyLine(leftover);
227
+ // Oversized first line (e.g. multi-MB inline image) left leftover unparsed
228
+ // above; scrape scalars out of it so we don't fall through to the tail and
229
+ // pick a mid-session cwd as projectPath.
230
+ if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
231
+ const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
232
+ if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdFromHead = scrapedCwd; }
233
+ if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
234
+ if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
197
235
  }
198
236
 
199
237
  result.cwd = lastCwdFromHead;
200
238
 
201
- if ((!result.slug || !result.projectPath || !result.gitBranch) && stat.size > HEAD_SIZE) {
202
- const tailStart = stat.size - TAIL_SIZE;
239
+ // Tail scan: always runs when the file extends past the head window, so
240
+ // `cwd` reflects the latest value even in long sessions where a late cwd
241
+ // switch sits past HEAD_MAX. Also backfills projectPath/slug/gitBranch
242
+ // when the head couldn't find them. projectPath is NOT overwritten — it
243
+ // stays anchored to the earliest cwd seen.
244
+ if (stat.size > offset) {
245
+ const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
203
246
  const tailBuf = Buffer.alloc(TAIL_SIZE);
204
247
  const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
205
248
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
249
+ let latestTailCwd = null;
206
250
  for (let i = lines.length - 1; i >= 0; i--) {
207
251
  try {
208
252
  const data = JSON.parse(lines[i]);
209
253
  if (!result.slug && data.slug) result.slug = data.slug;
210
254
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
211
255
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
212
- if (!result.cwd && data.cwd) result.cwd = data.cwd;
213
- if (result.slug && result.projectPath && result.gitBranch) break;
256
+ if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
257
+ if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
214
258
  } catch (e) {}
215
259
  }
260
+ if (latestTailCwd) result.cwd = latestTailCwd;
216
261
  }
217
-
218
- fs.closeSync(fd);
219
- } catch (e) {}
262
+ } catch (e) {
263
+ } finally {
264
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
265
+ }
220
266
 
221
267
  result.customTitle = readCustomTitle(jsonlPath, stat);
222
268
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.2.1",
3
+ "version": "3.2.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,6 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.0.0",
4
- "description": "Agent activity tracking for claude-code-kanban dashboard",
5
- "hooks": "./hooks/hooks.json"
3
+ "version": "1.0.1",
4
+ "description": "Agent activity tracking for claude-code-kanban dashboard"
6
5
  }
package/public/app.js CHANGED
@@ -2110,6 +2110,8 @@ function renderSessions() {
2110
2110
  // Update project dropdown
2111
2111
  updateProjectDropdown();
2112
2112
 
2113
+ // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
2114
+ // project filter → search filter → ensure pinned/sticky sessions are always included
2113
2115
  const LIVE_INDICATOR_MS = 10 * 1000;
2114
2116
  let filteredSessions = sessions;
2115
2117
  if (sessionFilter === 'active') {
@@ -2128,11 +2130,17 @@ function renderSessions() {
2128
2130
  if (isActive) activeSessionIds.add(s.id);
2129
2131
  return isActive;
2130
2132
  });
2133
+ // Force-include revealed/current sessions that didn't pass the active filter.
2134
+ // Skip pinned sessions — they are prepended separately below (lines ~2180) to preserve stable position.
2131
2135
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
2132
2136
  for (const id of [revealedPlanSessionId, revealedStorageSessionId, currentSessionId]) {
2133
- if (id && !filteredIds.has(id)) {
2137
+ if (id && !filteredIds.has(id) && !isAnyPinned(id)) {
2134
2138
  const session = sessions.find((s) => s.id === id);
2135
- if (session) filteredSessions.push(session);
2139
+ if (session) {
2140
+ const insertAt = filteredSessions.findIndex((s) => s.modifiedAt < session.modifiedAt);
2141
+ if (insertAt === -1) filteredSessions.push(session);
2142
+ else filteredSessions.splice(insertAt, 0, session);
2143
+ }
2136
2144
  }
2137
2145
  }
2138
2146
  }
@@ -4802,44 +4810,40 @@ async function showSessionInfoModal(sessionId) {
4802
4810
  const session = sessions.find((s) => s.id === sessionId);
4803
4811
  if (!session) return;
4804
4812
 
4805
- const promises = [];
4813
+ // Open modal immediately with session metadata (cwd / path / branch are
4814
+ // already in-memory). Plan / team / tasks are fetched in the background
4815
+ // and re-rendered when they arrive, so the modal doesn't block on network.
4816
+ _planSessionId = sessionId;
4817
+ const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
4818
+ showInfoModal(session, null, cachedTasks, null);
4819
+
4820
+ const rerender = (teamConfig, tasks, planContent) => {
4821
+ if (_planSessionId !== sessionId) return; // user opened a different modal
4822
+ const modal = document.getElementById('team-modal');
4823
+ if (!modal?.classList.contains('visible')) return; // user closed modal — don't reopen
4824
+ showInfoModal(session, teamConfig, tasks, planContent);
4825
+ };
4806
4826
 
4807
- // Fetch team config
4808
- let teamConfig = null;
4809
- if (session.isTeam) {
4810
- const teamId = session.teamName || sessionId;
4811
- promises.push(
4812
- fetch(`/api/teams/${teamId}`)
4827
+ const teamPromise = session.isTeam
4828
+ ? fetch(`/api/teams/${session.teamName || sessionId}`)
4813
4829
  .then((r) => (r.ok ? r.json() : null))
4814
4830
  .catch(() => null)
4815
- .then((data) => {
4816
- teamConfig = data;
4817
- }),
4818
- );
4819
- }
4831
+ : Promise.resolve(null);
4820
4832
 
4821
- // Fetch plan
4822
- let planContent = null;
4823
- promises.push(
4824
- fetch(`/api/sessions/${sessionId}/plan`)
4825
- .then((r) => (r.ok ? r.json() : null))
4826
- .catch(() => null)
4827
- .then((data) => {
4828
- planContent = data?.content || null;
4829
- }),
4830
- );
4833
+ const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
4834
+ .then((r) => (r.ok ? r.json() : null))
4835
+ .catch(() => null)
4836
+ .then((data) => data?.content || null);
4831
4837
 
4832
- await Promise.all(promises);
4838
+ const tasksPromise =
4839
+ cachedTasks.length > 0
4840
+ ? Promise.resolve(cachedTasks)
4841
+ : fetch(`/api/sessions/${sessionId}`)
4842
+ .then((r) => (r.ok ? r.json() : []))
4843
+ .catch(() => []);
4833
4844
 
4834
- let tasks = currentSessionId === sessionId ? currentTasks : [];
4835
- if (tasks.length === 0) {
4836
- try {
4837
- const r = await fetch(`/api/sessions/${sessionId}`);
4838
- if (r.ok) tasks = await r.json();
4839
- } catch {}
4840
- }
4841
- _planSessionId = sessionId;
4842
- showInfoModal(session, teamConfig, tasks, planContent);
4845
+ const [teamConfig, planContent, tasks] = await Promise.all([teamPromise, planPromise, tasksPromise]);
4846
+ rerender(teamConfig, tasks, planContent);
4843
4847
  }
4844
4848
 
4845
4849
  let _infoModalSessionId = null;
@@ -4883,7 +4887,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4883
4887
  const projectName = session.project.split(/[/\\]/).pop();
4884
4888
  infoRows.push(['Project', projectName, { openPath: session.projectDir }]);
4885
4889
  infoRows.push(['Path', session.project, { openPath: session.project }]);
4886
- if (session.cwd && session.cwd !== session.project) {
4890
+ if (session.cwd) {
4887
4891
  infoRows.push(['CWD', session.cwd, { openPath: session.cwd }]);
4888
4892
  }
4889
4893
  if (session.gitBranch) {
@@ -4996,6 +5000,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4996
5000
  }
4997
5001
 
4998
5002
  bodyEl.innerHTML = html;
5003
+ const alreadyVisible = modal.classList.contains('visible');
4999
5004
  _infoModalSessionId = session.id;
5000
5005
  updateStickyBtnState();
5001
5006
  updateDismissBtnState();
@@ -5003,6 +5008,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5003
5008
  if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
5004
5009
  modal.classList.add('visible');
5005
5010
 
5011
+ if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
5012
+
5006
5013
  const keyHandler = (e) => {
5007
5014
  if (e.key === 'Escape') {
5008
5015
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
@@ -5016,6 +5023,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5016
5023
 
5017
5024
  function closeTeamModal() {
5018
5025
  document.getElementById('team-modal').classList.remove('visible');
5026
+ _planSessionId = null;
5019
5027
  }
5020
5028
 
5021
5029
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML