claude-code-kanban 7.20.0 → 7.22.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/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const express = require('express');
5
5
  const path = require('node:path');
6
6
  const fs = require('node:fs').promises;
7
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, rmSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
7
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
8
8
  const _readline = require('node:readline');
9
9
  const chokidar = require('chokidar');
10
10
  const os = require('node:os');
@@ -67,6 +67,9 @@ const { readSettings } = require('./lib/claude-settings');
67
67
  const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
68
68
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
69
69
  const { createPaneStore, isOwnOrigin } = require('./lib/panes');
70
+ const { createAddonStore, sampleUrl } = require('./lib/addons');
71
+ const { writeJsonAtomic } = require('./lib/write-json-atomic');
72
+ const { createAddonRunner } = require('./lib/addon-runner');
70
73
  const { probeFraming, parseOrigin } = require('./lib/frame-policy');
71
74
  const { pickFolder } = require('./lib/folder-dialog');
72
75
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
@@ -74,7 +77,7 @@ const { countTaskDir } = require('./lib/task-counts');
74
77
  const { readTaskDir } = require('./lib/task-dir');
75
78
  const { foldAgentLines } = require('./lib/agent-log');
76
79
  const { projectMatcher, normalizeProjectPath, sessionProjectKey } = require('./public/project-match');
77
- const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
80
+ const { getParentVerdict, setParentVerdict, peekParentVerdict } = require('./lib/parent-cache');
78
81
 
79
82
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
80
83
  const { runInstall, runUninstall } = require("./install");
@@ -123,6 +126,7 @@ const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
123
126
  const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
124
127
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
125
128
  const PANES_FILE = path.join(CCK_DIR, 'panes.json');
129
+ const ADDONS_FILE = path.join(CCK_DIR, 'addons.json');
126
130
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
127
131
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
128
132
  const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
@@ -149,18 +153,6 @@ const SCRATCHPAD_ROOT = (() => {
149
153
  // #endregion
150
154
 
151
155
  // #region SERVER_STATE
152
- function writeJsonAtomic(file, obj, mode) {
153
- mkdirSync(path.dirname(file), { recursive: true });
154
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
155
- try {
156
- writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
157
- renameSync(tmp, file);
158
- } catch (e) {
159
- rmSync(tmp, { force: true });
160
- throw e;
161
- }
162
- }
163
-
164
156
  // Claude Code reads task files while the board writes them, so it must never see half a file.
165
157
  // On Windows a rename over a file another handle has open fails with EPERM or EBUSY until it closes.
166
158
  let taskWriteSeq = 0;
@@ -1562,10 +1554,11 @@ app.get('/api/sessions', async (req, res) => {
1562
1554
  const compactSuppressed = new Set();
1563
1555
  for (const [sid] of sessionsMap) {
1564
1556
  // Cheap pre-gate: only a session with a boundary anchor can be a continuation,
1565
- // and lookupParentSession reads JSONLs on a cold cache.
1566
- if (!metadata[sid]?.logicalParentUuid) continue;
1567
- const parent = lookupParentSession(sid);
1568
- if (parent.relation !== 'compact' || !sessionsMap.has(parent.parentSessionId)) continue;
1557
+ // and lookupParentSession reads JSONLs on a cold cache. A fork with no boundary
1558
+ // gets its badge only once a verdict exists (the info modal computes one).
1559
+ const parent = metadata[sid]?.logicalParentUuid ? lookupParentSession(sid) : peekParentVerdict(sid);
1560
+ if (parent?.relation === 'fork') sessionsMap.get(sid).forkedFromSessionId = parent.parentSessionId;
1561
+ if (parent?.relation !== 'compact' || !sessionsMap.has(parent.parentSessionId)) continue;
1569
1562
  compactSuppressed.add(parent.parentSessionId);
1570
1563
  sessionsMap.get(sid).continuedFromSessionId = parent.parentSessionId;
1571
1564
  }
@@ -3381,12 +3374,41 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3381
3374
  }
3382
3375
  });
3383
3376
 
3377
+ // A tab whose terminal is off, or whose token died with an earlier server, ignores
3378
+ // `session:launched`, so only a tab that claims the session counts as showing it.
3379
+ const LAUNCH_CLAIM_MS = 3000;
3380
+ const launchClaims = new Map();
3381
+ function awaitLaunchClaim(id) {
3382
+ const claimed = new Promise((resolve) => {
3383
+ launchClaims.set(id, resolve);
3384
+ setTimeout(resolve, LAUNCH_CLAIM_MS, false);
3385
+ });
3386
+ return claimed.finally(() => launchClaims.delete(id));
3387
+ }
3388
+
3389
+ app.post('/api/terminal/launched/:id', (req, res) => {
3390
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3391
+ launchClaims.get(req.params.id)?.(true);
3392
+ res.status(204).end();
3393
+ });
3394
+
3384
3395
  // The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
3396
+ // `show` is `claude-code-kanban new` from a shell outside the board: a board tab adds its card
3397
+ // and attaches, and with no tab to claim it the PTY is ended, because no one would see it.
3385
3398
  app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3386
3399
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3387
- const { id, cwd, name, model, effort, worktree, prompt } = req.body || {};
3388
- const started = await terminal.startNew({ id, cwd, name, model, effort, worktree, prompt });
3400
+ const { id, cwd, name, model, effort, worktree, prompt, claudeArgs, show } = req.body || {};
3401
+ if (show && !clients.size) return res.status(409).json({ error: 'no board tab is open' });
3402
+ const started = await terminal.startNew({ id, cwd, name, model, effort, worktree, prompt, extraArgs: claudeArgs });
3389
3403
  if (started.error) return res.status(started.status).json({ error: started.error });
3404
+ if (show) {
3405
+ const claimed = awaitLaunchClaim(started.id);
3406
+ broadcast({ type: 'session:launched', ...started });
3407
+ if (!(await claimed)) {
3408
+ await terminal.end(started.id, terminal.token);
3409
+ return res.status(409).json({ error: 'no open board tab can show the terminal' });
3410
+ }
3411
+ }
3390
3412
  res.status(201).json({ session: started.id, cwd: started.cwd });
3391
3413
  }));
