claude-code-kanban 7.5.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 (40) hide show
  1. package/hub-app.json +5 -0
  2. package/lib/panes.js +16 -2
  3. package/lib/parsers.js +27 -2
  4. package/lib/retention.js +29 -6
  5. package/lib/session-events.js +154 -42
  6. package/lib/show.js +385 -0
  7. package/lib/terminal-client.js +14 -1
  8. package/lib/terminal-host.js +2 -1
  9. package/lib/terminal.js +27 -7
  10. package/package.json +1 -1
  11. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  12. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
  13. package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
  14. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  15. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  16. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
  17. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  18. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  19. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  20. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  21. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  22. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  32. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
  33. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  34. package/public/app.js +1133 -108
  35. package/public/index.html +7 -6
  36. package/public/style.css +770 -9
  37. package/public/terminal-frame.js +0 -2
  38. package/public/tree-highlight.js +137 -15
  39. package/server.js +71 -24
  40. package/lib/dispatch.js +0 -32
package/hub-app.json CHANGED
@@ -6,6 +6,11 @@
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
7
  "publishes": ["session.changed"],
8
8
  "keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s"] },
9
+ "plugin": {
10
+ "id": "claude-code-kanban@claude-code-kanban",
11
+ "path": "plugin/plugins/claude-code-kanban",
12
+ "install": "claude-code-kanban --install --yes"
13
+ },
9
14
  "provides": {
10
15
  "projects": { "path": "/api/projects" },
11
16
  "terminal": { "liveWork": "/api/terminals" }
package/lib/panes.js CHANGED
@@ -124,7 +124,8 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
124
124
  }
125
125
 
126
126
  // The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
127
- function add(sessionId, { kind, target, title }) {
127
+ // `show` marks a show card's file, which the board renders with the card's theme.
128
+ function add(sessionId, { kind, target, title, show }) {
128
129
  if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
129
130
  if (kind === 'message' && target.length > MAX_MESSAGE_TARGET) {
130
131
  throw httpError(400, `A message target holds at most ${MAX_MESSAGE_TARGET} characters`, 'bad_target');
@@ -140,6 +141,7 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
140
141
  kind,
141
142
  target,
142
143
  title: paneTitle(kind, target, title),
144
+ ...(show && { show: true }),
143
145
  addedAt: now(),
144
146
  };
145
147
  layout.panes.push(pane);
@@ -157,6 +159,18 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
157
159
  return write(sessionId, layout);
158
160
  }
159
161
 
162
+ // Drops the show panes of these files; returns the new layout, or null when none matched.
163
+ function removeShow(sessionId, files) {
164
+ reload();
165
+ const layout = bySession.get(sessionId);
166
+ if (!layout) return null;
167
+ const keys = new Set(files.map((f) => targetKey('html', f)));
168
+ const kept = layout.panes.filter((p) => !(p.show && keys.has(targetKey(p.kind, p.target))));
169
+ if (kept.length === layout.panes.length) return null;
170
+ layout.panes = kept;
171
+ return write(sessionId, layout);
172
+ }
173
+
160
174
  // An empty title gives the pane its default title back. Returns null when the session has no such pane.
161
175
  function rename(sessionId, paneId, title) {
162
176
  reload();
@@ -197,7 +211,7 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
197
211
  return out;
198
212
  }
199
213
 
200
- return { get, add, remove, rename, reorder, prune, reload, fileTargets };
214
+ return { get, add, remove, removeShow, rename, reorder, prune, reload, fileTargets };
201
215
  }
202
216
 
203
217
  module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
package/lib/parsers.js CHANGED
@@ -1634,7 +1634,7 @@ async function readCompactSummaries(jsonlPath) {
1634
1634
  if (!text) continue;
1635
1635
  // Strip the "This session is being continued..." preamble if present.
1636
1636
  text = text.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '').trim();
1637
- if (text) results.push({ timestamp: obj.timestamp, summary: text });
1637
+ if (text) results.push({ timestamp: obj.timestamp, summary: text, source: 'inline' });
1638
1638
  } catch (_) {}
