claude-code-kanban 4.17.0 → 4.19.0
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/contain.js +78 -0
- package/lib/inline-assets.js +238 -0
- package/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/lib/parsers.js +8 -1
- package/package.json +3 -3
- package/public/app.js +181 -77
- package/public/index.html +19 -11
- package/public/style.css +35 -2
- package/server.js +146 -52
package/public/index.html
CHANGED
|
@@ -301,7 +301,7 @@
|
|
|
301
301
|
<button class="icon-btn" aria-label="Open in editor" title="Open in editor" onclick="openMsgInEditor()">
|
|
302
302
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
303
303
|
</button>
|
|
304
|
-
<button id="msg-detail-fullscreen-btn" class="icon-btn" aria-label="Toggle fullscreen" title="Toggle fullscreen" onclick="toggleModalFullscreen('msg-detail-modal')">
|
|
304
|
+
<button id="msg-detail-modal-fullscreen-btn" class="icon-btn" aria-label="Toggle fullscreen" title="Toggle fullscreen" onclick="toggleModalFullscreen('msg-detail-modal')">
|
|
305
305
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
306
306
|
</button>
|
|
307
307
|
<button class="modal-close" aria-label="Close" onclick="closeMsgDetailModal()">
|
|
@@ -310,7 +310,7 @@
|
|
|
310
310
|
</div>
|
|
311
311
|
</div>
|
|
312
312
|
<div class="modal-body" style="flex: 0 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden;">
|
|
313
|
-
<div id="msg-detail-body" class="rendered-md
|
|
313
|
+
<div id="msg-detail-body" class="rendered-md modal-zoomable"></div>
|
|
314
314
|
<div id="msg-detail-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted); flex-shrink: 0;"></div>
|
|
315
315
|
</div>
|
|
316
316
|
</div>
|
|
@@ -337,7 +337,7 @@
|
|
|
337
337
|
</div>
|
|
338
338
|
</div>
|
|
339
339
|
<div class="modal-body">
|
|
340
|
-
<div id="preview-modal-body" class="rendered-md"></div>
|
|
340
|
+
<div id="preview-modal-body" class="rendered-md modal-zoomable"></div>
|
|
341
341
|
<div id="preview-modal-meta"></div>
|
|
342
342
|
</div>
|
|
343
343
|
</div>
|
|
@@ -354,7 +354,7 @@
|
|
|
354
354
|
</svg>
|
|
355
355
|
</button>
|
|
356
356
|
</div>
|
|
357
|
-
<div class="modal-body">
|
|
357
|
+
<div class="modal-body modal-zoomable">
|
|
358
358
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0 24px;">
|
|
359
359
|
<div style="display: grid; gap: 16px; align-content: start;">
|
|
360
360
|
<div>
|
|
@@ -459,6 +459,14 @@
|
|
|
459
459
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+M</kbd></td>
|
|
460
460
|
<td style="padding: 4px 0; color: var(--text-primary);">Open last message detail</td>
|
|
461
461
|
</tr>
|
|
462
|
+
<tr>
|
|
463
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl++</kbd> / <kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+-</kbd></td>
|
|
464
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Modal text size</td>
|
|
465
|
+
</tr>
|
|
466
|
+
<tr>
|
|
467
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+0</kbd></td>
|
|
468
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Reset text size</td>
|
|
469
|
+
</tr>
|
|
462
470
|
<tr>
|
|
463
471
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+S</kbd></td>
|
|
464
472
|
<td style="padding: 4px 0; color: var(--text-primary);">Storage manager</td>
|
|
@@ -527,7 +535,7 @@
|
|
|
527
535
|
</button>
|
|
528
536
|
</div>
|
|
529
537
|
</div>
|
|
530
|
-
<div id="team-modal-body" class="modal-body"></div>
|
|
538
|
+
<div id="team-modal-body" class="modal-body modal-zoomable"></div>
|
|
531
539
|
<div class="modal-footer">
|
|
532
540
|
<button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId); closeTeamModal()">Dismiss</button>
|
|
533
541
|
<button class="btn btn-secondary" onclick="showToolStatsModal(_infoModalSessionId)" title="Tool invocation statistics">Tool Stats</button>
|
|
@@ -547,7 +555,7 @@
|
|
|
547
555
|
</svg>
|
|
548
556
|
</button>
|
|
549
557
|
</div>
|
|
550
|
-
<div id="tool-stats-modal-body" class="modal-body" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
|
|
558
|
+
<div id="tool-stats-modal-body" class="modal-body modal-zoomable" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
|
|
551
559
|
<div class="modal-footer">
|
|
552
560
|
<button class="btn btn-secondary" onclick="closeToolStatsModal()">Close</button>
|
|
553
561
|
</div>
|
|
@@ -576,7 +584,7 @@
|
|
|
576
584
|
</button>
|
|
577
585
|
</div>
|
|
578
586
|
</div>
|
|
579
|
-
<div id="plan-modal-body" class="modal-body detail-desc rendered-md
|
|
587
|
+
<div id="plan-modal-body" class="modal-body detail-desc rendered-md modal-zoomable"></div>
|
|
580
588
|
<div class="modal-footer">
|
|
581
589
|
<button class="btn btn-secondary" onclick="closePlanModal()">Close</button>
|
|
582
590
|
</div>
|
|
@@ -622,7 +630,7 @@
|
|
|
622
630
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
623
631
|
</button>
|
|
624
632
|
</div>
|
|
625
|
-
<div id="loop-modal-body" class="modal-body" style="overflow-y:auto;flex:0 1 auto;min-height:0;"></div>
|
|
633
|
+
<div id="loop-modal-body" class="modal-body modal-zoomable" style="overflow-y:auto;flex:0 1 auto;min-height:0;"></div>
|
|
626
634
|
<div class="modal-footer">
|
|
627
635
|
<button class="btn btn-secondary" onclick="closeLoopModal()">Close</button>
|
|
628
636
|
</div>
|
|
@@ -646,7 +654,7 @@
|
|
|
646
654
|
</button>
|
|
647
655
|
</div>
|
|
648
656
|
</div>
|
|
649
|
-
<div id="workflow-modal-body" class="modal-body" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
|
|
657
|
+
<div id="workflow-modal-body" class="modal-body modal-zoomable" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
|
|
650
658
|
<div class="modal-footer">
|
|
651
659
|
<button class="btn btn-secondary" onclick="closeWorkflowModal()">Close</button>
|
|
652
660
|
</div>
|
|
@@ -677,7 +685,7 @@
|
|
|
677
685
|
</button>
|
|
678
686
|
</div>
|
|
679
687
|
</div>
|
|
680
|
-
<div id="agent-modal-body" class="modal-body" style="overflow-y: auto; overflow-x: hidden; flex: 0 1 auto; min-height: 0;"></div>
|
|
688
|
+
<div id="agent-modal-body" class="modal-body modal-zoomable" style="overflow-y: auto; overflow-x: hidden; flex: 0 1 auto; min-height: 0;"></div>
|
|
681
689
|
<div class="modal-footer">
|
|
682
690
|
<button id="agent-modal-dismiss-btn" class="btn agent-dismiss-btn" style="display:none" onclick="dismissAgent(currentAgentModalId);closeAgentModal()">Dismiss</button>
|
|
683
691
|
<button class="btn btn-secondary" onclick="closeAgentModal()">Close</button>
|
|
@@ -720,7 +728,7 @@
|
|
|
720
728
|
<button class="storage-tab" data-tab="scratchpads" onclick="switchStorageTab('scratchpads')">Scratchpads</button>
|
|
721
729
|
<button class="storage-tab" data-tab="linked-docs" onclick="switchStorageTab('linked-docs')">Linked Docs</button>
|
|
722
730
|
</div>
|
|
723
|
-
<div class="modal-body" id="storage-modal-body"
|
|
731
|
+
<div class="modal-body modal-zoomable" id="storage-modal-body"></div>
|
|
724
732
|
<div class="modal-footer">
|
|
725
733
|
<button id="storage-cleanup-btn" class="btn btn-secondary" onclick="cleanupOrphanedStorage()">Clean Orphaned</button>
|
|
726
734
|
<button class="btn btn-secondary" onclick="closeStorageManager()">Close</button>
|
package/public/style.css
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
/* aliases: many rules reference --font-mono/--font-serif; keep them on-brand */
|
|
39
39
|
--font-mono: var(--mono);
|
|
40
40
|
--font-serif: var(--serif);
|
|
41
|
+
/* reading-surface scale for the message-detail and preview modals (Ctrl +/-/0) */
|
|
42
|
+
--modal-zoom: 1;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
* {
|
|
@@ -2803,6 +2805,9 @@ body::before {
|
|
|
2803
2805
|
}
|
|
2804
2806
|
.rendered-md blockquote {
|
|
2805
2807
|
margin: 0.6em 0;
|
|
2808
|
+
padding-left: 10px;
|
|
2809
|
+
border-left: 2px solid var(--border);
|
|
2810
|
+
color: var(--text-secondary);
|
|
2806
2811
|
}
|
|
2807
2812
|
.rendered-md table {
|
|
2808
2813
|
margin: 0.6em 0;
|
|
@@ -3729,10 +3734,12 @@ body.light .msg-assistant .msg-text {
|
|
|
3729
3734
|
overflow-y: auto;
|
|
3730
3735
|
}
|
|
3731
3736
|
|
|
3737
|
+
/* Height is a cap, not a floor — a two-line message should not sit in a 92vh box.
|
|
3738
|
+
The HTML preview keeps its explicit height below: its iframe is `height: 100%`
|
|
3739
|
+
and needs a resolvable parent height. */
|
|
3732
3740
|
.modal.fullscreen {
|
|
3733
3741
|
width: 80vw;
|
|
3734
3742
|
max-width: 80vw;
|
|
3735
|
-
height: 92vh;
|
|
3736
3743
|
max-height: 92vh;
|
|
3737
3744
|
}
|
|
3738
3745
|
|
|
@@ -3773,7 +3780,9 @@ body.light .msg-assistant .msg-text {
|
|
|
3773
3780
|
overflow: hidden;
|
|
3774
3781
|
}
|
|
3775
3782
|
|
|
3776
|
-
#
|
|
3783
|
+
#msg-detail-body,
|
|
3784
|
+
#preview-modal-body,
|
|
3785
|
+
#plan-modal-body {
|
|
3777
3786
|
word-break: break-word;
|
|
3778
3787
|
font-size: 13px;
|
|
3779
3788
|
line-height: 1.6;
|
|
@@ -3783,6 +3792,30 @@ body.light .msg-assistant .msg-text {
|
|
|
3783
3792
|
padding-right: 8px;
|
|
3784
3793
|
}
|
|
3785
3794
|
|
|
3795
|
+
/* Same reading size as the other modal bodies — `.detail-desc` is really the
|
|
3796
|
+
task-panel inline editor's style, and its 14px/1.7 is wrong here. */
|
|
3797
|
+
.agent-tab-panel .detail-desc {
|
|
3798
|
+
font-size: 13px;
|
|
3799
|
+
line-height: 1.6;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
/* One reading scale for every modal (Ctrl +/-/0). `zoom` rather than font-size
|
|
3803
|
+
because the bodies mix px, rem, inline sizes from tool rendering and an iframe.
|
|
3804
|
+
It hangs on the inner scrolling body — never on `.modal`, whose own vw/vh
|
|
3805
|
+
sizing would scale with it and push the dialog off-screen. */
|
|
3806
|
+
.modal-zoomable {
|
|
3807
|
+
zoom: var(--modal-zoom, 1);
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
/* Height is left to the flex column under `.storage-modal`'s 85vh cap — a `vh`
|
|
3811
|
+
cap here would be multiplied by the zoom above. */
|
|
3812
|
+
#storage-modal-body {
|
|
3813
|
+
overflow-y: auto;
|
|
3814
|
+
min-height: 200px;
|
|
3815
|
+
padding-top: 16px;
|
|
3816
|
+
padding-right: 8px;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3786
3819
|
#preview-modal-meta {
|
|
3787
3820
|
margin-top: 12px;
|
|
3788
3821
|
font-size: 11px;
|
package/server.js
CHANGED
|
@@ -8,7 +8,10 @@ const readline = require('readline');
|
|
|
8
8
|
const chokidar = require('chokidar');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const {
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
|
|
13
|
+
const { createNetGuard } = require('./lib/net-guard');
|
|
14
|
+
const { isContained } = require('./lib/contain');
|
|
12
15
|
|
|
13
16
|
const {
|
|
14
17
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -29,6 +32,7 @@ const {
|
|
|
29
32
|
updateLoopInfo,
|
|
30
33
|
buildLoopInfoFromState
|
|
31
34
|
} = require('./lib/parsers');
|
|
35
|
+
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
32
36
|
|
|
33
37
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
34
38
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -44,6 +48,13 @@ if (require("./cli").runCli(process.argv)) return;
|
|
|
44
48
|
const app = express();
|
|
45
49
|
const PORT = process.env.PORT || 3541;
|
|
46
50
|
|
|
51
|
+
// Mounted before express.json() so a rejected request never buffers a body, and
|
|
52
|
+
// before the /api catch-all so no route escapes the check.
|
|
53
|
+
const net = createNetGuard({ appName: 'Claude Task Kanban' });
|
|
54
|
+
app.use(net.hostGuard);
|
|
55
|
+
app.use(net.frameGuard);
|
|
56
|
+
app.use(net.originGuard);
|
|
57
|
+
|
|
47
58
|
// Parse --dir flag for custom Claude directory
|
|
48
59
|
function getClaudeDir() {
|
|
49
60
|
const dirIndex = process.argv.findIndex(arg => arg.startsWith('--dir'));
|
|
@@ -83,6 +94,8 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
|
83
94
|
const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
84
95
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
85
96
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
97
|
+
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
98
|
+
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
|
|
86
99
|
|
|
87
100
|
// Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
|
|
88
101
|
function readPins() {
|
|
@@ -197,8 +210,10 @@ function getGitBranch(cwd) {
|
|
|
197
210
|
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
|
|
198
211
|
let branch = null;
|
|
199
212
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
213
|
+
// cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
|
|
214
|
+
// read by git as an option.
|
|
215
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
216
|
+
cwd, encoding: 'utf8', timeout: 500, windowsHide: true
|
|
202
217
|
});
|
|
203
218
|
if (r.status === 0) {
|
|
204
219
|
const out = (r.stdout || '').trim();
|
|
@@ -253,8 +268,28 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
|
253
268
|
return result;
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
// isContained canonicalises both sides with realpath, which costs ~100x a plain
|
|
272
|
+
// path.join. This is called once per session on the /api/sessions hot path, and
|
|
273
|
+
// the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
|
|
274
|
+
// resolved path (or the null rejection) is memoized.
|
|
275
|
+
const teamPathCache = new Map();
|
|
276
|
+
const TEAM_PATH_CACHE_MAX = 1000;
|
|
277
|
+
|
|
278
|
+
function teamConfigPath(teamName) {
|
|
279
|
+
if (typeof teamName !== 'string' || !teamName) return null;
|
|
280
|
+
if (teamPathCache.has(teamName)) return teamPathCache.get(teamName);
|
|
281
|
+
const candidate = path.join(TEAMS_DIR, teamName, 'config.json');
|
|
282
|
+
// TEAMS_DIR follows --dir / CLAUDE_CONFIG_DIR, so containment is checked against
|
|
283
|
+
// the module-level constant rather than a hardcoded ~/.claude/teams.
|
|
284
|
+
const configPath = isContained(candidate, TEAMS_DIR) ? candidate : null;
|
|
285
|
+
if (teamPathCache.size >= TEAM_PATH_CACHE_MAX) teamPathCache.clear();
|
|
286
|
+
teamPathCache.set(teamName, configPath);
|
|
287
|
+
return configPath;
|
|
288
|
+
}
|
|
289
|
+
|
|
256
290
|
function isTeamSession(sessionId) {
|
|
257
|
-
|
|
291
|
+
const configPath = teamConfigPath(sessionId);
|
|
292
|
+
return !!configPath && existsSync(configPath);
|
|
258
293
|
}
|
|
259
294
|
|
|
260
295
|
const teamConfigCache = new Map();
|
|
@@ -264,8 +299,8 @@ function loadTeamConfig(teamName) {
|
|
|
264
299
|
const cached = teamConfigCache.get(teamName);
|
|
265
300
|
if (cached && Date.now() - cached.ts < TEAM_CACHE_TTL) return cached.data;
|
|
266
301
|
try {
|
|
267
|
-
const configPath =
|
|
268
|
-
if (!existsSync(configPath)) return null;
|
|
302
|
+
const configPath = teamConfigPath(teamName);
|
|
303
|
+
if (!configPath || !existsSync(configPath)) return null;
|
|
269
304
|
const data = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
270
305
|
teamConfigCache.set(teamName, { data, ts: Date.now() });
|
|
271
306
|
return data;
|
|
@@ -416,6 +451,12 @@ app.param('taskId', (req, res, next, val) => {
|
|
|
416
451
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
|
|
417
452
|
next();
|
|
418
453
|
});
|
|
454
|
+
// Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
|
|
455
|
+
// straight traversal before this.
|
|
456
|
+
app.param('name', (req, res, next, val) => {
|
|
457
|
+
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
|
|
458
|
+
next();
|
|
459
|
+
});
|
|
419
460
|
|
|
420
461
|
// Parse JSON bodies
|
|
421
462
|
app.use(express.json());
|
|
@@ -633,6 +674,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
633
674
|
// Direct assign (not guarded) so a /goal clear propagates as null.
|
|
634
675
|
existing.goal = info.goal || null;
|
|
635
676
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
677
|
+
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
636
678
|
return true;
|
|
637
679
|
}
|
|
638
680
|
|
|
@@ -719,7 +761,8 @@ function loadSessionMetadata() {
|
|
|
719
761
|
customTitle: sessionInfo.customTitle || null,
|
|
720
762
|
goal: sessionInfo.goal || null,
|
|
721
763
|
jsonlPath: jsonlPath,
|
|
722
|
-
logicalParentUuid: sessionInfo.logicalParentUuid || null
|
|
764
|
+
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
765
|
+
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
723
766
|
};
|
|
724
767
|
sessionIds.push(sessionId);
|
|
725
768
|
}
|
|
@@ -920,6 +963,14 @@ function getSessionDisplayName(sessionId, meta) {
|
|
|
920
963
|
return null;
|
|
921
964
|
}
|
|
922
965
|
|
|
966
|
+
// Derived by convention, not looked up: the harness creates the dir lazily, so a
|
|
967
|
+
// stat here would report "missing" for every session that has not written a temp
|
|
968
|
+
// file yet — and it would put IO on the session-list hot path. Pure string join.
|
|
969
|
+
function getScratchpadDir(id, meta) {
|
|
970
|
+
if (!meta.jsonlPath) return null;
|
|
971
|
+
return path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
|
|
972
|
+
}
|
|
973
|
+
|
|
923
974
|
function buildSessionObject(id, meta, overrides = {}) {
|
|
924
975
|
const logStat = overrides._logStat || getSessionLogStat(meta);
|
|
925
976
|
const logMtime = logStat.mtime;
|
|
@@ -951,6 +1002,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
951
1002
|
jsonlPath: meta.jsonlPath || null,
|
|
952
1003
|
tasksDir: null,
|
|
953
1004
|
projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
|
|
1005
|
+
scratchpadDir: getScratchpadDir(id, meta),
|
|
954
1006
|
contextStatus: getContextStatus(id, meta),
|
|
955
1007
|
...getPlanInfo(meta.slug),
|
|
956
1008
|
...getWorkflowInfoSummary(id),
|
|
@@ -1249,15 +1301,22 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1249
1301
|
|
|
1250
1302
|
// Suppress parent sessions that have a compact continuation — compaction is involuntary
|
|
1251
1303
|
// (context limit hit), not an intentional fork. Only the continuation is shown.
|
|
1304
|
+
// A fork off an already-compacted transcript looks identical from the metadata alone
|
|
1305
|
+
// (its copied compact_boundary gives the child a logicalParentUuid), so the verdict
|
|
1306
|
+
// comes from lookupParentSession, not from the anchor's presence.
|
|
1252
1307
|
const compactSuppressed = new Set();
|
|
1253
1308
|
for (const [sid] of sessionsMap) {
|
|
1254
|
-
|
|
1255
|
-
|
|
1309
|
+
// Cheap pre-gate: only a session with a boundary anchor can be a continuation,
|
|
1310
|
+
// and lookupParentSession reads JSONLs on a cold cache.
|
|
1311
|
+
if (!metadata[sid]?.logicalParentUuid) continue;
|
|
1256
1312
|
const parent = lookupParentSession(sid);
|
|
1257
|
-
if (parent.
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1313
|
+
if (parent.relation !== 'compact' || !sessionsMap.has(parent.parentSessionId)) continue;
|
|
1314
|
+
compactSuppressed.add(parent.parentSessionId);
|
|
1315
|
+
sessionsMap.get(sid).continuedFromSessionId = parent.parentSessionId;
|
|
1316
|
+
}
|
|
1317
|
+
// Invariant: a running process is never a superseded lineage, whatever the heuristics say.
|
|
1318
|
+
if (compactSuppressed.size) {
|
|
1319
|
+
for (const s of loadLiveSessions()) compactSuppressed.delete(s.sessionId);
|
|
1261
1320
|
}
|
|
1262
1321
|
for (const sid of compactSuppressed) sessionsMap.delete(sid);
|
|
1263
1322
|
|
|
@@ -1652,17 +1711,12 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
1652
1711
|
}
|
|
1653
1712
|
});
|
|
1654
1713
|
|
|
1655
|
-
function openInEditor(...targets) {
|
|
1656
|
-
const editor = process.env.EDITOR || 'code';
|
|
1657
|
-
spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
1714
|
// API: Open a workflow script in VS Code
|
|
1661
1715
|
app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
|
|
1662
1716
|
try {
|
|
1663
1717
|
const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
|
|
1664
1718
|
if (!wf) return res.status(404).json({ error: 'Workflow script not found' });
|
|
1665
|
-
openInEditor(wf.path);
|
|
1719
|
+
openInEditor([wf.path]);
|
|
1666
1720
|
res.json({ success: true });
|
|
1667
1721
|
} catch (error) {
|
|
1668
1722
|
console.error('Error opening workflow script in editor:', error);
|
|
@@ -1681,7 +1735,7 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
|
1681
1735
|
const planPath = path.join(PLANS_DIR, `${slug}.md`);
|
|
1682
1736
|
if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
|
|
1683
1737
|
|
|
1684
|
-
openInEditor(planPath);
|
|
1738
|
+
openInEditor([planPath]);
|
|
1685
1739
|
res.json({ success: true });
|
|
1686
1740
|
} catch (error) {
|
|
1687
1741
|
console.error('Error opening plan in editor:', error);
|
|
@@ -1693,10 +1747,12 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
|
1693
1747
|
app.post('/api/open-folder', (req, res) => {
|
|
1694
1748
|
try {
|
|
1695
1749
|
const { folder, file } = req.body;
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1750
|
+
const targets = [folder ? assertOpenTarget(folder, 'folder') : CLAUDE_DIR];
|
|
1751
|
+
if (file) targets.push(assertOpenTarget(file, 'file'));
|
|
1752
|
+
openInEditor(targets);
|
|
1698
1753
|
res.json({ success: true });
|
|
1699
1754
|
} catch (error) {
|
|
1755
|
+
if (error.status) return res.status(error.status).json({ error: error.message });
|
|
1700
1756
|
console.error('Error opening folder:', error);
|
|
1701
1757
|
res.status(500).json({ error: 'Failed to open folder' });
|
|
1702
1758
|
}
|
|
@@ -1707,8 +1763,9 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1707
1763
|
try {
|
|
1708
1764
|
const { content, title, file } = req.body;
|
|
1709
1765
|
if (file) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1766
|
+
const resolved = assertOpenTarget(file, 'file');
|
|
1767
|
+
openInEditor([resolved]);
|
|
1768
|
+
return res.json({ success: true, path: resolved });
|
|
1712
1769
|
}
|
|
1713
1770
|
if (!content) return res.status(400).json({ error: 'No content provided' });
|
|
1714
1771
|
|
|
@@ -1717,9 +1774,10 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1717
1774
|
const tmpFile = path.join(os.tmpdir(), `claude-kanban-${safeName}-${hash}.md`);
|
|
1718
1775
|
require('fs').writeFileSync(tmpFile, content, 'utf8');
|
|
1719
1776
|
|
|
1720
|
-
openInEditor(tmpFile);
|
|
1777
|
+
openInEditor([tmpFile]);
|
|
1721
1778
|
res.json({ success: true, path: tmpFile });
|
|
1722
1779
|
} catch (error) {
|
|
1780
|
+
if (error.status) return res.status(error.status).json({ error: error.message });
|
|
1723
1781
|
console.error('Error opening in editor:', error);
|
|
1724
1782
|
res.status(500).json({ error: 'Failed to open in editor' });
|
|
1725
1783
|
}
|
|
@@ -2091,12 +2149,14 @@ function findForkAnchorUuid(jsonlPath) {
|
|
|
2091
2149
|
}
|
|
2092
2150
|
return firstUuid;
|
|
2093
2151
|
}
|
|
2094
|
-
// Fallback when metadata cache lacks
|
|
2095
|
-
//
|
|
2152
|
+
// Fallback when the metadata cache lacks the boundary pair (older entries, cold cache).
|
|
2153
|
+
// Reached only from lookupParentSession, whose result is memoized for the process life —
|
|
2154
|
+
// at most one read per session, never per request.
|
|
2096
2155
|
// Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
|
|
2097
2156
|
// always sits in the preamble before the first user/assistant record.
|
|
2098
2157
|
const COMPACT_ANCHOR_READ_MAX = 1048576;
|
|
2099
|
-
|
|
2158
|
+
// Returns { anchor, boundaryUuid } for the preamble compact_boundary, or null.
|
|
2159
|
+
function findCompactBoundary(jsonlPath) {
|
|
2100
2160
|
let fd;
|
|
2101
2161
|
try {
|
|
2102
2162
|
fd = openSync(jsonlPath, 'r');
|
|
@@ -2110,17 +2170,23 @@ function findCompactAnchorUuid(jsonlPath) {
|
|
|
2110
2170
|
try {
|
|
2111
2171
|
const d = JSON.parse(l);
|
|
2112
2172
|
if (d.type === 'user' || d.type === 'assistant') return null;
|
|
2113
|
-
if (d.subtype === 'compact_boundary' && d.logicalParentUuid)
|
|
2173
|
+
if (d.subtype === 'compact_boundary' && d.logicalParentUuid) {
|
|
2174
|
+
return { anchor: d.logicalParentUuid, boundaryUuid: d.uuid || null };
|
|
2175
|
+
}
|
|
2114
2176
|
} catch { /* skip malformed */ }
|
|
2115
2177
|
}
|
|
2116
2178
|
return null;
|
|
2117
2179
|
} catch { return null; }
|
|
2118
2180
|
finally { if (fd !== undefined) { try { closeSync(fd); } catch {} } }
|
|
2119
2181
|
}
|
|
2120
|
-
|
|
2182
|
+
// forkProbeUuid (optional): the child's own compact_boundary uuid. It only shows up
|
|
2183
|
+
// in the parent when the child copied the record — i.e. a fork off an already-compacted
|
|
2184
|
+
// transcript, not a compact continuation. Probed once, on the winner's text, which the
|
|
2185
|
+
// scan already read.
|
|
2186
|
+
function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, maxBirthtimeMs, forkProbeUuid) {
|
|
2121
2187
|
let files;
|
|
2122
2188
|
try { files = readdirSync(projectDir); } catch { return null; }
|
|
2123
|
-
let best = null;
|
|
2189
|
+
let best = null, bestBirthtime = Infinity, bestText = null;
|
|
2124
2190
|
for (const f of files) {
|
|
2125
2191
|
if (!f.endsWith('.jsonl')) continue;
|
|
2126
2192
|
const fp = path.join(projectDir, f);
|
|
@@ -2128,7 +2194,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2128
2194
|
let birthtime = 0;
|
|
2129
2195
|
try { birthtime = statSync(fp).birthtimeMs; } catch { continue; }
|
|
2130
2196
|
if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
|
|
2131
|
-
if (
|
|
2197
|
+
if (birthtime >= bestBirthtime) continue;
|
|
2132
2198
|
let text;
|
|
2133
2199
|
try { text = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
2134
2200
|
if (!text.includes(targetUuid)) continue;
|
|
@@ -2137,32 +2203,51 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2137
2203
|
try {
|
|
2138
2204
|
const d = JSON.parse(l);
|
|
2139
2205
|
if (d.uuid === targetUuid && d.sessionId) {
|
|
2140
|
-
best = { parentSessionId: d.sessionId, parentJsonlPath: fp,
|
|
2206
|
+
best = { parentSessionId: d.sessionId, parentJsonlPath: fp, isFork: false };
|
|
2207
|
+
bestBirthtime = birthtime;
|
|
2208
|
+
bestText = text;
|
|
2141
2209
|
break;
|
|
2142
2210
|
}
|
|
2143
2211
|
} catch { /* skip */ }
|
|
2144
2212
|
}
|
|
2145
2213
|
}
|
|
2146
2214
|
if (!best) return null;
|
|
2147
|
-
|
|
2215
|
+
if (forkProbeUuid) best.isFork = bestText.includes(forkProbeUuid);
|
|
2216
|
+
return best;
|
|
2148
2217
|
}
|
|
2149
2218
|
function lookupParentSession(sessionId) {
|
|
2150
2219
|
if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
|
|
2151
2220
|
const meta = loadSessionMetadata()[sessionId];
|
|
2152
|
-
|
|
2221
|
+
// relation is the single verdict: 'none' (no parent found), 'compact' (this session
|
|
2222
|
+
// continues a lineage the parent could not hold) or 'fork' (an intentional branch off
|
|
2223
|
+
// a parent that keeps its own life). isCompact/isFork are derived views of it.
|
|
2224
|
+
const result = { parentSessionId: null, parentJsonlPath: null, relation: 'none', isCompact: false, isFork: false };
|
|
2153
2225
|
if (meta?.jsonlPath) {
|
|
2154
|
-
|
|
2155
|
-
|
|
2226
|
+
// Metadata entries cached before compactBoundaryUuid existed carry the anchor
|
|
2227
|
+
// without the boundary uuid — re-read rather than lose fork detection.
|
|
2228
|
+
const boundary = (meta.logicalParentUuid && meta.compactBoundaryUuid)
|
|
2229
|
+
? { anchor: meta.logicalParentUuid, boundaryUuid: meta.compactBoundaryUuid }
|
|
2230
|
+
: findCompactBoundary(meta.jsonlPath);
|
|
2231
|
+
const compactAnchor = boundary?.anchor ?? meta.logicalParentUuid ?? null;
|
|
2232
|
+
// A fork copies the parent's early records verbatim, so its first uuid is the anchor.
|
|
2156
2233
|
const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
|
|
2157
2234
|
if (anchorUuid) {
|
|
2158
2235
|
let selfBirthtime;
|
|
2159
2236
|
try { selfBirthtime = statSync(meta.jsonlPath).birthtimeMs; } catch { /* ignore */ }
|
|
2160
2237
|
if (selfBirthtime != null) {
|
|
2161
|
-
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime);
|
|
2162
|
-
if (hit)
|
|
2238
|
+
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime, boundary?.boundaryUuid);
|
|
2239
|
+
if (hit) {
|
|
2240
|
+
// No compact boundary at all → fork by construction; with one, the copied
|
|
2241
|
+
// boundary uuid (hit.isFork) is what separates a fork from a continuation.
|
|
2242
|
+
result.parentSessionId = hit.parentSessionId;
|
|
2243
|
+
result.parentJsonlPath = hit.parentJsonlPath;
|
|
2244
|
+
result.relation = (!compactAnchor || hit.isFork) ? 'fork' : 'compact';
|
|
2245
|
+
}
|
|
2163
2246
|
}
|
|
2164
2247
|
}
|
|
2165
2248
|
}
|
|
2249
|
+
result.isCompact = result.relation === 'compact';
|
|
2250
|
+
result.isFork = result.relation === 'fork';
|
|
2166
2251
|
parentSessionCache.set(sessionId, result);
|
|
2167
2252
|
return result;
|
|
2168
2253
|
}
|
|
@@ -2656,7 +2741,21 @@ async function validatePreviewFile(absPath) {
|
|
|
2656
2741
|
|
|
2657
2742
|
async function readPreviewFile(absPath) {
|
|
2658
2743
|
const { kind } = await validatePreviewFile(absPath);
|
|
2659
|
-
|
|
2744
|
+
const raw = await fs.readFile(absPath, 'utf8');
|
|
2745
|
+
if (kind !== 'html') return { content: raw, kind };
|
|
2746
|
+
// The client renders HTML into a `srcdoc` iframe, which has no base URL — sibling
|
|
2747
|
+
// assets have to travel inside the document or they never load.
|
|
2748
|
+
try {
|
|
2749
|
+
const { html, skipped } = await inlineHtmlAssets(raw, absPath);
|
|
2750
|
+
for (const s of skipped) {
|
|
2751
|
+
console.warn(`Preview: skipped inlining ${s.path} (${s.reason}, ${s.size} bytes)`);
|
|
2752
|
+
}
|
|
2753
|
+
return { content: html, kind };
|
|
2754
|
+
} catch (e) {
|
|
2755
|
+
// Inlining is an enhancement: a failure here must not cost the user the preview.
|
|
2756
|
+
console.error('Preview: asset inlining failed, serving as authored:', e.message);
|
|
2757
|
+
return { content: raw, kind };
|
|
2758
|
+
}
|
|
2660
2759
|
}
|
|
2661
2760
|
|
|
2662
2761
|
// Paths copied out of a browser or a slide deck carry a URL fragment
|
|
@@ -3112,28 +3211,23 @@ async function prewarmCaches() {
|
|
|
3112
3211
|
}
|
|
3113
3212
|
}
|
|
3114
3213
|
|
|
3115
|
-
const
|
|
3116
|
-
const actualPort = server.address().port;
|
|
3214
|
+
const onReady = (actualPort) => {
|
|
3117
3215
|
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
3216
|
+
const warning = net.exposureWarning();
|
|
3217
|
+
if (warning) console.log(warning);
|
|
3118
3218
|
|
|
3119
3219
|
if (process.argv.includes('--open')) {
|
|
3120
3220
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
3121
3221
|
}
|
|
3122
3222
|
setImmediate(prewarmCaches);
|
|
3123
|
-
}
|
|
3223
|
+
};
|
|
3224
|
+
|
|
3225
|
+
const server = net.listenLoopback(app, PORT, onReady);
|
|
3124
3226
|
|
|
3125
3227
|
server.on('error', (err) => {
|
|
3126
3228
|
if (err.code === 'EADDRINUSE') {
|
|
3127
3229
|
console.log(`Port ${PORT} in use, trying random port...`);
|
|
3128
|
-
|
|
3129
|
-
const actualPort = fallback.address().port;
|
|
3130
|
-
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
3131
|
-
|
|
3132
|
-
if (process.argv.includes('--open')) {
|
|
3133
|
-
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
3134
|
-
}
|
|
3135
|
-
setImmediate(prewarmCaches);
|
|
3136
|
-
});
|
|
3230
|
+
net.listenLoopback(app, 0, onReady);
|
|
3137
3231
|
} else {
|
|
3138
3232
|
throw err;
|
|
3139
3233
|
}
|