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/cli.js +334 -19
- package/hub-app.json +6 -0
- package/lib/addon-runner.js +177 -0
- package/lib/addons.js +238 -0
- package/lib/panes.js +14 -1
- package/lib/parent-cache.js +5 -1
- package/lib/retention.js +2 -1
- package/lib/terminal.js +5 -3
- package/lib/write-json-atomic.js +16 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/addon/SKILL.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/addon/references/build.md +77 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -0
- package/public/app.js +904 -163
- package/public/index.html +9 -4
- package/public/style.css +360 -72
- package/public/themes.css +93 -93
- package/server.js +165 -29
- package/skill-guides/dispatch.md +5 -2
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 };
|
package/lib/parent-cache.js
CHANGED
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.
|
|
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
|
package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|