@worca/ui 0.33.0 → 0.34.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -64,8 +64,12 @@ export const DISPATCH_DEFAULTS = {
64
64
  },
65
65
  },
66
66
  subagents: {
67
- always_disallowed: ['general-purpose'],
68
- default_denied: [],
67
+ always_disallowed: [],
68
+ // general-purpose spawns an unconstrained full-tool Claude session, so it
69
+ // stays denied under the '*' wildcard — but as default_denied (not
70
+ // always_disallowed) a project can re-allow it per agent by naming it in
71
+ // per_agent_allow.
72
+ default_denied: ['general-purpose'],
69
73
  per_agent_allow: { _defaults: ['*'] },
70
74
  },
71
75
  };
@@ -60,7 +60,10 @@ function _absorbFlatDispatchKeys(dispatch) {
60
60
  // Mirror of normalize_dispatch_defaults() in src/worca/hooks/tracking.py.
61
61
  // Bumped when a new one-time normalization is added; stamped onto
62
62
  // governance.dispatch_migration_version so it runs exactly once per config.
63
- export const DISPATCH_MIGRATION_VERSION = 1;
63
+ // v1: collapse stale Explore-only subagent default; narrow worca-* skills glob.
64
+ // v2: move general-purpose from subagents.always_disallowed to default_denied
65
+ // (still denied by default, but allowable per-agent).
66
+ export const DISPATCH_MIGRATION_VERSION = 2;
64
67
 
65
68
  // Pre-W-054 (W-038-era) shipped subagent default: every pipeline agent capped
66
69
  // to Explore-only. coordinator:[] / empty lists fall through to _defaults and
@@ -151,6 +154,36 @@ export function adoptNarrowedSkillsDenylist(skillsCfg) {
151
154
  return true;
152
155
  }
153
156
 
157
+ /**
158
+ * Move general-purpose from subagents.always_disallowed to default_denied so
159
+ * it is allowable per-agent (still denied under the '*' wildcard). Only fires
160
+ * on an untouched denylist (exactly `['general-purpose']`); a customized list
161
+ * is left alone. Preserves existing default_denied entries. Returns true if
162
+ * changed. Mirror of adopt_general_purpose_allowable() in tracking.py.
163
+ *
164
+ * @param {object} subagentsCfg
165
+ * @returns {boolean}
166
+ */
167
+ export function adoptGeneralPurposeAllowable(subagentsCfg) {
168
+ if (!subagentsCfg || typeof subagentsCfg !== 'object') return false;
169
+ const current = subagentsCfg.always_disallowed;
170
+ if (
171
+ !Array.isArray(current) ||
172
+ current.length !== 1 ||
173
+ current[0] !== 'general-purpose'
174
+ ) {
175
+ return false;
176
+ }
177
+ const denied = Array.isArray(subagentsCfg.default_denied)
178
+ ? subagentsCfg.default_denied
179
+ : [];
180
+ subagentsCfg.always_disallowed = [];
181
+ subagentsCfg.default_denied = denied.includes('general-purpose')
182
+ ? denied
183
+ : [...denied, 'general-purpose'];
184
+ return true;
185
+ }
186
+
154
187
  /**
155
188
  * Apply one-time dispatch-default normalizations, gated by a version stamp.
156
189
  * Brings an *untouched* config up to current shipped defaults for the two
@@ -177,6 +210,11 @@ export function normalizeDispatchDefaults(governanceCfg) {
177
210
  'governance.dispatch.skills.always_disallowed: narrowed legacy "worca-*" glob to the current must-disallow set',
178
211
  );
179
212
  }
213
+ if (adoptGeneralPurposeAllowable(dispatch.subagents)) {
214
+ changes.push(
215
+ 'governance.dispatch.subagents: moved general-purpose from always_disallowed to default_denied (now allowable per-agent)',
216
+ );
217
+ }
180
218
  governanceCfg.dispatch_migration_version = DISPATCH_MIGRATION_VERSION;
181
219
  return changes;
182
220
  }
@@ -1587,9 +1587,9 @@ export function createProjectScopedRoutes({
1587
1587
  router.get('/templates', (req, res) => {
1588
1588
  const root = req.project.projectRoot;
1589
1589
  const tiers = [
1590
- { tier: 'worca', dir: join(root, '.claude', 'worca', 'templates') },
1591
- { tier: 'project', dir: join(root, '.claude', 'templates') },
1592
1590
  { tier: 'user', dir: templatesDir() },
1591
+ { tier: 'project', dir: join(root, '.claude', 'templates') },
1592
+ { tier: 'worca', dir: join(root, '.claude', 'worca', 'templates') },
1593
1593
  ];
1594
1594
 
1595
1595
  const templates = [];
@@ -0,0 +1,11 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ export function safeWatch(...args) {
4
+ const w = watch(...args);
5
+ w.on('error', (err) => {
6
+ if (err && err.code !== 'EPERM' && err.code !== 'ENOENT') {
7
+ console.error('[safeWatch] watcher error:', err);
8
+ }
9
+ });
10
+ return w;
11
+ }
package/server/watcher.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, readdirSync, readFileSync, watch } from 'node:fs';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import {
6
6
  assignEventsToIterations,
7
7
  readDispatchEventsFromJsonl,
8
8
  } from './dispatch-events-aggregator.js';
9
+ import { safeWatch } from './safe-watch.js';
9
10
 
10
11
  /**
11
12
  * Enrich a status object with dispatch events read from events.jsonl in the
@@ -366,7 +367,7 @@ export function watchEvents(runDir, callback) {
366
367
  function startFileWatcher() {
367
368
  if (closed || fileWatcher) return;
368
369
  try {
369
- fileWatcher = watch(eventsPath, (eventType) => {
370
+ fileWatcher = safeWatch(eventsPath, (eventType) => {
370
371
  if (eventType === 'change') {
371
372
  processNewContent();
372
373
  } else if (eventType === 'rename') {
@@ -405,7 +406,7 @@ export function watchEvents(runDir, callback) {
405
406
  // Watch the run directory so we detect events.jsonl being created
406
407
  if (existsSync(runDir)) {
407
408
  try {
408
- dirWatcher = watch(
409
+ dirWatcher = safeWatch(
409
410
  runDir,
410
411
  { recursive: false },
411
412
  (_eventType, filename) => {
@@ -4,13 +4,14 @@
4
4
  * because fs.watch on macOS misses SQLite WAL writes done via mmap.
5
5
  */
6
6
 
7
- import { existsSync, statSync, unwatchFile, watch, watchFile } from 'node:fs';
7
+ import { existsSync, statSync, unwatchFile, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import {
10
10
  countIssuesByRunLabel,
11
11
  enrichIssuesWithDeps,
12
12
  listIssuesShallow,
13
13
  } from './beads-reader.js';
14
+ import { safeWatch } from './safe-watch.js';
14
15
 
15
16
  const BEADS_DEBOUNCE_MS = 500;
16
17
  const BEADS_POLL_MS = 2000;
@@ -132,7 +133,7 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
132
133
  if (existsSync(beadsDir)) {
133
134
  // fs.watch for directory-level events (checkpoint writes to main db)
134
135
  try {
135
- fsWatcher = watch(beadsDir, (_event, filename) => {
136
+ fsWatcher = safeWatch(beadsDir, (_event, filename) => {
136
137
  if (filename?.startsWith('beads.db')) scheduleBeadsRefresh();
137
138
  });
138
139
  } catch {
@@ -3,10 +3,11 @@
3
3
  * Emits fleet-update WS events when a fleet manifest is written (§13.5).
4
4
  */
5
5
 
6
- import { existsSync, readFileSync, watch } from 'node:fs';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { effectiveFleetStatus } from './fleet-routes.js';
9
9
  import { fleetRunsDir as resolveFleetRunsDir } from './paths.js';
10
+ import { safeWatch } from './safe-watch.js';
10
11
 
11
12
  const FLEET_DEBOUNCE_MS = 200;
12
13
 
@@ -100,7 +101,7 @@ export function createFleetManifestWatcher({
100
101
 
101
102
  try {
102
103
  if (existsSync(fleetRunsDir)) {
103
- fsWatcher = watch(
104
+ fsWatcher = safeWatch(
104
105
  fleetRunsDir,
105
106
  { persistent: false },
106
107
  (_event, filename) => {
@@ -3,7 +3,7 @@
3
3
  * Owns logWatchers map and logLineCounts tracking.
4
4
  */
5
5
 
6
- import { existsSync, readdirSync, statSync, watch } from 'node:fs';
6
+ import { existsSync, readdirSync, statSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import {
9
9
  fileByteLength,
@@ -13,6 +13,7 @@ import {
13
13
  readNewLines,
14
14
  resolveLogPath,
15
15
  } from './log-tailer.js';
16
+ import { safeWatch } from './safe-watch.js';
16
17
 
17
18
  /**
18
19
  * @param {{
@@ -69,7 +70,7 @@ export function createLogWatcher({
69
70
  if (!existsSync(filePath)) return;
70
71
  logByteOffsets.set(key, fileByteLength(filePath));
71
72
  const watcherRunId = explicitRunId || currentActiveRunId();
72
- const watcher = watch(filePath, (eventType) => {
73
+ const watcher = safeWatch(filePath, (eventType) => {
73
74
  if (eventType === 'change') {
74
75
  try {
75
76
  const prevOffset = logByteOffsets.get(key) || 0;
@@ -109,7 +110,7 @@ export function createLogWatcher({
109
110
  const dirKey = _watcherKey(explicitRunId, stage, null, '__dir');
110
111
  if (logWatchers.has(dirKey)) return;
111
112
  try {
112
- const dirWatcher = watch(stageDir, (_eventType, filename) => {
113
+ const dirWatcher = safeWatch(stageDir, (_eventType, filename) => {
113
114
  if (filename && /^iter-\d+\.log$/.test(filename)) {
114
115
  const iterNum = parseInt(filename.match(/\d+/)[0], 10);
115
116
  const iterPath = join(stageDir, filename);
@@ -167,7 +168,7 @@ export function createLogWatcher({
167
168
  if (logWatchers.has(dirKey)) return;
168
169
  if (!existsSync(logsDir)) return;
169
170
  try {
170
- const dirWatcher = watch(logsDir, (_eventType, filename) => {
171
+ const dirWatcher = safeWatch(logsDir, (_eventType, filename) => {
171
172
  if (!filename) return;
172
173
  if (filename.endsWith('.log')) {
173
174
  const stage = filename.replace('.log', '');
@@ -6,11 +6,12 @@
6
6
  * Supports dynamic project add/remove via fs.watch on projects.d/.
7
7
  */
8
8
 
9
- import { existsSync, watch } from 'node:fs';
9
+ import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { WebSocketServer } from 'ws';
12
12
  import { fleetRunsDir, workspaceRunsDir } from './paths.js';
13
13
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
14
+ import { safeWatch } from './safe-watch.js';
14
15
  import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
15
16
  import { readProjectWorcaVersion } from './worca-setup.js';
16
17
  import { peekBeadsCounts } from './ws-beads-watcher.js';
@@ -40,6 +41,10 @@ export function attachWsServer(httpServer, config) {
40
41
  } = config;
41
42
  const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
42
43
 
44
+ // WSS created with an external server does not auto-close when the server
45
+ // closes — explicitly bridge the lifecycle so watchers are torn down.
46
+ httpServer.on('close', () => wss.close());
47
+
43
48
  // 1. Client manager — owns subs WeakMap and heartbeat
44
49
  const clientManager = createClientManager({ wss });
45
50
 
@@ -115,7 +120,7 @@ export function attachWsServer(httpServer, config) {
115
120
  const projectsDir = join(prefsDir, 'projects.d');
116
121
  try {
117
122
  if (existsSync(projectsDir)) {
118
- dirWatcher = watch(projectsDir, { persistent: false }, () => {
123
+ dirWatcher = safeWatch(projectsDir, { persistent: false }, () => {
119
124
  if (debounceTimer) clearTimeout(debounceTimer);
120
125
  debounceTimer = setTimeout(() => {
121
126
  debounceTimer = null;
@@ -3,14 +3,9 @@
3
3
  * Owns refresh scheduling, lastPipelineStatus tracking, and the status/runsDirWatcher FSWatchers.
4
4
  */
5
5
 
6
- import {
7
- existsSync,
8
- mkdirSync,
9
- readdirSync,
10
- readFileSync,
11
- watch,
12
- } from 'node:fs';
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
13
7
  import { join } from 'node:path';
8
+ import { safeWatch } from './safe-watch.js';
14
9
  import { readSettings } from './settings-reader.js';
15
10
  import { discoverRunsAsync } from './watcher.js';
16
11
 
@@ -225,7 +220,7 @@ export function createStatusWatcher({
225
220
  const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
226
221
  if (!existsSync(wtRunsDir)) continue;
227
222
  try {
228
- const w = watch(
223
+ const w = safeWatch(
229
224
  wtRunsDir,
230
225
  { recursive: true },
231
226
  (_eventType, filename) => {
@@ -322,7 +317,7 @@ export function createStatusWatcher({
322
317
  // rename-over) replace the inode. After one 'rename' event the
323
318
  // watcher goes dead because it tracked the old inode. We
324
319
  // re-establish the watcher on the new file after a short delay.
325
- statusWatcher = watch(statusFile, (eventType) => {
320
+ statusWatcher = safeWatch(statusFile, (eventType) => {
326
321
  scheduleRefresh();
327
322
  if (eventType === 'rename') {
328
323
  // File replaced (atomic write) — re-watch the new inode
@@ -338,7 +333,7 @@ export function createStatusWatcher({
338
333
  } else if (existsSync(runDir)) {
339
334
  // status.json doesn't exist yet — watch the directory for its creation,
340
335
  // then switch to watching the file once it appears.
341
- statusWatcher = watch(
336
+ statusWatcher = safeWatch(
342
337
  runDir,
343
338
  { recursive: false },
344
339
  (_eventType, filename) => {
@@ -373,7 +368,7 @@ export function createStatusWatcher({
373
368
  // Watch worcaDir for legacy status.json changes
374
369
  try {
375
370
  if (existsSync(worcaDir)) {
376
- activeRunWatcher = watch(
371
+ activeRunWatcher = safeWatch(
377
372
  worcaDir,
378
373
  { recursive: false },
379
374
  (_eventType, filename) => {
@@ -395,7 +390,7 @@ export function createStatusWatcher({
395
390
  const runsDir = join(worcaDir, 'runs');
396
391
  try {
397
392
  if (existsSync(runsDir)) {
398
- runsDirWatcher = watch(
393
+ runsDirWatcher = safeWatch(
399
394
  runsDir,
400
395
  { recursive: true },
401
396
  (_eventType, filename) => {
@@ -414,7 +409,7 @@ export function createStatusWatcher({
414
409
  const pipelinesDirPath = join(worcaDir, 'multi', 'pipelines.d');
415
410
  try {
416
411
  mkdirSync(pipelinesDirPath, { recursive: true });
417
- pipelinesDirWatcher = watch(
412
+ pipelinesDirWatcher = safeWatch(
418
413
  pipelinesDirPath,
419
414
  { recursive: false },
420
415
  (_eventType, filename) => {
@@ -6,9 +6,10 @@
6
6
  * Separate from fleet-update per W-040 §13.5 — never multiplexed.
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, watch } from 'node:fs';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { workspaceRunsDir as resolveWorkspaceRunsDir } from './paths.js';
12
+ import { safeWatch } from './safe-watch.js';
12
13
 
13
14
  const WS_DEBOUNCE_MS = 200;
14
15
 
@@ -102,7 +103,7 @@ export function createWorkspaceManifestWatcher({
102
103
 
103
104
  try {
104
105
  if (existsSync(workspaceRunsDir)) {
105
- fsWatcher = watch(
106
+ fsWatcher = safeWatch(
106
107
  workspaceRunsDir,
107
108
  { persistent: false },
108
109
  (_event, filename) => {