@worca/ui 0.8.1 → 0.9.0-rc.2

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.
Files changed (35) hide show
  1. package/app/main.bundle.js +1424 -755
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +399 -23
  4. package/package.json +5 -4
  5. package/server/app.js +341 -6
  6. package/server/dispatch-events-aggregator.js +161 -0
  7. package/server/ensure-webhook.js +66 -0
  8. package/server/index.js +22 -0
  9. package/server/integrations/adapter.js +91 -0
  10. package/server/integrations/adapters/discord.js +109 -0
  11. package/server/integrations/adapters/slack.js +106 -0
  12. package/server/integrations/adapters/telegram.js +228 -0
  13. package/server/integrations/adapters/webhook_out.js +253 -0
  14. package/server/integrations/allowlist.js +19 -0
  15. package/server/integrations/chat_context.js +68 -0
  16. package/server/integrations/commands/control.js +120 -0
  17. package/server/integrations/commands/global.js +239 -0
  18. package/server/integrations/commands/parser.js +29 -0
  19. package/server/integrations/commands/project.js +394 -0
  20. package/server/integrations/config-loader.js +40 -0
  21. package/server/integrations/index.js +390 -0
  22. package/server/integrations/markdown.js +220 -0
  23. package/server/integrations/rate_limiter.js +131 -0
  24. package/server/integrations/renderers.js +191 -0
  25. package/server/integrations/rest_client.js +17 -0
  26. package/server/integrations/verify.js +23 -0
  27. package/server/process-manager.js +61 -2
  28. package/server/project-registry.js +37 -0
  29. package/server/project-routes.js +175 -6
  30. package/server/settings-validator.js +279 -2
  31. package/server/subagents-discovery.js +116 -0
  32. package/server/version-check.js +35 -0
  33. package/server/watcher.js +37 -10
  34. package/server/worca-setup.js +15 -1
  35. package/server/ws-modular.js +6 -2
@@ -20,9 +20,11 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { Router } from 'express';
22
22
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
23
+ import { ensureWebhookForUi } from './ensure-webhook.js';
23
24
  import { readPreferences } from './preferences.js';
24
25
  import { ProcessManager } from './process-manager.js';