1639
1639
  }
1640
1640
  } catch (_) {}
@@ -1665,7 +1665,7 @@ async function readCompactSummaries(jsonlPath) {
1665
1665
  for (const b of blocks) {
1666
1666
  if (b.type !== 'text' || !b.text) continue;
1667
1667
  const match = b.text.match(/<summary>([\s\S]*?)(?:<\/summary>|$)/);
1668
- if (match) { results.push({ timestamp: lastTs, summary: match[1].trim() }); found = true; break; }
1668
+ if (match) { results.push({ timestamp: lastTs, summary: match[1].trim(), source: 'subagent' }); found = true; break; }
1669
1669
  }
1670
1670
  if (found) break;
1671
1671
  } catch (_) {}
@@ -1675,6 +1675,30 @@ async function readCompactSummaries(jsonlPath) {
1675
1675
  return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1676
1676
  }
1677
1677
 
1678
+ // A subagent summary's timestamp is the compact agent's last record, written just
1679
+ // before the parent's isCompactSummary record; compactions are minutes apart.
1680
+ const COMPACT_SUMMARY_MATCH_MS = 2 * 60 * 1000;
1681
+
1682
+ function fillCompactSummaries(chips, summaries) {
1683
+ const candidates = summaries.filter(s => s.source === 'subagent' && s.timestamp);
1684
+ const used = new Set();
1685
+ for (const chip of chips) {
1686
+ if (chip.compactSummary || !chip.timestamp) continue;
1687
+ const at = Date.parse(chip.timestamp);
1688
+ let best = null;
1689
+ let bestGap = COMPACT_SUMMARY_MATCH_MS;
1690
+ for (const s of candidates) {
1691
+ if (used.has(s)) continue;
1692
+ const gap = Math.abs(Date.parse(s.timestamp) - at);
1693
+ if (gap <= bestGap) { best = s; bestGap = gap; }
1694
+ }
1695
+ if (best) {
1696
+ used.add(best);
1697
+ chip.compactSummary = best.summary;
1698
+ }
1699
+ }
1700
+ }
1701
+
1678
1702
  async function findTerminatedTeammates(jsonlPath) {
1679
1703
  return (await buildSessionDigest(jsonlPath)).terminated;
1680
1704
  }
