@worca/ui 0.26.0 → 0.28.0-rc.1

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Dispatch governance migration — JS port of Python
3
+ * _migrate_dispatch_governance() in src/worca/cli/init.py (§10.3).
4
+ *
5
+ * Mutates worcaConfig in place following the same pattern as
6
+ * global-keys.js:extractAndStripGlobalKeys().
7
+ */
8
+
9
+ import { DISPATCH_DEFAULTS } from './dispatch-defaults.js';
10
+
11
+ const _DISPATCH_SECTION_KEYS = new Set(['tools', 'skills', 'subagents']);
12
+
13
+ /**
14
+ * Returns true if `dispatch` has at least one agent-name key directly at the
15
+ * top level (the pre-W-038 legacy flat shape, e.g. `{planner: [...]}`),
16
+ * rather than the W-054 nested shape `{tools, skills, subagents}`.
17
+ */
18
+ function _isLegacyFlatDispatch(dispatch) {
19
+ if (!dispatch || typeof dispatch !== 'object' || Array.isArray(dispatch)) {
20
+ return false;
21
+ }
22
+ for (const key of Object.keys(dispatch)) {
23
+ if (!_DISPATCH_SECTION_KEYS.has(key) && Array.isArray(dispatch[key])) {
24
+ return true;
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Strip agent-name keys from `dispatch`, moving their values into
32
+ * `dispatch.subagents.per_agent_allow` so the legacy flat shape is normalized.
33
+ */
34
+ function _absorbFlatDispatchKeys(dispatch) {
35
+ const flatKeys = [];
36
+ for (const key of Object.keys(dispatch)) {
37
+ if (!_DISPATCH_SECTION_KEYS.has(key) && Array.isArray(dispatch[key])) {
38
+ flatKeys.push(key);
39
+ }
40
+ }
41
+ if (flatKeys.length === 0) return false;
42
+
43
+ if (!dispatch.subagents) dispatch.subagents = {};
44
+ if (!dispatch.subagents.per_agent_allow) {
45
+ dispatch.subagents.per_agent_allow = {};
46
+ }
47
+ for (const key of flatKeys) {
48
+ const incoming = dispatch[key];
49
+ const existing = dispatch.subagents.per_agent_allow[key];
50
+ if (!existing || existing.length === 0) {
51
+ dispatch.subagents.per_agent_allow[key] = incoming;
52
+ }
53
+ delete dispatch[key];
54
+ }
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Migrate legacy governance.subagent_dispatch and/or legacy flat
60
+ * governance.dispatch (agent-keyed) → governance.dispatch.subagents.per_agent_allow.
61
+ *
62
+ * Seeds _defaults, adds tools/skills defaults, drops _dispatch_legacy.
63
+ * Idempotent — returns [] on already-migrated configs.
64
+ *
65
+ * @param {object} worcaConfig — the `worca` object from settings (mutated)
66
+ * @returns {string[]} list of change descriptions (empty = no-op)
67
+ */
68
+ export function migrateDispatchGovernance(worcaConfig) {
69
+ const changes = [];
70
+ const gov = worcaConfig.governance;
71
+ if (!gov || typeof gov !== 'object') return changes;
72
+
73
+ const hasSubagentDispatch = 'subagent_dispatch' in gov;
74
+ const hasLegacyFlatDispatch = _isLegacyFlatDispatch(gov.dispatch);
75
+
76
+ if (!hasSubagentDispatch && !hasLegacyFlatDispatch) return changes;
77
+
78
+ if (!gov.dispatch || Array.isArray(gov.dispatch)) gov.dispatch = {};
79
+ const dispatch = gov.dispatch;
80
+
81
+ // Absorb legacy flat shape (pre-W-038) first so subagent_dispatch values
82
+ // take precedence below.
83
+ if (hasLegacyFlatDispatch) {
84
+ _absorbFlatDispatchKeys(dispatch);
85
+ changes.push(
86
+ 'governance.dispatch (flat agent-keyed) -> governance.dispatch.subagents (W-054)',
87
+ );
88
+ }
89
+
90
+ if (hasSubagentDispatch) {
91
+ const old = gov.subagent_dispatch;
92
+ delete gov.subagent_dispatch;
93
+ if (!dispatch.subagents) dispatch.subagents = {};
94
+ if (!dispatch.subagents.per_agent_allow) {
95
+ dispatch.subagents.per_agent_allow = {};
96
+ }
97
+ Object.assign(dispatch.subagents.per_agent_allow, old);
98
+ changes.push(
99
+ 'governance.subagent_dispatch -> governance.dispatch.subagents (W-054)',
100
+ );
101
+ }
102
+
103
+ if (!dispatch.subagents) dispatch.subagents = {};
104
+ const subagents = dispatch.subagents;
105
+
106
+ if (!subagents.per_agent_allow) subagents.per_agent_allow = {};
107
+ if (!('_defaults' in subagents.per_agent_allow)) {
108
+ subagents.per_agent_allow._defaults = [
109
+ ...DISPATCH_DEFAULTS.subagents.per_agent_allow._defaults,
110
+ ];
111
+ }
112
+
113
+ if (!subagents.always_disallowed) {
114
+ subagents.always_disallowed = [
115
+ ...DISPATCH_DEFAULTS.subagents.always_disallowed,
116
+ ];
117
+ }
118
+ if (!subagents.default_denied) {
119
+ subagents.default_denied = [...DISPATCH_DEFAULTS.subagents.default_denied];
120
+ }
121
+
122
+ if (!dispatch.tools) {
123
+ dispatch.tools = structuredClone(DISPATCH_DEFAULTS.tools);
124
+ }
125
+ if (!dispatch.skills) {
126
+ dispatch.skills = structuredClone(DISPATCH_DEFAULTS.skills);
127
+ }
128
+
129
+ delete gov._dispatch_legacy;
130
+
131
+ return changes;
132
+ }
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const CACHE_TTL_MS = 30_000;
4
+ const cache = new Map();
5
+
6
+ function _resolveDefaultBranch(projectRoot) {
7
+ try {
8
+ const out = execFileSync(
9
+ 'git',
10
+ ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
11
+ { cwd: projectRoot, encoding: 'utf8', timeout: 5000 },
12
+ );
13
+ const branch = out.trim().replace(/^origin\//, '');
14
+ if (branch) return branch;
15
+ } catch {
16
+ // no symbolic-ref configured — fall through
17
+ }
18
+ return 'main';
19
+ }
20
+
21
+ export function getDefaultBranch(projectRoot) {
22
+ const now = Date.now();
23
+ const hit = cache.get(projectRoot);
24
+ if (hit && hit.expiresAt > now) return hit.value;
25
+ const value = _resolveDefaultBranch(projectRoot);
26
+ cache.set(projectRoot, { value, expiresAt: now + CACHE_TTL_MS });
27
+ return value;
28
+ }
29
+
30
+ export function _clearDefaultBranchCache() {
31
+ cache.clear();
32
+ }
@@ -0,0 +1,31 @@
1
+ [
2
+ { "name": "batch", "group": "Built-in" },
3
+ { "name": "claude-api", "group": "Built-in" },
4
+ { "name": "debug", "group": "Built-in" },
5
+ { "name": "fewer-permission-prompts", "group": "Built-in" },
6
+ { "name": "init", "group": "Built-in" },
7
+ { "name": "loop", "group": "Built-in" },
8
+ { "name": "review", "group": "Built-in" },
9
+ { "name": "schedule", "group": "Built-in" },
10
+ { "name": "security-review", "group": "Built-in" },
11
+ { "name": "simplify", "group": "Built-in" },
12
+ { "name": "update-config", "group": "Built-in" },
13
+ { "name": "hookify:hookify", "group": "Plugin" },
14
+ { "name": "hookify:configure", "group": "Plugin" },
15
+ { "name": "hookify:list", "group": "Plugin" },
16
+ { "name": "hookify:writing-rules", "group": "Plugin" },
17
+ { "name": "feature-dev:feature-dev", "group": "Plugin" },
18
+ { "name": "feature-dev:code-reviewer", "group": "Plugin" },
19
+ { "name": "feature-dev:code-architect", "group": "Plugin" },
20
+ { "name": "feature-dev:code-explorer", "group": "Plugin" },
21
+ { "name": "claude-md-management:revise-claude-md", "group": "Plugin" },
22
+ { "name": "claude-md-management:claude-md-improver", "group": "Plugin" },
23
+ { "name": "worca-install", "group": "Worca" },
24
+ { "name": "worca-rc", "group": "Worca" },
25
+ { "name": "worca-release", "group": "Worca" },
26
+ { "name": "worca-sync", "group": "Worca" },
27
+ { "name": "worca-sync-pr", "group": "Worca" },
28
+ { "name": "worca-sync-commit", "group": "Worca" },
29
+ { "name": "worca-analyze", "group": "Worca" },
30
+ { "name": "worca-agent-override", "group": "Worca" }
31
+ ]
@@ -0,0 +1,18 @@
1
+ [
2
+ { "name": "Agent", "group": "Core" },
3
+ { "name": "Bash", "group": "Core" },
4
+ { "name": "Edit", "group": "Core" },
5
+ { "name": "Glob", "group": "Core" },
6
+ { "name": "Grep", "group": "Core" },
7
+ { "name": "Read", "group": "Core" },
8
+ { "name": "Write", "group": "Core" },
9
+ { "name": "Skill", "group": "Core" },
10
+ { "name": "WebFetch", "group": "Core" },
11
+ { "name": "WebSearch", "group": "Core" },
12
+ { "name": "NotebookEdit", "group": "Core" },
13
+ { "name": "EnterPlanMode", "group": "Mode" },
14
+ { "name": "EnterWorktree", "group": "Mode" },
15
+ { "name": "TodoWrite", "group": "Task" },
16
+ { "name": "TaskCreate", "group": "Task" },
17
+ { "name": "TaskUpdate", "group": "Task" }
18
+ ]
@@ -115,8 +115,13 @@ export function createModelEnvRouter({ settingsPath: staticPath } = {}) {
115
115
 
116
116
  let baseChanged = false;
117
117
  if (resolvedId) {
118
- // Prefer string form when there's no other metadata keeps JSON minimal.
119
- const nextBaseEntry = resolvedId;
118
+ // When env exists in local, base MUST use the object form `{id}` so
119
+ // deepMerge({id}, {env}) preserves the id. With the string form,
120
+ // deepMerge would see a non-object base and discard it, dropping the id
121
+ // entirely — the bug behind empty Model ID after Duplicate/Paste.
122
+ // String form stays the default when there's no env, to keep JSON minimal.
123
+ const hasEnv = Object.keys(envIn).length > 0;
124
+ const nextBaseEntry = hasEnv ? { id: resolvedId } : resolvedId;
120
125
  if (JSON.stringify(baseEntry) !== JSON.stringify(nextBaseEntry)) {
121
126
  base.worca.models[model] = nextBaseEntry;
122
127
  baseChanged = true;
@@ -23,7 +23,9 @@ import { actionAllowed } from '../app/utils/state-actions.js';
23
23
  import { atomicWriteSync } from './atomic-write.js';
24
24
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
25
25
  import { dispatchExternal } from './dispatch-external.js';
26
+ import { migrateDispatchGovernance } from './dispatch-migration.js';
26
27
  import { ensureWebhookForUi } from './ensure-webhook.js';
28
+ import { getDefaultBranch } from './git-helpers.js';
27
29
  import { extractAndStripGlobalKeys } from './global-keys.js';
28
30
  import { LaunchLock } from './launch-lock.js';
29
31
  import { createModelEnvRouter } from './model-env-routes.js';
@@ -361,7 +363,8 @@ export function createProjectScopedRoutes({
361
363
  router.get('/runs', requireWorcaDir, (req, res) => {
362
364
  try {
363
365
  const runs = discoverRuns(req.project.worcaDir);
364
- const response = { ok: true, runs };
366
+ const default_branch = getDefaultBranch(req.project.projectRoot);
367
+ const response = { ok: true, runs, default_branch };
365
368
  // Include settings so multi-project clients can use loop limits, etc.
366
369
  const { settingsPath } = req.project;
367
370
  if (settingsPath && existsSync(settingsPath)) {
@@ -478,7 +481,18 @@ export function createProjectScopedRoutes({
478
481
  });
479
482
  }
480
483
 
481
- const validation = validateSettingsPayload(body);
484
+ let existingForValidation = {};
485
+ try {
486
+ if (existsSync(settingsPath)) {
487
+ existingForValidation = JSON.parse(readFileSync(settingsPath, 'utf8'));
488
+ }
489
+ } catch {
490
+ existingForValidation = {};
491
+ }
492
+
493
+ const validation = validateSettingsPayload(body, {
494
+ existing: existingForValidation,
495
+ });
482
496
  if (!validation.valid) {
483
497
  return res.status(400).json({
484
498
  error: {
@@ -504,22 +518,15 @@ export function createProjectScopedRoutes({
504
518
  if (!base.worca || typeof base.worca !== 'object') base.worca = {};
505
519
  base.worca = deepMerge(base.worca, body.worca);
506
520
 
507
- if (
508
- body.worca.governance &&
509
- typeof body.worca.governance === 'object' &&
510
- body.worca.governance.subagent_dispatch !== undefined &&
511
- base.worca.governance &&
512
- typeof base.worca.governance === 'object' &&
513
- 'dispatch' in base.worca.governance
514
- ) {
515
- delete base.worca.governance.dispatch;
516
- }
517
521
  baseChanged = true;
518
522
  }
519
523
 
520
524
  // STEP 1: extract misplaced global keys + inert milestone keys
521
525
  const autoMigrated = extractAndStripGlobalKeys(base);
522
526
 
527
+ // STEP 1a: migrate legacy subagent_dispatch → dispatch.subagents (W-054)
528
+ if (base.worca) migrateDispatchGovernance(base.worca);
529
+
523
530
  // STEP 2: write extracted global keys to ~/.worca/settings.json
524
531
  const globalSettingsPath = prefsDir
525
532
  ? join(prefsDir, 'settings.json')
@@ -558,12 +565,43 @@ export function createProjectScopedRoutes({
558
565
  atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
559
566
  }
560
567
 
568
+ // STEP 3a: strip shadowed worca keys from settings.local.json. Local is
569
+ // deep-merged over base on read, so a stale `worca.<key>` copy in local
570
+ // would resurrect after the user saves a new value. `models` is excluded
571
+ // because its env-portion lives in local by design (see model-env-routes).
572
+ const lp = localPathFor(settingsPath);
573
+ let localChanged = false;
574
+ const localForPrune = readLocalSettings(settingsPath);
575
+ if (
576
+ body.worca &&
577
+ typeof body.worca === 'object' &&
578
+ localForPrune.worca &&
579
+ typeof localForPrune.worca === 'object'
580
+ ) {
581
+ for (const key of Object.keys(body.worca)) {
582
+ if (key === 'models') continue;
583
+ if (key in localForPrune.worca) {
584
+ delete localForPrune.worca[key];
585
+ localChanged = true;
586
+ }
587
+ }
588
+ if (localChanged && Object.keys(localForPrune.worca).length === 0) {
589
+ delete localForPrune.worca;
590
+ }
591
+ }
592
+
561
593
  if (body.permissions !== undefined) {
562
- const lp = localPathFor(settingsPath);
563
- const local = readLocalSettings(settingsPath);
564
- local.permissions = body.permissions;
594
+ localForPrune.permissions = body.permissions;
595
+ localChanged = true;
596
+ }
597
+
598
+ if (localChanged) {
565
599
  mkdirSync(dirname(lp), { recursive: true });
566
- writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
600
+ writeFileSync(
601
+ lp,
602
+ `${JSON.stringify(localForPrune, null, 2)}\n`,
603
+ 'utf8',
604
+ );
567
605
  }
568
606
 
569
607
  const merged = readMergedSettings(settingsPath);
@@ -36,8 +36,12 @@ const VALID_PRICING_FIELDS = [
36
36
  'cache_read_per_mtok',
37
37
  ];
38
38
 
39
- export function validateSettingsPayload(body) {
39
+ export function validateSettingsPayload(body, options = {}) {
40
40
  const details = [];
41
+ const existingWorca =
42
+ options.existing && typeof options.existing === 'object'
43
+ ? options.existing.worca || {}
44
+ : {};
41
45
 
42
46
  if (body.worca !== undefined) {
43
47
  if (
@@ -49,7 +53,16 @@ export function validateSettingsPayload(body) {
49
53
  return { valid: false, details };
50
54
  }
51
55
  const w = body.worca;
52
- const validModels = deriveValidModels(w);
56
+ // Sections like agents/pricing reference model keys that may live in another
57
+ // section saved earlier. Merge persisted models with body-supplied models so
58
+ // a single-section save (e.g. agents-only) doesn't reject custom models.
59
+ const mergedModels = {
60
+ ...(existingWorca.models && typeof existingWorca.models === 'object'
61
+ ? existingWorca.models
62
+ : {}),
63
+ ...(w.models && typeof w.models === 'object' ? w.models : {}),
64
+ };
65
+ const validModels = deriveValidModels({ models: mergedModels });
53
66
 
54
67
  // agents
55
68
  if (w.agents !== undefined) {
@@ -383,19 +396,91 @@ export function validateSettingsPayload(body) {
383
396
  ) {
384
397
  details.push('governance.dispatch must be an object');
385
398
  } else {
399
+ const DISPATCH_SECTIONS = ['tools', 'skills', 'subagents'];
386
400
  for (const [key, val] of Object.entries(g.dispatch)) {
387
- if (!VALID_AGENTS.includes(key)) {
388
- details.push(`Unknown dispatch agent: "${key}"`);
389
- continue;
390
- }
391
- if (!Array.isArray(val)) {
392
- details.push(`Dispatch for "${key}" must be an array`);
393
- continue;
394
- }
395
- for (const v of val) {
396
- if (typeof v !== 'string') {
397
- details.push(`Dispatch entry for "${key}" must be a string`);
401
+ if (DISPATCH_SECTIONS.includes(key)) {
402
+ // W-054 nested shape: dispatch.{tools,skills,subagents}
403
+ if (
404
+ typeof val !== 'object' ||
405
+ val === null ||
406
+ Array.isArray(val)
407
+ ) {
408
+ details.push(`governance.dispatch.${key} must be an object`);
409
+ continue;
410
+ }
411
+ for (const tierKey of ['always_disallowed', 'default_denied']) {
412
+ if (val[tierKey] === undefined) continue;
413
+ if (!Array.isArray(val[tierKey])) {
414
+ details.push(
415
+ `governance.dispatch.${key}.${tierKey} must be an array`,
416
+ );
417
+ continue;
418
+ }
419
+ for (const entry of val[tierKey]) {
420
+ if (typeof entry !== 'string') {
421
+ details.push(
422
+ `governance.dispatch.${key}.${tierKey} entries must be strings`,
423
+ );
424
+ }
425
+ }
426
+ }
427
+ if (val.per_agent_allow !== undefined) {
428
+ if (
429
+ typeof val.per_agent_allow !== 'object' ||
430
+ val.per_agent_allow === null ||
431
+ Array.isArray(val.per_agent_allow)
432
+ ) {
433
+ details.push(
434
+ `governance.dispatch.${key}.per_agent_allow must be an object`,
435
+ );
436
+ } else {
437
+ for (const [agent, allowList] of Object.entries(
438
+ val.per_agent_allow,
439
+ )) {
440
+ if (
441
+ agent !== '_defaults' &&
442
+ !VALID_AGENTS.includes(agent) &&
443
+ agent !== 'workspace_planner'
444
+ ) {
445
+ details.push(
446
+ `governance.dispatch.${key}.per_agent_allow: unknown agent "${agent}"`,
447
+ );
448
+ continue;
449
+ }
450
+ if (!Array.isArray(allowList)) {
451
+ details.push(
452
+ `governance.dispatch.${key}.per_agent_allow.${agent} must be an array`,
453
+ );
454
+ continue;
455
+ }
456
+ for (const entry of allowList) {
457
+ if (typeof entry !== 'string') {
458
+ details.push(
459
+ `governance.dispatch.${key}.per_agent_allow.${agent} entries must be strings`,
460
+ );
461
+ }
462
+ }
463
+ }
464
+ }
465
+ }
466
+ } else if (
467
+ VALID_AGENTS.includes(key) ||
468
+ key === 'workspace_planner'
469
+ ) {
470
+ // Pre-W-054 legacy flat shape — tolerated for migration.
471
+ if (!Array.isArray(val)) {
472
+ details.push(`Dispatch for "${key}" must be an array`);
473
+ continue;
474
+ }
475
+ for (const v of val) {
476
+ if (typeof v !== 'string') {
477
+ details.push(
478
+ `Dispatch entry for "${key}" must be a string`,
479
+ );
480
+ }
398
481
  }
482
+ } else {
483
+ details.push(`Unknown dispatch key: "${key}"`);
399
484
  }
400
485
  }
401
486
  }
package/server/watcher.js CHANGED
@@ -157,6 +157,7 @@ export function discoverRuns(worcaDir) {
157
157
  ...status,
158
158
  worktree_worca_dir: join(reg.worktree_path, '.worca'),
159
159
  is_worktree_run: true,
160
+ head_branch: reg.branch || null,
160
161
  fleet_id: reg.fleet_id || null,
161
162
  workspace_id: reg.workspace_id || null,
162
163
  group_type: reg.group_type || null,
@@ -311,6 +312,7 @@ export async function discoverRunsAsync(worcaDir) {
311
312
  ...status,
312
313
  worktree_worca_dir: join(reg.worktree_path, '.worca'),
313
314
  is_worktree_run: true,
315
+ head_branch: reg.branch || null,
314
316
  fleet_id: reg.fleet_id || null,
315
317
  workspace_id: reg.workspace_id || null,
316
318
  group_type: reg.group_type || null,
@@ -20,6 +20,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import * as fsp from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { Router } from 'express';
23
+ import { getDefaultBranch } from './git-helpers.js';
23
24
  import { pruneWorktrees, removeWorktree } from './worktree-ops.js';
24
25
 
25
26
  const CLEANUP_CONCURRENCY = 4;
@@ -362,6 +363,7 @@ async function _listWorktrees(worcaDir) {
362
363
  started_at: m.reg.started_at || null,
363
364
  status: m.status,
364
365
  removable: m.status !== 'running',
366
+ target_branch: m.reg.target_branch || null,
365
367
  fleet_id: m.reg.fleet_id || null,
366
368
  workspace_id: m.reg.workspace_id || null,
367
369
  group_type: m.reg.group_type || null,
@@ -399,9 +401,11 @@ export function createWorktreesRouter() {
399
401
  }
400
402
  try {
401
403
  const worktrees = await _listWorktrees(worcaDir);
404
+ const default_branch = getDefaultBranch(req.project?.projectRoot);
402
405
  res.json({
403
406
  ok: true,
404
407
  worktrees,
408
+ default_branch,
405
409
  // Documents the semantics shift in `disk_bytes` (project files only).
406
410
  // Clients can render this as a caveat next to disk totals.
407
411
  disk_walk_skip_dirs: [...WALK_SKIP_DIRS],
@@ -682,14 +682,32 @@ export function createMessageRouter({
682
682
  ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
683
683
  return;
684
684
  }
685
- const issues = await listIssuesByLabel(beadsDbPath, `run:${runId}`);
686
- console.log(
687
- '[list-beads-by-run] runId=%s count=%d statuses=%o',
688
- runId,
689
- issues.length,
690
- issues.map((i) => i.status),
691
- );
692
- ws.send(JSON.stringify(makeOk(req, { issues, runId })));
685
+ try {
686
+ const issues = await listIssuesByLabel(beadsDbPath, `run:${runId}`);
687
+ console.log(
688
+ '[list-beads-by-run] runId=%s count=%d statuses=%o',
689
+ runId,
690
+ issues.length,
691
+ issues.map((i) => i.status),
692
+ );
693
+ ws.send(JSON.stringify(makeOk(req, { issues, runId })));
694
+ } catch (err) {
695
+ // Don't return empty issues on failure — the UI would treat that as
696
+ // "all beads deleted" and tear down the open <sl-details> panel. Let
697
+ // the client keep its last-known-good state until the next poll.
698
+ console.warn(
699
+ `[list-beads-by-run] runId=${runId} failed: ${err?.message || err}`,
700
+ );
701
+ ws.send(
702
+ JSON.stringify(
703
+ makeError(
704
+ req,
705
+ 'beads_unavailable',
706
+ `bd query failed: ${err?.message || err}`,
707
+ ),
708
+ ),
709
+ );
710
+ }
693
711
  return;
694
712
  }
695
713