claude-code-kanban 3.2.2 → 3.2.4
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 +70 -24
- package/package.json +1 -1
- package/public/app.js +34 -34
- package/server.js +15 -1
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
|
-
|
|
180
|
-
const
|
|
204
|
+
fd = fs.openSync(jsonlPath, 'r');
|
|
205
|
+
const CHUNK_SIZE = 16384;
|
|
181
206
|
const TAIL_SIZE = 16384;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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 (!
|
|
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
|
-
|
|
219
|
-
|
|
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
package/public/app.js
CHANGED
|
@@ -4810,44 +4810,40 @@ async function showSessionInfoModal(sessionId) {
|
|
|
4810
4810
|
const session = sessions.find((s) => s.id === sessionId);
|
|
4811
4811
|
if (!session) return;
|
|
4812
4812
|
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
//
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
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
|
+
};
|
|
4826
|
+
|
|
4827
|
+
const teamPromise = session.isTeam
|
|
4828
|
+
? fetch(`/api/teams/${session.teamName || sessionId}`)
|
|
4821
4829
|
.then((r) => (r.ok ? r.json() : null))
|
|
4822
4830
|
.catch(() => null)
|
|
4823
|
-
|
|
4824
|
-
teamConfig = data;
|
|
4825
|
-
}),
|
|
4826
|
-
);
|
|
4827
|
-
}
|
|
4831
|
+
: Promise.resolve(null);
|
|
4828
4832
|
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
4834
|
-
.catch(() => null)
|
|
4835
|
-
.then((data) => {
|
|
4836
|
-
planContent = data?.content || null;
|
|
4837
|
-
}),
|
|
4838
|
-
);
|
|
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);
|
|
4839
4837
|
|
|
4840
|
-
|
|
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(() => []);
|
|
4841
4844
|
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
try {
|
|
4845
|
-
const r = await fetch(`/api/sessions/${sessionId}`);
|
|
4846
|
-
if (r.ok) tasks = await r.json();
|
|
4847
|
-
} catch {}
|
|
4848
|
-
}
|
|
4849
|
-
_planSessionId = sessionId;
|
|
4850
|
-
showInfoModal(session, teamConfig, tasks, planContent);
|
|
4845
|
+
const [teamConfig, planContent, tasks] = await Promise.all([teamPromise, planPromise, tasksPromise]);
|
|
4846
|
+
rerender(teamConfig, tasks, planContent);
|
|
4851
4847
|
}
|
|
4852
4848
|
|
|
4853
4849
|
let _infoModalSessionId = null;
|
|
@@ -4891,7 +4887,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
4891
4887
|
const projectName = session.project.split(/[/\\]/).pop();
|
|
4892
4888
|
infoRows.push(['Project', projectName, { openPath: session.projectDir }]);
|
|
4893
4889
|
infoRows.push(['Path', session.project, { openPath: session.project }]);
|
|
4894
|
-
if (session.cwd
|
|
4890
|
+
if (session.cwd) {
|
|
4895
4891
|
infoRows.push(['CWD', session.cwd, { openPath: session.cwd }]);
|
|
4896
4892
|
}
|
|
4897
4893
|
if (session.gitBranch) {
|
|
@@ -5004,6 +5000,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
5004
5000
|
}
|
|
5005
5001
|
|
|
5006
5002
|
bodyEl.innerHTML = html;
|
|
5003
|
+
const alreadyVisible = modal.classList.contains('visible');
|
|
5007
5004
|
_infoModalSessionId = session.id;
|
|
5008
5005
|
updateStickyBtnState();
|
|
5009
5006
|
updateDismissBtnState();
|
|
@@ -5011,6 +5008,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
5011
5008
|
if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
|
|
5012
5009
|
modal.classList.add('visible');
|
|
5013
5010
|
|
|
5011
|
+
if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
|
|
5012
|
+
|
|
5014
5013
|
const keyHandler = (e) => {
|
|
5015
5014
|
if (e.key === 'Escape') {
|
|
5016
5015
|
if (document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
@@ -5024,6 +5023,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
5024
5023
|
|
|
5025
5024
|
function closeTeamModal() {
|
|
5026
5025
|
document.getElementById('team-modal').classList.remove('visible');
|
|
5026
|
+
_planSessionId = null;
|
|
5027
5027
|
}
|
|
5028
5028
|
|
|
5029
5029
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
package/server.js
CHANGED
|
@@ -387,9 +387,23 @@ function loadSessionMetadata() {
|
|
|
387
387
|
resolvedProjectPath = sessionInfo.projectPath;
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
const candidateProject = indexProjectPath || sessionInfo.projectPath || null;
|
|
391
|
+
const existing = metadata[sessionId];
|
|
392
|
+
// Same sessionId can appear in multiple project dirs (e.g. "shadow"
|
|
393
|
+
// JSONLs that only hold custom-title/agent-name records when a session
|
|
394
|
+
// is continued from a worktree). Don't let a weaker entry (no cwd, no
|
|
395
|
+
// project) overwrite a previously resolved one — just merge scalars.
|
|
396
|
+
if (existing && existing.project && !candidateProject) {
|
|
397
|
+
if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
|
|
398
|
+
if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
|
|
399
|
+
if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
|
|
400
|
+
sessionIds.push(sessionId);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
390
404
|
metadata[sessionId] = {
|
|
391
405
|
slug: sessionInfo.slug,
|
|
392
|
-
project:
|
|
406
|
+
project: candidateProject,
|
|
393
407
|
cwd: sessionInfo.cwd || null,
|
|
394
408
|
gitBranch: sessionInfo.gitBranch || null,
|
|
395
409
|
customTitle: sessionInfo.customTitle || null,
|