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/lib/addons.js ADDED
@@ -0,0 +1,238 @@
1
+ // Dock add-ons: web pages on the rail beside the embedded terminal. A global entry shows in every
2
+ // session; a session's entries and its param overrides expire with the session (docs/retention.md).
3
+ // The file is shared by every board process on the config dir and the CLI, as panes.json is.
4
+
5
+ const path = require('node:path');
6
+ const { httpError } = require('./http-error');
7
+ const { isLoopbackAddress } = require('./net-guard');
8
+ const { oneLine } = require('./one-line');
9
+ const { pruneMap } = require('./retention');
10
+
11
+ const MAX_ADDONS = 20;
12
+ const MAX_TITLE = 100;
13
+ const MAX_ICON = 2;
14
+ const MAX_PARAM = 500;
15
+ const MAX_RUN = 1000;
16
+ const BUILTINS = ['sessionId', 'cwd', 'project'];
17
+ const PARAM_NAME = /^[A-Za-z][\w-]{0,31}$/;
18
+ const KEY = /^[a-z0-9][a-z0-9._-]{0,63}$/;
19
+ const PLACEHOLDER = /\{([^{}]*)\}/g;
20
+
21
+ const placeholders = (url) => [...url.matchAll(PLACEHOLDER)].map((m) => m[1]);
22
+ const sampleUrl = (url) => url.replace(PLACEHOLDER, 'x');
23
+
24
+ // A value of '' in an override drops that key, so the entry's own value applies again.
25
+ function cleanParams(raw, { allowEmpty = false } = {}) {
26
+ if (raw == null) return {};
27
+ if (typeof raw !== 'object' || Array.isArray(raw)) throw httpError(400, 'params must be an object', 'bad_param');
28
+ const out = {};
29
+ for (const [k, v] of Object.entries(raw)) {
30
+ if (!PARAM_NAME.test(k) || BUILTINS.includes(k)) throw httpError(400, `Bad param name: ${k}`, 'bad_param');
31
+ if (typeof v !== 'string' || v.length > MAX_PARAM || (!v && !allowEmpty)) {
32
+ throw httpError(400, `Param ${k} needs a value of at most ${MAX_PARAM} characters`, 'bad_param');
33
+ }
34
+ out[k] = v;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // Returns the URL with each placeholder filled by `x`, for the syntax and origin checks.
40
+ function checkAddonUrl(url, params) {
41
+ if (typeof url !== 'string' || !url) throw httpError(400, 'url is required', 'bad_url');
42
+ for (const name of placeholders(url)) {
43
+ if (!BUILTINS.includes(name) && !(name in params)) {
44
+ throw httpError(400, `No value for {${name}}: pass --param ${name}=<value>, or use {${BUILTINS.join('}, {')}}`, 'missing_param');
45
+ }
46
+ }
47
+ const sample = sampleUrl(url);
48
+ let u;
49
+ try {
50
+ u = new URL(sample);
51
+ } catch {
52
+ throw httpError(400, `Not a URL: ${url}`, 'bad_url');
53
+ }
54
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw httpError(400, 'An add-on URL must be http(s)', 'bad_url');
55
+ return sample;
56
+ }
57
+
58
+ // cck starts `run` only for a page on this machine, so the port it checks is the server's own.
59
+ function cleanRun(run, cwd, sample, isGlobal) {
60
+ if (run == null || run === '') {
61
+ if (cwd) throw httpError(400, 'cwd needs run', 'bad_run');
62
+ return {};
63
+ }
64
+ if (!isGlobal) throw httpError(400, 'Only a global add-on has a run command', 'bad_run');
65
+ if (typeof run !== 'string' || run.length > MAX_RUN || /[\r\n\0]/.test(run)) {
66
+ throw httpError(400, `run must be one line of at most ${MAX_RUN} characters`, 'bad_run');
67
+ }
68
+ if (cwd != null && (typeof cwd !== 'string' || !path.isAbsolute(cwd))) throw httpError(400, 'cwd must be an absolute path', 'bad_run');
69
+ if (!isLoopbackAddress(new URL(sample).hostname)) throw httpError(400, 'A run command needs a URL on localhost', 'bad_run');
70
+ return { run, ...(cwd && { cwd }) };
71
+ }
72
+
73
+ const emptyData = () => ({ nextId: 1, global: [], sessions: new Map() });
74
+ const copy = (e) => ({ ...e, params: { ...e.params } });
75
+
76
+ const isEntry = (e) => e && typeof e.id === 'string' && typeof e.url === 'string' && typeof e.title === 'string';
77
+
78
+ function cleanData(raw) {
79
+ const data = emptyData();
80
+ if (!raw || typeof raw !== 'object') return data;
81
+ const ids = new Set();
82
+ const keep = (list) =>
83
+ (Array.isArray(list) ? list : [])
84
+ .filter((e) => isEntry(e) && !ids.has(e.id) && ids.add(e.id))
85
+ .slice(0, MAX_ADDONS)
86
+ .map((e) => ({ ...e, params: e.params && typeof e.params === 'object' ? e.params : {} }));
87
+ data.global = keep(raw.global);
88
+ for (const [sid, s] of Object.entries(raw.sessions || {})) {
89
+ if (!s || !Number.isFinite(s.updatedAt)) continue;
90
+ data.sessions.set(sid, { addons: keep(s.addons), params: s.params && typeof s.params === 'object' ? s.params : {}, updatedAt: s.updatedAt });
91
+ }
92
+ const maxId = Math.max(0, ...[...ids].map((id) => Number(id.slice(1)) || 0));
93
+ data.nextId = Math.max(Number.isInteger(raw.nextId) ? raw.nextId : 1, maxId + 1);
94
+ return data;
95
+ }
96
+
97
+ const toFile = (data) => ({ version: 1, nextId: data.nextId, global: data.global, sessions: Object.fromEntries(data.sessions) });
98
+
99
+ /**
100
+ * @param {object} o
101
+ * @param {() => object|null|undefined} o.load the file's JSON, null when there is none, or undefined
102
+ * when it has not changed since the last load or save
103
+ * @param {(data: object) => void} o.save
104
+ * @param {(global: object[]) => void} [o.onChange] called with the global entries after a write, and after a
105
+ * reload that changed something
106
+ * @param {(url: string) => boolean} [o.isOwnOrigin] true for a URL on the board's or the hub's origin
107
+ */
108
+ function createAddonStore({ load, save, onChange = () => {}, isOwnOrigin = () => false, now = Date.now }) {
109
+ let data = emptyData();
110
+ let saved = '';
111
+
112
+ function reload() {
113
+ const raw = load();
114
+ if (raw === undefined) return;
115
+ data = cleanData(raw);
116
+ const next = JSON.stringify(toFile(data));
117
+ if (next === saved) return;
118
+ saved = next;
119
+ onChange(data.global.map(copy));
120
+ }
121
+ reload();
122
+
123
+ function write() {
124
+ const file = toFile(data);
125
+ saved = JSON.stringify(file);
126
+ save(file);
127
+ onChange(data.global.map(copy));
128
+ }
129
+
130
+ const session = (sid) => data.sessions.get(sid) || { addons: [], params: {}, updatedAt: 0 };
131
+
132
+ function touch(sid, s) {
133
+ s.updatedAt = now();
134
+ data.sessions.set(sid, s);
135
+ }
136
+
137
+ function list(sid) {
138
+ const s = sid ? session(sid) : null;
139
+ return {
140
+ global: data.global.map(copy),
141
+ session: s ? s.addons.map(copy) : [],
142
+ params: s ? structuredClone(s.params) : {},
143
+ };
144
+ }
145
+
146
+ // The same URL added again in the same scope returns its entry, so a repeated add is harmless;
147
+ // a new `run` or `cwd` replaces the entry's. `scope` is 'global' or a session id.
148
+ // A `key` (an installer's name for the entry, such as a manifest id) finds the entry instead,
149
+ // else the entry with the same URL, and the add replaces its fields: an update keeps the id.
150
+ function add(scope, { url, title, icon, params, run, cwd, key }) {
151
+ if (key != null && (typeof key !== 'string' || !KEY.test(key))) throw httpError(400, `Bad key: ${key}`, 'bad_key');
152
+ const clean = cleanParams(params);
153
+ const sample = checkAddonUrl(url, clean);
154
+ if (isOwnOrigin(sample)) throw httpError(400, 'An add-on cannot show the board or the hub itself', 'own_origin');
155
+ const isGlobal = scope === 'global';
156
+ const start = cleanRun(run, cwd, sample, isGlobal);
157
+ reload();
158
+ const s = isGlobal ? null : session(scope);
159
+ const addons = isGlobal ? data.global : s.addons;
160
+ const glyph = [...oneLine(icon, 16)].slice(0, MAX_ICON).join('');
161
+ const fields = { url, title: oneLine(title, MAX_TITLE) || new URL(sample).host, ...(glyph && { icon: glyph }), ...start, params: clean };
162
+ const found = key
163
+ ? (addons.find((e) => e.key === key) ?? addons.find((e) => e.url === url && !e.key))
164
+ : addons.find((e) => e.url === url);
165
+ if (found) {
166
+ let next = found;
167
+ if (key) next = { id: found.id, key, ...fields, addedAt: found.addedAt };
168
+ else if (start.run && (start.run !== found.run || start.cwd !== found.cwd)) {
169
+ next = { ...found, ...start };
170
+ if (!start.cwd) delete next.cwd;
171
+ }
172
+ if (JSON.stringify(next) !== JSON.stringify(found)) {
173
+ addons[addons.indexOf(found)] = next;
174
+ if (!isGlobal) touch(scope, s);
175
+ write();
176
+ }
177
+ return { addon: copy(next), added: false };
178
+ }
179
+ if (addons.length >= MAX_ADDONS) throw httpError(409, `At most ${MAX_ADDONS} add-ons ${isGlobal ? 'are global' : 'per session'}`, 'addon_limit');
180
+ const addon = { id: `a${data.nextId++}`, ...(key && { key }), ...fields, addedAt: now() };
181
+ addons.push(addon);
182
+ if (!isGlobal) touch(scope, s);
183
+ write();
184
+ return { addon: copy(addon), added: true };
185
+ }
186
+
187
+ // A global id needs no session. Returns the removed entry with its scope, or null.
188
+ function remove(sid, id) {
189
+ reload();
190
+ const g = data.global.findIndex((e) => e.id === id);
191
+ if (g >= 0) {
192
+ const [addon] = data.global.splice(g, 1);
193
+ for (const s of data.sessions.values()) delete s.params[id];
194
+ write();
195
+ return { addon, scope: 'global' };
196
+ }
197
+ const s = sid && data.sessions.get(sid);
198
+ const i = s ? s.addons.findIndex((e) => e.id === id) : -1;
199
+ if (i < 0) return null;
200
+ const [addon] = s.addons.splice(i, 1);
201
+ delete s.params[id];
202
+ touch(sid, s);
203
+ write();
204
+ return { addon, scope: sid };
205
+ }
206
+
207
+ // Sets this session's values for the add-on's params. Returns null when the session sees no such add-on.
208
+ function setParams(sid, id, params) {
209
+ const clean = cleanParams(params, { allowEmpty: true });
210
+ reload();
211
+ const s = session(sid);
212
+ if (!data.global.some((e) => e.id === id) && !s.addons.some((e) => e.id === id)) return null;
213
+ const next = { ...s.params[id], ...clean };
214
+ for (const [k, v] of Object.entries(next)) if (!v) delete next[k];
215
+ if (Object.keys(next).length) s.params[id] = next;
216
+ else delete s.params[id];
217
+ touch(sid, s);
218
+ write();
219
+ return list(sid);
220
+ }
221
+
222
+ // Returns how many sessions went; writes only when one did. Global entries never expire.
223
+ function prune({ known, maxAgeMs }) {
224
+ reload();
225
+ const removed = pruneMap(data.sessions, (s) => s.updatedAt, { known, maxAgeMs, now: now() });
226
+ if (removed) write();
227
+ return removed;
228
+ }
229
+
230
+ function getGlobal(id) {
231
+ const e = data.global.find((g) => g.id === id);
232
+ return e ? copy(e) : null;
233
+ }
234
+
235
+ return { list, getGlobal, add, remove, setParams, prune, reload };
236
+ }
237
+
238
+ module.exports = { createAddonStore, sampleUrl, MAX_ADDONS };
package/lib/panes.js CHANGED
@@ -183,6 +183,19 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
183
183
  return write(sessionId, layout);
184
184
  }
185
185
 
186
+ // Returns null when the session has no such pane.
187
+ function setDock(sessionId, paneId, on) {
188
+ reload();
189
+ const layout = bySession.get(sessionId);
190
+ const pane = layout?.panes.find((p) => p.id === paneId);
191
+ if (!pane) return null;
192
+ if (on && pane.kind !== 'url') throw httpError(400, 'Only a URL pane can go in the dock', 'bad_dock');
193
+ if (!!pane.dock === on) return get(sessionId);
194
+ if (on) pane.dock = true;
195
+ else delete pane.dock;
196
+ return write(sessionId, layout);
197
+ }
198
+
186
199
  // `order` is every pane id of the session, in the new tab order.
187
200
  function reorder(sessionId, order) {
188
201
  reload();
@@ -211,7 +224,7 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
211
224
  return out;
212
225
  }
213
226
 
214
- return { get, add, remove, removeShow, rename, reorder, prune, reload, fileTargets };
227
+ return { get, add, remove, removeShow, rename, setDock, reorder, prune, reload, fileTargets };
215
228
  }