@@ -2028,6 +2052,7 @@ module.exports = {
2028
2052
  buildAgentProgressMap,
2029
2053
  buildSessionDigest,
2030
2054
  readCompactSummaries,
2055
+ fillCompactSummaries,
2031
2056
  readArtifactLinks,
2032
2057
  readScratchpadCreations,
2033
2058
  findTerminatedTeammates,
package/lib/retention.js CHANGED
@@ -38,11 +38,24 @@ function pruneMap(map, atOf, { known, maxAgeMs, now }) {
38
38
  return removed;
39
39
  }
40
40
 
41
+ const textOrNull = (v) => (typeof v === 'string' && v ? v : null);
42
+
43
+ function dispatchedEntry(e) {
44
+ return {
45
+ parent: textOrNull(e.parent),
46
+ at: e.at,
47
+ name: textOrNull(e.name),
48
+ group: textOrNull(e.group),
49
+ cwd: textOrNull(e.cwd),
50
+ worktree: e.worktree === true ? true : textOrNull(e.worktree),
51
+ };
52
+ }
53
+
41
54
  /**
42
55
  * Sessions started through `dispatch start`, kept after their terminal ends so the card
43
- * keeps its marker.
56
+ * keeps its marker, and across a restart so `dispatch list` finds a session cck resumes.
44
57
  * @param {object} o
45
- * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
58
+ * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at, name, group, cwd, worktree}}}` or null
46
59
  * @param {(data: object) => void} o.save
47
60
  */
48
61
  function createDispatchedStore({ load, save, now = Date.now }) {
@@ -52,15 +65,15 @@ function createDispatchedStore({ load, save, now = Date.now }) {
52
65
  if (saved && typeof saved === 'object') {
53
66
  for (const [id, e] of Object.entries(saved)) {
54
67
  if (!e || !Number.isFinite(e.at)) continue;
55
- entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, at: e.at });
68
+ entries.set(id, dispatchedEntry(e));
56
69
  }
57
70
  }
58
71
 
59
72
  const persist = () => save({ version: 1, sessions: Object.fromEntries(entries) });
60
73
 
61
- function record(id, parent) {
74
+ function record(id, info = {}) {
62
75
  entries.delete(id);
63
- entries.set(id, { parent: parent || null, at: now() });
76
+ entries.set(id, dispatchedEntry({ ...info, at: now() }));
64
77
  if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
65
78
  persist();
66
79
  }
@@ -72,7 +85,17 @@ function createDispatchedStore({ load, save, now = Date.now }) {
72
85
  return removed;
73
86
  }
74
87
 
75
- return { record, prune, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
88
+ // Derived, not kept: cck resumes every terminal on restart, and a list kept in memory
89
+ // would lose those sessions.
90
+ function running(isRunning, { parent } = {}) {
91
+ const out = [];
92
+ for (const [session, { at, ...m }] of entries) {
93
+ if (isRunning(session) && (!parent || m.parent === parent)) out.push({ session, ...m, startedAt: at });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ return { record, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
76
99
  }
77
100
 
78
101
  // Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
@@ -3,6 +3,7 @@
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
9
  // Tells a live session that the board moved one of its tasks. The plugin's doorbell mod
@@ -14,6 +15,11 @@ const { oneLine } = require('./one-line');
14
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.
@@ -32,14 +38,12 @@ const sanitizeEventLine = (line) => oneLine(line, 1500);
32
38
  // marks the board as the sender.
33
39
  const EVENT_PREFIX = '[kanban board]';
34
40
 
35
- function moveMeaning(from, to) {
36
- if (to === 'in_progress') {
37
- return 'Start this task now, and set it to completed with TaskUpdate when the work is done.';
38
- }
39
- if (from === 'in_progress' && to === 'pending') return 'Stop working on it and park it.';
40
- if (to === 'completed') return 'The user considers it done: do not keep working on it.';
41
- if (to === 'cancelled') return 'Abandon it. Undo nothing unless asked.';
42
- return '';
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}.`;
43
47
  }
44
48
 
45
49
  // Everything after `Description:` is the description verbatim to end of line, so no amount
@@ -47,9 +51,7 @@ function moveMeaning(from, to) {
47
51
  // value that needs delimiting.
48
52
  function formatTaskMoved(taskId, prevStatus, task) {
49
53
  const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
50
- const from = prevStatus ? ` from ${prevStatus}` : '';
51
- const meaning = moveMeaning(prevStatus, task.status);
52
- const head = `${EVENT_PREFIX} The user moved task ${taskId} "${subject}"${from} to ${task.status}.${meaning ? ` ${meaning}` : ''}`;
54
+ const head = `${EVENT_PREFIX} ${moveCommand(prevStatus, task.status, `${taskId} "${subject}"`)}`;
53
55
  return task.description ? `${head} Description: ${task.description}` : head;
54
56
  }
55
57
 
@@ -63,30 +65,98 @@ function boardEventsOn(cfg) {
63
65
  // of line, so a space in the config dir cannot split it.
64
66
  function formatReviewSubmitted(count, label, reviewFile) {
65
67
  const comments = `${count} review comment${count === 1 ? '' : 's'}`;
66
- return `${EVENT_PREFIX} The user left ${comments} on ${label}. Address them: ${reviewFile}`;
68
+ return `${EVENT_PREFIX} Address ${comments} on ${label}: ${reviewFile}`;
67
69
  }
68
70
 
69
- // A doorbell between two polls has no waiter for a moment, so this can miss a live one.
70
- // The caller then falls back to another route, which only costs a duplicate route, never
71
- // a lost review.
72
- function hasSessionListener(sessionId) {
73
- 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);
74
85
  }
75
86
 
76
- function enqueueSessionEvent(sessionId, line) {
77
- const text = sanitizeEventLine(line);
78
- if (!sessionId || !text) return;
79
- 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);
80
103
  if (!bucket) {
81
104
  bucket = { queue: [], waiters: new Set() };
82
- sessionEventBuckets.set(sessionId, bucket);
105
+ sessionEventBuckets.set(key, bucket);
83
106
  }
84
- bucket.queue.push(text);
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 });
85
128
  // A session with no doorbell attached must not grow without bound.
86
129
  if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
87
130
  for (const wake of [...bucket.waiters]) wake();
88
131
  }
89
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
+
90
160
  const MAX_WAIT_SEC = 120;
91
161
  const TOPIC_RE = /^[a-z]+$/;
92
162
 
@@ -100,46 +170,87 @@ function topicKey(topic, sessionId) {
100
170
  return topic ? `${topic}:${sessionId}` : sessionId;
101
171
  }
102
172
 
173
+ function seqParam(value) {
174
+ const n = Number(value);
175
+ return Number.isSafeInteger(n) && n > 0 ? n : 0;
176
+ }
177
+
103
178
  // Long-poll drained by the doorbell mod. Routing stays in server.js; this is the handler.
104
- function handleSessionEvents(req, res) {
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) {
105
185
  const topic = req.query.topic;
106
186
  if (topic !== undefined && !(typeof topic === 'string' && TOPIC_RE.test(topic))) {
107
187
  return res.status(400).json({ error: 'invalid topic' });
108
188
  }
109
189
  const sessionId = topicKey(topic, req.params.sessionId);
110
- const bucket = sessionEventBuckets.get(sessionId);
111
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
+ }
112
210
 
113
211
  // A doorbell can attach long after the board moved something (a resumed session, a board
114
212
  // that came up later). Those lines are read as instructions, and an hours-old instruction
115
- // is worse than no instruction, so `first=1` drops the whole backlog. drain() (not a bare
116
- // truncate) so an emptied bucket is still evicted from the map.
117
- if (bucket && req.query.first === '1') drain(sessionId, bucket);
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);
217
+
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 });
118
225
 
119
- if (bucket?.queue.length) return res.json({ events: drain(sessionId, bucket) });
120
- if (!wait) return res.json({ events: [] });
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([]));
121
230
 
122
- const pending = bucket || { queue: [], waiters: new Set() };
123
- sessionEventBuckets.set(sessionId, pending);
231
+ const pending = bucketFor(sessionId);
124
232
 
125
- const send = () => {
233
+ // A closed request has nobody to read the answer, so its lines stay queued.
234
+ const send = (closed) => {
126
235
  // Set.delete is the whole idempotency story: whichever of enqueue, timeout, or
127
236
  // client disconnect gets here first is the one that answers.
128
237
  if (!pending.waiters.delete(send)) return;
129
238
  clearTimeout(timer);
130
- req.removeListener('close', send);
131
- const events = drain(sessionId, pending);
132
- 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));
133
243
  };
244
+ const onClose = () => send(true);
134
245
  const timer = setTimeout(send, wait * 1000);
135
246
  pending.waiters.add(send);
136
- req.on('close', send);
247
+ req.on('close', onClose);
137
248
  }
138
249
 
139
- function drain(sessionId, bucket) {
140
- const events = bucket.queue.splice(0);
141
- if (!bucket.queue.length && !bucket.waiters.size) sessionEventBuckets.delete(sessionId);
142
- return events;
250
+ function evictIfEmpty(sessionId, bucket) {
251
+ if (!bucket.queue.length && !bucket.waiters.size && sessionEventBuckets.get(sessionId) === bucket) {
252
+ sessionEventBuckets.delete(sessionId);
253
+ }
143
254
  }
144
255
 
145
256
  module.exports = {
@@ -149,7 +260,8 @@ module.exports = {
149
260
  formatTaskMoved,
150
261
  boardEventsOn,
151
262
  formatReviewSubmitted,
152
- hasSessionListener,
263
+ hasDoorbell,
264
+ configureSessionEvents,
153
265
  enqueueSessionEvent,
154
266
  handleSessionEvents,
155
267
  topicKey,