@worca/ui 0.30.0 → 0.32.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/app/styles.css CHANGED
@@ -766,6 +766,13 @@ h1, h2, h3, h4, h5, h6 {
766
766
  to { transform: rotate(360deg); }
767
767
  }
768
768
 
769
+ .effort-zap-icon {
770
+ width: 12px;
771
+ height: 12px;
772
+ vertical-align: -1px;
773
+ margin-right: 2px;
774
+ }
775
+
769
776
  /* --- 13. Stage Connector --- */
770
777
  .stage-connector {
771
778
  width: 36px;
@@ -2418,6 +2425,13 @@ sl-details.agent-prompt-section::part(content) {
2418
2425
  margin-bottom: 12px;
2419
2426
  }
2420
2427
 
2428
+ /* Divider + gap before the user message header */
2429
+ .agent-prompt-block + .agent-prompt-block {
2430
+ margin-top: 18px;
2431
+ padding-top: 16px;
2432
+ border-top: 1px solid var(--border-subtle);
2433
+ }
2434
+
2421
2435
  .agent-prompt-label-row {
2422
2436
  display: flex;
2423
2437
  align-items: center;
@@ -2448,12 +2462,14 @@ sl-details.agent-prompt-section::part(content) {
2448
2462
  flex-shrink: 0;
2449
2463
  }
2450
2464
 
2465
+ /* Accent the section headers (not the message bodies) so each block reads as
2466
+ a labelled section; the bodies stay neutral. */
2451
2467
  .agent-prompt-label {
2452
- font-size: 11px;
2453
- font-weight: 600;
2454
- color: var(--muted);
2468
+ font-size: 12px;
2469
+ font-weight: 700;
2470
+ color: var(--accent);
2455
2471
  text-transform: uppercase;
2456
- letter-spacing: 0.5px;
2472
+ letter-spacing: 0.6px;
2457
2473
  margin-bottom: 4px;
2458
2474
  }
2459
2475
 
@@ -4779,9 +4795,9 @@ sl-details.learnings-panel::part(content) {
4779
4795
 
4780
4796
  .bead-tooltip-header {
4781
4797
  display: flex;
4782
- align-items: center;
4783
- justify-content: space-between;
4784
- gap: 12px;
4798
+ flex-direction: column;
4799
+ align-items: flex-start;
4800
+ gap: 4px;
4785
4801
  }
4786
4802
 
4787
4803
  sl-tooltip.bead-tooltip::part(body) {
@@ -4790,6 +4806,7 @@ sl-tooltip.bead-tooltip::part(body) {
4790
4806
 
4791
4807
  .bead-tooltip-badges {
4792
4808
  display: flex;
4809
+ flex-wrap: wrap;
4793
4810
  align-items: center;
4794
4811
  gap: 4px;
4795
4812
  }
@@ -4825,7 +4842,7 @@ sl-tooltip.bead-tooltip::part(body) {
4825
4842
 
4826
4843
  .bead-tooltip-excerpt {
4827
4844
  font-size: 12px;
4828
- white-space: pre-wrap;
4845
+ white-space: normal;
4829
4846
  margin-bottom: 2px;
4830
4847
  }
4831
4848
 
@@ -6240,6 +6257,47 @@ sl-dialog.markdown-dialog::part(body) {
6240
6257
  text-align: left;
6241
6258
  }
6242
6259
 
6260
+ /* --- Markdown context overrides --- */
6261
+
6262
+ .markdown-inline.markdown-body {
6263
+ display: inline;
6264
+ font-size: inherit;
6265
+ line-height: inherit;
6266
+ }
6267
+ .markdown-inline.markdown-body p {
6268
+ display: inline;
6269
+ margin: 0;
6270
+ }
6271
+ .markdown-inline.markdown-body code {
6272
+ font-size: 0.85em;
6273
+ }
6274
+
6275
+ .agent-prompt-content .markdown-body {
6276
+ white-space: normal;
6277
+ font-size: 12px;
6278
+ line-height: 1.5;
6279
+ }
6280
+ .agent-prompt-content .markdown-body h1,
6281
+ .agent-prompt-content .markdown-body h2,
6282
+ .agent-prompt-content .markdown-body h3 {
6283
+ margin: 0.8em 0 0.3em;
6284
+ }
6285
+
6286
+ .bead-tooltip-excerpt.markdown-body {
6287
+ white-space: normal;
6288
+ font-size: 12px;
6289
+ max-height: 200px;
6290
+ overflow-y: auto;
6291
+ }
6292
+ .bead-tooltip-excerpt.markdown-body p {
6293
+ margin: 0.3em 0;
6294
+ }
6295
+ .bead-tooltip-excerpt.markdown-body pre {
6296
+ font-size: 11px;
6297
+ max-height: 120px;
6298
+ overflow: auto;
6299
+ }
6300
+
6243
6301
  .workspace-run-card .workspace-card-root,
6244
6302
  .workspace-run-card .workspace-card-name {
6245
6303
  font-family: var(--font-mono);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -65,6 +65,7 @@
65
65
  "@xterm/addon-fit": "^0.11.0",
66
66
  "@xterm/addon-search": "^0.16.0",
67
67
  "@xterm/xterm": "^6.0.0",
68
+ "dompurify": "^3.4.5",
68
69
  "express": "^5.2.1",
69
70
  "lit-html": "^3.3.1",
70
71
  "lucide": "^0.577.0",
@@ -14,6 +14,13 @@ async function runBd(args, dbPath) {
14
14
  return JSON.parse(stdout);
15
15
  }
16
16
 
17
+ export function extractEffortFromLabels(labels) {
18
+ if (!labels || labels.length === 0) return null;
19
+ const match = labels.find((l) => l.startsWith('worca-effort:'));
20
+ if (!match) return null;
21
+ return match.slice('worca-effort:'.length);
22
+ }
23
+
17
24
  function transformIssue(issue, deps) {
18
25
  const depends_on = (deps || []).map((d) => d.id);
19
26
  const blocked_by = (deps || [])
@@ -29,30 +36,39 @@ function transformIssue(issue, deps) {
29
36
  external_ref: issue.external_ref || null,
30
37
  depends_on,
31
38
  blocked_by,
39
+ effort: extractEffortFromLabels(issue.labels),
32
40
  };
33
41
  }
34
42
 
35
- async function enrichWithDeps(issues, dbPath) {
36
- const needDeps = issues.filter((i) => i.dependency_count > 0);
37
- if (needDeps.length === 0) {
38
- return issues.map((i) => transformIssue(i, []));
39
- }
40
- const detailed = await runBd(['show', ...needDeps.map((i) => i.id)], dbPath);
41
- const depMap = new Map(detailed.map((d) => [d.id, d.dependencies || []]));
42
- return issues.map((i) => transformIssue(i, depMap.get(i.id) || []));
43
+ export async function enrichIssuesWithDeps(issues, dbPath) {
44
+ if (issues.length === 0) return [];
45
+ const detailed = await runBd(['show', ...issues.map((i) => i.id)], dbPath);
46
+ const detailMap = new Map(detailed.map((d) => [d.id, d]));
47
+ return issues.map((i) => {
48
+ const d = detailMap.get(i.id);
49
+ return transformIssue(d || i, d?.dependencies || []);
50
+ });
43
51
  }
44
52
 
45
53
  export function dbExists(beadsDb) {
46
54
  return existsSync(beadsDb);
47
55
  }
48
56
 
57
+ export async function listIssuesShallow(beadsDb) {
58
+ try {
59
+ return await runBd(['list', '--limit', '0'], beadsDb);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
49
65
  export async function listIssues(beadsDb) {
50
66
  try {
51
67
  const issues = await runBd(['list', '--limit', '0'], beadsDb);
52
- // Must await here — without it, an enrichWithDeps rejection (e.g. bd show
68
+ // Must await here — without it, an enrichIssuesWithDeps rejection (e.g. bd show
53
69
  // SIGTERM under daemon contention) escapes the try/catch and propagates
54
70
  // to the WS handler as an unhandled rejection, crashing Node.
55
- return await enrichWithDeps(issues, beadsDb);
71
+ return await enrichIssuesWithDeps(issues, beadsDb);
56
72
  } catch {
57
73
  return [];
58
74
  }
@@ -64,7 +80,7 @@ export async function listIssuesByLabel(beadsDb, label) {
64
80
  ['list', '--label-any', label, '--all', '--limit', '0'],
65
81
  beadsDb,
66
82
  );
67
- return await enrichWithDeps(issues, beadsDb);
83
+ return await enrichIssuesWithDeps(issues, beadsDb);
68
84
  };
69
85
  try {
70
86
  return await attempt();
@@ -89,10 +105,9 @@ export async function listUnlinkedIssues(beadsDb) {
89
105
  const labels = d?.labels || [];
90
106
  return !labels.some((l) => l.startsWith('run:'));
91
107
  });
92
- // detailed already has dependencies, use them directly
93
108
  return unlinked.map((i) => {
94
109
  const d = detailMap.get(i.id);
95
- return transformIssue(i, d?.dependencies || []);
110
+ return transformIssue(d || i, d?.dependencies || []);
96
111
  });
97
112
  } catch {
98
113
  return [];
package/server/index.js CHANGED
@@ -90,22 +90,21 @@ server.on('error', (err) => {
90
90
  process.exit(1);
91
91
  });
92
92
 
93
- const { broadcast, scheduleRefresh, resolveRunProject } = attachWsServer(
94
- server,
95
- {
93
+ const { broadcast, scheduleRefresh, resolveRunProject, getBeadsCounts } =
94
+ attachWsServer(server, {
96
95
  worcaDir,
97
96
  settingsPath,
98
97
  prefsPath: preferencesPath(),
99
98
  prefsDir,
100
99
  webhookInbox,
101
100
  projectRoot,
102
- },
103
- );
101
+ });
104
102
 
105
- // Expose broadcast, scheduleRefresh, and resolveRunProject to REST route handlers
103
+ // Expose broadcast, scheduleRefresh, resolveRunProject, and getBeadsCounts to REST route handlers
106
104
  app.locals.broadcast = broadcast;
107
105
  app.locals.scheduleRefresh = scheduleRefresh;
108
106
  app.locals.resolveRunProject = resolveRunProject;
107
+ app.locals.getBeadsCounts = getBeadsCounts;
109
108
 
110
109
  // Boot chat integrations only in global mode — project-scoped instances skip
111
110
  // integrations to avoid duplicate Telegram long-poll connections on the same bot.
@@ -211,6 +211,11 @@ export function createGlobalHandlers({ chatContext, prefsDir, restClient }) {
211
211
  } else if (elapsed) {
212
212
  parts.push(` **Duration:** ${elapsed}`);
213
213
  }
214
+ if (run.beads_total > 0) {
215
+ parts.push(
216
+ ` **Beads:** ${run.beads_done ?? 0}/${run.beads_total}`,
217
+ );
218
+ }
214
219
  lines.push(parts.join('\n'));
215
220
  }
216
221
  }
@@ -181,6 +181,9 @@ function fmtStatusBlock(run) {
181
181
  const iterPart = iteration ? ` (iteration ${iteration})` : '';
182
182
  parts.push(` **Stage:** ${stage}${iterPart}`);
183
183
  }
184
+ if (run.beads_total > 0) {
185
+ parts.push(` **Beads:** ${run.beads_done ?? 0}/${run.beads_total}`);
186
+ }
184
187
  if (elapsed) parts.push(` **Duration:** ${elapsed}`);
185
188
  if (cost) parts.push(` **Cost:** ${cost}`);
186
189
  if (ps === 'completed' && run.pr_url)
@@ -114,6 +114,9 @@ function renderStageCompleted(envelope) {
114
114
  parts.push(` **Stage:** ${p.stage ?? 'unknown'} completed`);
115
115
  const dur = fmtMs(p.duration_ms);
116
116
  if (dur) parts.push(` **Duration:** ${dur}`);
117
+ if (p.beads_total > 0) {
118
+ parts.push(` **Beads:** ${p.beads_done ?? 0}/${p.beads_total}`);
119
+ }
117
120
  return mdMsg(parts.join('\n'), 'success');
118
121
  }
119
122
 
@@ -504,6 +507,17 @@ function renderWorkspaceGuideConflict(envelope) {
504
507
  return mdMsg(parts.join('\n'), 'warning');
505
508
  }
506
509
 
510
+ function renderBeadNext(envelope) {
511
+ const p = envelope.payload;
512
+ const parts = [`⚙ **Run:** \`${runId(envelope)}\``];
513
+ const label =
514
+ p.max_beads != null
515
+ ? `${p.bead_iteration}/${p.max_beads}`
516
+ : `${p.bead_iteration}`;
517
+ parts.push(` **Bead:** ${label}`);
518
+ return mdMsg(parts.join('\n'), 'info');
519
+ }
520
+
507
521
  // ---------------------------------------------------------------------------
508
522
  // Registry
509
523
  // ---------------------------------------------------------------------------
@@ -550,6 +564,7 @@ const EVENT_RENDERERS = {
550
564
  // than Tier-1 defaults. Callers that want them can pull from this export and
551
565
  // register them in their own pipeline.
552
566
  export const OPT_IN_RENDERERS = {
567
+ 'pipeline.bead.next': renderBeadNext,
553
568
  'fleet.launched': renderFleetLaunched,
554
569
  'workspace.launched': renderWorkspaceLaunched,
555
570
  'workspace.plan.started': renderWorkspacePlanStarted,
@@ -360,10 +360,29 @@ export function createProjectScopedRoutes({
360
360
  });
361
361
 
362
362
  // GET /api/projects/:projectId/runs — list runs for this project
363
- router.get('/runs', requireWorcaDir, (req, res) => {
363
+ router.get('/runs', requireWorcaDir, async (req, res) => {
364
364
  try {
365
365
  const runs = discoverRuns(req.project.worcaDir);
366
366
  const default_branch = getDefaultBranch(req.project.projectRoot);
367
+
368
+ const { getBeadsCounts } = req.app.locals;
369
+ if (getBeadsCounts) {
370
+ try {
371
+ const counts = await getBeadsCounts(req.project.name);
372
+ if (counts) {
373
+ for (const run of runs) {
374
+ const c = counts[run.id];
375
+ if (c) {
376
+ run.beads_done = c.done;
377
+ run.beads_total = c.total;
378
+ }
379
+ }
380
+ }
381
+ } catch {
382
+ /* non-fatal — runs returned without bead counts */
383
+ }
384
+ }
385
+
367
386
  const response = { ok: true, runs, default_branch };
368
387
  // Include settings so multi-project clients can use loop limits, etc.
369
388
  const { settingsPath } = req.project;
@@ -4,32 +4,108 @@
4
4
  * because fs.watch on macOS misses SQLite WAL writes done via mmap.
5
5
  */
6
6
 
7
- import { existsSync, unwatchFile, watch, watchFile } from 'node:fs';
7
+ import { existsSync, statSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
- import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
9
+ import {
10
+ countIssuesByRunLabel,
11
+ enrichIssuesWithDeps,
12
+ listIssuesShallow,
13
+ } from './beads-reader.js';
10
14
 
11
15
  const BEADS_DEBOUNCE_MS = 500;
12
16
  const BEADS_POLL_MS = 2000;
13
17
 
18
+ /**
19
+ * Resolve the beads.db path for a project's worcaDir, independent of any
20
+ * watcher instance. Used by tier-independent count lookups (chat-only mode,
21
+ * where no beadsWatcher exists because the WatcherSet is in TIER_POLLING).
22
+ * @param {string} worcaDir
23
+ * @returns {string}
24
+ */
25
+ export function beadsDbPathFor(worcaDir) {
26
+ return resolve(join(worcaDir, '..', '.beads', 'beads.db'));
27
+ }
28
+
14
29
  /**
15
30
  * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps
16
31
  */
17
32
  export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
18
- const beadsDbPath = resolve(join(worcaDir, '..', '.beads', 'beads.db'));
33
+ const beadsDbPath = beadsDbPathFor(worcaDir);
19
34
  const beadsDir = resolve(join(worcaDir, '..', '.beads'));
20
35
  const beadsWalPath = `${beadsDbPath}-wal`;
21
36
  let fsWatcher = null;
22
37
  let BEADS_REFRESH_TIMER = null;
38
+ let lastPayloadJson = null;
39
+ let lastSelfReadWalStat = null;
40
+ let latestCounts = {};
41
+ // In-flight guard + trailing coalesce. The refresh body spawns several `bd`
42
+ // subprocesses (listIssuesShallow, then enrichIssuesWithDeps +
43
+ // countIssuesByRunLabel when the fingerprint changes) that, on a large beads
44
+ // db, take seconds. The debounce only collapses scheduling — once the async body
45
+ // is awaiting, a fresh db/WAL event would otherwise start an overlapping
46
+ // refresh and pile up bd processes unbounded. Allow at most one refresh in
47
+ // flight; events arriving mid-refresh collapse into a single trailing pass.
48
+ let refreshing = false;
49
+ let refreshPending = false;
50
+ let lastListFingerprint = null;
51
+
52
+ function computeFingerprint(issues) {
53
+ const sorted = [...issues].sort((a, b) => (a.id < b.id ? -1 : 1));
54
+ return JSON.stringify(
55
+ sorted.map((i) => ({
56
+ id: i.id,
57
+ status: i.status,
58
+ priority: i.priority,
59
+ title: i.title,
60
+ updated_at: i.updated_at,
61
+ // dependency_count/dependent_count come free in `bd list` (no `bd show`).
62
+ // They catch dependency-edge changes (`bd dep add/remove`) that don't
63
+ // bump an issue's own updated_at — without them the fingerprint would
64
+ // bail and leave depends_on/blocked_by (and blocked badges) stale.
65
+ dependency_count: i.dependency_count,
66
+ dependent_count: i.dependent_count,
67
+ })),
68
+ );
69
+ }
70
+
71
+ function recordWalStat() {
72
+ try {
73
+ const s = statSync(beadsWalPath);
74
+ lastSelfReadWalStat = { mtimeMs: s.mtimeMs, size: s.size };
75
+ } catch {
76
+ lastSelfReadWalStat = null;
77
+ }
78
+ }
23
79
 
24
80
  function scheduleBeadsRefresh() {
25
81
  if (BEADS_REFRESH_TIMER) clearTimeout(BEADS_REFRESH_TIMER);
26
- BEADS_REFRESH_TIMER = setTimeout(async () => {
27
- BEADS_REFRESH_TIMER = null;
28
- try {
29
- const [issues, counts] = await Promise.all([
30
- listIssues(beadsDbPath),
31
- countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
32
- ]);
82
+ BEADS_REFRESH_TIMER = setTimeout(runBeadsRefresh, BEADS_DEBOUNCE_MS);
83
+ }
84
+
85
+ async function runBeadsRefresh() {
86
+ BEADS_REFRESH_TIMER = null;
87
+ if (refreshing) {
88
+ refreshPending = true;
89
+ return;
90
+ }
91
+ refreshing = true;
92
+ try {
93
+ const shallowIssues = await listIssuesShallow(beadsDbPath);
94
+ const fingerprint = computeFingerprint(shallowIssues);
95
+ if (fingerprint === lastListFingerprint) {
96
+ recordWalStat();
97
+ return;
98
+ }
99
+ lastListFingerprint = fingerprint;
100
+
101
+ const [issues, counts] = await Promise.all([
102
+ enrichIssuesWithDeps(shallowIssues, beadsDbPath),
103
+ countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
104
+ ]);
105
+ latestCounts = counts;
106
+ const payloadJson = JSON.stringify({ issues, counts });
107
+ if (payloadJson !== lastPayloadJson) {
108
+ lastPayloadJson = payloadJson;
33
109
  broadcaster.broadcast(
34
110
  'beads-update',
35
111
  {
@@ -40,10 +116,17 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
40
116
  },
41
117
  projectId,
42
118
  );
43
- } catch {
44
- /* ignore */
45
119
  }
46
- }, BEADS_DEBOUNCE_MS);
120
+ recordWalStat();
121
+ } catch {
122
+ /* ignore */
123
+ } finally {
124
+ refreshing = false;
125
+ if (refreshPending) {
126
+ refreshPending = false;
127
+ scheduleBeadsRefresh();
128
+ }
129
+ }
47
130
  }
48
131
 
49
132
  if (existsSync(beadsDir)) {
@@ -60,6 +143,13 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
60
143
  // on macOS. watchFile tolerates a missing file; it starts firing once created.
61
144
  watchFile(beadsWalPath, { interval: BEADS_POLL_MS }, (curr, prev) => {
62
145
  if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) {
146
+ if (
147
+ lastSelfReadWalStat &&
148
+ curr.mtimeMs === lastSelfReadWalStat.mtimeMs &&
149
+ curr.size === lastSelfReadWalStat.size
150
+ ) {
151
+ return;
152
+ }
63
153
  scheduleBeadsRefresh();
64
154
  }
65
155
  });
@@ -78,5 +168,108 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
78
168
  }
79
169
  }
80
170
 
81
- return { getBeadsDbPath, destroy };
171
+ function getLatestCounts() {
172
+ return latestCounts;
173
+ }
174
+
175
+ return { getBeadsDbPath, getLatestCounts, destroy };
176
+ }
177
+
178
+ // Throttle window for the cold-path bead-count read (used by REST /runs and chat
179
+ // when no live watcher is warm). The read costs seconds on a large db, so a short
180
+ // TTL lets repeated /runs across many projects re-spawn `bd` too often. Counts are
181
+ // advisory and the live watcher keeps a viewed project fresh, so 30s is ample.
182
+ const FALLBACK_TTL_MS = 30000;
183
+ /** @type {Map<string, { ts: number, counts: object }>} */
184
+ const fallbackCache = new Map();
185
+ /** @type {Map<string, Promise<object>>} */
186
+ const fallbackInflight = new Map();
187
+
188
+ /**
189
+ * Resolve per-run bead counts for a WatcherSet, independent of its tier.
190
+ *
191
+ * Fast path: the live watcher cache, populated only while a UI client is
192
+ * subscribed (TIER_FULL). Fallback: an on-demand DB read for callers that
193
+ * have no active watcher — e.g. chat integrations hitting REST with no
194
+ * browser open, where the WatcherSet sits in TIER_POLLING and `beadsWatcher`
195
+ * is null. The fallback is TTL-cached and in-flight-deduplicated so repeated
196
+ * chat polling does not spawn a `bd` subprocess per request.
197
+ *
198
+ * @param {{ projectId?: string, worcaDir?: string, beadsWatcher?: { getLatestCounts: () => object } | null }} [wset]
199
+ * @returns {Promise<Record<string, { total: number, done: number }>>}
200
+ */
201
+ export async function resolveBeadsCounts(wset) {
202
+ if (!wset || !wset.worcaDir) return {};
203
+
204
+ const live = wset.beadsWatcher?.getLatestCounts();
205
+ if (live && Object.keys(live).length > 0) return live;
206
+
207
+ const key = wset.projectId ?? wset.worcaDir;
208
+ const cached = fallbackCache.get(key);
209
+ if (cached && Date.now() - cached.ts < FALLBACK_TTL_MS) return cached.counts;
210
+ if (fallbackInflight.has(key)) return fallbackInflight.get(key);
211
+
212
+ const dbPath = beadsDbPathFor(wset.worcaDir);
213
+ if (!existsSync(dbPath)) return {};
214
+
215
+ const promise = (async () => {
216
+ try {
217
+ const counts = await countIssuesByRunLabel(dbPath);
218
+ fallbackCache.set(key, { ts: Date.now(), counts });
219
+ return counts;
220
+ } catch {
221
+ return {};
222
+ } finally {
223
+ fallbackInflight.delete(key);
224
+ }
225
+ })();
226
+ fallbackInflight.set(key, promise);
227
+ return promise;
228
+ }
229
+
230
+ /**
231
+ * Non-blocking variant of {@link resolveBeadsCounts}: returns whatever counts
232
+ * are already available (the live watcher cache, or a fresh TTL-cached fallback)
233
+ * WITHOUT ever awaiting the cold `bd` query. When nothing is cached it kicks off
234
+ * a background refresh (reusing the same TTL cache + in-flight dedup) so a later
235
+ * caller gets real data, and returns {} immediately.
236
+ *
237
+ * Used by the REST `/runs` endpoint: a cold `bd show` on a large beads db
238
+ * (worca-cc has hundreds of issues — the cold read takes ~10s) would otherwise
239
+ * block the run list and, fanned out across every project, stall the whole UI.
240
+ * Bead counts on run cards are advisory and arrive via the `beads-update`
241
+ * broadcast once warm; the web run-detail does not use them at all.
242
+ *
243
+ * @param {{ projectId?: string, worcaDir?: string, beadsWatcher?: { getLatestCounts: () => object } | null }} [wset]
244
+ * @returns {Record<string, { total: number, done: number }>}
245
+ */
246
+ export function peekBeadsCounts(wset) {
247
+ if (!wset || !wset.worcaDir) return {};
248
+
249
+ const live = wset.beadsWatcher?.getLatestCounts();
250
+ if (live && Object.keys(live).length > 0) return live;
251
+
252
+ const key = wset.projectId ?? wset.worcaDir;
253
+ const cached = fallbackCache.get(key);
254
+ if (cached && Date.now() - cached.ts < FALLBACK_TTL_MS) return cached.counts;
255
+
256
+ // Cold cache: warm it in the background (dedup'd), but never block the caller.
257
+ if (!fallbackInflight.has(key)) {
258
+ const dbPath = beadsDbPathFor(wset.worcaDir);
259
+ if (existsSync(dbPath)) {
260
+ const promise = (async () => {
261
+ try {
262
+ const counts = await countIssuesByRunLabel(dbPath);
263
+ fallbackCache.set(key, { ts: Date.now(), counts });
264
+ return counts;
265
+ } catch {
266
+ return {};
267
+ } finally {
268
+ fallbackInflight.delete(key);
269
+ }
270
+ })();
271
+ fallbackInflight.set(key, promise);
272
+ }
273
+ }
274
+ return {};
82
275
  }
@@ -11,7 +11,6 @@ import { join } from 'node:path';
11
11
  import { isRequest, makeError, makeOk } from '../app/protocol.js';
12
12
  import {
13
13
  dbExists as beadsDbExists,
14
- countIssuesByRunLabel,
15
14
  getIssue,
16
15
  listDistinctRunLabels,
17
16
  listIssues,
@@ -35,6 +34,7 @@ import {
35
34
  import { resolveRunDir } from './run-dir-resolver.js';
36
35
  import { readSettings } from './settings-reader.js';
37
36
  import { discoverRuns } from './watcher.js';
37
+ import { resolveBeadsCounts } from './ws-beads-watcher.js';
38
38
 
39
39
  /**
40
40
  * @param {{
@@ -643,19 +643,12 @@ export function createMessageRouter({
643
643
  return;
644
644
  }
645
645
 
646
- // list-beads-counts
646
+ // list-beads-counts — tier-independent: resolveBeadsCounts falls back to
647
+ // an on-demand DB read when no beadsWatcher exists (TIER_POLLING), so
648
+ // chat/REST callers with no browser open still get counts.
647
649
  if (req.type === 'list-beads-counts') {
648
650
  const proj = resolveProject(ws, req.payload);
649
- if (!proj.wset.beadsWatcher) {
650
- ws.send(JSON.stringify(makeOk(req, { counts: {} })));
651
- return;
652
- }
653
- const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
654
- if (!beadsDbExists(beadsDbPath)) {
655
- ws.send(JSON.stringify(makeOk(req, { counts: {} })));
656
- return;
657
- }
658
- const counts = await countIssuesByRunLabel(beadsDbPath);
651
+ const counts = await resolveBeadsCounts(proj.wset);
659
652
  ws.send(JSON.stringify(makeOk(req, { counts })));
660
653
  return;
661
654
  }
@@ -13,6 +13,7 @@ import { fleetRunsDir, workspaceRunsDir } from './paths.js';
13
13
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
14
14
  import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
15
15
  import { readProjectWorcaVersion } from './worca-setup.js';
16
+ import { peekBeadsCounts } from './ws-beads-watcher.js';
16
17
  import { createBroadcaster } from './ws-broadcaster.js';
17
18
  import { createClientManager } from './ws-client-manager.js';
18
19
  import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
@@ -330,10 +331,17 @@ export function attachWsServer(httpServer, config) {
330
331
  return null;
331
332
  }
332
333
 
334
+ // Non-blocking: returns cached/live counts and warms cold caches in the
335
+ // background. The REST /runs endpoint must never block on a slow `bd` read.
336
+ function getBeadsCounts(projectId) {
337
+ return peekBeadsCounts(watcherSets.get(projectId));
338
+ }
339
+
333
340
  return {
334
341
  wss,
335
342
  broadcast: broadcaster.broadcast,
336
343
  scheduleRefresh,
337
344
  resolveRunProject,
345
+ getBeadsCounts,
338
346
  };
339
347
  }