216
229
 
217
230
  module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
@@ -24,6 +24,10 @@ function getParentVerdict(sessionId, meta) {
24
24
  return v.result;
25
25
  }
26
26
 
27
+ // No staleness check, so no file access: for the session list's fork badge, where a stale
28
+ // verdict costs a wrong icon until the next lookup.
29
+ const peekParentVerdict = (sessionId) => verdicts.get(sessionId)?.result || null;
30
+
27
31
  // self: the child's stat from the lookup. parentIno: null when no parent was found.
28
32
  function setParentVerdict(sessionId, meta, self, parentIno, result) {
29
33
  verdicts.set(sessionId, {
@@ -55,4 +59,4 @@ function importParentVerdicts(entries) {
55
59
  }
56
60
  }
57
61
 
58
- module.exports = { getParentVerdict, setParentVerdict, parentVerdictsDirty, exportParentVerdicts, importParentVerdicts };
62
+ module.exports = { getParentVerdict, setParentVerdict, peekParentVerdict, parentVerdictsDirty, exportParentVerdicts, importParentVerdicts };
package/lib/retention.js CHANGED
@@ -43,6 +43,7 @@ const textOrNull = (v) => (typeof v === 'string' && v ? v : null);
43
43
  function dispatchedEntry(e) {
44
44
  return {
45
45
  parent: textOrNull(e.parent),
46
+ child: e.child === true,
46
47
  at: e.at,
47
48
  name: textOrNull(e.name),
48
49
  group: textOrNull(e.group),
@@ -55,7 +56,7 @@ function dispatchedEntry(e) {
55
56
  * Sessions started through `dispatch start`, kept after their terminal ends so the card
56
57
  * keeps its marker, and across a restart so `dispatch list` finds a session cck resumes.
57
58
  * @param {object} o
58
- * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at, name, group, cwd, worktree}}}` or null
59
+ * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, child, at, name, group, cwd, worktree}}}` or null
59
60
  * @param {(data: object) => void} o.save
60
61
  */
61
62
  function createDispatchedStore({ load, save, now = Date.now }) {
package/lib/terminal.js CHANGED
@@ -288,6 +288,8 @@ function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl, terminalId }) {
288
288
  if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
289
289
  env.TERM = 'xterm-256color';
290
290
  env.COLORTERM = 'truecolor';
291
+ // Claude Code draws OSC 8 links only for terminals it knows; the frame's xterm.js opens them.
292
+ env.FORCE_HYPERLINK = '1';
291
293
  return env;
292
294
  }
293
295
 
@@ -663,7 +665,7 @@ function createTerminalService(o) {
663
665
  const s = {
664
666
  id, terminalId, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
665
667
  name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, awaitingTrust: false, edit: !!spec?.edit,
666
- forkOf: spec?.forkOf || null, resumeOf: spec?.resume || null, restore: spec?.restore !== false,
668
+ forkOf: spec?.forkOf || null, resumeOf: spec?.resume || null, restore: spec?.restore !== false, prompted: !!spec?.prompt,
667
669
  };
668
670
  if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
669
671
  else if (spec?.prompt) queueText(s, spec.prompt, true);
@@ -893,7 +895,7 @@ function createTerminalService(o) {
893
895
  function list() {
894
896
  return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
895
897
  id: s.id, terminalId: s.terminalId, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
896
- name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, claudeExited: !!s.claudeExitedAt, awaitingTrust: s.awaitingTrust,
898
+ name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, prompted: s.prompted, claudeExited: !!s.claudeExitedAt, awaitingTrust: s.awaitingTrust,
897
899
  }));
898
900
  }
