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.
Files changed (48) hide show
  1. package/README.md +5 -3
  2. package/cli.js +95 -29
  3. package/hub-app.json +5 -0
  4. package/lib/panes.js +41 -6
  5. package/lib/parsers.js +42 -3
  6. package/lib/retention.js +54 -6
  7. package/lib/scratchpad-dir.js +19 -2
  8. package/lib/session-cache.js +1 -1
  9. package/lib/session-events.js +174 -47
  10. package/lib/show.js +385 -0
  11. package/lib/task-dir.js +26 -0
  12. package/lib/terminal-client.js +14 -1
  13. package/lib/terminal-host.js +2 -1
  14. package/lib/terminal.js +28 -8
  15. package/package.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
  17. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +136 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/index.ts +4 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  20. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  21. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +3 -3
  22. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  32. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  33. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  34. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  35. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  36. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  37. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +133 -0
  38. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  39. package/public/app.js +2151 -1108
  40. package/public/index.html +19 -36
  41. package/public/style.css +1047 -125
  42. package/public/terminal-frame.js +0 -2
  43. package/public/tree-highlight.js +137 -15
  44. package/server.js +237 -178
  45. package/lib/dispatch.js +0 -32
  46. package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
  47. package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
  48. package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
@@ -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 postman monitor
9
- // (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
10
- // each line, which Claude Code delivers into that session as a task notification.
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 postman never replays
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
- // Lines are plain sentences so they still read right once the skill that explains them has
32
- // left the agent's context. EVENT_PREFIX marks the board as the sender.
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 from = prevStatus ? ` from ${prevStatus}` : '';
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} The user left ${comments} on ${label}. Address them: ${reviewFile}`;
68
+ return `${EVENT_PREFIX} Address ${comments} on ${label}: ${reviewFile}`;
51
69
  }
52
70
 
53
- // A postman between two polls has no waiter for a moment, so this can miss a live one.
54
- // The caller then falls back to another route, which only costs a duplicate route, never
55
- // a lost review.
56
- function hasSessionListener(sessionId) {
57
- return !!sessionEventBuckets.get(sessionId)?.waiters.size;
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
- function enqueueSessionEvent(sessionId, line) {
61
- const text = sanitizeEventLine(line);
62
- if (!sessionId || !text) return;
63
- let bucket = sessionEventBuckets.get(sessionId);
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(sessionId, bucket);
105
+ sessionEventBuckets.set(key, bucket);
67
106
  }
68
- bucket.queue.push(text);
69
- // A session with no postman attached must not grow without bound.
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, so a postman on a topic never prints task moves the
82
- // user did not grant with the follow skill. No topic is the task-move bucket. Plugins
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
- // Long-poll drained by the postman monitor. Routing stays in server.js; this is the handler.
89
- function handleSessionEvents(req, res) {
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 postman is armed by a skill invocation, so it can attach long after the board moved
99
- // something. Those lines are read as instructions, and an hours-old instruction is worse
100
- // than no instruction, so the grant starts the session's history rather than inheriting
101
- // it: `first=1` drops the whole backlog. drain() (not a bare truncate) so an emptied
102
- // bucket is still evicted from the map.
103
- if (bucket && req.query.first === '1') drain(sessionId, bucket);
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
- if (bucket?.queue.length) return res.json({ events: drain(sessionId, bucket) });
106
- if (!wait) return res.json({ events: [] });
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 pending = bucket || { queue: [], waiters: new Set() };
109
- sessionEventBuckets.set(sessionId, pending);
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 send = () => {
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', send);
117
- const events = drain(sessionId, pending);
118
- if (!res.writableEnded) res.json({ events });
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', send);
247
+ req.on('close', onClose);
123
248
  }
124
249
 
125
- function drain(sessionId, bucket) {
126
- const events = bucket.queue.splice(0);
127
- if (!bucket.queue.length && !bucket.waiters.size) sessionEventBuckets.delete(sessionId);
128
- return events;
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
- hasSessionListener,
263
+ hasDoorbell,
264
+ configureSessionEvents,
138
265
  enqueueSessionEvent,
139
266
  handleSessionEvents,
140
267
  topicKey,