@worca/ui 0.19.0 → 0.21.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.
@@ -1,6 +1,7 @@
1
1
  // server/settings-validator.js
2
2
  import { STAGE_ORDER } from '../app/utils/stage-order.js';
3
3
  import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
4
+ import { DEFAULT_MODELS, deriveValidModels } from './model-validation.js';
4
5
 
5
6
  const VALID_AGENTS = [
6
7
  'planner',
@@ -13,7 +14,7 @@ const VALID_AGENTS = [
13
14
  'learner',
14
15
  ];
15
16
  const VALID_STAGES = STAGE_ORDER;
16
- export const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
17
+ export const VALID_MODELS = DEFAULT_MODELS;
17
18
  const VALID_LOOPS = [
18
19
  'implement_test',
19
20
  'pr_changes',
@@ -27,7 +28,7 @@ const VALID_GUARDS = [
27
28
  'block_force_push',
28
29
  'restrict_git_commit',
29
30
  ];
30
- const VALID_PRICING_MODELS = ['opus', 'sonnet'];
31
+ const DEFAULT_PRICING_MODELS = ['opus', 'sonnet'];
31
32
  const VALID_PRICING_FIELDS = [
32
33
  'input_per_mtok',
33
34
  'output_per_mtok',
@@ -48,6 +49,7 @@ export function validateSettingsPayload(body) {
48
49
  return { valid: false, details };
49
50
  }
50
51
  const w = body.worca;
52
+ const validModels = deriveValidModels(w);
51
53
 
52
54
  // agents
53
55
  if (w.agents !== undefined) {
@@ -63,7 +65,7 @@ export function validateSettingsPayload(body) {
63
65
  details.push(`Unknown agent name: "${name}"`);
64
66
  continue;
65
67
  }
66
- if (cfg.model !== undefined && !VALID_MODELS.includes(cfg.model)) {
68
+ if (cfg.model !== undefined && !validModels.includes(cfg.model)) {
67
69
  details.push(`Invalid model "${cfg.model}" for agent "${name}"`);
68
70
  }
69
71
  if (cfg.max_turns !== undefined) {
@@ -192,6 +194,9 @@ export function validateSettingsPayload(body) {
192
194
  details.push('pricing must be an object');
193
195
  } else {
194
196
  const p = w.pricing;
197
+ const validPricingModels = [
198
+ ...new Set([...DEFAULT_PRICING_MODELS, ...validModels]),
199
+ ];
195
200
  if (p.models !== undefined) {
196
201
  if (
197
202
  typeof p.models !== 'object' ||
@@ -201,7 +206,7 @@ export function validateSettingsPayload(body) {
201
206
  details.push('pricing.models must be an object');
202
207
  } else {
203
208
  for (const [model, costs] of Object.entries(p.models)) {
204
- if (!VALID_PRICING_MODELS.includes(model)) {
209
+ if (!validPricingModels.includes(model)) {
205
210
  details.push(`Unknown pricing model: "${model}"`);
206
211
  continue;
207
212
  }
@@ -864,12 +869,13 @@ export function validateGlobalSettings(prefs) {
864
869
  }
865
870
  }
866
871
 
872
+ const globalValidModels = deriveValidModels(w);
867
873
  if (
868
874
  w.circuit_breaker?.classifier_model !== undefined &&
869
- !VALID_MODELS.includes(w.circuit_breaker.classifier_model)
875
+ !globalValidModels.includes(w.circuit_breaker.classifier_model)
870
876
  ) {
871
877
  details.push(
872
- `circuit_breaker.classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
878
+ `circuit_breaker.classifier_model must be one of: ${globalValidModels.join(', ')}`,
873
879
  );
874
880
  }
875
881
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
+ import { ensureBdDaemon } from './bd-daemon.js';
13
14
  import { resolveRunDir } from './run-dir-resolver.js';
14
15
  import { createBeadsWatcher } from './ws-beads-watcher.js';
15
16
  import { createEventWatcher } from './ws-event-watcher.js';
@@ -146,6 +147,7 @@ export class WatcherSet {
146
147
 
147
148
  // Beads watcher
148
149
  if (!this.beadsWatcher) {
150
+ ensureBdDaemon(worcaDir).catch(() => {});
149
151
  try {
150
152
  this.beadsWatcher = this._factories.createBeadsWatcher({
151
153
  worcaDir,
@@ -2,33 +2,37 @@
2
2
  * Shared worktree operations — single owner of `git worktree remove` shell-out.
3
3
  */
4
4
 
5
- import { execFileSync } from 'node:child_process';
6
- import {
7
- existsSync,
8
- lstatSync,
9
- readFileSync,
10
- rmSync,
11
- unlinkSync,
12
- } from 'node:fs';
5
+ import { execFile } from 'node:child_process';
6
+ import { existsSync, lstatSync } from 'node:fs';
7
+ import { readFile, rm, unlink } from 'node:fs/promises';
13
8
  import { join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+
11
+ const execFileAsync = promisify(execFile);
14
12
 
15
13
  /**
16
14
  * Remove a worktree and its registry entry.
17
15
  * Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
18
16
  * 1. Attempt `git worktree remove --force <path>` from the project root
19
- * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rmSync
17
+ * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rm (async)
20
18
  * 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
21
19
  * drops the entry even when the directory was removed manually
20
+ * (skipped when skipPrune is true — caller is responsible for pruning later)
22
21
  * 4. Delete the registry file
23
22
  */
24
- export function removeWorktree(worcaDir, runId) {
23
+ export async function removeWorktree(
24
+ worcaDir,
25
+ runId,
26
+ { skipPrune = false } = {},
27
+ ) {
25
28
  const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
26
29
  const projectRoot = join(worcaDir, '..');
27
30
  let worktreePath = null;
28
31
 
29
32
  if (existsSync(regFile)) {
30
33
  try {
31
- const reg = JSON.parse(readFileSync(regFile, 'utf8'));
34
+ const content = await readFile(regFile, 'utf8');
35
+ const reg = JSON.parse(content);
32
36
  worktreePath = reg.worktree_path || null;
33
37
  } catch {
34
38
  /* ignore */
@@ -37,11 +41,15 @@ export function removeWorktree(worcaDir, runId) {
37
41
 
38
42
  if (worktreePath && existsSync(worktreePath)) {
39
43
  try {
40
- execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
41
- cwd: projectRoot,
42
- stdio: 'pipe',
43
- timeout: 30_000,
44
- });
44
+ await execFileAsync(
45
+ 'git',
46
+ ['worktree', 'remove', '--force', worktreePath],
47
+ {
48
+ cwd: projectRoot,
49
+ stdio: 'pipe',
50
+ timeout: 30_000,
51
+ },
52
+ );
45
53
  } catch {
46
54
  let isRealDir = false;
47
55
  try {
@@ -51,13 +59,36 @@ export function removeWorktree(worcaDir, runId) {
51
59
  /* ignore */
52
60
  }
53
61
  if (isRealDir) {
54
- rmSync(worktreePath, { recursive: true, force: true });
62
+ await rm(worktreePath, { recursive: true, force: true });
55
63
  }
56
64
  }
57
65
  }
58
66
 
67
+ if (!skipPrune) {
68
+ try {
69
+ await execFileAsync('git', ['worktree', 'prune'], {
70
+ cwd: projectRoot,
71
+ stdio: 'pipe',
72
+ timeout: 30_000,
73
+ });
74
+ } catch {
75
+ /* non-fatal */
76
+ }
77
+ }
78
+
79
+ if (existsSync(regFile)) {
80
+ await unlink(regFile);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Run `git worktree prune` once for the project at worcaDir.
86
+ * Use after a batch of removeWorktree({ skipPrune: true }) calls.
87
+ */
88
+ export async function pruneWorktrees(worcaDir) {
89
+ const projectRoot = join(worcaDir, '..');
59
90
  try {
60
- execFileSync('git', ['worktree', 'prune'], {
91
+ await execFileAsync('git', ['worktree', 'prune'], {
61
92
  cwd: projectRoot,
62
93
  stdio: 'pipe',
63
94
  timeout: 30_000,
@@ -65,8 +96,4 @@ export function removeWorktree(worcaDir, runId) {
65
96
  } catch {
66
97
  /* non-fatal */
67
98
  }
68
-
69
- if (existsSync(regFile)) {
70
- unlinkSync(regFile);
71
- }
72
99
  }
@@ -3,14 +3,56 @@
3
3
  *
4
4
  * GET /worktrees — list worktree entries enriched with disk/age/group data
5
5
  * DELETE /worktrees/:run_id — remove a worktree (409 if running, 412 if resumable/grouped without ?force=1)
6
+ * POST /worktrees/cleanup — batch remove (always returns 200 with `{ok, results, failed_count}`)
6
7
  *
7
8
  * Expects req.project.worcaDir to be set by projectResolver middleware.
9
+ *
10
+ * NOTE on disk semantics: `disk_bytes` reflects project files only — vendored
11
+ * and derived directories listed in WALK_SKIP_DIRS (node_modules, .git, .venv,
12
+ * dist, build, .next, etc.) are skipped during the walk. This answers "how
13
+ * much project disk would I free?" rather than raw on-disk bytes, and makes
14
+ * cold first loads ~10× faster on node_modules-heavy worktrees. The route
15
+ * surfaces `disk_walk_skip_dirs` in the GET response so clients can document
16
+ * the discrepancy with `du -sh`.
8
17
  */
9
18
 
10
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import * as fsp from 'node:fs/promises';
11
21
  import { join } from 'node:path';
12
22
  import { Router } from 'express';
13
- import { removeWorktree } from './worktree-ops.js';
23
+ import { pruneWorktrees, removeWorktree } from './worktree-ops.js';
24
+
25
+ const CLEANUP_CONCURRENCY = 4;
26
+
27
+ /**
28
+ * Run an array of `{run_id, fn}` tasks with bounded concurrency.
29
+ * Tasks are expected to return a result object — but if one throws,
30
+ * the limiter converts the throw into an attributable failure result
31
+ * so a single bad task can't halt the rest of the batch.
32
+ */
33
+ async function runWithConcurrencyLimit(tasks, limit) {
34
+ const results = new Array(tasks.length);
35
+ let nextIdx = 0;
36
+ async function worker() {
37
+ while (nextIdx < tasks.length) {
38
+ const idx = nextIdx++;
39
+ const { run_id, fn } = tasks[idx];
40
+ try {
41
+ results[idx] = await fn();
42
+ } catch (err) {
43
+ results[idx] = {
44
+ run_id,
45
+ ok: false,
46
+ error: err?.message || String(err),
47
+ };
48
+ }
49
+ }
50
+ }
51
+ await Promise.all(
52
+ Array.from({ length: Math.min(limit, tasks.length) }, worker),
53
+ );
54
+ return results;
55
+ }
14
56
 
15
57
  const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
16
58
 
@@ -18,61 +60,103 @@ const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
18
60
  const _diskCache = new Map();
19
61
  const DISK_CACHE_TTL_MS = 30_000;
20
62
 
63
+ /**
64
+ * Directory names skipped during the disk walk. These are vendored or derived
65
+ * trees that dominate file count without changing the user's mental model of
66
+ * "project disk". Excluding them drops the walked file count by ~10–20× on
67
+ * typical worktrees and keeps `disk_bytes` focused on the project's own
68
+ * source files — closing the gap between "raw on-disk bytes" and "bytes I
69
+ * would actually free by cleaning up this worktree".
70
+ */
71
+ export const WALK_SKIP_DIRS = new Set([
72
+ 'node_modules',
73
+ '.git',
74
+ '.venv',
75
+ 'venv',
76
+ '__pycache__',
77
+ '.pytest_cache',
78
+ '.mypy_cache',
79
+ '.ruff_cache',
80
+ 'dist',
81
+ 'build',
82
+ '.next',
83
+ '.turbo',
84
+ '.cache',
85
+ ]);
86
+
21
87
  /**
22
88
  * Sum file sizes under a directory tree. Cross-platform: prior `du -sb`
23
89
  * relied on GNU coreutils and silently returned 0 on macOS / BSD du,
24
90
  * which is why the Worktrees view always showed "0 B".
25
91
  *
26
- * Skips symlinks (don't follow into other trees) and is bounded by
92
+ * Skips symlinks (don't follow into other trees), skips directory names in
93
+ * WALK_SKIP_DIRS (node_modules, .git, build/cache dirs), and is bounded by
27
94
  * MAX_WALK_FILES so a runaway directory can't hang the request.
95
+ * Override the cap with WORCA_DISK_WALK_MAX (positive integer); the
96
+ * raised default of 1M handles node_modules-heavy worktrees, but very
97
+ * large monorepos may still want a higher ceiling.
28
98
  * Errors on individual entries are swallowed so a transiently-locked
29
99
  * file doesn't poison the whole sum.
30
100
  */
31
- const MAX_WALK_FILES = 100_000;
32
- function _walkDirSize(rootPath) {
33
- let total = 0;
101
+ function _resolveWalkCap() {
102
+ const raw = process.env.WORCA_DISK_WALK_MAX;
103
+ if (raw) {
104
+ const n = Number.parseInt(raw, 10);
105
+ if (Number.isFinite(n) && n > 0) return n;
106
+ }
107
+ return 1_000_000;
108
+ }
109
+ const MAX_WALK_FILES = _resolveWalkCap();
110
+ export async function walkDirSize(rootPath, maxFiles = MAX_WALK_FILES) {
111
+ let bytes = 0;
34
112
  let count = 0;
35
113
  const stack = [rootPath];
36
- while (stack.length > 0 && count < MAX_WALK_FILES) {
114
+ while (stack.length > 0 && count < maxFiles) {
37
115
  const cur = stack.pop();
38
- let entries;
116
+ let dir;
39
117
  try {
40
- entries = readdirSync(cur, { withFileTypes: true });
118
+ dir = await fsp.opendir(cur);
41
119
  } catch {
42
120
  continue;
43
121
  }
44
- for (const e of entries) {
122
+ for await (const e of dir) {
45
123
  count++;
46
- if (count >= MAX_WALK_FILES) break;
124
+ if (count >= maxFiles) break;
47
125
  const child = join(cur, e.name);
48
126
  if (e.isSymbolicLink()) continue;
49
127
  if (e.isDirectory()) {
50
- stack.push(child);
128
+ if (!WALK_SKIP_DIRS.has(e.name)) stack.push(child);
51
129
  } else if (e.isFile()) {
52
130
  try {
53
- total += statSync(child).size;
131
+ const st = await fsp.stat(child);
132
+ bytes += st.size;
54
133
  } catch {
55
134
  /* ignore — file vanished mid-walk */
56
135
  }
57
136
  }
58
137
  }
59
138
  }
60
- return total;
139
+ return { bytes, truncated: count >= maxFiles };
61
140
  }
62
141
 
63
- function _getDiskBytes(worktreePath) {
142
+ async function _getDiskBytes(worktreePath) {
64
143
  const now = Date.now();
65
144
  const hit = _diskCache.get(worktreePath);
66
- if (hit && hit.expiry > now) return hit.bytes;
145
+ if (hit && hit.expiry > now)
146
+ return { bytes: hit.bytes, truncated: hit.truncated };
67
147
 
68
- let bytes = 0;
148
+ let result = { bytes: 0, truncated: false };
69
149
  try {
70
- bytes = _walkDirSize(worktreePath);
150
+ result = await walkDirSize(worktreePath);
71
151
  } catch {
72
- bytes = 0;
152
+ result = { bytes: 0, truncated: false };
73
153
  }
74
- _diskCache.set(worktreePath, { bytes, expiry: now + DISK_CACHE_TTL_MS });
75
- return bytes;
154
+ _diskCache.set(worktreePath, {
155
+ bytes: result.bytes,
156
+ truncated: result.truncated,
157
+ expiry: now + DISK_CACHE_TTL_MS,
158
+ });
159
+ return result;
76
160
  }
77
161
 
78
162
  /**
@@ -108,7 +192,7 @@ function _readWorktreeStatus(worktreePath) {
108
192
  return null;
109
193
  }
110
194
 
111
- function _listWorktrees(worcaDir) {
195
+ async function _listWorktrees(worcaDir) {
112
196
  const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
113
197
  if (!existsSync(pipelinesDir)) return [];
114
198
 
@@ -142,12 +226,18 @@ function _listWorktrees(worcaDir) {
142
226
  }
143
227
  }
144
228
 
229
+ let diskInfo = { bytes: 0, truncated: false };
230
+ if (worktreeExists) {
231
+ diskInfo = await _getDiskBytes(worktreePath);
232
+ }
233
+
145
234
  entries.push({
146
235
  run_id: reg.run_id || '',
147
236
  title: reg.title || '',
148
237
  branch: reg.branch || '',
149
238
  worktree_path: worktreePath,
150
- disk_bytes: worktreeExists ? _getDiskBytes(worktreePath) : 0,
239
+ disk_bytes: diskInfo.bytes,
240
+ truncated: diskInfo.truncated,
151
241
  age_seconds: ageSeconds,
152
242
  // started_at lets the client sort with the same sortByStartDesc helper
153
243
  // used by run-list, keeping ordering consistent across views.
@@ -182,7 +272,7 @@ export function createWorktreesRouter() {
182
272
  const router = Router({ mergeParams: true });
183
273
 
184
274
  // GET /worktrees
185
- router.get('/', (req, res) => {
275
+ router.get('/', async (req, res) => {
186
276
  const worcaDir = req.project?.worcaDir;
187
277
  if (!worcaDir) {
188
278
  return res
@@ -190,15 +280,21 @@ export function createWorktreesRouter() {
190
280
  .json({ ok: false, error: 'worcaDir not configured' });
191
281
  }
192
282
  try {
193
- const worktrees = _listWorktrees(worcaDir);
194
- res.json({ ok: true, worktrees });
283
+ const worktrees = await _listWorktrees(worcaDir);
284
+ res.json({
285
+ ok: true,
286
+ worktrees,
287
+ // Documents the semantics shift in `disk_bytes` (project files only).
288
+ // Clients can render this as a caveat next to disk totals.
289
+ disk_walk_skip_dirs: [...WALK_SKIP_DIRS],
290
+ });
195
291
  } catch (err) {
196
292
  res.status(500).json({ ok: false, error: err.message });
197
293
  }
198
294
  });
199
295
 
200
296
  // DELETE /worktrees/:run_id
201
- router.delete('/:run_id', (req, res) => {
297
+ router.delete('/:run_id', async (req, res) => {
202
298
  const worcaDir = req.project?.worcaDir;
203
299
  if (!worcaDir) {
204
300
  return res
@@ -261,12 +357,125 @@ export function createWorktreesRouter() {
261
357
  });
262
358
  }
263
359
 
264
- removeWorktree(worcaDir, run_id);
360
+ await removeWorktree(worcaDir, run_id);
361
+ if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
265
362
  res.json({ ok: true, run_id });
266
363
  } catch (err) {
267
364
  res.status(500).json({ ok: false, error: err.message });
268
365
  }
269
366
  });
270
367
 
368
+ // POST /worktrees/cleanup
369
+ //
370
+ // Batch worktree removal. Always responds with HTTP 200 and a JSON body of
371
+ // shape `{ ok, results, failed_count }`, where `ok` is the AND of per-id
372
+ // outcomes and `failed_count` is the number of entries with `ok: false`.
373
+ // Per-entry errors carry a `code` field (`running`, `resumable_or_grouped`)
374
+ // when actionable. Clients must inspect `results[]` — a single bad id never
375
+ // aborts the batch, and partial failures are not signalled via HTTP status.
376
+ router.post('/cleanup', async (req, res) => {
377
+ const worcaDir = req.project?.worcaDir;
378
+ if (!worcaDir) {
379
+ return res
380
+ .status(501)
381
+ .json({ ok: false, error: 'worcaDir not configured' });
382
+ }
383
+
384
+ const { run_ids, force = false } = req.body || {};
385
+ if (!Array.isArray(run_ids) || run_ids.length === 0) {
386
+ return res
387
+ .status(400)
388
+ .json({ ok: false, error: 'run_ids must be a non-empty array' });
389
+ }
390
+ for (const id of run_ids) {
391
+ if (!_validateRunId(id)) {
392
+ return res
393
+ .status(400)
394
+ .json({ ok: false, error: `Invalid run ID: ${id}` });
395
+ }
396
+ }
397
+
398
+ const tasks = run_ids.map((run_id) => ({
399
+ run_id,
400
+ fn: async () => {
401
+ const regFile = join(
402
+ worcaDir,
403
+ 'multi',
404
+ 'pipelines.d',
405
+ `${run_id}.json`,
406
+ );
407
+ if (!existsSync(regFile)) {
408
+ return {
409
+ run_id,
410
+ ok: false,
411
+ error: `Worktree "${run_id}" not found`,
412
+ };
413
+ }
414
+
415
+ let reg;
416
+ try {
417
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
418
+ } catch {
419
+ return {
420
+ run_id,
421
+ ok: false,
422
+ error: 'Failed to read registry entry',
423
+ };
424
+ }
425
+
426
+ let status = reg.status || 'unknown';
427
+ if (reg.worktree_path && existsSync(reg.worktree_path)) {
428
+ const actual = _readWorktreeStatus(reg.worktree_path);
429
+ if (actual) status = actual;
430
+ }
431
+
432
+ if (status === 'running') {
433
+ return {
434
+ run_id,
435
+ ok: false,
436
+ error: 'Cannot remove a running worktree',
437
+ code: 'running',
438
+ };
439
+ }
440
+
441
+ const isResumable = RESUMABLE_STATUSES.has(status);
442
+ const isGrouped = !!(reg.fleet_id || reg.workspace_id);
443
+ if (!force && (isResumable || isGrouped)) {
444
+ return {
445
+ run_id,
446
+ ok: false,
447
+ error:
448
+ 'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
449
+ code: 'resumable_or_grouped',
450
+ };
451
+ }
452
+
453
+ try {
454
+ await removeWorktree(worcaDir, run_id, { skipPrune: true });
455
+ if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
456
+ return { run_id, ok: true };
457
+ } catch (err) {
458
+ return { run_id, ok: false, error: err.message };
459
+ }
460
+ },
461
+ }));
462
+
463
+ let results;
464
+ try {
465
+ results = await runWithConcurrencyLimit(tasks, CLEANUP_CONCURRENCY);
466
+ } catch (err) {
467
+ return res.status(500).json({ ok: false, error: err.message });
468
+ }
469
+
470
+ try {
471
+ await pruneWorktrees(worcaDir);
472
+ } catch {
473
+ /* non-fatal */
474
+ }
475
+
476
+ const failed_count = results.reduce((n, r) => (r.ok ? n : n + 1), 0);
477
+ res.json({ ok: failed_count === 0, failed_count, results });
478
+ });
479
+
271
480
  return router;
272
481
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
- import { listIssues } from './beads-reader.js';
9
+ import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
10
10
 
11
11
  const BEADS_DEBOUNCE_MS = 500;
12
12
  const BEADS_POLL_MS = 2000;
@@ -26,11 +26,15 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
26
26
  BEADS_REFRESH_TIMER = setTimeout(async () => {
27
27
  BEADS_REFRESH_TIMER = null;
28
28
  try {
29
- const issues = await listIssues(beadsDbPath);
29
+ const [issues, counts] = await Promise.all([
30
+ listIssues(beadsDbPath),
31
+ countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
32
+ ]);
30
33
  broadcaster.broadcast(
31
34
  'beads-update',
32
35
  {
33
36
  issues,
37
+ counts,
34
38
  dbExists: true,
35
39
  dbPath: beadsDbPath,
36
40
  },