899
901
 
@@ -933,7 +935,7 @@ function createTerminalService(o) {
933
935
  if (reason) return { status: 403, error: reason };
934
936
  if (msg.id != null && !(typeof msg.id === 'string' && UUID_RE.test(msg.id))) return { status: 400, error: 'invalid session id' };
935
937
  const r = await start(msg.id || crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
936
- return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
938
+ return r.error ? r : { id: r.session.id, cwd: r.session.cwd, name: r.session.name, prompted: r.session.prompted };
937
939
  }
938
940
 
939
941
  // Pastes into claude's input box and stops there: Enter would also submit whatever the
@@ -0,0 +1,16 @@
1
+ const path = require('node:path');
2
+ const { mkdirSync, writeFileSync, renameSync, rmSync } = require('node:fs');
3
+
4
+ function writeJsonAtomic(file, obj, mode) {
5
+ mkdirSync(path.dirname(file), { recursive: true });
6
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
7
+ try {
8
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
9
+ renameSync(tmp, file);
10
+ } catch (e) {
11
+ rmSync(tmp, { force: true });
12
+ throw e;
13
+ }
14
+ }
15
+
16
+ module.exports = { writeJsonAtomic };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.20.0",
3
+ "version": "7.22.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
5
5
  }
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: addon
3
+ description: Dock add-ons, pages on the rail beside cck's embedded terminal. Use to build a new add-on, or to add, list, set or remove one.
4
+ argument-hint: '[add <url> | list | set <id> k=v | rm <id> | build <what>] [--global]'
5
+ ---
6
+
7
+ # Dock add-ons
8
+
9
+ An add-on is a page from a server the user runs. It stays on the dock rail of this session, or of every session with `--global`, until it is removed. For a view of this conversation only, post a show card instead.
10
+
11
+ ## Add, list, set, remove
12
+
13
+ 1. Read `claude-code-kanban help addon <verb>`. It names the URL placeholders the board fills per session and when `--global` applies.
14
+ 2. Run `claude-code-kanban addon <verb> ...` and report its output line to the user. The user opens the add-on from the rail.
15
+
16
+ ## Build a new add-on
17
+
18
+ When the page does not exist yet, follow [references/build.md](references/build.md), then register it as above.
@@ -0,0 +1,77 @@
1
+ # Build an add-on
2
+
3
+ An add-on is one HTML page and a small server that serves it. The board frames the page by its URL, so any stack works; this guide uses Node with no dependencies. The build is done when the server answers 200 and `addon add` printed an id.
4
+
5
+ ## 1. Write the server and the page
6
+
7
+ Put them in a folder of their own, outside the project when the add-on serves more than one project:
8
+
9
+ ```js
10
+ // server.js: node server.js [port]
11
+ const http = require('node:http');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const port = Number(process.env.PORT || process.argv[2]) || 4100;
15
+ const page = fs.readFileSync(path.join(__dirname, 'index.html'));
16
+ http.createServer((req, res) => {
17
+ const url = new URL(req.url, 'http://x');
18
+ if (url.pathname === '/') return res.writeHead(200, { 'Content-Type': 'text/html' }).end(page);
19
+ res.writeHead(404).end();
20
+ }).listen(port, '127.0.0.1', () => console.log(`http://localhost:${port}/`));
21
+ ```
22
+
23
+ The server binds to `127.0.0.1` and sends its pages with no `X-Frame-Options` or `frame-ancestors` header, so the board can frame them.
24
+
25
+ ## 2. Read the context from the URL
26
+
27
+ The URL is the page's only input from the board. The page reads the values the board filled in, such as `?cwd={cwd}`, from `location.search`, and passes them to its server on each request.
28
+
29
+ ## 3. Take the board's theme
30
+
31
+ Style the page with the variables of the HTML contract in [the show skill](../../show/SKILL.md#html-contract), in its flat style. Set fallback values for them in `:where(:root)`, and apply the board's values from its message, which it sends on each load and each theme change:
32
+
33
+ ```js
34
+ addEventListener('message', (e) => {
35
+ if (e.source !== parent || e.data?.type !== 'cck-show:tokens') return;
36
+ for (const [k, v] of Object.entries(e.data.tokens)) document.documentElement.style.setProperty(k, v);
37
+ });
38
+ ```
39
+
40
+ ## 4. Hand back the board's keys and focus
41
+
42
+ While the page has focus, it gets every key press, so the board's keys (Alt+Enter to expand, Alt+W, Alt+↑/↓, the terminal and hub keys) stop working, and the board cannot tell that the dock has focus. Put this script in the page, as it is. The board sends the presses it wants (`hub:hostKeys`), the script hands only those back, and it reports focus so the dock lights up:
43
+
44
+ ```js
45
+ let boardKeys = new Set();
46
+ let board = null;
47
+ const sigs = (e) => {
48
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'].filter(Boolean).join('+');
49
+ return [e.code && `${mods}|c:${e.code}`, e.key && e.key !== 'Unidentified' && `${mods}|k:${e.key.toLowerCase()}`];
50
+ };
51
+ addEventListener('message', (e) => {
52
+ if (e.source !== parent || e.data?.type !== 'hub:hostKeys') return;
53
+ board = e.origin;
54
+ boardKeys = new Set(e.data.keys.map((k) => sigs(k).find(Boolean)));
55
+ });
56
+ addEventListener('keydown', (e) => {
57
+ if (!board || !sigs(e).some((s) => boardKeys.has(s))) return;
58
+ e.preventDefault();
59
+ parent.postMessage({ type: 'hub:hostKey', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }, board);
60
+ }, true);
61
+ for (const on of [true, false]) addEventListener(on ? 'focus' : 'blur', () => parent.postMessage({ type: 'cck-show:focus', on }, '*'));
62
+ ```
63
+
64
+ ## 5. Push updates
65
+
66
+ When the content follows files, the server watches them (`fs.watch`) and pushes each change to the page over server-sent events (`text/event-stream`), and the page updates in place. A push costs nothing while nothing changes; the board unloads a page hidden for a minute.
67
+
68
+ ## 6. Fit the dock
69
+
70
+ The dock is 360 to 900 px wide and the full height of the terminal. Lay the page out in normal flow that wraps at 360 px, and let the body scroll.
71
+
72
+ ## 7. Run and register
73
+
74
+ 1. Start the server where the user can see and stop it, such as a tmux window, or ask the user to start it.
75
+ 2. Check that it answers 200: `curl -s -o /dev/null -w '%{http_code}' http://localhost:4100/`.
76
+ 3. Register it with `claude-code-kanban addon add "<url>" --title <t>`. When the user asked for every session, add `--global --run "<start command>" --cwd <server folder>`, so the board starts the server when the add-on opens. The server reads its port from `PORT`.
77
+ 4. Tell the user it is on the rail. For a session add-on, also give the command that starts the server again after a restart.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: dispatch
3
3
  description: Dispatch tasks to new Claude Code sessions in the kanban board's terminal. Use when the user asks to dispatch or delegate a task to another session.
