claude-code-kanban 7.4.0 → 7.6.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/README.md +5 -3
- package/cli.js +95 -29
- package/hub-app.json +5 -0
- package/lib/panes.js +41 -6
- package/lib/parsers.js +42 -3
- package/lib/retention.js +54 -6
- package/lib/scratchpad-dir.js +19 -2
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +174 -47
- package/lib/show.js +385 -0
- package/lib/task-dir.js +26 -0
- package/lib/terminal-client.js +14 -1
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +28 -8
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +136 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +4 -0
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +3 -3
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +133 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
- package/public/app.js +2151 -1108
- package/public/index.html +19 -36
- package/public/style.css +1047 -125
- package/public/terminal-frame.js +0 -2
- package/public/tree-highlight.js +137 -15
- package/server.js +237 -178
- package/lib/dispatch.js +0 -32
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
- package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
package/lib/session-events.js
CHANGED
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
// bucket-map invariants here are the difference between a bounded queue and a map that
|
|
4
4
|
// grows one permanent entry per session id ever named in a request path.
|
|
5
5
|
|
|
6
|
+
const crypto = require('node:crypto');
|
|
6
7
|
const { oneLine } = require('./one-line');
|
|
7
8
|
|
|
8
|
-
// Tells a live session that the board moved one of its tasks. The
|
|
9
|
-
// (plugin/plugins/claude-code-kanban/
|
|
10
|
-
//
|
|
9
|
+
// Tells a live session that the board moved one of its tasks. The plugin's doorbell mod
|
|
10
|
+
// (plugin/plugins/claude-code-kanban/hooks/doorbell.ts) drains this queue and submits the
|
|
11
|
+
// lines to that session as a prompt.
|
|
11
12
|
//
|
|
12
13
|
// Deliberately in-memory and lossy. The task file is the durable command -- a dropped
|
|
13
14
|
// event only means the agent notices on its next turn instead of immediately -- so a
|
|
14
|
-
// disk queue would buy nothing. Reading consumes, so a restarted
|
|
15
|
+
// disk queue would buy nothing. Reading consumes, so a restarted session never replays
|
|
15
16
|
// a backlog and acts on the same move twice.
|
|
16
17
|
//
|
|
18
|
+
// A mod that acks changes when a line leaves: on its ack, not on the read. A line with a
|
|
19
|
+
// `marker` (a review) is the durable exception. Its comments exist nowhere else the agent
|
|
20
|
+
// reads, so it survives the first-attach drop and /clear, and server.js keeps the marker
|
|
21
|
+
// file until the ack, which `restore` reads back after a board restart.
|
|
22
|
+
//
|
|
17
23
|
// A bucket exists only while it holds something: an undelivered line or a waiting
|
|
18
24
|
// poller. Without that, the map would grow one permanent entry per session id ever
|
|
19
25
|
// asked for -- and the id comes straight off the request path.
|
|
@@ -28,49 +34,129 @@ const sessionEventBuckets = new Map();
|
|
|
28
34
|
// last: truncation eats its tail first and leaves the machine-readable head intact.
|
|
29
35
|
const sanitizeEventLine = (line) => oneLine(line, 1500);
|
|
30
36
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
37
|
+
// No skill explains the lines, so each one says what the user means by it. EVENT_PREFIX
|
|
38
|
+
// marks the board as the sender.
|
|
33
39
|
const EVENT_PREFIX = '[kanban board]';
|
|
34
40
|
|
|
41
|
+
function moveCommand(from, to, ref) {
|
|
42
|
+
if (to === 'in_progress') return `Start task ${ref} now. Mark it completed when done.`;
|
|
43
|
+
if (from === 'in_progress' && to === 'pending') return `Stop work on task ${ref} and leave it for later.`;
|
|
44
|
+
if (to === 'completed') return `Stop work on task ${ref}: it is done.`;
|
|
45
|
+
if (to === 'cancelled') return `Drop task ${ref}. Keep the changes made so far.`;
|
|
46
|
+
return `Task ${ref} is now ${to}.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
// Everything after `Description:` is the description verbatim to end of line, so no amount
|
|
36
50
|
// of board text can pose as a further part of the line. That leaves the subject as the only
|
|
37
51
|
// value that needs delimiting.
|
|
38
52
|
function formatTaskMoved(taskId, prevStatus, task) {
|
|
39
53
|
const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
|
|
40
|
-
const
|
|
41
|
-
const head = `${EVENT_PREFIX} The user moved task ${taskId} "${subject}"${from} to ${task.status}.`;
|
|
54
|
+
const head = `${EVENT_PREFIX} ${moveCommand(prevStatus, task.status, `${taskId} "${subject}"`)}`;
|
|
42
55
|
return task.description ? `${head} Description: ${task.description}` : head;
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
// The `boardEvents` section of <CCK_DIR>/config.json. On unless it says enabled: false.
|
|
59
|
+
function boardEventsOn(cfg) {
|
|
60
|
+
return cfg?.boardEvents?.enabled !== false;
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
// The comments themselves live in the review file; the line only points at it, because a
|
|
46
64
|
// batch of quotes would not survive the one-line cap. The path comes last and runs to end
|
|
47
65
|
// of line, so a space in the config dir cannot split it.
|
|
48
66
|
function formatReviewSubmitted(count, label, reviewFile) {
|
|
49
67
|
const comments = `${count} review comment${count === 1 ? '' : 's'}`;
|
|
50
|
-
return `${EVENT_PREFIX}
|
|
68
|
+
return `${EVENT_PREFIX} Address ${comments} on ${label}: ${reviewFile}`;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
// Names this board run in every reply, so a mod drops `got` and `ack` counted against a run
|
|
72
|
+
// that has since restarted.
|
|
73
|
+
const BOARD_RUN = crypto.randomBytes(6).toString('hex');
|
|
74
|
+
let lastSeq = 0;
|
|
75
|
+
|
|
76
|
+
// Ids come off request paths, so both maps are capped; the oldest entry goes first.
|
|
77
|
+
const MAX_IDS = 500;
|
|
78
|
+
const doorbells = new Map();
|
|
79
|
+
const aliases = new Map();
|
|
80
|
+
|
|
81
|
+
function remember(map, key, value) {
|
|
82
|
+
map.delete(key);
|
|
83
|
+
map.set(key, value);
|
|
84
|
+
if (map.size > MAX_IDS) map.delete(map.keys().next().value);
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
// Set by server.js: `onDelivered(entry)` runs once a review line is acked (or drained by a
|
|
88
|
+
// mod without acks); `restore(sessionId)` returns the review lines still pending on disk.
|
|
89
|
+
const hooks = { onDelivered: null, restore: null };
|
|
90
|
+
|
|
91
|
+
function configureSessionEvents(opts) {
|
|
92
|
+
Object.assign(hooks, opts);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// After /clear the mod polls under a new id, and the board may still send to the old one.
|
|
96
|
+
// `adopt` keeps each alias pointing at the newest id, so one lookup is enough.
|
|
97
|
+
function resolveSessionId(sessionId) {
|
|
98
|
+
return aliases.get(sessionId) ?? sessionId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bucketFor(key) {
|
|
102
|
+
let bucket = sessionEventBuckets.get(key);
|
|
64
103
|
if (!bucket) {
|
|
65
104
|
bucket = { queue: [], waiters: new Set() };
|
|
66
|
-
sessionEventBuckets.set(
|
|
105
|
+
sessionEventBuckets.set(key, bucket);
|
|
67
106
|
}
|
|
68
|
-
bucket
|
|
69
|
-
|
|
107
|
+
return bucket;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function partition(list, pred) {
|
|
111
|
+
const yes = [];
|
|
112
|
+
const no = [];
|
|
113
|
+
for (const e of list) (pred(e) ? yes : no).push(e);
|
|
114
|
+
return [yes, no];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// True once this session's doorbell has polled during this board run. A review then always
|
|
118
|
+
// goes into the queue: the mod takes it on its next poll, so a busy turn only delays it.
|
|
119
|
+
function hasDoorbell(sessionId) {
|
|
120
|
+
return doorbells.has(resolveSessionId(sessionId));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function enqueueSessionEvent(sessionId, line, { marker } = {}) {
|
|
124
|
+
const text = sanitizeEventLine(line);
|
|
125
|
+
if (!sessionId || !text) return;
|
|
126
|
+
const bucket = bucketFor(resolveSessionId(sessionId));
|
|
127
|
+
bucket.queue.push({ seq: ++lastSeq, text, marker });
|
|
128
|
+
// A session with no doorbell attached must not grow without bound.
|
|
70
129
|
if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
|
|
71
130
|
for (const wake of [...bucket.waiters]) wake();
|
|
72
131
|
}
|
|
73
132
|
|
|
133
|
+
function delivered(entries) {
|
|
134
|
+
for (const e of entries) if (e.marker) hooks.onDelivered?.(e);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function adopt(prev, sessionId) {
|
|
138
|
+
if (!prev || prev === sessionId) return;
|
|
139
|
+
for (const [from, to] of aliases) if (to === prev) aliases.set(from, sessionId);
|
|
140
|
+
remember(aliases, prev, sessionId);
|
|
141
|
+
const old = sessionEventBuckets.get(prev);
|
|
142
|
+
if (!old) return;
|
|
143
|
+
const [durable, rest] = partition(old.queue, (e) => e.marker);
|
|
144
|
+
old.queue = rest;
|
|
145
|
+
evictIfEmpty(prev, old);
|
|
146
|
+
if (!durable.length) return;
|
|
147
|
+
const bucket = bucketFor(sessionId);
|
|
148
|
+
bucket.queue.push(...durable);
|
|
149
|
+
bucket.queue.sort((a, b) => a.seq - b.seq);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function restorePending(sessionId) {
|
|
153
|
+
const lines = await hooks.restore(sessionId).catch(() => []);
|
|
154
|
+
const queued = new Set(sessionEventBuckets.get(sessionId)?.queue.map((e) => e.marker));
|
|
155
|
+
for (const { text, marker } of lines) {
|
|
156
|
+
if (!queued.has(marker)) enqueueSessionEvent(sessionId, text, { marker });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
74
160
|
const MAX_WAIT_SEC = 120;
|
|
75
161
|
const TOPIC_RE = /^[a-z]+$/;
|
|
76
162
|
|
|
@@ -78,54 +164,93 @@ function clampWait(sec) {
|
|
|
78
164
|
return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
|
|
79
165
|
}
|
|
80
166
|
|
|
81
|
-
// Each topic rides its own bucket
|
|
82
|
-
//
|
|
83
|
-
// before 2.22.0 run a `--topic dispatch` postman, which must keep getting nothing.
|
|
167
|
+
// Each topic rides its own bucket. No topic is the task-move bucket. Plugins before 2.22.0
|
|
168
|
+
// run a `--topic dispatch` monitor, which must keep getting nothing.
|
|
84
169
|
function topicKey(topic, sessionId) {
|
|
85
170
|
return topic ? `${topic}:${sessionId}` : sessionId;
|
|
86
171
|
}
|
|
87
172
|
|
|
88
|
-
|
|
89
|
-
|
|
173
|
+
function seqParam(value) {
|
|
174
|
+
const n = Number(value);
|
|
175
|
+
return Number.isSafeInteger(n) && n > 0 ? n : 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Long-poll drained by the doorbell mod. Routing stays in server.js; this is the handler.
|
|
179
|
+
//
|
|
180
|
+
// A mod that acks sends `got` (the last seq it took) and `ack` (the last seq it submitted to
|
|
181
|
+
// the session). The board answers only lines past `got` and keeps each line until its ack, so
|
|
182
|
+
// a reply lost on the way, or a mod that dies before its submit, gets the line again. A mod
|
|
183
|
+
// before acks sends neither, and a line is gone once a reply carries it.
|
|
184
|
+
async function handleSessionEvents(req, res) {
|
|
90
185
|
const topic = req.query.topic;
|
|
91
186
|
if (topic !== undefined && !(typeof topic === 'string' && TOPIC_RE.test(topic))) {
|
|
92
187
|
return res.status(400).json({ error: 'invalid topic' });
|
|
93
188
|
}
|
|
94
189
|
const sessionId = topicKey(topic, req.params.sessionId);
|
|
95
|
-
const bucket = sessionEventBuckets.get(sessionId);
|
|
96
190
|
const wait = clampWait(req.query.wait);
|
|
191
|
+
const acks = req.query.got !== undefined;
|
|
192
|
+
const trusted = acks && req.query.board === BOARD_RUN;
|
|
193
|
+
const got = trusted ? seqParam(req.query.got) : 0;
|
|
194
|
+
const ack = trusted ? seqParam(req.query.ack) : 0;
|
|
195
|
+
|
|
196
|
+
if (!topic) {
|
|
197
|
+
aliases.delete(sessionId);
|
|
198
|
+
const known = doorbells.has(sessionId);
|
|
199
|
+
remember(doorbells, sessionId, true);
|
|
200
|
+
if (req.query.first === '1' && typeof req.query.prev === 'string') adopt(req.query.prev, sessionId);
|
|
201
|
+
if (!known && hooks.restore) await restorePending(sessionId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const bucket = sessionEventBuckets.get(sessionId);
|
|
205
|
+
if (bucket && ack) {
|
|
206
|
+
const [done, rest] = partition(bucket.queue, (e) => e.seq <= ack);
|
|
207
|
+
bucket.queue = rest;
|
|
208
|
+
delivered(done);
|
|
209
|
+
}
|
|
97
210
|
|
|
98
|
-
// A
|
|
99
|
-
//
|
|
100
|
-
// than no instruction, so
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
if (bucket && req.query.first === '1')
|
|
211
|
+
// A doorbell can attach long after the board moved something (a resumed session, a board
|
|
212
|
+
// that came up later). Those lines are read as instructions, and an hours-old instruction
|
|
213
|
+
// is worse than no instruction, so `first=1` drops the backlog of moves, and an emptied
|
|
214
|
+
// bucket is still evicted from the map. Reviews are kept: the review file says what to
|
|
215
|
+
// address however late it arrives.
|
|
216
|
+
if (bucket && req.query.first === '1') bucket.queue = bucket.queue.filter((e) => e.marker);
|
|
104
217
|
|
|
105
|
-
|
|
106
|
-
|
|
218
|
+
const take = (b) => {
|
|
219
|
+
if (acks) return b.queue.filter((e) => e.seq > got);
|
|
220
|
+
const all = b.queue.splice(0);
|
|
221
|
+
delivered(all);
|
|
222
|
+
return all;
|
|
223
|
+
};
|
|
224
|
+
const reply = (entries) => ({ events: entries.map((e) => e.text), seqs: entries.map((e) => e.seq), board: BOARD_RUN });
|
|
107
225
|
|
|
108
|
-
const
|
|
109
|
-
|
|
226
|
+
const ready = bucket ? take(bucket) : [];
|
|
227
|
+
if (bucket) evictIfEmpty(sessionId, bucket);
|
|
228
|
+
if (ready.length) return res.json(reply(ready));
|
|
229
|
+
if (!wait) return res.json(reply([]));
|
|
110
230
|
|
|
111
|
-
const
|
|
231
|
+
const pending = bucketFor(sessionId);
|
|
232
|
+
|
|
233
|
+
// A closed request has nobody to read the answer, so its lines stay queued.
|
|
234
|
+
const send = (closed) => {
|
|
112
235
|
// Set.delete is the whole idempotency story: whichever of enqueue, timeout, or
|
|
113
236
|
// client disconnect gets here first is the one that answers.
|
|
114
237
|
if (!pending.waiters.delete(send)) return;
|
|
115
238
|
clearTimeout(timer);
|
|
116
|
-
req.removeListener('close',
|
|
117
|
-
const
|
|
118
|
-
|
|
239
|
+
req.removeListener('close', onClose);
|
|
240
|
+
const entries = closed === true || res.writableEnded ? [] : take(pending);
|
|
241
|
+
evictIfEmpty(sessionId, pending);
|
|
242
|
+
if (!res.writableEnded) res.json(reply(entries));
|
|
119
243
|
};
|
|
244
|
+
const onClose = () => send(true);
|
|
120
245
|
const timer = setTimeout(send, wait * 1000);
|
|
121
246
|
pending.waiters.add(send);
|
|
122
|
-
req.on('close',
|
|
247
|
+
req.on('close', onClose);
|
|
123
248
|
}
|
|
124
249
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
250
|
+
function evictIfEmpty(sessionId, bucket) {
|
|
251
|
+
if (!bucket.queue.length && !bucket.waiters.size && sessionEventBuckets.get(sessionId) === bucket) {
|
|
252
|
+
sessionEventBuckets.delete(sessionId);
|
|
253
|
+
}
|
|
129
254
|
}
|
|
130
255
|
|
|
131
256
|
module.exports = {
|
|
@@ -133,8 +258,10 @@ module.exports = {
|
|
|
133
258
|
sessionEventBuckets,
|
|
134
259
|
sanitizeEventLine,
|
|
135
260
|
formatTaskMoved,
|
|
261
|
+
boardEventsOn,
|
|
136
262
|
formatReviewSubmitted,
|
|
137
|
-
|
|
263
|
+
hasDoorbell,
|
|
264
|
+
configureSessionEvents,
|
|
138
265
|
enqueueSessionEvent,
|
|
139
266
|
handleSessionEvents,
|
|
140
267
|
topicKey,
|