@virtengine/openfleet 0.25.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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,405 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, statSync, readFileSync } from "node:fs";
3
+ import { readdir, rm, stat } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import {
6
+ loadSharedWorkspaceRegistry,
7
+ saveSharedWorkspaceRegistry,
8
+ sweepExpiredLeases,
9
+ } from "./shared-workspace-registry.mjs";
10
+
11
+ /**
12
+ * workspace-reaper.mjs
13
+ *
14
+ * Cleans up expired workspace leases and orphaned git worktrees after crashes.
15
+ * Implements safety checks to avoid deleting active workspaces.
16
+ */
17
+
18
+ const DEFAULT_ORPHAN_THRESHOLD_HOURS = 24;
19
+ const DEFAULT_AGGRESSIVE_THRESHOLD_HOURS = 1; // For completed task cleanup
20
+ const DEFAULT_PROCESS_CHECK_RETRIES = 3;
21
+ const IS_WINDOWS = process.platform === "win32";
22
+
23
+ function buildGitEnv() {
24
+ const env = { ...process.env };
25
+ delete env.GIT_DIR;
26
+ delete env.GIT_WORK_TREE;
27
+ delete env.GIT_INDEX_FILE;
28
+ return env;
29
+ }
30
+
31
+ function ensureIso(date) {
32
+ return new Date(date).toISOString();
33
+ }
34
+
35
+ /**
36
+ * Check if a process is running by PID
37
+ */
38
+ function isProcessRunning(pid) {
39
+ if (!Number.isFinite(pid) || pid <= 0) return false;
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Extract PIDs from common lock files or process indicators
50
+ */
51
+ function extractPidsFromPath(worktreePath) {
52
+ const pids = new Set();
53
+ const lockPaths = [
54
+ resolve(worktreePath, ".git", "index.lock"),
55
+ resolve(worktreePath, ".openfleet.pid"),
56
+ resolve(worktreePath, ".vk-executor.pid"),
57
+ ];
58
+ for (const lockPath of lockPaths) {
59
+ if (!existsSync(lockPath)) continue;
60
+ try {
61
+ const content = readFileSync(lockPath, "utf8").trim();
62
+ const pidMatch = /^\d+$/.test(content) ? Number(content) : null;
63
+ if (pidMatch && pidMatch > 0) {
64
+ pids.add(pidMatch);
65
+ }
66
+ } catch {
67
+ // Ignore errors reading lock files
68
+ }
69
+ }
70
+ return Array.from(pids);
71
+ }
72
+
73
+ /**
74
+ * Cross-platform sleep
75
+ */
76
+ function sleep(ms) {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+
80
+ /**
81
+ * Check if a worktree path has any active processes
82
+ */
83
+ async function hasActiveProcesses(worktreePath, options = {}) {
84
+ const retries = options.retries || DEFAULT_PROCESS_CHECK_RETRIES;
85
+ const pids = extractPidsFromPath(worktreePath);
86
+ if (pids.length === 0) return false;
87
+
88
+ for (let i = 0; i < retries; i++) {
89
+ for (const pid of pids) {
90
+ if (isProcessRunning(pid)) {
91
+ return true;
92
+ }
93
+ }
94
+ if (i < retries - 1) {
95
+ // Wait a bit before retry
96
+ await sleep(1000);
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Get last modified time of most recently modified file in directory
104
+ */
105
+ async function getLastModifiedTime(dirPath) {
106
+ try {
107
+ if (!existsSync(dirPath)) return null;
108
+ let latestMtime = null;
109
+ const entries = await readdir(dirPath, { withFileTypes: true, recursive: false });
110
+ for (const entry of entries) {
111
+ if (entry.name === ".git") {
112
+ continue;
113
+ }
114
+ const fullPath = resolve(dirPath, entry.name);
115
+ try {
116
+ const stats = await stat(fullPath);
117
+ if (!latestMtime || stats.mtime > latestMtime) {
118
+ latestMtime = stats.mtime;
119
+ }
120
+ } catch {
121
+ // Ignore errors on individual files
122
+ }
123
+ }
124
+ return latestMtime;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Check if a worktree is truly orphaned (safe to delete)
132
+ */
133
+ async function isWorktreeOrphaned(worktreePath, options = {}) {
134
+ const now = options.now ? new Date(options.now) : new Date();
135
+ const thresholdHours = options.orphanThresholdHours || DEFAULT_ORPHAN_THRESHOLD_HOURS;
136
+
137
+ // Safety check 1: Path must exist
138
+ if (!existsSync(worktreePath)) {
139
+ return { orphaned: false, reason: "path_not_found" };
140
+ }
141
+
142
+ // Safety check 2: No active processes
143
+ if (await hasActiveProcesses(worktreePath, options)) {
144
+ return { orphaned: false, reason: "active_process" };
145
+ }
146
+
147
+ // Safety check 3: Check last modified time
148
+ const lastModified = await getLastModifiedTime(worktreePath);
149
+ if (lastModified) {
150
+ const ageHours = (now.getTime() - lastModified.getTime()) / (1000 * 60 * 60);
151
+ if (ageHours < thresholdHours) {
152
+ return { orphaned: false, reason: "recently_modified", ageHours };
153
+ }
154
+ }
155
+
156
+ // Safety check 4: Check git status for uncommitted work
157
+ // Only check git status if .git is a FILE (proper worktree), not a directory
158
+ const gitPath = resolve(worktreePath, ".git");
159
+ const isProperWorktree = existsSync(gitPath) && !statSync(gitPath).isDirectory();
160
+
161
+ if (isProperWorktree) {
162
+ try {
163
+ const cmd = IS_WINDOWS
164
+ ? `cd /d "${worktreePath}" && git status --porcelain`
165
+ : `cd "${worktreePath}" && git status --porcelain`;
166
+ const gitStatus = execSync(cmd, {
167
+ encoding: "utf8",
168
+ env: buildGitEnv(),
169
+ stdio: ["pipe", "pipe", "ignore"],
170
+ }).trim();
171
+ if (gitStatus.length > 0) {
172
+ return { orphaned: false, reason: "uncommitted_changes" };
173
+ }
174
+ } catch {
175
+ // If git command fails, worktree might be corrupted - consider orphaned
176
+ }
177
+ }
178
+
179
+ return { orphaned: true, lastModified, ageHours: lastModified ? (now.getTime() - lastModified.getTime()) / (1000 * 60 * 60) : null };
180
+ }
181
+
182
+ /**
183
+ * Find git worktrees in a given directory
184
+ */
185
+ async function findGitWorktrees(searchPath) {
186
+ const worktrees = [];
187
+ try {
188
+ if (!existsSync(searchPath)) return worktrees;
189
+ const entries = await readdir(searchPath, { withFileTypes: true });
190
+ for (const entry of entries) {
191
+ if (!entry.isDirectory()) continue;
192
+ const fullPath = resolve(searchPath, entry.name);
193
+ const gitPath = resolve(fullPath, ".git");
194
+ if (existsSync(gitPath)) {
195
+ worktrees.push({
196
+ path: fullPath,
197
+ name: entry.name,
198
+ });
199
+ }
200
+ }
201
+ } catch (err) {
202
+ console.warn(`[workspace-reaper] failed to scan ${searchPath}: ${err.message || err}`);
203
+ }
204
+ return worktrees;
205
+ }
206
+
207
+ /**
208
+ * Clean orphaned worktrees with safety checks
209
+ */
210
+ export async function cleanOrphanedWorktrees(options = {}) {
211
+ const now = options.now ? new Date(options.now) : new Date();
212
+ const searchPaths = Array.isArray(options.searchPaths)
213
+ ? options.searchPaths
214
+ : [process.env.VK_WORKTREE_BASE || "/tmp/vibe-kanban/worktrees"];
215
+
216
+ const results = {
217
+ scanned: 0,
218
+ cleaned: 0,
219
+ skipped: 0,
220
+ errors: [],
221
+ cleaned_paths: [],
222
+ skipped_reasons: {},
223
+ };
224
+
225
+ for (const searchPath of searchPaths) {
226
+ const worktrees = await findGitWorktrees(searchPath);
227
+ for (const worktree of worktrees) {
228
+ results.scanned++;
229
+ const orphanCheck = await isWorktreeOrphaned(worktree.path, options);
230
+
231
+ if (!orphanCheck.orphaned) {
232
+ results.skipped++;
233
+ const reason = orphanCheck.reason || "unknown";
234
+ results.skipped_reasons[reason] = (results.skipped_reasons[reason] || 0) + 1;
235
+ continue;
236
+ }
237
+
238
+ // Safe to delete
239
+ if (options.dryRun) {
240
+ results.cleaned_paths.push({
241
+ path: worktree.path,
242
+ dryRun: true,
243
+ ageHours: orphanCheck.ageHours,
244
+ });
245
+ results.cleaned++;
246
+ continue;
247
+ }
248
+
249
+ try {
250
+ await rm(worktree.path, { recursive: true, force: true });
251
+ results.cleaned++;
252
+ results.cleaned_paths.push({
253
+ path: worktree.path,
254
+ ageHours: orphanCheck.ageHours,
255
+ cleanedAt: ensureIso(now),
256
+ });
257
+ } catch (err) {
258
+ results.errors.push({
259
+ path: worktree.path,
260
+ error: err.message || String(err),
261
+ });
262
+ }
263
+ }
264
+ }
265
+
266
+ return results;
267
+ }
268
+
269
+ /**
270
+ * Prune git worktree metadata
271
+ * Removes stale entries from .git/worktrees/
272
+ */
273
+ export async function pruneWorktreeMetadata(repoPath, options = {}) {
274
+ const results = {
275
+ pruned: 0,
276
+ errors: [],
277
+ };
278
+
279
+ try {
280
+ const gitEnv = buildGitEnv();
281
+ execSync('git worktree prune', {
282
+ cwd: repoPath,
283
+ stdio: options.verbose ? 'inherit' : 'pipe',
284
+ env: { ...gitEnv, GIT_EDITOR: ':', GIT_MERGE_AUTOEDIT: 'no' },
285
+ });
286
+ results.pruned++;
287
+ } catch (err) {
288
+ results.errors.push(err.message || String(err));
289
+ }
290
+
291
+ return results;
292
+ }
293
+
294
+ /**
295
+ * Get worktree count for monitoring
296
+ */
297
+ export async function getWorktreeCount(repoPath) {
298
+ try {
299
+ const gitEnv = buildGitEnv();
300
+ const output = execSync('git worktree list --porcelain', {
301
+ cwd: repoPath,
302
+ encoding: 'utf8',
303
+ env: gitEnv,
304
+ });
305
+ // Count "worktree" lines (one per worktree including main)
306
+ const count = (output.match(/^worktree /gm) || []).length;
307
+ return count - 1; // Subtract 1 for main worktree
308
+ } catch {
309
+ return 0;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Run full reaper sweep: expired leases + orphaned worktrees
315
+ */
316
+ export async function runReaperSweep(options = {}) {
317
+ const now = options.now ? new Date(options.now) : new Date();
318
+ const results = {
319
+ timestamp: ensureIso(now),
320
+ leases: {
321
+ expired: 0,
322
+ cleaned: 0,
323
+ errors: [],
324
+ },
325
+ worktrees: {
326
+ scanned: 0,
327
+ cleaned: 0,
328
+ skipped: 0,
329
+ errors: [],
330
+ },
331
+ };
332
+
333
+ // Step 1: Sweep expired leases
334
+ try {
335
+ const leaseResult = await sweepExpiredLeases({ ...options, now });
336
+ results.leases.expired = leaseResult.expired?.length || 0;
337
+ results.leases.cleaned = leaseResult.expired?.length || 0;
338
+ } catch (err) {
339
+ results.leases.errors.push(err.message || String(err));
340
+ }
341
+
342
+ // Step 2: Clean orphaned worktrees
343
+ try {
344
+ const worktreeResult = await cleanOrphanedWorktrees({ ...options, now });
345
+ results.worktrees.scanned = worktreeResult.scanned;
346
+ results.worktrees.cleaned = worktreeResult.cleaned;
347
+ results.worktrees.skipped = worktreeResult.skipped;
348
+ results.worktrees.errors = worktreeResult.errors || [];
349
+ results.worktrees.skipped_reasons = worktreeResult.skipped_reasons || {};
350
+ results.worktrees.cleaned_paths = worktreeResult.cleaned_paths || [];
351
+ } catch (err) {
352
+ results.worktrees.errors.push({ error: err.message || String(err) });
353
+ }
354
+
355
+ return results;
356
+ }
357
+
358
+ /**
359
+ * Format reaper results for logging
360
+ */
361
+ export function formatReaperResults(results) {
362
+ const lines = [];
363
+ lines.push(`[workspace-reaper] Sweep completed at ${results.timestamp}`);
364
+
365
+ if (results.leases) {
366
+ lines.push(` Leases: ${results.leases.expired} expired, ${results.leases.cleaned} cleaned`);
367
+ if (results.leases.errors.length > 0) {
368
+ lines.push(` Lease errors: ${results.leases.errors.length}`);
369
+ }
370
+ }
371
+
372
+ if (results.worktrees) {
373
+ lines.push(
374
+ ` Worktrees: ${results.worktrees.scanned} scanned, ${results.worktrees.cleaned} cleaned, ${results.worktrees.skipped} skipped`,
375
+ );
376
+ if (results.worktrees.skipped_reasons) {
377
+ for (const [reason, count] of Object.entries(results.worktrees.skipped_reasons)) {
378
+ lines.push(` - ${reason}: ${count}`);
379
+ }
380
+ }
381
+ if (results.worktrees.errors.length > 0) {
382
+ lines.push(` Worktree errors: ${results.worktrees.errors.length}`);
383
+ }
384
+ }
385
+
386
+ return lines.join("\n");
387
+ }
388
+
389
+ /**
390
+ * Calculate reaper metrics for monitoring
391
+ */
392
+ export function calculateReaperMetrics(results) {
393
+ return {
394
+ timestamp: results.timestamp,
395
+ leases_expired: results.leases?.expired || 0,
396
+ leases_cleaned: results.leases?.cleaned || 0,
397
+ lease_errors: results.leases?.errors?.length || 0,
398
+ worktrees_scanned: results.worktrees?.scanned || 0,
399
+ worktrees_cleaned: results.worktrees?.cleaned || 0,
400
+ worktrees_skipped: results.worktrees?.skipped || 0,
401
+ worktree_errors: results.worktrees?.errors?.length || 0,
402
+ total_cleaned: (results.leases?.cleaned || 0) + (results.worktrees?.cleaned || 0),
403
+ total_errors: (results.leases?.errors?.length || 0) + (results.worktrees?.errors?.length || 0),
404
+ };
405
+ }
@@ -0,0 +1,238 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
7
+
8
+ const DEFAULT_REGISTRY = {
9
+ version: 1,
10
+ default_workspace: "primary",
11
+ workspaces: [
12
+ {
13
+ id: "primary",
14
+ name: "Primary Coordinator",
15
+ host: "localhost",
16
+ role: "coordinator",
17
+ capabilities: ["planning", "triage", "routing"],
18
+ model_priorities: ["CODEX:DEFAULT"],
19
+ vk_workspace_id: "primary",
20
+ mentions: ["primary", "coord", "coordinator"],
21
+ },
22
+ ],
23
+ };
24
+
25
+ function normalizeId(value) {
26
+ return String(value || "")
27
+ .trim()
28
+ .toLowerCase();
29
+ }
30
+
31
+ function normalizeExecutorProfile(profile) {
32
+ if (!profile) return null;
33
+ if (typeof profile === "string") {
34
+ const [executor, variant] = profile.split(":");
35
+ if (!executor) return null;
36
+ return {
37
+ executor: executor.trim().toUpperCase(),
38
+ variant: (variant || "DEFAULT").trim().toUpperCase(),
39
+ };
40
+ }
41
+ if (profile.executor && profile.variant) {
42
+ return {
43
+ executor: String(profile.executor).toUpperCase(),
44
+ variant: String(profile.variant).toUpperCase(),
45
+ };
46
+ }
47
+ if (profile.executor_profile_id) {
48
+ return normalizeExecutorProfile(profile.executor_profile_id);
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function normalizeWorkspace(workspace) {
54
+ if (!workspace) return null;
55
+ const id = normalizeId(workspace.id);
56
+ if (!id) return null;
57
+ const mentions = Array.isArray(workspace.mentions)
58
+ ? workspace.mentions.map(normalizeId).filter(Boolean)
59
+ : [];
60
+ const aliases = Array.isArray(workspace.aliases)
61
+ ? workspace.aliases.map(normalizeId).filter(Boolean)
62
+ : [];
63
+ return {
64
+ ...workspace,
65
+ id,
66
+ mentions,
67
+ aliases,
68
+ role: workspace.role || "workspace",
69
+ model_priorities: Array.isArray(workspace.model_priorities)
70
+ ? workspace.model_priorities
71
+ : [],
72
+ };
73
+ }
74
+
75
+ export async function loadWorkspaceRegistry(options = {}) {
76
+ const registryPath = options.registryPath
77
+ ? resolve(options.registryPath)
78
+ : resolve(__dirname, "workspaces.json");
79
+ let registry = null;
80
+ const errors = [];
81
+ const warnings = [];
82
+ if (existsSync(registryPath)) {
83
+ try {
84
+ const raw = await readFile(registryPath, "utf8");
85
+ registry = JSON.parse(raw);
86
+ } catch (err) {
87
+ errors.push(`Failed to read ${registryPath}: ${err.message || err}`);
88
+ console.warn(
89
+ `[workspace-registry] failed to read ${registryPath}: ${err.message || err}`,
90
+ );
91
+ }
92
+ } else {
93
+ warnings.push(`Registry file not found: ${registryPath} — using defaults`);
94
+ }
95
+ if (!registry) {
96
+ registry = { ...DEFAULT_REGISTRY };
97
+ }
98
+
99
+ const workspaces = Array.isArray(registry.workspaces)
100
+ ? registry.workspaces.map(normalizeWorkspace).filter(Boolean)
101
+ : [];
102
+ const defaultWorkspace =
103
+ normalizeId(registry.default_workspace) ||
104
+ DEFAULT_REGISTRY.default_workspace;
105
+
106
+ if (workspaces.length === 0) {
107
+ warnings.push("No workspaces configured — using built-in defaults");
108
+ }
109
+
110
+ return {
111
+ registry: {
112
+ version: registry.version || DEFAULT_REGISTRY.version,
113
+ default_workspace: defaultWorkspace,
114
+ workspaces,
115
+ registry_path: registryPath,
116
+ },
117
+ // Also spread top-level for backward compat
118
+ version: registry.version || DEFAULT_REGISTRY.version,
119
+ default_workspace: defaultWorkspace,
120
+ workspaces,
121
+ registry_path: registryPath,
122
+ errors,
123
+ warnings,
124
+ };
125
+ }
126
+
127
+ export function resolveWorkspace(registry, candidateId) {
128
+ if (!registry || !Array.isArray(registry.workspaces)) return null;
129
+ const target = normalizeId(candidateId);
130
+ if (!target) return null;
131
+ return (
132
+ registry.workspaces.find((w) => w.id === target) ||
133
+ registry.workspaces.find((w) => w.aliases?.includes(target)) ||
134
+ registry.workspaces.find((w) => w.mentions?.includes(target)) ||
135
+ null
136
+ );
137
+ }
138
+
139
+ export function getLocalWorkspace(registry, envWorkspaceId) {
140
+ if (!registry || !Array.isArray(registry.workspaces)) return null;
141
+ const explicit = normalizeId(envWorkspaceId);
142
+ const defaultId = registry.default_workspace;
143
+ const id = explicit || defaultId || "primary";
144
+ return (
145
+ resolveWorkspace(registry, id) ||
146
+ registry.workspaces[0] ||
147
+ normalizeWorkspace({ id })
148
+ );
149
+ }
150
+
151
+ export function listWorkspaceIds(registry) {
152
+ if (!registry || !Array.isArray(registry.workspaces)) return [];
153
+ return registry.workspaces.map((w) => w.id);
154
+ }
155
+
156
+ export function selectExecutorProfile(workspace, override) {
157
+ const overrideProfile = normalizeExecutorProfile(override);
158
+ if (overrideProfile) {
159
+ return overrideProfile;
160
+ }
161
+ if (!workspace) {
162
+ return { executor: "CODEX", variant: "DEFAULT" };
163
+ }
164
+ for (const entry of workspace.model_priorities || []) {
165
+ const profile = normalizeExecutorProfile(entry);
166
+ if (profile) return profile;
167
+ }
168
+ return { executor: "CODEX", variant: "DEFAULT" };
169
+ }
170
+
171
+ export function parseWorkspaceMentions(text, registry) {
172
+ const targets = new Set();
173
+ const normalizedText = String(text || "");
174
+ const mentionMatches = normalizedText.matchAll(/@([A-Za-z0-9_-]+)/g);
175
+ for (const match of mentionMatches) {
176
+ const id = match[1];
177
+ if (id && id.toLowerCase() === "all") {
178
+ return { targets, broadcast: true };
179
+ }
180
+ const workspace = resolveWorkspace(registry, id);
181
+ if (workspace) {
182
+ targets.add(workspace.id);
183
+ }
184
+ }
185
+ const prefixMatches = normalizedText.matchAll(/\[ws:([A-Za-z0-9_-]+)\]/gi);
186
+ for (const match of prefixMatches) {
187
+ const id = match[1];
188
+ if (id && id.toLowerCase() === "all") {
189
+ return { targets, broadcast: true };
190
+ }
191
+ const workspace = resolveWorkspace(registry, id);
192
+ if (workspace) {
193
+ targets.add(workspace.id);
194
+ }
195
+ }
196
+ return { targets, broadcast: false };
197
+ }
198
+
199
+ export function stripWorkspaceMentions(text, registry) {
200
+ const ids = listWorkspaceIds(registry);
201
+ if (!ids.length) return String(text || "").trim();
202
+ let result = String(text || "");
203
+ for (const id of ids) {
204
+ const mention = new RegExp(`@${id}\\b`, "gi");
205
+ const prefix = new RegExp(`\\[ws:${id}\\]`, "gi");
206
+ result = result.replace(mention, "").replace(prefix, "");
207
+ }
208
+ return result.replace(/\s{2,}/g, " ").trim();
209
+ }
210
+
211
+ export function formatBusMessage({ workspaceId, type, text }) {
212
+ const prefixId = workspaceId || "unknown";
213
+ const typeTag = type ? `[${type}]` : "";
214
+ const prefix = `[ws:${prefixId}]${typeTag}`;
215
+ const lines = String(text || "").split(/\r?\n/);
216
+ if (lines.length === 0) {
217
+ return `${prefix}`;
218
+ }
219
+ lines[0] = `${prefix} ${lines[0]}`.trim();
220
+ return lines.join("\n");
221
+ }
222
+
223
+ export function formatRegistryDiagnostics(errors, warnings) {
224
+ const parts = [];
225
+ if (errors && errors.length > 0) {
226
+ parts.push(
227
+ `❌ Registry errors:\n${errors.map((e) => ` • ${e}`).join("\n")}`,
228
+ );
229
+ }
230
+ if (warnings && warnings.length > 0) {
231
+ parts.push(`⚠️ ${warnings.map((w) => w).join("\n⚠️ ")}`);
232
+ }
233
+ return parts.length > 0 ? parts.join("\n") : null;
234
+ }
235
+
236
+ export function getDefaultModelPriority() {
237
+ return ["CODEX:DEFAULT", "COPILOT:CLAUDE_OPUS_4_6"];
238
+ }