4
- argument-hint: '<task> [--handoff] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]] [-- <claude args>]'
4
+ argument-hint: '<task> [--handoff] [--child | --group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]] [-- <claude args>]'
5
5
  ---
6
6
 
7
7
  # Kanban dispatch
@@ -28,11 +28,11 @@ One worker, one reply. The reply can go to any peer. When another session owns t
28
28
 
29
29
  ## Handoff
30
30
 
31
- One worker, no reply line. The worker owns the task, and you save the turn a reply costs.
31
+ One worker, no reply line, `--group`. The worker owns the task, and you save the turn a reply costs.
32
32
 
33
33
  ## Orchestrator-workers
34
34
 
35
- One worker per independent part, all started before you wait on any. They run at the same time, and each holds only its own part in context. Give each a unique `--name` and one shared `--group`, then combine the replies.
35
+ One worker per independent part, all started before you wait on any. They run at the same time, and each holds only its own part in context. Give each a unique `--name` and `--child`, then combine the replies.
36
36
 
37
37
  ## Prompt chaining
38
38
 
@@ -21,6 +21,7 @@ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$
21
21
  | `task` | `list` for a session, a project or the whole board |
22
22
  | `project` | `list` |
23
23
  | `dispatch` | `start`, `list`, `end` — run `/claude-code-kanban:dispatch` instead |
24
+ | `addon` | `add`, `list`, `set`, `rm` a page on the dock rail — run `/claude-code-kanban:addon` instead |
24
25
  | `terminal` | `list` every terminal in cck, read-only |
25
26
 
26
27
  ## Run a command