claude-code-kanban 6.1.2 → 7.0.1
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/README.md +3 -2
- package/cli.js +200 -164
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/retention.js +14 -8
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +30 -22
- package/public/app.js +940 -111
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +452 -8
- package/public/tree-highlight.js +74 -0
- package/server.js +133 -40
- package/skill-guides/dispatch.md +1 -1
package/server.js
CHANGED
|
@@ -15,6 +15,8 @@ const { createNetGuard } = require('./lib/net-guard');
|
|
|
15
15
|
const { isContained } = require('./lib/contain');
|
|
16
16
|
const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
|
|
17
17
|
const { fileUrlToPath } = require('./lib/file-url');
|
|
18
|
+
const { httpError: previewError } = require('./lib/http-error');
|
|
19
|
+
const { oneLine } = require('./lib/one-line');
|
|
18
20
|
const { pluginStatus } = require('./lib/plugin-status');
|
|
19
21
|
const { getAutoCompact } = require('./lib/auto-compact');
|
|
20
22
|
|
|
@@ -42,7 +44,7 @@ const {
|
|
|
42
44
|
} = require('./lib/parsers');
|
|
43
45
|
const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
44
46
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
45
|
-
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir
|
|
47
|
+
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
46
48
|
const { readTerminalConfig } = require('./lib/terminal');
|
|
47
49
|
const { createTerminalClient } = require('./lib/terminal-client');
|
|
48
50
|
const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
|
|
@@ -52,8 +54,11 @@ const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispa
|
|
|
52
54
|
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
|
|
53
55
|
const { freshRateLimits } = require('./lib/rate-limits');
|
|
54
56
|
const { createWorktreeStore } = require('./lib/worktrees');
|
|
57
|
+
const { createScratchpadDirResolver } = require('./lib/scratchpad-dir');
|
|
55
58
|
const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
|
|
56
59
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
60
|
+
const { createPaneStore, isOwnOrigin } = require('./lib/panes');
|
|
61
|
+
const { probeFraming, parseOrigin } = require('./lib/frame-policy');
|
|
57
62
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
58
63
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
59
64
|
const { countTaskDir } = require('./lib/task-counts');
|
|
@@ -105,6 +110,7 @@ const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
|
105
110
|
const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
|
|
106
111
|
const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
|
|
107
112
|
const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
113
|
+
const PANES_FILE = path.join(CCK_DIR, 'panes.json');
|
|
108
114
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
109
115
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
110
116
|
const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
|
|
@@ -1122,25 +1128,10 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1122
1128
|
return null;
|
|
1123
1129
|
}
|
|
1124
1130
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
if (!meta.jsonlPath) return null;
|
|
1130
|
-
const byProject = path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
|
|
1131
|
-
|
|
1132
|
-
// A session launched with `claude -w` starts in the main checkout and enters the
|
|
1133
|
-
// worktree afterwards, so the harness keys its scratchpad on the repo while the
|
|
1134
|
-
// transcript ends up filed under the worktree. Neither the project nor any `cwd`
|
|
1135
|
-
// in the log records that, so the two candidates are told apart by which one the
|
|
1136
|
-
// harness actually created. Probed only for worktree sessions, and only until one
|
|
1137
|
-
// of them exists — before that there is nothing to disambiguate and `byProject`,
|
|
1138
|
-
// right for a session started inside the worktree, stands.
|
|
1139
|
-
const wt = worktrees.resolve(meta.project);
|
|
1140
|
-
if (!wt || existsSync(byProject)) return byProject;
|
|
1141
|
-
const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
|
|
1142
|
-
return existsSync(byRepo) ? byRepo : byProject;
|
|
1143
|
-
}
|
|
1131
|
+
const getScratchpadDir = createScratchpadDirResolver({
|
|
1132
|
+
root: SCRATCHPAD_ROOT,
|
|
1133
|
+
resolveWorktree: worktrees.resolve,
|
|
1134
|
+
});
|
|
1144
1135
|
|
|
1145
1136
|
function buildSessionObject(id, meta, overrides = {}) {
|
|
1146
1137
|
const logStat = overrides._logStat || getSessionLogStat(meta);
|
|
@@ -3688,25 +3679,19 @@ const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
|
|
|
3688
3679
|
// Source in a modal is for reading, not for scrolling a generated bundle.
|
|
3689
3680
|
const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
|
|
3690
3681
|
|
|
3691
|
-
function previewError(status, message) {
|
|
3692
|
-
const err = new Error(message);
|
|
3693
|
-
err.status = status;
|
|
3694
|
-
return err;
|
|
3695
|
-
}
|
|
3696
|
-
|
|
3697
3682
|
// Existence + kind of a file target, with the HTTP status codes both the preview and
|
|
3698
3683
|
// the link-a-file endpoints report. `kind` is null for anything the previewer can't
|
|
3699
3684
|
// render — the caller decides whether that disqualifies the path.
|
|
3700
3685
|
async function statFileTarget(absPath) {
|
|
3701
3686
|
try {
|
|
3702
3687
|
const stats = await fs.stat(absPath);
|
|
3703
|
-
if (!stats.isFile()) throw previewError(400, 'Not a file');
|
|
3688
|
+
if (!stats.isFile()) throw previewError(400, 'Not a file', 'not_a_file');
|
|
3704
3689
|
const kind = previewKindFor(absPath);
|
|
3705
3690
|
return { size: stats.size, kind: kind === 'image' ? kind : await confirmKind(absPath, kind) };
|
|
3706
3691
|
} catch (e) {
|
|
3707
3692
|
if (e.status) throw e;
|
|
3708
|
-
if (e.code === 'ENOENT') throw previewError(404, 'File not found');
|
|
3709
|
-
if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
|
|
3693
|
+
if (e.code === 'ENOENT') throw previewError(404, 'File not found', 'file_not_found');
|
|
3694
|
+
if (e.code === 'EISDIR') throw previewError(400, 'Not a file', 'not_a_file');
|
|
3710
3695
|
throw e;
|
|
3711
3696
|
}
|
|
3712
3697
|
}
|
|
@@ -3714,7 +3699,7 @@ async function statFileTarget(absPath) {
|
|
|
3714
3699
|
function enforcePreviewSize(kind, size) {
|
|
3715
3700
|
const max = kind === 'text' ? PREVIEW_TEXT_MAX_BYTES : PREVIEW_MAX_BYTES;
|
|
3716
3701
|
if (size > max) {
|
|
3717
|
-
throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)
|
|
3702
|
+
throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)`, 'too_large');
|
|
3718
3703
|
}
|
|
3719
3704
|
}
|
|
3720
3705
|
|
|
@@ -3722,7 +3707,7 @@ function enforcePreviewSize(kind, size) {
|
|
|
3722
3707
|
// validation (and its status codes) but never the content.
|
|
3723
3708
|
async function validatePreviewFile(absPath) {
|
|
3724
3709
|
const { kind, size } = await statFileTarget(absPath);
|
|
3725
|
-
if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file');
|
|
3710
|
+
if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file', 'not_previewable');
|
|
3726
3711
|
enforcePreviewSize(kind, size);
|
|
3727
3712
|
return { kind, size };
|
|
3728
3713
|
}
|
|
@@ -3804,7 +3789,7 @@ app.post('/api/preview', async (req, res) => {
|
|
|
3804
3789
|
// API: Link a file to a session's sidebar docs without opening the preview modal.
|
|
3805
3790
|
// Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
|
|
3806
3791
|
// opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
|
|
3807
|
-
// An http(s) URL is linked as it is. `open` comes from `
|
|
3792
|
+
// An http(s) URL is linked as it is. `open` comes from `doc preview <url>`: a tab cannot
|
|
3808
3793
|
// open a URL without a click, so the tab on screen offers an Open button.
|
|
3809
3794
|
app.post('/api/document/link', async (req, res) => {
|
|
3810
3795
|
try {
|
|
@@ -3959,6 +3944,116 @@ app.get('/api/file/resolve', async (req, res) => {
|
|
|
3959
3944
|
|
|
3960
3945
|
// #endregion
|
|
3961
3946
|
|
|
3947
|
+
// #region PANES
|
|
3948
|
+
// Tabs next to Board in a session view. Each change broadcasts the whole layout with its rev,
|
|
3949
|
+
// so a tab replaces its copy when the rev is newer instead of applying a diff. Another board
|
|
3950
|
+
// on the same config dir writes the same file; the watcher broadcasts its changes here.
|
|
3951
|
+
const panesFileStamp = () => {
|
|
3952
|
+
try {
|
|
3953
|
+
const s = statSync(PANES_FILE);
|
|
3954
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
3955
|
+
} catch {
|
|
3956
|
+
return null;
|
|
3957
|
+
}
|
|
3958
|
+
};
|
|
3959
|
+
let panesStamp;
|
|
3960
|
+
const panes = createPaneStore({
|
|
3961
|
+
load: () => {
|
|
3962
|
+
const stamp = panesFileStamp();
|
|
3963
|
+
if (stamp === panesStamp) return undefined;
|
|
3964
|
+
panesStamp = stamp;
|
|
3965
|
+
try { return JSON.parse(readFileSync(PANES_FILE, 'utf8')); } catch { return null; }
|
|
3966
|
+
},
|
|
3967
|
+
save: (data) => {
|
|
3968
|
+
writeJsonAtomic(PANES_FILE, data);
|
|
3969
|
+
panesStamp = panesFileStamp();
|
|
3970
|
+
},
|
|
3971
|
+
onChange: (sessionId, layout) => broadcast({ type: 'pane:changed', sessionId, layout }),
|
|
3972
|
+
});
|
|
3973
|
+
chokidar.watch(PANES_FILE, { ignoreInitial: true }).on('all', (event) => {
|
|
3974
|
+
if (event === 'add' || event === 'change') panes.reload();
|
|
3975
|
+
});
|
|
3976
|
+
|
|
3977
|
+
let boardPort = null;
|
|
3978
|
+
const boardHosts = [...net.ALLOWED_HOSTS.split(','), net.EXPOSED ? net.BIND_HOST : '']
|
|
3979
|
+
.map((h) => h.trim().toLowerCase())
|
|
3980
|
+
.filter(Boolean);
|
|
3981
|
+
|
|
3982
|
+
function paneRouteError(res, error, what) {
|
|
3983
|
+
if (!error.status) console.error(`Error in ${what}:`, error);
|
|
3984
|
+
const code = error.status ? error.code : 'internal';
|
|
3985
|
+
res.status(error.status || 500).json({ error: error.message || `${what} failed`, code });
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
async function resolvePaneTarget(target, base) {
|
|
3989
|
+
const url = linkUrl(target);
|
|
3990
|
+
if (url) {
|
|
3991
|
+
if (isOwnOrigin(url, { boardPort, boardHosts, hubUrl: process.env.HUB_URL })) {
|
|
3992
|
+
throw previewError(400, 'A pane cannot show the board or the hub itself', 'own_origin');
|
|
3993
|
+
}
|
|
3994
|
+
return { kind: 'url', target: url };
|
|
3995
|
+
}
|
|
3996
|
+
const hasBase = typeof base === 'string' && path.isAbsolute(base);
|
|
3997
|
+
if (!hasBase && !path.isAbsolute(fileUrlToPath(target))) {
|
|
3998
|
+
throw previewError(400, 'target must be an http(s) URL, an absolute file path, or a relative path with an absolute base', 'bad_target');
|
|
3999
|
+
}
|
|
4000
|
+
const abs = resolvePreviewPath(target, base);
|
|
4001
|
+
const { kind } = await validatePreviewFile(abs);
|
|
4002
|
+
return { kind, target: await fs.realpath(abs) };
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
app.get('/api/panes/:sessionId', (req, res) => {
|
|
4006
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4007
|
+
res.json(panes.get(req.params.sessionId));
|
|
4008
|
+
});
|
|
4009
|
+
|
|
4010
|
+
app.post('/api/panes/:sessionId', async (req, res) => {
|
|
4011
|
+
try {
|
|
4012
|
+
const { target, base, title } = req.body || {};
|
|
4013
|
+
if (typeof target !== 'string' || !target) throw previewError(400, 'target is required', 'bad_target');
|
|
4014
|
+
res.json(panes.add(req.params.sessionId, { ...(await resolvePaneTarget(target, base)), title }));
|
|
4015
|
+
} catch (error) {
|
|
4016
|
+
paneRouteError(res, error, 'POST /api/panes');
|
|
4017
|
+
}
|
|
4018
|
+
});
|
|
4019
|
+
|
|
4020
|
+
app.patch('/api/panes/:sessionId', (req, res) => {
|
|
4021
|
+
try {
|
|
4022
|
+
res.json({ layout: panes.reorder(req.params.sessionId, req.body?.order) });
|
|
4023
|
+
} catch (error) {
|
|
4024
|
+
paneRouteError(res, error, 'PATCH /api/panes');
|
|
4025
|
+
}
|
|
4026
|
+
});
|
|
4027
|
+
|
|
4028
|
+
// Probes only a pane's own target, so the route cannot fetch an arbitrary URL. `ancestors` is
|
|
4029
|
+
// the comma-separated origins above the frame: the board's page origin, then the hub's. The CLI
|
|
4030
|
+
// has no page, so without it the board's own origin and HUB_URL stand in; under the hub the page
|
|
4031
|
+
// is on the proxy port, which agrees for 'self', 'none' and policies that name neither.
|
|
4032
|
+
app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
|
|
4033
|
+
try {
|
|
4034
|
+
const { sessionId, paneId } = req.params;
|
|
4035
|
+
const pane = panes.get(sessionId).panes.find((p) => p.id === paneId && p.kind === 'url');
|
|
4036
|
+
if (!pane) throw previewError(404, `No URL pane ${paneId} in session ${sessionId}`, 'no_pane');
|
|
4037
|
+
const fallback = [`http://localhost:${boardPort}`, process.env.HUB_URL].filter(Boolean).join(',');
|
|
4038
|
+
const ancestors = String(req.query.ancestors || fallback)
|
|
4039
|
+
.split(',')
|
|
4040
|
+
.slice(0, 5)
|
|
4041
|
+
.filter((o) => parseOrigin(o));
|
|
4042
|
+
if (!ancestors.length) throw previewError(400, 'ancestors must name at least one http(s) origin', 'bad_ancestors');
|
|
4043
|
+
res.json({ frameable: await probeFraming(pane.target, ancestors) });
|
|
4044
|
+
} catch (error) {
|
|
4045
|
+
paneRouteError(res, error, 'GET /api/panes/framing');
|
|
4046
|
+
}
|
|
4047
|
+
});
|
|
4048
|
+
|
|
4049
|
+
app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
|
|
4050
|
+
const { sessionId, paneId } = req.params;
|
|
4051
|
+
const layout = panes.remove(sessionId, paneId);
|
|
4052
|
+
if (!layout) return res.status(404).json({ error: `No pane ${paneId} in session ${sessionId}`, code: 'no_pane' });
|
|
4053
|
+
res.json({ layout });
|
|
4054
|
+
});
|
|
4055
|
+
// #endregion
|
|
4056
|
+
|
|
3962
4057
|
// #region REVIEW
|
|
3963
4058
|
// API: Send review comments on something the board shows (a file preview today) to a
|
|
3964
4059
|
// session. The source is described, not interpreted, so a new kind of preview needs a
|
|
@@ -3973,11 +4068,7 @@ function parseReviewBody(body) {
|
|
|
3973
4068
|
const { source, comments } = body || {};
|
|
3974
4069
|
if (!source || !REVIEW_KIND_RE.test(source.kind)) throw previewError(400, 'source.kind is required');
|
|
3975
4070
|
// The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
|
|
3976
|
-
const label =
|
|
3977
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
3978
|
-
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
3979
|
-
.trim()
|
|
3980
|
-
.slice(0, 200);
|
|
4071
|
+
const label = oneLine(source.label, 200);
|
|
3981
4072
|
if (!label) throw previewError(400, 'source.label is required');
|
|
3982
4073
|
if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
|
|
3983
4074
|
throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
|
|
@@ -4335,17 +4426,18 @@ async function cleanupAgentActivity() {
|
|
|
4335
4426
|
} catch { /* agent-activity dir may not exist */ }
|
|
4336
4427
|
}
|
|
4337
4428
|
|
|
4338
|
-
// Dispatch markers, reviews, context status and worktrees: see docs/retention.md.
|
|
4429
|
+
// Dispatch markers, pane layouts, reviews, context status and worktrees: see docs/retention.md.
|
|
4339
4430
|
async function runRetention() {
|
|
4340
4431
|
try {
|
|
4341
4432
|
const scan = await scanTranscripts(PROJECTS_DIR);
|
|
4342
4433
|
const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
|
|
4343
4434
|
const markers = dispatched.prune(opts);
|
|
4435
|
+
const layouts = panes.prune(opts);
|
|
4344
4436
|
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4345
4437
|
const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
|
|
4346
4438
|
const wts = worktrees.prune(scan?.dirs);
|
|
4347
|
-
if (markers || reviews || contexts || wts) {
|
|
4348
|
-
console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees`);
|
|
4439
|
+
if (markers || layouts || reviews || contexts || wts) {
|
|
4440
|
+
console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees`);
|
|
4349
4441
|
}
|
|
4350
4442
|
} catch (e) {
|
|
4351
4443
|
console.warn('[retention] failed:', e.message);
|
|
@@ -4404,6 +4496,7 @@ async function prewarmCaches() {
|
|
|
4404
4496
|
}
|
|
4405
4497
|
|
|
4406
4498
|
const onReady = (actualPort) => {
|
|
4499
|
+
boardPort = Number(actualPort);
|
|
4407
4500
|
terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
|
|
4408
4501
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
4409
4502
|
// monitor cannot assume it -- publish the live one where it can read it.
|
package/skill-guides/dispatch.md
CHANGED
|
@@ -31,5 +31,5 @@ A crashed session sends nothing. To learn when one ends, `SendMessage` it with `
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
claude-code-kanban dispatch list --json # still running in cck's terminal
|
|
34
|
-
claude-code-kanban session
|
|
34
|
+
claude-code-kanban session view <session-id> # its transcript path; read the last lines
|
|
35
35
|
```
|