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/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, encodeProjectDirName } = require('./lib/claude-dir');
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
- // Derived by convention, not looked up: the harness creates the dir lazily, so a
1126
- // stat here would report "missing" for every session that has not written a temp
1127
- // file yet — and it would put IO on the session-list hot path. Pure string join.
1128
- function getScratchpadDir(id, meta) {
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 `preview-doc <url>`: a tab cannot
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 = String(source.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.
@@ -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 peek <session-id> --limit 20
34
+ claude-code-kanban session view <session-id> # its transcript path; read the last lines
35
35
  ```