25
26
  import {
27
+ getMaxProjects,
26
28
  readProjects,
27
29
  removeProject,
28
30
  SLUG_RE,
@@ -36,6 +38,8 @@ import {
36
38
  readMergedSettings,
37
39
  } from './settings-merge.js';
38
40
  import { validateSettingsPayload } from './settings-validator.js';
41
+ import { isVersionBehind } from './version-check.js';
42
+ import { getVersionInfo } from './versions.js';
39
43
  import { discoverRuns } from './watcher.js';
40
44
  import {
41
45
  checkWorcaInstalled,
@@ -135,7 +139,12 @@ export function projectResolver({ prefsDir, projectRoot }) {
135
139
  /**
136
140
  * Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
137
141
  */
138
- export function createProjectRoutes({ prefsDir, projectRoot }) {
142
+ export function createProjectRoutes({
143
+ prefsDir,
144
+ projectRoot,
145
+ serverHost,
146
+ serverPort,
147
+ }) {
139
148
  const router = Router();
140
149
 
141
150
  // GET /api/projects — list all projects (or synthesized default)
@@ -166,6 +175,17 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
166
175
  }
167
176
  try {
168
177
  writeProject(prefsDir, entry);
178
+ // Auto-configure webhook so pipeline events reach this UI server
179
+ if (serverHost && serverPort) {
180
+ try {
181
+ ensureWebhookForUi(entry.path, {
182
+ host: serverHost,
183
+ port: serverPort,
184
+ });
185
+ } catch {
186
+ /* best-effort — don't fail project creation */
187
+ }
188
+ }
169
189
  res.status(201).json({ ok: true, project: entry });
170
190
  } catch (err) {
171
191
  res.status(400).json({ ok: false, error: err.message });
@@ -182,15 +202,128 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
182
202
  res.json({ ok: true, removed: id });
183
203
  });
184
204
 
205
+ // POST /api/projects/batch — register multiple projects atomically
206
+ router.post('/batch', (req, res) => {
207
+ const { projects: batch } = req.body || {};
208
+ if (!Array.isArray(batch) || batch.length === 0) {
209
+ return res
210
+ .status(400)
211
+ .json({ ok: false, error: 'projects must be a non-empty array' });
212
+ }
213
+
214
+ // Validate all entries first (all-or-nothing)
215
+ const failed = [];
216
+ for (const entry of batch) {
217
+ const validation = validateProjectEntry(entry);
218
+ if (!validation.valid) {
219
+ failed.push({ name: entry?.name ?? '', error: validation.error });
220
+ continue;
221
+ }
222
+ if (!existsSync(entry.path)) {
223
+ failed.push({
224
+ name: entry.name,
225
+ error: `directory does not exist: ${entry.path}`,
226
+ });
227
+ }
228
+ }
229
+ if (failed.length > 0) {
230
+ return res.status(400).json({
231
+ ok: false,
232
+ error: `${failed.length} project${failed.length > 1 ? 's' : ''} failed validation`,
233
+ failed,
234
+ });
235
+ }
236
+
237
+ // Check for intra-batch duplicate names
238
+ const batchNames = batch.map((e) => e?.name).filter(Boolean);
239
+ if (new Set(batchNames).size < batchNames.length) {
240
+ return res
241
+ .status(400)
242
+ .json({ ok: false, error: 'Duplicate names within batch' });
243
+ }
244
+
245
+ // Check for intra-batch duplicate paths
246
+ const batchPaths = batch.map((e) => e?.path).filter(Boolean);
247
+ if (new Set(batchPaths).size < batchPaths.length) {
248
+ return res
249
+ .status(400)
250
+ .json({ ok: false, error: 'Duplicate paths within batch' });
251
+ }
252
+
253
+ // Check for duplicate paths against existing projects
254
+ const existing = readProjects(prefsDir);
255
+ const existingPaths = new Set(
256
+ existing.map((p) => p.path.replace(/\/+$/, '')),
257
+ );
258
+ const duplicates = batch.filter((entry) =>
259
+ existingPaths.has(entry.path.replace(/\/+$/, '')),
260
+ );
261
+ if (duplicates.length > 0) {
262
+ return res.status(400).json({
263
+ ok: false,
264
+ error: `${duplicates.length} project${duplicates.length > 1 ? 's' : ''} already registered`,
265
+ failed: duplicates.map((entry) => ({
266
+ name: entry.name,
267
+ error: `path already registered: ${entry.path}`,
268
+ })),
269
+ });
270
+ }
271
+
272
+ // Check max projects limit
273
+ const max = getMaxProjects(prefsDir);
274
+ if (existing.length + batch.length > max) {
275
+ return res.status(400).json({
276
+ ok: false,
277
+ error: `adding ${batch.length} project${batch.length > 1 ? 's' : ''} would exceed the limit of ${max}`,
278
+ });
279
+ }
280
+
281
+ // Write all projects — roll back on partial failure
282
+ const written = [];
283
+ try {
284
+ for (const entry of batch) {
285
+ writeProject(prefsDir, entry);
286
+ written.push(entry.name);
287
+ if (serverHost && serverPort) {
288
+ try {
289
+ ensureWebhookForUi(entry.path, {
290
+ host: serverHost,
291
+ port: serverPort,
292
+ });
293
+ } catch {
294
+ /* best-effort */
295
+ }
296
+ }
297
+ }
298
+ res.status(201).json({ ok: true, projects: batch });
299
+ } catch (err) {
300
+ for (const name of written) {
301
+ try {
302
+ removeProject(prefsDir, name);
303
+ } catch {
304
+ // ignore rollback errors
305
+ }
306
+ }
307
+ res.status(400).json({ ok: false, error: err.message });
308
+ }
309
+ });
310
+
185
311
  return router;
186
312
  }
187
313
 
188
314
  /**
189
315
  * Router for project-scoped sub-routes.
190
316
  * The projectResolver middleware must run before this to set req.project.
317
+ * @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
318
+ * worca-cc version lookup for /worca-status' `outdated` flag.
191
319
  */
192
- export function createProjectScopedRoutes() {
320
+ export function createProjectScopedRoutes({
321
+ prefsDir = null,
322
+ serverHost,
323
+ serverPort,
324
+ } = {}) {
193
325
  const router = Router({ mergeParams: true });
326
+ const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
194
327
 
195
328
  // Guard: run-related, cost, and pipeline routes require worcaDir
196
329
  function requireWorcaDir(req, res, next) {
@@ -1213,10 +1346,35 @@ export function createProjectScopedRoutes() {
1213
1346
  res.json({ ok: true, templates });
1214
1347
  });
1215
1348
 
1216
- // GET /api/projects/:projectId/worca-status — check worca installation state
1217
- router.get('/worca-status', (req, res) => {
1218
- const installed = checkWorcaInstalled(req.project.projectRoot);
1219
- res.json({ ok: true, installed });
1349
+ // GET /api/projects/:projectId/worca-status — check worca installation state.
1350
+ // `outdated` is true when the project's installed worca-cc version is
1351
+ // strictly behind the active (dev-path or globally-installed) worca-cc.
1352
+ router.get('/worca-status', async (req, res) => {
1353
+ const { projectRoot } = req.project;
1354
+ const installed = checkWorcaInstalled(projectRoot);
1355
+ if (!installed) {
1356
+ return res.json({
1357
+ ok: true,
1358
+ installed: false,
1359
+ version: null,
1360
+ outdated: false,
1361
+ });
1362
+ }
1363
+ const version = readProjectWorcaVersion(projectRoot);
1364
+ let outdated = false;
1365
+ if (version != null) {
1366
+ try {
1367
+ const versionInfo = await getVersionInfo({
1368
+ prefsPath,
1369
+ worcaVersion: req.app.locals.worcaVersion || null,
1370
+ });
1371
+ outdated = isVersionBehind(version, versionInfo.activeWorcaCc);
1372
+ } catch {
1373
+ // Best-effort — if version lookup fails, treat as not outdated
1374
+ outdated = false;
1375
+ }
1376
+ }
1377
+ res.json({ ok: true, installed: true, version, outdated });
1220
1378
  });
1221
1379
 
1222
1380
  // POST /api/projects/:projectId/worca-setup — install or update worca
@@ -1238,6 +1396,17 @@ export function createProjectScopedRoutes() {
1238
1396
 
1239
1397
  try {
1240
1398
  const { pid } = runWorcaSetup(projectRoot, { source });
1399
+ // Auto-configure webhook so pipeline events reach this UI server
1400
+ if (serverHost && serverPort) {
1401
+ try {
1402
+ ensureWebhookForUi(projectRoot, {
1403
+ host: serverHost,
1404
+ port: serverPort,
1405
+ });
1406
+ } catch {
1407
+ /* best-effort */
1408
+ }
1409
+ }
1241
1410
  res.json({ ok: true, pid });
1242
1411
  } catch (err) {
1243
1412
  res.status(500).json({ ok: false, error: err.message });
@@ -323,8 +323,35 @@ export function validateSettingsPayload(body) {
323
323
  continue;
324
324
  }
325
325
  for (const v of val) {
326
- if (!VALID_AGENTS.includes(v)) {
327
- details.push(`Unknown agent "${v}" in dispatch for "${key}"`);
326
+ if (typeof v !== 'string') {
327
+ details.push(`Dispatch entry for "${key}" must be a string`);
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ if (g.subagent_dispatch !== undefined) {
334
+ if (
335
+ typeof g.subagent_dispatch !== 'object' ||
336
+ g.subagent_dispatch === null ||
337
+ Array.isArray(g.subagent_dispatch)
338
+ ) {
339
+ details.push('governance.subagent_dispatch must be an object');
340
+ } else {
341
+ for (const [key, val] of Object.entries(g.subagent_dispatch)) {
342
+ if (!VALID_AGENTS.includes(key)) {
343
+ details.push(`Unknown subagent_dispatch agent: "${key}"`);
344
+ continue;
345
+ }
346
+ if (!Array.isArray(val)) {
347
+ details.push(`subagent_dispatch for "${key}" must be an array`);
348
+ continue;
349
+ }
350
+ for (const v of val) {
351
+ if (typeof v !== 'string') {
352
+ details.push(
353
+ `subagent_dispatch entry for "${key}" must be a string`,
354
+ );
328
355
  }
329
356
  }
330
357
  }
@@ -505,3 +532,253 @@ export function validateSettingsPayload(body) {
505
532
 
506
533
  return details.length ? { valid: false, details } : { valid: true };
507
534
  }
535
+
536
+ const VALID_WEBHOOK_OUT_FORMATS = [
537
+ 'generic-json',
538
+ 'slack-compatible',
539
+ 'discord-compatible',
540
+ 'teams-card',
541
+ 'ntfy',
542
+ 'plain-text',
543
+ ];
544
+
545
+ export function validateIntegrationsConfig(cfg) {
546
+ const details = [];
547
+
548
+ if (cfg.schema_version === undefined || cfg.schema_version !== 1) {
549
+ details.push('schema_version must be present and equal to 1');
550
+ }
551
+
552
+ if (cfg.enabled !== undefined && typeof cfg.enabled !== 'boolean') {
553
+ details.push('enabled must be a boolean');
554
+ }
555
+
556
+ if (cfg.webhook_secret_env !== undefined) {
557
+ if (
558
+ typeof cfg.webhook_secret_env !== 'string' ||
559
+ cfg.webhook_secret_env.length === 0
560
+ ) {
561
+ details.push('webhook_secret_env must be a non-empty string');
562
+ }
563
+ }
564
+
565
+ if (cfg.webhook_secrets_env !== undefined) {
566
+ if (
567
+ typeof cfg.webhook_secrets_env !== 'string' ||
568
+ cfg.webhook_secrets_env.length === 0
569
+ ) {
570
+ details.push('webhook_secrets_env must be a non-empty string');
571
+ }
572
+ }
573
+
574
+ if (
575
+ cfg.strict_inbox_verification !== undefined &&
576
+ typeof cfg.strict_inbox_verification !== 'boolean'
577
+ ) {
578
+ details.push('strict_inbox_verification must be a boolean');
579
+ }
580
+
581
+ // telegram
582
+ if (cfg.telegram !== undefined) {
583
+ if (
584
+ typeof cfg.telegram !== 'object' ||
585
+ cfg.telegram === null ||
586
+ Array.isArray(cfg.telegram)
587
+ ) {
588
+ details.push('telegram must be an object');
589
+ } else {
590
+ const tg = cfg.telegram;
591
+ if (tg.enabled !== undefined && typeof tg.enabled !== 'boolean') {
592
+ details.push('telegram.enabled must be a boolean');
593
+ }
594
+ const hasTgToken =
595
+ (typeof tg.bot_token === 'string' && tg.bot_token.length > 0) ||
596
+ (typeof tg.bot_token_env === 'string' && tg.bot_token_env.length > 0);
597
+ if (!hasTgToken) {
598
+ details.push(
599
+ 'telegram requires bot_token or bot_token_env (non-empty string)',
600
+ );
601
+ }
602
+ if (
603
+ tg.chat_id === undefined ||
604
+ (typeof tg.chat_id !== 'string' && typeof tg.chat_id !== 'number')
605
+ ) {
606
+ details.push('telegram.chat_id must be a string or number');
607
+ }
608
+ if (tg.events === undefined || !Array.isArray(tg.events)) {
609
+ details.push('telegram.events must be an array');
610
+ } else {
611
+ for (let i = 0; i < tg.events.length; i++) {
612
+ if (typeof tg.events[i] !== 'string' || tg.events[i].length === 0) {
613
+ details.push(`telegram.events[${i}] must be a non-empty string`);
614
+ }
615
+ }
616
+ }
617
+ if (tg.rate_limit_per_min !== undefined) {
618
+ if (
619
+ !Number.isInteger(tg.rate_limit_per_min) ||
620
+ tg.rate_limit_per_min < 1
621
+ ) {
622
+ details.push(
623
+ 'telegram.rate_limit_per_min must be a positive integer',
624
+ );
625
+ }
626
+ }
627
+ }
628
+ }
629
+
630
+ // discord
631
+ if (cfg.discord !== undefined) {
632
+ if (
633
+ typeof cfg.discord !== 'object' ||
634
+ cfg.discord === null ||
635
+ Array.isArray(cfg.discord)
636
+ ) {
637
+ details.push('discord must be an object');
638
+ } else {
639
+ const dc = cfg.discord;
640
+ if (dc.enabled !== undefined && typeof dc.enabled !== 'boolean') {
641
+ details.push('discord.enabled must be a boolean');
642
+ }
643
+ const hasDcToken =
644
+ (typeof dc.bot_token === 'string' && dc.bot_token.length > 0) ||
645
+ (typeof dc.bot_token_env === 'string' && dc.bot_token_env.length > 0);
646
+ if (!hasDcToken) {
647
+ details.push(
648
+ 'discord requires bot_token or bot_token_env (non-empty string)',
649
+ );
650
+ }
651
+ if (
652
+ dc.channel_id === undefined ||
653
+ typeof dc.channel_id !== 'string' ||
654
+ dc.channel_id.length === 0
655
+ ) {
656
+ details.push('discord.channel_id must be a non-empty string');
657
+ }
658
+ if (dc.events !== undefined && !Array.isArray(dc.events)) {
659
+ details.push('discord.events must be an array');
660
+ } else if (Array.isArray(dc.events)) {
661
+ for (let i = 0; i < dc.events.length; i++) {
662
+ if (typeof dc.events[i] !== 'string' || dc.events[i].length === 0) {
663
+ details.push(`discord.events[${i}] must be a non-empty string`);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ }
669
+
670
+ // slack
671
+ if (cfg.slack !== undefined) {
672
+ if (
673
+ typeof cfg.slack !== 'object' ||
674
+ cfg.slack === null ||
675
+ Array.isArray(cfg.slack)
676
+ ) {
677
+ details.push('slack must be an object');
678
+ } else {
679
+ const sl = cfg.slack;
680
+ if (sl.enabled !== undefined && typeof sl.enabled !== 'boolean') {
681
+ details.push('slack.enabled must be a boolean');
682
+ }
683
+ const hasSlUrl =
684
+ (typeof sl.webhook_url === 'string' && sl.webhook_url.length > 0) ||
685
+ (typeof sl.webhook_url_env === 'string' &&
686
+ sl.webhook_url_env.length > 0);
687
+ if (!hasSlUrl) {
688
+ details.push(
689
+ 'slack requires webhook_url or webhook_url_env (non-empty string)',
690
+ );
691
+ }
692
+ if (sl.events !== undefined && !Array.isArray(sl.events)) {
693
+ details.push('slack.events must be an array');
694
+ } else if (Array.isArray(sl.events)) {
695
+ for (let i = 0; i < sl.events.length; i++) {
696
+ if (typeof sl.events[i] !== 'string' || sl.events[i].length === 0) {
697
+ details.push(`slack.events[${i}] must be a non-empty string`);
698
+ }
699
+ }
700
+ }
701
+ }
702
+ }
703
+
704
+ // webhook_out
705
+ if (cfg.webhook_out !== undefined) {
706
+ if (
707
+ typeof cfg.webhook_out !== 'object' ||
708
+ cfg.webhook_out === null ||
709
+ Array.isArray(cfg.webhook_out)
710
+ ) {
711
+ details.push('webhook_out must be an object');
712
+ } else {
713
+ const wo = cfg.webhook_out;
714
+ if (wo.enabled !== undefined && typeof wo.enabled !== 'boolean') {
715
+ details.push('webhook_out.enabled must be a boolean');
716
+ }
717
+ if (wo.endpoints !== undefined) {
718
+ if (!Array.isArray(wo.endpoints)) {
719
+ details.push('webhook_out.endpoints must be an array');
720
+ } else {
721
+ for (let i = 0; i < wo.endpoints.length; i++) {
722
+ const ep = wo.endpoints[i];
723
+ const pfx = `webhook_out.endpoints[${i}]`;
724
+ if (typeof ep !== 'object' || ep === null || Array.isArray(ep)) {
725
+ details.push(`${pfx} must be an object`);
726
+ continue;
727
+ }
728
+ if (
729
+ ep.url === undefined ||
730
+ typeof ep.url !== 'string' ||
731
+ ep.url.trim().length === 0
732
+ ) {
733
+ details.push(`${pfx}.url must be a non-empty string`);
734
+ } else {
735
+ try {
736
+ const parsed = new URL(ep.url);
737
+ if (
738
+ parsed.protocol !== 'http:' &&
739
+ parsed.protocol !== 'https:'
740
+ ) {
741
+ details.push(`${pfx}.url must use http or https protocol`);
742
+ }
743
+ } catch {
744
+ details.push(`${pfx}.url is not a valid URL`);
745
+ }
746
+ }
747
+ if (ep.format !== undefined) {
748
+ if (!VALID_WEBHOOK_OUT_FORMATS.includes(ep.format)) {
749
+ details.push(
750
+ `${pfx}.format must be one of: ${VALID_WEBHOOK_OUT_FORMATS.join(', ')}`,
751
+ );
752
+ }
753
+ }
754
+ if (ep.headers !== undefined) {
755
+ if (
756
+ typeof ep.headers !== 'object' ||
757
+ ep.headers === null ||
758
+ Array.isArray(ep.headers)
759
+ ) {
760
+ details.push(`${pfx}.headers must be an object`);
761
+ }
762
+ }
763
+ if (ep.events !== undefined && !Array.isArray(ep.events)) {
764
+ details.push(`${pfx}.events must be an array`);
765
+ } else if (Array.isArray(ep.events)) {
766
+ for (let j = 0; j < ep.events.length; j++) {
767
+ if (
768
+ typeof ep.events[j] !== 'string' ||
769
+ ep.events[j].length === 0
770
+ ) {
771
+ details.push(
772
+ `${pfx}.events[${j}] must be a non-empty string`,
773
+ );
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ return details.length ? { valid: false, details } : { valid: true };
784
+ }
@@ -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}>}