3392
3414
 
@@ -3499,7 +3521,7 @@ const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
3499
3521
  function withDispatchMarker(sessions) {
3500
3522
  return sessions.map((s) => {
3501
3523
  const marker = dispatched.get(s.id);
3502
- return marker ? { ...s, dispatched: { parent: marker.parent } } : s;
3524
+ return marker ? { ...s, dispatched: { parent: marker.parent, child: marker.child } } : s;
3503
3525
  });
3504
3526
  }
3505
3527
 
@@ -3507,13 +3529,15 @@ function withDispatchMarker(sessions) {
3507
3529
  // TERMINAL_TOKEN_FILE. The started session never holds it.
3508
3530
  app.post('/api/dispatch', terminalRoute(async (req, res) => {
3509
3531
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3510
- const { cwd, spec, name, model, worktree, taskList, parent, group, claudeArgs } = req.body || {};
3532
+ const { cwd, spec, name, model, worktree, taskList, parent, child, group, claudeArgs } = req.body || {};
3511
3533
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3512
3534
  if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3513
3535
  if (group != null && !(typeof group === 'string' && group.trim())) return res.status(400).json({ error: 'group must be a group name, path or id' });
3536
+ if (child && !parent) return res.status(400).json({ error: 'child needs a parent session' });
3537
+ if (child && group) return res.status(400).json({ error: 'pass child or group, not both' });
3514
3538
  const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
3515
3539
  if (started.error) return res.status(started.status).json({ error: started.error });
3516
- dispatched.record(started.id, { parent, cwd: started.cwd, name, group, worktree });
3540
+ dispatched.record(started.id, { parent, child: child === true, cwd: started.cwd, name, group, worktree });
3517
3541
  // Joined before dispatch-update, so the board places the session from its first frame. The
3518
3542
  // starter stays where it is: moving it would jump it under the user.
3519
3543
  let joined = null;
@@ -4368,9 +4392,11 @@ app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
4368
4392
  app.patch('/api/panes/:sessionId/:paneId', (req, res) => {
4369
4393
  try {
4370
4394
  const { sessionId, paneId } = req.params;
4371
- const title = req.body?.title;
4372
- if (typeof title !== 'string') throw previewError(400, 'title must be a string', 'bad_title');
4373
- const layout = panes.rename(sessionId, paneId, title);
4395
+ const { title, dock } = req.body || {};
4396
+ if (typeof dock !== 'boolean' && typeof title !== 'string') {
4397
+ throw previewError(400, 'title must be a string, or dock a boolean', 'bad_title');
4398
+ }
4399
+ const layout = typeof dock === 'boolean' ? panes.setDock(sessionId, paneId, dock) : panes.rename(sessionId, paneId, title);
4374
4400
  if (!layout) throw previewError(404, `No pane ${paneId} in session ${sessionId}`, 'no_pane');
4375
4401
  res.json({ layout });
4376
4402
  } catch (error) {
@@ -4386,6 +4412,115 @@ app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4386
4412
  });
4387
4413
  // #endregion
4388
4414
 
4415
+ // #region ADDONS
4416
+ // Web pages on the dock rail (lib/addons.js). The board fills each URL for its terminal's
4417
+ // session, so the routes carry the templates; every change sends one `addon:changed` and the
4418
+ // boards refetch.
4419
+ const addonRunner = createAddonRunner({ logDir: path.join(CCK_DIR, 'addons') });
4420
+ process.on('exit', addonRunner.shutdown);
4421
+ const addons = createAddonStore({
4422
+ ...stampedJsonFile(ADDONS_FILE, writeJsonAtomic),
4423
+ onChange: (global) => {
4424
+ addonRunner.retain(global);
4425
+ broadcast({ type: 'addon:changed' });
4426
+ },
4427
+ isOwnOrigin: (url) => isOwnOrigin(url, { boardPort, boardHosts, hubUrl: process.env.HUB_URL }),
4428
+ });
4429
+ chokidar.watch(ADDONS_FILE, { ignoreInitial: true }).on('all', (event) => {
4430
+ if (event === 'add' || event === 'change') addons.reload();
4431
+ });
4432
+
4433
+ app.get('/api/addons', (req, res) => {
4434
+ const sessionId = typeof req.query.session === 'string' ? req.query.session : null;
4435
+ res.json(addons.list(sessionId));
4436
+ });
4437
+
4438
+ app.post('/api/addons', (req, res) => {
4439
+ try {
4440
+ const { scope, url, title, icon, params, run, cwd, key } = req.body || {};
4441
+ if (typeof scope !== 'string' || !scope) throw previewError(400, "scope must be 'global' or a session id", 'bad_scope');
4442
+ // A run command is a command line the board will start, so it needs what dispatch needs.
4443
+ if (run && !terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
4444
+ const result = addons.add(scope, { url, title, icon, params, run, cwd, key });
4445
+ res.status(result.added ? 201 : 200).json(result);
4446
+ } catch (error) {
4447
+ paneRouteError(res, error, 'POST /api/addons');
4448
+ }
4449
+ });
4450
+
4451
+ app.patch('/api/addons/:id', (req, res) => {
4452
+ try {
4453
+ const { session, params } = req.body || {};
4454
+ if (typeof session !== 'string' || !session) throw previewError(400, 'session is required', 'bad_scope');
4455
+ const list = addons.setParams(session, req.params.id, params);
4456
+ if (!list) throw previewError(404, `No add-on ${req.params.id} in session ${session}`, 'no_addon');
4457
+ res.json(list);
4458
+ } catch (error) {
4459
+ paneRouteError(res, error, 'PATCH /api/addons/:id');
4460
+ }
4461
+ });
4462
+
4463
+ app.delete('/api/addons/:id', (req, res) => {
4464
+ const session = typeof req.query.session === 'string' ? req.query.session : null;
4465
+ const removed = addons.remove(session, req.params.id);
4466
+ if (!removed) return res.status(404).json({ error: `No add-on ${req.params.id} here`, code: 'no_addon' });
4467
+ res.json(removed);
4468
+ });
4469
+
4470
+ app.post('/api/addons/:id/start', async (req, res) => {
4471
+ const entry = addons.getGlobal(req.params.id);
4472
+ if (!entry) return res.status(404).json({ error: `No global add-on ${req.params.id}`, code: 'no_addon' });
4473
+ try {
4474
+ res.json(await addonRunner.ensure(entry));
4475
+ } catch (error) {
4476
+ res.status(500).json({ state: 'failed', error: error.message });
4477
+ }
4478
+ });
4479
+
4480
+ // The hub's app launcher rows (`launcher.items` in hub-app.json): the global add-ons,
4481
+ // with Restart for the ones whose server the board runs.
4482
+ const LAUNCHER_ITEMS = '/api/hub/launcher-items';
4483
+ app.get(LAUNCHER_ITEMS, async (_req, res) => {
4484
+ const items = await Promise.all(
4485
+ addons.list(null).global.map(async (a) => {
4486
+ let detail = a.url;
4487
+ try {
4488
+ detail = new URL(sampleUrl(a.url)).host;
4489
+ } catch {}
4490
+ return {
4491
+ id: a.id,
4492
+ name: a.title,
4493
+ kind: 'add-on',
4494
+ detail,
4495
+ state: a.run ? await addonRunner.status(a) : '',
4496
+ path: ADDONS_FILE,
4497
+ ops: a.run ? ['restart', 'remove'] : ['remove'],
4498
+ };
4499
+ }),
4500
+ );
4501
+ res.json({ items });
4502
+ });
4503
+
4504
+ app.post(`${LAUNCHER_ITEMS}/:id/restart`, async (req, res) => {
4505
+ const entry = addons.getGlobal(req.params.id);
4506
+ if (!entry?.run) return res.status(404).json({ error: `No global add-on ${req.params.id} with a run command`, code: 'no_addon' });
4507
+ try {
4508
+ const result = await addonRunner.restart(entry);
4509
+ if (result.state === 'failed' || result.state === 'external') return res.status(409).json(result);
4510
+ broadcast({ type: 'addon:restarted', id: entry.id });
4511
+ res.json({ state: 'running', message: `Restarted ${entry.title}` });
4512
+ } catch (error) {
4513
+ res.status(500).json({ state: 'failed', error: error.message });
4514
+ }
4515
+ });
4516
+
4517
+ app.post(`${LAUNCHER_ITEMS}/:id/remove`, (req, res) => {
4518
+ const removed = addons.remove(null, req.params.id);
4519
+ if (!removed) return res.status(404).json({ error: `No global add-on ${req.params.id}`, code: 'no_addon' });
4520
+ res.json({ message: `Removed ${removed.addon.title}` });
4521
+ });
4522
+ // #endregion
4523
+
4389
4524
  // #region REVIEW
4390
4525
  // API: Send review comments on something the board shows (a file preview today) to a
4391
4526
  // session. The source is described, not interpreted, so a new kind of preview needs a
@@ -4820,7 +4955,7 @@ async function cleanupAgentActivity() {
4820
4955
  } catch { /* agent-activity dir may not exist */ }
4821
4956
  }
4822
4957
 
4823
- // Dispatch markers, pane layouts, reviews, attachments, context status and worktrees: see docs/retention.md.
4958
+ // Dispatch markers, pane layouts, add-on sessions, reviews, attachments, context status and worktrees: see docs/retention.md.
4824
4959
  async function runRetention() {
4825
4960
  try {
4826
4961
  const scan = await scanTranscripts(PROJECTS_DIR);
@@ -4828,14 +4963,15 @@ async function runRetention() {
4828
4963
  const markers = dispatched.prune(opts);
4829
4964
  const layouts = panes.prune(opts);
4830
4965
  if (layouts) queuePaneFileWatch();
4966
+ const addonSessions = addons.prune(opts);
4831
4967
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4832
4968
  const attachments = await pruneSessionDirs(ATTACHMENTS_DIR, opts);
4833
4969
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4834
4970
  const wts = worktrees.prune(scan?.dirs);
4835
4971
  const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4836
4972
  const grouped = expireGroupMembers(scan?.ids);
4837
- if (markers || layouts || reviews || attachments || contexts || wts || taskMaps || grouped.members) {
4838
- console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${attachments} attachments, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4973
+ if (markers || layouts || addonSessions || reviews || attachments || contexts || wts || taskMaps || grouped.members) {
4974
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${addonSessions} add-on sessions, ${reviews} reviews, ${attachments} attachments, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4839
4975
  }
4840
4976
  } catch (e) {
4841
4977
  console.warn('[retention] failed:', e.message);
@@ -11,11 +11,14 @@ cck sends nothing back. To hear from the session, the spec tells it to `SendMess
11
11
  ## Start
12
12
 
13
13
  ```bash
14
- claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --json -- <claude args>
14
+ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> (--child | --group <group>) --json -- <claude args>
15
15
  ```
16
16
 
17
17
  - `--name` is the sidebar name and the session's peer name. Kebab-case and unique: `fix-login-redirect`.
18
- - `--group` names the effort (`auth-refactor`, or a path like `auth-refactor/api`) and shows the session under that sidebar group. Pass it on every dispatch that belongs to the effort; a dispatch without it goes to its project. A group it makes goes when none of its sessions shows as active; an existing group stays.
18
+ - Every dispatch picks its **place** in the sidebar. Ask one question: will its result come back to you?
19
+ - Yes: `--child`. It is your part of the work (a fix, a review, research you will use). Its card shows under yours, wherever yours is.
20
+ - No: `--group <group>`. It is separate work the user follows on the board (a handoff, an unrelated task). `<group>` names the effort (`auth-refactor`, or a path like `auth-refactor/api`); pass the same group to each dispatch in that effort. A group it makes goes when none of its sessions shows as active; an existing group stays.
21
+ - If you cannot tell, ask the user in one line before you start the session.
19
22
  - `--spec-file` keeps a long spec out of shell quoting.
20
23
  - Everything after `--` goes to `claude` as it is: any flag in `claude --help`, e.g. `-- --permission-mode auto --add-dir ../shared`. Keep each value one shell word of plain characters (no quotes, `%` or control characters); long text belongs in the spec. cck owns the session id, name, model and worktree, so pass those with its own flags.
21
24
  - After a cck restart the terminal comes back with `claude --resume <id>` alone, so the args after `--` apply to the first run only.