beads-ui 0.1.2 → 0.3.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 (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
@@ -0,0 +1,179 @@
1
+ # Subscription Push Protocol — per‑subscription full‑issue envelopes (Breaking)
2
+
3
+ ```
4
+ Date: 2025-10-26
5
+ Status: Implemented
6
+ Owner: agent
7
+ ```
8
+
9
+ This document specifies the push‑only protocol used by beads‑ui to deliver list
10
+ updates from the local server to the client. It replaces the legacy
11
+ notify‑then‑fetch model. There is no version negotiation or fallback.
12
+
13
+ ## Overview
14
+
15
+ - Transport: single WebSocket connection per client
16
+ - Encoding: JSON text frames
17
+ - Subscriptions: one client‑chosen `id` per active list subscription
18
+ - Delivery: per‑subscription envelopes with full issue payloads
19
+ - Messages: `snapshot` | `upsert` | `delete`
20
+ - Ordering: strictly increasing `revision` per subscription key and connection
21
+
22
+ ## Envelopes
23
+
24
+ ```ts
25
+ export type SnapshotEnvelope = {
26
+ type: 'snapshot';
27
+ id: string; // client subscription id
28
+ revision: number; // starts at 1 and increments per envelope
29
+ issues: Issue[]; // full list for this subscription
30
+ };
31
+
32
+ export type UpsertEnvelope = {
33
+ type: 'upsert';
34
+ id: string;
35
+ revision: number;
36
+ issue: Issue; // full issue payload
37
+ };
38
+
39
+ export type DeleteEnvelope = {
40
+ type: 'delete';
41
+ id: string;
42
+ revision: number;
43
+ issue_id: string; // id only
44
+ };
45
+ ```
46
+
47
+ Notes
48
+
49
+ - Server serializes refresh runs per subscription key and emits envelopes in
50
+ `revision` order. Clients MUST ignore any envelope with `revision <=` the last
51
+ applied for the same `id`.
52
+ - Clients SHOULD treat `upsert` as idempotent and MAY additionally guard on an
53
+ `issue.updated_at` timestamp to ignore stale updates racing with local state.
54
+
55
+ ## Handshake (subscribe‑list)
56
+
57
+ Client subscribes to a list with a chosen `id`, a `type`, and optional `params`.
58
+
59
+ Client → Server
60
+
61
+ ```json
62
+ {
63
+ "id": "req-1",
64
+ "type": "subscribe-list",
65
+ "payload": { "id": "ready", "type": "ready-issues" }
66
+ }
67
+ ```
68
+
69
+ Server → Client
70
+
71
+ ```json
72
+ {
73
+ "id": "req-1",
74
+ "ok": true,
75
+ "type": "subscribe-list",
76
+ "payload": { "id": "ready", "key": "ready-issues:{}" }
77
+ }
78
+ ```
79
+
80
+ Immediately after the ack, the server sends a `snapshot` envelope containing the
81
+ full list for that subscription `id` with `revision: 1`.
82
+
83
+ ```json
84
+ {
85
+ "id": "evt-1730000000000",
86
+ "ok": true,
87
+ "type": "snapshot",
88
+ "payload": {
89
+ "type": "snapshot",
90
+ "id": "ready",
91
+ "revision": 1,
92
+ "issues": [{ "id": "UI-1", "title": "..." }]
93
+ }
94
+ }
95
+ ```
96
+
97
+ ## Updates
98
+
99
+ Subsequent refreshes emit `upsert` and `delete` envelopes as the list changes.
100
+
101
+ ```json
102
+ {
103
+ "id": "evt-1730000000100",
104
+ "ok": true,
105
+ "type": "upsert",
106
+ "payload": {
107
+ "type": "upsert",
108
+ "id": "ready",
109
+ "revision": 2,
110
+ "issue": { "id": "UI-2", "status": "in_progress" }
111
+ }
112
+ }
113
+ ```
114
+
115
+ ```json
116
+ {
117
+ "id": "evt-1730000000200",
118
+ "ok": true,
119
+ "type": "delete",
120
+ "payload": {
121
+ "type": "delete",
122
+ "id": "ready",
123
+ "revision": 3,
124
+ "issue_id": "UI-9"
125
+ }
126
+ }
127
+ ```
128
+
129
+ ## Reconnect Behavior
130
+
131
+ - On reconnect, clients resubscribe using the same `id` values as needed. The
132
+ server treats this as a new connection and sends a fresh `snapshot` with
133
+ `revision: 1` for each active subscription.
134
+
135
+ ## Diagrams
136
+
137
+ ```mermaid
138
+ sequenceDiagram
139
+ participant C as Client
140
+ participant S as Server
141
+ C->>S: subscribe-list { id, type, params }
142
+ S-->>C: ack { id, key }
143
+ S-->>C: snapshot { id, schema, revision:1, issues:[...] }
144
+ S-->>C: upsert/delete { id, schema, revision:n, ... }
145
+ ```
146
+
147
+ ```mermaid
148
+ stateDiagram-v2
149
+ [*] --> Idle
150
+ Idle --> Subscribed: subscribe-list(id)
151
+ Subscribed --> Subscribed: snapshot(rev=1)
152
+ Subscribed --> Subscribed: upsert/delete(rev++)
153
+ Subscribed --> Idle: unsubscribe-list(id) / disconnect
154
+ ```
155
+
156
+ ## Client Responsibilities
157
+
158
+ - Maintain one `SubscriptionIssueStore` per active subscription `id`.
159
+ - Apply envelopes strictly in `revision` order; ignore stale revisions.
160
+ - Render list components from `store.snapshot()` (deterministic order).
161
+ - Dispose stores on route/tab changes.
162
+
163
+ Detail view
164
+
165
+ - Detail pages use the same mechanism with a single‑item subscription, e.g.
166
+ `{ type: 'issue-detail', params: { id: 'UI-1' } }` under a client id like
167
+ `detail:UI-1`. The server returns a one‑element list for `snapshot` and
168
+ `upsert` events.
169
+
170
+ ## Rollout and Compatibility
171
+
172
+ - Breaking change: no flags and no compatibility layer with the legacy
173
+ notify‑then‑fetch flow. Update both client and server together.
174
+
175
+ ## See Also
176
+
177
+ - ADR 002 — Per‑Subscription Stores and Full‑Issue Push
178
+ - `docs/data-exchange-subscription-plan.md` (server refresh and publish model)
179
+ - `docs/subscription-issue-store.md` (store API and usage examples)
@@ -0,0 +1,112 @@
1
+ # SubscriptionIssueStore — API and Usage Examples
2
+
3
+ ```
4
+ Date: 2025-10-26
5
+ Status: Implemented
6
+ Owner: agent
7
+ ```
8
+
9
+ The `SubscriptionIssueStore` is a per‑subscription in‑memory store that owns the
10
+ issues for a single list subscription. It applies server push envelopes
11
+ (`snapshot`/`upsert`/`delete`) in revision order and exposes a deterministic,
12
+ read‑only snapshot for rendering.
13
+
14
+ See also: `docs/protocol/issues-push-v2.md` for the wire protocol.
15
+
16
+ ## API
17
+
18
+ Factory: `app/data/subscription-issue-store.js`
19
+
20
+ ```js
21
+ import { createSubscriptionIssueStore } from '../app/data/subscription-issue-store.js';
22
+
23
+ // Create at view mount (id is client-chosen)
24
+ const store = createSubscriptionIssueStore('ready');
25
+
26
+ // Listen for changes
27
+ const unsubscribe = store.subscribe(() => {
28
+ render(store.snapshot());
29
+ });
30
+
31
+ // Apply push envelopes from the WebSocket client
32
+ ws.on('message', (evt) => {
33
+ const msg = JSON.parse(evt.data);
34
+ if (msg && msg.ok === true && msg.payload && msg.payload.id === 'ready') {
35
+ // payload has { type, id, schema, revision, ... }
36
+ store.applyPush(msg.payload);
37
+ }
38
+ });
39
+
40
+ // Read helpers
41
+ store.size(); // number of issues
42
+ store.getById('UI-1'); // lookup by id
43
+
44
+ // Dispose on unmount
45
+ unsubscribe();
46
+ store.dispose();
47
+ ```
48
+
49
+ Options: deterministic sort can be customized per list via the optional
50
+ `{ sort(a,b) }` parameter when constructing the store.
51
+
52
+ ## Subscribing to a List
53
+
54
+ Pair store creation with the subscribe‑list handshake. The server will send a
55
+ `snapshot` immediately after the ack, followed by `upsert`/`delete`.
56
+
57
+ ```js
58
+ // Request a subscription
59
+ socket.send(
60
+ JSON.stringify({
61
+ id: 'req-1',
62
+ type: 'subscribe-list',
63
+ payload: { id: 'ready', type: 'ready-issues' }
64
+ })
65
+ );
66
+
67
+ socket.addEventListener('message', (ev) => {
68
+ const frame = JSON.parse(ev.data);
69
+ if (frame.ok && frame.type === 'snapshot' && frame.payload.id === 'ready') {
70
+ store.applyPush(frame.payload);
71
+ }
72
+ if (frame.ok && frame.type === 'upsert' && frame.payload.id === 'ready') {
73
+ store.applyPush(frame.payload);
74
+ }
75
+ if (frame.ok && frame.type === 'delete' && frame.payload.id === 'ready') {
76
+ store.applyPush(frame.payload);
77
+ }
78
+ });
79
+ ```
80
+
81
+ ## Rendering Pattern (List component)
82
+
83
+ ```js
84
+ /** @param {{ store: ReturnType<typeof createSubscriptionIssueStore> }} props */
85
+ export function ListView({ store }) {
86
+ let items = store.snapshot();
87
+
88
+ const un = store.subscribe(() => {
89
+ items = store.snapshot();
90
+ requestRender();
91
+ });
92
+
93
+ // framework-specific teardown
94
+ onUnmount(() => un());
95
+
96
+ return html`<ul>
97
+ ${items.map((it) => html`<li data-id=${it.id}>${it.title}</li>`)}
98
+ </ul>`;
99
+ }
100
+ ```
101
+
102
+ ## Ordering and Identity
103
+
104
+ - Default sort: priority asc, then `created_at` desc, then id asc.
105
+ - When upserting, the store preserves object identity for existing ids by
106
+ mutating fields in place. This reduces unnecessary re‑renders.
107
+
108
+ ## Reconnects
109
+
110
+ - On reconnect, repeat the subscribe‑list call. The server sends a fresh
111
+ `snapshot` with `revision: 1`. The store ignores stale envelopes using the
112
+ `revision` guard.
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "beads-ui",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
+ "description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
5
+ "homepage": "https://github.com/mantoni/beads-ui",
4
6
  "type": "module",
5
7
  "bin": {
6
8
  "bdui": "bin/bdui.js"
@@ -18,7 +20,7 @@
18
20
  "format": "prettier --write .",
19
21
  "format:check": "prettier --check .",
20
22
  "preversion": "npm run all",
21
- "version": "changes",
23
+ "version": "changes --commits --footer",
22
24
  "postversion": "git push --follow-tags && npm publish"
23
25
  },
24
26
  "devDependencies": {
@@ -40,9 +42,11 @@
40
42
  "vitest": "^2.1.3"
41
43
  },
42
44
  "dependencies": {
45
+ "dompurify": "^3.3.0",
43
46
  "esbuild": "^0.25.11",
44
47
  "express": "^5.1.0",
45
48
  "lit-html": "^3.3.1",
49
+ "marked": "^16.4.1",
46
50
  "ws": "^8.18.3"
47
51
  },
48
52
  "files": [
@@ -54,5 +58,9 @@
54
58
  "LICENSE",
55
59
  "README.md",
56
60
  "!**/*.test.js"
57
- ]
61
+ ],
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "https://github.com/mantoni/beads-ui.git"
65
+ }
58
66
  }
