@worca/ui 0.8.1 → 0.9.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,116 @@
1
+ /**
2
+ * Subagent discovery for the settings dispatch-rule editor.
3
+ *
4
+ * Walks three sources (built-ins, user-global, plugin cache) and returns a
5
+ * deduplicated list matching the shape used by `worca-ui/app/views/
6
+ * dispatch-tag-state.js` (`{name, label, group}`).
7
+ *
8
+ * The three sources:
9
+ * 1. Built-ins — hardcoded Claude Code types that are not on disk.
10
+ * 2. User — `<userDir>/*.md`, one file per subagent.
11
+ * 3. Plugins — `<pluginCacheDir>/<marketplace>/<plugin>/<version>/agents/*.md`.
12
+ * Deduped by the qualified name `<plugin>:<agent>` — first file wins
13
+ * across versions (the set of agents within a plugin is stable in
14
+ * practice; when two versions disagree we prefer filesystem order for
15
+ * determinism rather than trying to parse semver from directory names).
16
+ */
17
+
18
+ import { existsSync, readdirSync, statSync } from 'node:fs';
19
+ import { basename, join } from 'node:path';
20
+
21
+ // Built-in Claude Code subagents — shipped with a factory CC install, no
22
+ // plugins required. Mirror this list in worca-ui/app/views/
23
+ // dispatch-tag-state.js (KNOWN_TYPES) so the UI falls back to the same set
24
+ // when the /api/subagents fetch fails.
25
+ export const BUILTINS = [
26
+ { name: 'Explore', label: '(built-in)', group: 'Built-in' },
27
+ { name: 'general-purpose', label: '(built-in)', group: 'Built-in' },
28
+ { name: 'Plan', label: '(built-in)', group: 'Built-in' },
29
+ { name: 'statusline-setup', label: '(built-in)', group: 'Built-in' },
30
+ { name: 'claude-code-guide', label: '(built-in)', group: 'Built-in' },
31
+ ];
32
+
33
+ function listMarkdownBasenames(dir) {
34
+ if (!dir || !existsSync(dir)) return [];
35
+ try {
36
+ return readdirSync(dir)
37
+ .filter((n) => n.endsWith('.md'))
38
+ .map((n) => basename(n, '.md'));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function listSubdirs(dir) {
45
+ if (!existsSync(dir)) return [];
46
+ try {
47
+ return readdirSync(dir).filter((n) => {
48
+ try {
49
+ return statSync(join(dir, n)).isDirectory();
50
+ } catch {
51
+ return false;
52
+ }
53
+ });
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Discover all subagent types reachable from the given directories.
61
+ *
62
+ * @param {object} options
63
+ * @param {Array<{name:string,label:string,group:string}>} [options.builtins]
64
+ * @param {string} [options.userDir] e.g. ~/.claude/agents
65
+ * @param {string} [options.pluginCacheDir] e.g. ~/.claude/plugins/cache
66
+ * @param {string} [options.projectAgentsDir] e.g. <project>/.claude/agents
67
+ * @returns {Array<{name:string,label:string,group:string}>}
68
+ */
69
+ export function discoverSubagents({
70
+ builtins = BUILTINS,
71
+ userDir,
72
+ pluginCacheDir,
73
+ projectAgentsDir,
74
+ } = {}) {
75
+ const result = [...builtins];
76
+ const seen = new Set(result.map((t) => t.name));
77
+
78
+ for (const name of listMarkdownBasenames(userDir)) {
79
+ if (!seen.has(name)) {
80
+ seen.add(name);
81
+ result.push({ name, label: '(user)', group: 'User' });
82
+ }
83
+ }
84
+
85
+ if (pluginCacheDir && existsSync(pluginCacheDir)) {
86
+ for (const marketplace of listSubdirs(pluginCacheDir)) {
87
+ const marketplaceDir = join(pluginCacheDir, marketplace);
88
+ for (const plugin of listSubdirs(marketplaceDir)) {
89
+ const pluginDir = join(marketplaceDir, plugin);
90
+ for (const version of listSubdirs(pluginDir)) {
91
+ const agentsDir = join(pluginDir, version, 'agents');
92
+ for (const agent of listMarkdownBasenames(agentsDir)) {
93
+ const qualified = `${plugin}:${agent}`;
94
+ if (!seen.has(qualified)) {
95
+ seen.add(qualified);
96
+ result.push({
97
+ name: qualified,
98
+ label: '(plugin)',
99
+ group: 'Plugin',
100
+ });
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ for (const name of listMarkdownBasenames(projectAgentsDir)) {
109
+ if (!seen.has(name)) {
110
+ seen.add(name);
111
+ result.push({ name, label: '(project)', group: 'Project' });
112
+ }
113
+ }
114
+
115
+ return result;
116
+ }
@@ -40,6 +40,41 @@ export function meetsMinimum(installed, minimum) {
40
40
  return true; // equal
41
41
  }
42
42
 
43
+ /**
44
+ * Parse a version string into comparable parts, tracking RC suffixes.
45
+ * "0.6.0rc7" → { parts: [0, 6, 0], rc: 7 }
46
+ * "0.6.0" → { parts: [0, 6, 0], rc: Infinity } (stable > any rc)
47
+ * "0.1.0-rc.5" → { parts: [0, 1, 0], rc: 5 }
48
+ */
49
+ export function parseVersion(v) {
50
+ if (!v) return { parts: [], rc: Infinity };
51
+ const rcMatch = v.match(/^(.+?)[-.]?rc\.?(\d+)$/);
52
+ const base = rcMatch ? rcMatch[1] : v;
53
+ const rc = rcMatch ? parseInt(rcMatch[2], 10) : Infinity;
54
+ const parts = base.split('.').map((s) => parseInt(s, 10) || 0);
55
+ return { parts, rc };
56
+ }
57
+
58
+ /**
59
+ * Returns true if `project` version is strictly behind `active`.
60
+ * RC-aware: "0.6.0rc3" is behind "0.6.0". Returns false if either arg is falsy.
61
+ */
62
+ export function isVersionBehind(project, active) {
63
+ if (!project || !active) return false;
64
+ const p = parseVersion(project);
65
+ const a = parseVersion(active);
66
+ const len = Math.max(p.parts.length, a.parts.length);
67
+ for (let i = 0; i < len; i++) {
68
+ const pv = p.parts[i] || 0;
69
+ const av = a.parts[i] || 0;
70
+ if (pv < av) return true;
71
+ if (pv > av) return false;
72
+ }
73
+ // Same base version — compare RC numbers
74
+ if (p.rc < a.rc) return true;
75
+ return false;
76
+ }
77
+
43
78
  /**
44
79
  * Run `worca --version` and check compatibility.
45
80
  * @returns {Promise<{ok: boolean, installed: string|null, minimum: string, message: string}>}
package/server/watcher.js CHANGED
@@ -2,6 +2,25 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, readdirSync, readFileSync, watch } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import {
6
+ assignEventsToIterations,
7
+ readDispatchEventsFromJsonl,
8
+ } from './dispatch-events-aggregator.js';
9
+
10
+ /**
11
+ * Enrich a status object with dispatch events read from events.jsonl in the
12
+ * same run directory. Mutates `status.stages` by adding `dispatch_events` to
13
+ * matching iterations. No-op when events.jsonl is missing (e.g. a run that
14
+ * started before the emit was wired, or a run with no dispatches).
15
+ */
16
+ function enrichWithDispatchEvents(status, runDir) {
17
+ if (!status?.stages) return status;
18
+ const eventsPath = join(runDir, 'events.jsonl');
19
+ const events = readDispatchEventsFromJsonl(eventsPath);
20
+ if (events.length === 0) return status;
21
+ status.stages = assignEventsToIterations(events, status.stages);
22
+ return status;
23
+ }
5
24
 
6
25
  export function createRunId(status) {
7
26
  // Prefer run_id from status (new per-run format)
@@ -35,9 +54,11 @@ export function discoverRuns(worcaDir) {
35
54
  if (existsSync(activeRunPath)) {
36
55
  try {
37
56
  const activeId = readFileSync(activeRunPath, 'utf8').trim();
38
- const candidate = join(worcaDir, 'runs', activeId, 'status.json');
57
+ const runDir = join(worcaDir, 'runs', activeId);
58
+ const candidate = join(runDir, 'status.json');
39
59
  if (existsSync(candidate)) {
40
- const status = JSON.parse(readFileSync(candidate, 'utf8'));
60
+ let status = JSON.parse(readFileSync(candidate, 'utf8'));
61
+ status = enrichWithDispatchEvents(status, runDir);
41
62
  const active =
42
63
  !isTerminal(status) && status.pipeline_status === 'running';
43
64
  const id = createRunId(status);
@@ -53,10 +74,12 @@ export function discoverRuns(worcaDir) {
53
74
  const runsDir = join(worcaDir, 'runs');
54
75
  if (existsSync(runsDir)) {
55
76
  for (const entry of readdirSync(runsDir)) {
56
- const statusPath = join(runsDir, entry, 'status.json');
77
+ const runDir = join(runsDir, entry);
78
+ const statusPath = join(runDir, 'status.json');
57
79
  if (!existsSync(statusPath)) continue;
58
80
  try {
59
- const status = JSON.parse(readFileSync(statusPath, 'utf8'));
81
+ let status = JSON.parse(readFileSync(statusPath, 'utf8'));
82
+ status = enrichWithDispatchEvents(status, runDir);
60
83
  const id = createRunId(status);
61
84
  if (seenIds.has(id)) continue;
62
85
  seenIds.add(id);
@@ -140,8 +163,10 @@ export async function discoverRunsAsync(worcaDir) {
140
163
  const activeRunPath = join(worcaDir, 'active_run');
141
164
  try {
142
165
  const activeId = (await readFile(activeRunPath, 'utf8')).trim();
143
- const candidate = join(worcaDir, 'runs', activeId, 'status.json');
144
- const status = JSON.parse(await readFile(candidate, 'utf8'));
166
+ const runDir = join(worcaDir, 'runs', activeId);
167
+ const candidate = join(runDir, 'status.json');
168
+ let status = JSON.parse(await readFile(candidate, 'utf8'));
169
+ status = enrichWithDispatchEvents(status, runDir);
145
170
  const active = !isTerminal(status) && status.pipeline_status === 'running';
146
171
  const id = createRunId(status);
147
172
  runs.push({ id, active, ...status });
@@ -156,15 +181,17 @@ export async function discoverRunsAsync(worcaDir) {
156
181
  const entries = await readdir(runsDir);
157
182
  const readPromises = entries.map(async (entry) => {
158
183
  try {
159
- const statusPath = join(runsDir, entry, 'status.json');
184
+ const runDir = join(runsDir, entry);
185
+ const statusPath = join(runDir, 'status.json');
160
186
  const status = JSON.parse(await readFile(statusPath, 'utf8'));
161
- return status;
187
+ return { status, runDir };
162
188
  } catch {
163
189
  return null;
164
190
  }
165
191
  });
166
- for (const status of await Promise.all(readPromises)) {
167
- if (!status) continue;
192
+ for (const result of await Promise.all(readPromises)) {
193
+ if (!result) continue;
194
+ const status = enrichWithDispatchEvents(result.status, result.runDir);
168
195
  const id = createRunId(status);
169
196
  if (seenIds.has(id)) continue;
170
197
  seenIds.add(id);
@@ -20,10 +20,24 @@ export function checkWorcaInstalled(projectPath) {
20
20
  }
21
21
 
22
22
  /**
23
- * Read the worca-cc version from a project's .claude/worca/__init__.py.
23
+ * Read the worca-cc version from a project's worca installation.
24
+ * Tries .claude/worca/version.json first, then falls back to __init__.py.
24
25
  * Returns the version string or null if not found.
25
26
  */
26
27
  export function readProjectWorcaVersion(projectPath) {
28
+ // Try version.json first (preferred format)
29
+ try {
30
+ const versionJson = JSON.parse(
31
+ readFileSync(
32
+ join(projectPath, '.claude', 'worca', 'version.json'),
33
+ 'utf8',
34
+ ),
35
+ );
36
+ if (versionJson.version) return versionJson.version;
37
+ } catch {
38
+ // fall through to __init__.py
39
+ }
40
+ // Fall back to __init__.py
27
41
  try {
28
42
  const initPy = readFileSync(
29
43
  join(projectPath, '.claude', 'worca', '__init__.py'),
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { WebSocketServer } from 'ws';
12
12
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
13
13
  import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
14
+ import { readProjectWorcaVersion } from './worca-setup.js';
14
15
  import { createBroadcaster } from './ws-broadcaster.js';
15
16
  import { createClientManager } from './ws-client-manager.js';
16
17
  import { createMessageRouter } from './ws-message-router.js';
@@ -157,9 +158,12 @@ export function attachWsServer(httpServer, config) {
157
158
  }
158
159
 
159
160
  // Broadcast projects-updated to all clients
161
+ // Shape must match GET /api/projects so frontend state stays consistent
162
+ // (include worcaVersion — without it, clients would show "unknown" after
163
+ // the WS event clobbers the enriched REST response on add/remove)
160
164
  const projectList = freshProjects.map((p) => ({
161
- name: p.name,
162
- path: p.path,
165
+ ...p,
166
+ worcaVersion: readProjectWorcaVersion(p.path),
163
167
  }));
164
168
  broadcaster.broadcast('projects-updated', { projects: projectList });
165
169
  }