package/server/bd.js CHANGED
@@ -42,14 +42,12 @@ export function runBd(args, options = {}) {
42
42
 
43
43
  if (child.stdout) {
44
44
  child.stdout.setEncoding('utf8');
45
- /** @param {string} chunk */
46
45
  child.stdout.on('data', (chunk) => {
47
46
  out_chunks.push(String(chunk));
48
47
  });
49
48
  }
50
49
  if (child.stderr) {
51
50
  child.stderr.setEncoding('utf8');
52
- /** @param {string} chunk */
53
51
  child.stderr.on('data', (chunk) => {
54
52
  err_chunks.push(String(chunk));
55
53
  });
@@ -19,7 +19,8 @@ import { openUrl, waitForServer } from './open.js';
19
19
  * @param {{ no_open?: boolean }} [options]
20
20
  */
21
21
  export async function handleStart(options) {
22
- const no_open = options?.no_open === true;
22
+ // Default behavior: do not open a browser unless explicitly requested.
23
+ const no_open = options?.no_open !== false;
23
24
  const existing_pid = readPidFile();
24
25
  if (existing_pid && isProcessRunning(existing_pid)) {
25
26
  printServerUrl();
@@ -35,8 +36,7 @@ export async function handleStart(options) {
35
36
  printServerUrl();
36
37
  // Auto-open the browser once for a fresh daemon start
37
38
  if (!no_open) {
38
- const cfg = getConfig();
39
- const url = 'http://' + cfg.host + ':' + String(cfg.port);
39
+ const { url } = getConfig();
40
40
  // Wait briefly for the server to accept connections (single retry window)
41
41
  await waitForServer(url, 600);
42
42
  // Best-effort open; ignore result
@@ -80,12 +80,19 @@ export async function handleStop() {
80
80
  * Handle `restart` command: stop (ignore not-running) then start.
81
81
  * @returns {Promise<number>} Exit code (0 on success)
82
82
  */
83
- export async function handleRestart() {
83
+ /**
84
+ * Handle `restart` command: stop (ignore not-running) then start.
85
+ * Accepts the same options as `handleStart` and passes them through,
86
+ * so restart only opens a browser when `no_open` is explicitly false.
87
+ * @param {{ no_open?: boolean }} [options]
88
+ * @returns {Promise<number>}
89
+ */
90
+ export async function handleRestart(options) {
84
91
  const stop_code = await handleStop();
85
92
  // 0 = stopped, 2 = not running; both are acceptable to proceed
86
93
  if (stop_code !== 0 && stop_code !== 2) {
87
94
  return 1;
88
95
  }
89
- const start_code = await handleStart();
96
+ const start_code = await handleStart(options);
90
97
  return start_code === 0 ? 0 : 1;
91
98
  }
@@ -1,9 +1,13 @@
1
+ /**
2
+ * @import { SpawnOptions } from 'node:child_process'
3
+ */
1
4
  import { spawn } from 'node:child_process';
2
5
  import fs from 'node:fs';
3
6
  import os from 'node:os';
4
7
  import path from 'node:path';
5
8
  import { fileURLToPath } from 'node:url';
6
9
  import { getConfig } from '../config.js';
10
+ import { resolveDbPath } from '../db.js';
7
11
 
8
12
  /**
9
13
  * Resolve the runtime directory used for PID and log files.
@@ -12,13 +16,11 @@ import { getConfig } from '../config.js';
12
16
  * @returns {string}
13
17
  */
14
18
  export function getRuntimeDir() {
15
- /** @type {string | undefined} */
16
19
  const override_dir = process.env.BDUI_RUNTIME_DIR;
17
20
  if (override_dir && override_dir.length > 0) {
18
21
  return ensureDir(override_dir);
19
22
  }
20
23
 
21
- /** @type {string | undefined} */
22
24
  const xdg_dir = process.env.XDG_RUNTIME_DIR;
23
25
  if (xdg_dir && xdg_dir.length > 0) {
24
26
  return ensureDir(path.join(xdg_dir, 'beads-ui'));
@@ -148,7 +150,7 @@ export function startDaemon() {
148
150
  log_fd = -1;
149
151
  }
150
152
 
151
- /** @type {import('node:child_process').SpawnOptions} */
153
+ /** @type {SpawnOptions} */
152
154
  const opts = {
153
155
  detached: true,
154
156
  env: { ...process.env },
@@ -233,7 +235,12 @@ function sleep(ms) {
233
235
  * Print the server URL derived from current config.
234
236
  */
235
237
  export function printServerUrl() {
236
- const cfg = getConfig();
237
- const url = 'http://' + cfg.host + ':' + String(cfg.port);
238
+ const { url } = getConfig();
238
239
  console.log(url);
240
+
241
+ // Resolve from the caller's working directory by default
242
+ const resolved_db = resolveDbPath();
243
+ console.log(
244
+ `db: ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
245
+ );
239
246
  }
@@ -17,6 +17,10 @@ export function parseArgs(args) {
17
17
  flags.push('help');
18
18
  continue;
19
19
  }
20
+ if (token === '--open') {
21
+ flags.push('open');
22
+ continue;
23
+ }
20
24
  if (token === '--no-open') {
21
25
  flags.push('no-open');
22
26
  continue;
@@ -53,19 +57,44 @@ export async function main(args) {
53
57
  }
54
58
 
55
59
  if (command === 'start') {
56
- /** @type {{ no_open: boolean }} */
60
+ /**
61
+ * Default behavior: do NOT open a browser.
62
+ * `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
63
+ */
57
64
  const options = {
58
- no_open:
59
- flags.includes('no-open') ||
60
- String(process.env.BDUI_NO_OPEN || '') === '1'
65
+ no_open: true
61
66
  };
67
+
68
+ const has_open = flags.includes('open');
69
+ const has_no_open = flags.includes('no-open');
70
+ const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
71
+
72
+ if (has_open) {
73
+ options.no_open = false;
74
+ } else if (has_no_open) {
75
+ options.no_open = true;
76
+ } else if (env_no_open) {
77
+ options.no_open = true;
78
+ }
62
79
  return await handleStart(options);
63
80
  }
64
81
  if (command === 'stop') {
65
82
  return await handleStop();
66
83
  }
67
84
  if (command === 'restart') {
68
- return await handleRestart();
85
+ const options = { no_open: true };
86
+ const has_open = flags.includes('open');
87
+ const has_no_open = flags.includes('no-open');
88
+ const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
89
+
90
+ if (has_open) {
91
+ options.no_open = false;
92
+ } else if (has_no_open) {
93
+ options.no_open = true;
94
+ } else if (env_no_open) {
95
+ options.no_open = true;
96
+ }
97
+ return await handleRestart(options);
69
98
  }
70
99
 
71
100
  // Unknown command path (should not happen due to parseArgs guard)
@@ -7,13 +7,13 @@ export function printUsage(out_stream) {
7
7
  'Usage: bdui <command> [options]',
8
8
  '',
9
9
  'Commands:',
10
- ' start Start the UI server (daemonized in later steps)',
10
+ ' start Start the UI server',
11
11
  ' stop Stop the UI server',
12
12
  ' restart Restart the UI server',
13
13
  '',
14
14
  'Options:',
15
15
  ' -h, --help Show this help message',
16
- ' --no-open Do not open the browser on start',
16
+ ' --open Open the browser after start/restart',
17
17
  ''
18
18
  ];
19
19
  for (const line of lines) {
package/server/config.js CHANGED
@@ -3,27 +3,33 @@ import { fileURLToPath } from 'node:url';
3
3
 
4
4
  /**
5
5
  * Resolve runtime configuration for the server.
6
- * @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string }}
6
+ * Notes:
7
+ * - `app_dir` is resolved relative to the installed package location.
8
+ * - `root_dir` represents the directory where the process was invoked
9
+ * (i.e., the current working directory) so DB resolution follows the
10
+ * caller's context rather than the install location.
11
+ * @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string, url: string }}
7
12
  */
8
13
  export function getConfig() {
9
14
  const this_file = fileURLToPath(new URL(import.meta.url));
10
15
  const server_dir = path.dirname(this_file);
11
- const root_dir = path.resolve(server_dir, '..');
16
+ const package_root = path.resolve(server_dir, '..');
17
+ // Always reflect the directory from which the process was started
18
+ const root_dir = process.cwd();
12
19
 
13
- /** @type {number} */
14
20
  let port_value = Number.parseInt(process.env.PORT || '', 10);
15
21
  if (!Number.isFinite(port_value)) {
16
22
  port_value = 3000;
17
23
  }
18
24
 
19
- /** @type {string} */
20
25
  const host_value = '127.0.0.1';
21
26
 
22
27
  return {
23
28
  host: host_value,
24
29
  port: port_value,
25
30
  env: process.env.NODE_ENV ? String(process.env.NODE_ENV) : 'development',
26
- app_dir: path.resolve(root_dir, 'app'),
27
- root_dir
31
+ app_dir: path.resolve(package_root, 'app'),
32
+ root_dir,
33
+ url: `http://${host_value}:${port_value}`
28
34
  };
29
35
  }
package/server/db.js CHANGED
@@ -58,7 +58,6 @@ export function findNearestBeadsDb(start) {
58
58
  const beadsDir = path.join(dir, '.beads');
59
59
  try {
60
60
  const entries = fs.readdirSync(beadsDir, { withFileTypes: true });
61
- /** @type {string[]} */
62
61
  const dbs = entries
63
62
  .filter((e) => e.isFile() && e.name.endsWith('.db'))
64
63
  .map((e) => e.name)
package/server/index.js CHANGED
@@ -7,14 +7,18 @@ import { attachWsServer } from './ws.js';
7
7
  const config = getConfig();
8
8
  const app = createApp(config);
9
9
  const server = createServer(app);
10
- const { notifyIssuesChanged } = attachWsServer(server, {
10
+ const { scheduleListRefresh } = attachWsServer(server, {
11
11
  path: '/ws',
12
- heartbeat_ms: 30000
12
+ heartbeat_ms: 30000,
13
+ // Coalesce DB change bursts into one refresh run
14
+ refresh_debounce_ms: 75
13
15
  });
14
16
 
15
- // Watch the active beads DB and push invalidation (targeted when possible)
16
- watchDb(config.root_dir, (payload) => {
17
- notifyIssuesChanged(payload);
17
+ // Watch the active beads DB and schedule subscription refresh for active lists
18
+ watchDb(config.root_dir, () => {
19
+ // Schedule subscription list refresh run for active subscriptions
20
+ scheduleListRefresh();
21
+ // v2: all updates flow via subscription push envelopes only
18
22
  });
19
23
 
20
24
  server.listen(config.port, config.host, () => {