@chllming/wave-orchestration 0.5.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +549 -0
  3. package/docs/agents/wave-deploy-verifier-role.md +34 -0
  4. package/docs/agents/wave-documentation-role.md +30 -0
  5. package/docs/agents/wave-evaluator-role.md +43 -0
  6. package/docs/agents/wave-infra-role.md +34 -0
  7. package/docs/agents/wave-integration-role.md +32 -0
  8. package/docs/agents/wave-launcher-role.md +37 -0
  9. package/docs/context7/bundles.json +91 -0
  10. package/docs/plans/component-cutover-matrix.json +112 -0
  11. package/docs/plans/component-cutover-matrix.md +49 -0
  12. package/docs/plans/context7-wave-orchestrator.md +130 -0
  13. package/docs/plans/current-state.md +44 -0
  14. package/docs/plans/master-plan.md +16 -0
  15. package/docs/plans/migration.md +23 -0
  16. package/docs/plans/wave-orchestrator.md +254 -0
  17. package/docs/plans/waves/wave-0.md +165 -0
  18. package/docs/reference/github-packages-setup.md +52 -0
  19. package/docs/reference/migration-0.2-to-0.5.md +622 -0
  20. package/docs/reference/npmjs-trusted-publishing.md +55 -0
  21. package/docs/reference/repository-guidance.md +18 -0
  22. package/docs/reference/runtime-config/README.md +85 -0
  23. package/docs/reference/runtime-config/claude.md +105 -0
  24. package/docs/reference/runtime-config/codex.md +81 -0
  25. package/docs/reference/runtime-config/opencode.md +93 -0
  26. package/docs/research/agent-context-sources.md +57 -0
  27. package/docs/roadmap.md +626 -0
  28. package/package.json +53 -0
  29. package/releases/manifest.json +101 -0
  30. package/scripts/context7-api-check.sh +21 -0
  31. package/scripts/context7-export-env.sh +52 -0
  32. package/scripts/research/agent-context-archive.mjs +472 -0
  33. package/scripts/research/generate-agent-context-indexes.mjs +85 -0
  34. package/scripts/research/import-agent-context-archive.mjs +793 -0
  35. package/scripts/research/manifests/harness-and-blackboard-2026-03-21.mjs +201 -0
  36. package/scripts/wave-autonomous.mjs +13 -0
  37. package/scripts/wave-cli-bootstrap.mjs +27 -0
  38. package/scripts/wave-dashboard.mjs +11 -0
  39. package/scripts/wave-human-feedback.mjs +11 -0
  40. package/scripts/wave-launcher.mjs +11 -0
  41. package/scripts/wave-local-executor.mjs +13 -0
  42. package/scripts/wave-orchestrator/agent-state.mjs +416 -0
  43. package/scripts/wave-orchestrator/autonomous.mjs +367 -0
  44. package/scripts/wave-orchestrator/clarification-triage.mjs +605 -0
  45. package/scripts/wave-orchestrator/config.mjs +848 -0
  46. package/scripts/wave-orchestrator/context7.mjs +464 -0
  47. package/scripts/wave-orchestrator/coord-cli.mjs +286 -0
  48. package/scripts/wave-orchestrator/coordination-store.mjs +987 -0
  49. package/scripts/wave-orchestrator/coordination.mjs +768 -0
  50. package/scripts/wave-orchestrator/dashboard-renderer.mjs +254 -0
  51. package/scripts/wave-orchestrator/dashboard-state.mjs +473 -0
  52. package/scripts/wave-orchestrator/dep-cli.mjs +219 -0
  53. package/scripts/wave-orchestrator/docs-queue.mjs +75 -0
  54. package/scripts/wave-orchestrator/executors.mjs +385 -0
  55. package/scripts/wave-orchestrator/feedback.mjs +372 -0
  56. package/scripts/wave-orchestrator/install.mjs +540 -0
  57. package/scripts/wave-orchestrator/launcher.mjs +3879 -0
  58. package/scripts/wave-orchestrator/ledger.mjs +332 -0
  59. package/scripts/wave-orchestrator/local-executor.mjs +263 -0
  60. package/scripts/wave-orchestrator/replay.mjs +246 -0
  61. package/scripts/wave-orchestrator/roots.mjs +10 -0
  62. package/scripts/wave-orchestrator/routing-state.mjs +542 -0
  63. package/scripts/wave-orchestrator/shared.mjs +405 -0
  64. package/scripts/wave-orchestrator/terminals.mjs +209 -0
  65. package/scripts/wave-orchestrator/traces.mjs +1094 -0
  66. package/scripts/wave-orchestrator/wave-files.mjs +1923 -0
  67. package/scripts/wave.mjs +103 -0
  68. package/wave.config.json +115 -0
@@ -0,0 +1,405 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ DEFAULT_WAVE_LANE as CONFIG_DEFAULT_WAVE_LANE,
6
+ loadWaveConfig,
7
+ resolveLaneProfile,
8
+ } from "./config.mjs";
9
+ import { PACKAGE_ROOT, WORKSPACE_ROOT } from "./roots.mjs";
10
+
11
+ export { PACKAGE_ROOT };
12
+ export const REPO_ROOT = WORKSPACE_ROOT;
13
+
14
+ export const DEFAULT_WAVE_LANE = CONFIG_DEFAULT_WAVE_LANE;
15
+ export const DEFAULT_TIMEOUT_MINUTES = 240;
16
+ export const DEFAULT_MAX_RETRIES_PER_WAVE = 1;
17
+ export const DEFAULT_AGENT_RATE_LIMIT_RETRIES = 2;
18
+ export const DEFAULT_AGENT_RATE_LIMIT_BASE_DELAY_SECONDS = 20;
19
+ export const DEFAULT_AGENT_RATE_LIMIT_MAX_DELAY_SECONDS = 180;
20
+ export const DEFAULT_AGENT_LAUNCH_STAGGER_MS = 1200;
21
+ export const DEFAULT_WAIT_PROGRESS_INTERVAL_MS = 3000;
22
+ export const DEFAULT_REFRESH_MS = 2000;
23
+ export const DEFAULT_WATCH_REFRESH_MS = 2000;
24
+ export const DEFAULT_WAIT_TIMEOUT_SECONDS = 1800;
25
+ export const TMUX_COMMAND_TIMEOUT_MS = 15000;
26
+ export const MESSAGEBOARD_PROMPT_MAX_CHARS = 8000;
27
+ export const DASHBOARD_MAX_EVENTS = 120;
28
+ export const DASHBOARD_MESSAGEBOARD_TAIL_LINES = 28;
29
+ export const DASHBOARD_MESSAGEBOARD_TAIL_CHARS = 12000;
30
+ export const FINAL_EXIT_DELAY_MS = 15000;
31
+ export const LOCK_RETRY_INTERVAL_MS = 50;
32
+ export const LOCK_TIMEOUT_MS = 5000;
33
+ export const LOCK_STALE_MS = 5 * 60 * 1000;
34
+ export const ORCHESTRATOR_DETAIL_MAX_CHARS = 1000;
35
+ export const WAVE_TERMINAL_STATES = new Set(["completed", "failed", "timed_out"]);
36
+ export const TERMINAL_STATES = new Set(["completed", "failed", "timed_out"]);
37
+ export const TERMINAL_ICON = "circuit-board";
38
+ export const TERMINAL_COLOR = "terminal.ansiBrightMagenta";
39
+ export const DASHBOARD_TERMINAL_ICON = "pulse";
40
+ export const DASHBOARD_TERMINAL_COLOR = "terminal.ansiBlue";
41
+ export const PHASE_SIGNAL_REGEX = /^\[wave-phase\]\s*([a-z_][a-z0-9_-]*)\s*$/gim;
42
+ export const WAVE_VERDICT_REGEX =
43
+ /^\[wave-verdict\]\s*(pass|concerns|blocked|hold|fail)\s*(?:detail=(.*))?$/gim;
44
+ export const REPORT_VERDICT_REGEX =
45
+ /^Verdict:\s*(PASS|CONCERNS|BLOCKED|HOLD|FAIL)\b(?:\s*[-:]?\s*(.*))?$/gim;
46
+ export const DEPLOY_SIGNAL_REGEX =
47
+ /^\[deploy-status\]\s*service=([a-z0-9_.:-]+)\s+state=(deploying|healthy|failed|rolledover)\s*(?:detail=(.*))?$/gim;
48
+ export const INFRA_SIGNAL_REGEX =
49
+ /^\[infra-status\]\s*kind=([a-z0-9_.:-]+)\s+target=([a-z0-9_.:@/-]+)\s+state=([a-z0-9_.:-]+)\s*(?:detail=(.*))?$/gim;
50
+
51
+ export function sanitizeLaneName(value) {
52
+ const lane = String(value || "")
53
+ .trim()
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9_-]+/g, "-")
56
+ .replace(/-+/g, "-")
57
+ .replace(/^-+|-+$/g, "");
58
+ if (!lane) {
59
+ throw new Error("Lane name is required");
60
+ }
61
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(lane)) {
62
+ throw new Error(`Invalid lane: ${value}`);
63
+ }
64
+ return lane;
65
+ }
66
+
67
+ export function sanitizeOrchestratorId(value) {
68
+ const id = String(value || "")
69
+ .trim()
70
+ .replace(/\s+/g, "-")
71
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
72
+ .replace(/-+/g, "-")
73
+ .replace(/^-+|-+$/g, "");
74
+ if (!id) {
75
+ throw new Error("Orchestrator ID is required");
76
+ }
77
+ return id.slice(0, 64);
78
+ }
79
+
80
+ export function buildLanePaths(laneInput = DEFAULT_WAVE_LANE, options = {}) {
81
+ const config = options.config || loadWaveConfig();
82
+ const laneProfile = resolveLaneProfile(config, laneInput || config.defaultLane);
83
+ const lane = laneProfile.lane;
84
+ const laneTmux = lane.replace(/-/g, "_");
85
+ const runVariant = String(options.runVariant || "")
86
+ .trim()
87
+ .toLowerCase();
88
+ if (runVariant && runVariant !== "dry-run") {
89
+ throw new Error(`Unsupported lane path variant: ${options.runVariant}`);
90
+ }
91
+ const docsDir = path.join(REPO_ROOT, laneProfile.docsDir);
92
+ const plansDir = path.join(REPO_ROOT, laneProfile.plansDir);
93
+ const preferredWavesDir = path.join(REPO_ROOT, laneProfile.wavesDir);
94
+ const legacyWavesDir = path.join(docsDir, "waves");
95
+ const baseStateDir = path.join(REPO_ROOT, laneProfile.paths.stateRoot, `${lane}-wave-launcher`);
96
+ const stateDir = runVariant === "dry-run" ? path.join(baseStateDir, "dry-run") : baseStateDir;
97
+ const orchestratorStateDir =
98
+ runVariant === "dry-run"
99
+ ? path.join(stateDir, "orchestrator")
100
+ : path.join(REPO_ROOT, laneProfile.paths.orchestratorStateDir);
101
+ const feedbackStateDir = path.join(orchestratorStateDir, "feedback");
102
+ return {
103
+ config,
104
+ laneProfile,
105
+ lane,
106
+ runVariant,
107
+ docsDir,
108
+ plansDir,
109
+ wavesDir:
110
+ fs.existsSync(preferredWavesDir) || !fs.existsSync(legacyWavesDir)
111
+ ? preferredWavesDir
112
+ : legacyWavesDir,
113
+ legacyWavesDir,
114
+ promptsDir: path.join(stateDir, "prompts"),
115
+ logsDir: path.join(stateDir, "logs"),
116
+ statusDir: path.join(stateDir, "status"),
117
+ messageboardsDir: path.join(stateDir, "messageboards"),
118
+ dashboardsDir: path.join(stateDir, "dashboards"),
119
+ coordinationDir: path.join(stateDir, "coordination"),
120
+ assignmentsDir: path.join(stateDir, "assignments"),
121
+ inboxesDir: path.join(stateDir, "inboxes"),
122
+ ledgerDir: path.join(stateDir, "ledger"),
123
+ integrationDir: path.join(stateDir, "integration"),
124
+ dependencySnapshotsDir: path.join(stateDir, "dependencies"),
125
+ docsQueueDir: path.join(stateDir, "docs-queue"),
126
+ tracesDir: path.join(stateDir, "traces"),
127
+ context7CacheDir: path.join(stateDir, "context7-cache"),
128
+ executorOverlaysDir: path.join(stateDir, "executors"),
129
+ stateDir,
130
+ terminalsPath: path.join(REPO_ROOT, laneProfile.paths.terminalsPath),
131
+ context7BundleIndexPath: path.join(REPO_ROOT, laneProfile.paths.context7BundleIndexPath),
132
+ componentCutoverMatrixDocPath: path.join(
133
+ REPO_ROOT,
134
+ laneProfile.paths.componentCutoverMatrixDocPath,
135
+ ),
136
+ componentCutoverMatrixJsonPath: path.join(
137
+ REPO_ROOT,
138
+ laneProfile.paths.componentCutoverMatrixJsonPath,
139
+ ),
140
+ sharedPlanDocs: laneProfile.sharedPlanDocs,
141
+ requiredPromptReferences: laneProfile.validation.requiredPromptReferences,
142
+ rolePromptDir: laneProfile.roles.rolePromptDir,
143
+ evaluatorAgentId: laneProfile.roles.evaluatorAgentId,
144
+ integrationAgentId: laneProfile.roles.integrationAgentId,
145
+ documentationAgentId: laneProfile.roles.documentationAgentId,
146
+ evaluatorRolePromptPath: laneProfile.roles.evaluatorRolePromptPath,
147
+ integrationRolePromptPath: laneProfile.roles.integrationRolePromptPath,
148
+ documentationRolePromptPath: laneProfile.roles.documentationRolePromptPath,
149
+ requireDocumentationStewardFromWave:
150
+ laneProfile.validation.requireDocumentationStewardFromWave,
151
+ requireContext7DeclarationsFromWave:
152
+ laneProfile.validation.requireContext7DeclarationsFromWave,
153
+ requireExitContractsFromWave: laneProfile.validation.requireExitContractsFromWave,
154
+ requireIntegrationStewardFromWave:
155
+ laneProfile.validation.requireIntegrationStewardFromWave,
156
+ requireComponentPromotionsFromWave:
157
+ laneProfile.validation.requireComponentPromotionsFromWave,
158
+ requireAgentComponentsFromWave: laneProfile.validation.requireAgentComponentsFromWave,
159
+ executors: laneProfile.executors,
160
+ capabilityRouting: laneProfile.capabilityRouting,
161
+ defaultManifestPath: path.join(stateDir, "waves.manifest.json"),
162
+ defaultRunStatePath: path.join(stateDir, "run-state.json"),
163
+ globalDashboardPath: path.join(stateDir, "dashboards", "global.json"),
164
+ launcherLockPath: path.join(stateDir, "launcher.lock"),
165
+ terminalNamePrefix: `${lane}-wave`,
166
+ dashboardTerminalNamePrefix: `${lane}-wave-dashboard`,
167
+ globalDashboardTerminalName: `${lane}-wave-dashboard-global`,
168
+ tmuxSessionPrefix: `oc_${laneTmux}_wave`,
169
+ tmuxDashboardSessionPrefix: `oc_${laneTmux}_wave_dashboard`,
170
+ tmuxGlobalDashboardSessionPrefix: `oc_${laneTmux}_wave_dashboard_global`,
171
+ tmuxSocketName: `oc_${laneTmux}_waves`,
172
+ orchestratorStateDir,
173
+ defaultOrchestratorBoardPath: path.join(
174
+ orchestratorStateDir,
175
+ "messageboards",
176
+ "orchestrator.md",
177
+ ),
178
+ feedbackStateDir,
179
+ feedbackRequestsDir: path.join(feedbackStateDir, "requests"),
180
+ feedbackTriageDir: path.join(stateDir, "feedback", "triage"),
181
+ crossLaneDependenciesDir: path.join(REPO_ROOT, laneProfile.paths.orchestratorStateDir, "dependencies"),
182
+ runtimePolicy: laneProfile.runtimePolicy,
183
+ };
184
+ }
185
+
186
+ export function parsePositiveInt(value, flagName) {
187
+ if (value === undefined || value === null || value === "") {
188
+ throw new Error(`${flagName} requires a value`);
189
+ }
190
+ const parsed = Number.parseInt(String(value), 10);
191
+ if (!Number.isFinite(parsed) || parsed <= 0) {
192
+ throw new Error(`${flagName} must be a positive integer, got: ${value}`);
193
+ }
194
+ return parsed;
195
+ }
196
+
197
+ export function parseNonNegativeInt(value, flagName) {
198
+ if (value === undefined || value === null || value === "") {
199
+ throw new Error(`${flagName} requires a value`);
200
+ }
201
+ const parsed = Number.parseInt(String(value), 10);
202
+ if (!Number.isFinite(parsed) || parsed < 0) {
203
+ throw new Error(`${flagName} must be a non-negative integer, got: ${value}`);
204
+ }
205
+ return parsed;
206
+ }
207
+
208
+ export function sleep(ms) {
209
+ return new Promise((resolve) => setTimeout(resolve, ms));
210
+ }
211
+
212
+ export function sleepSync(ms) {
213
+ const timeout = Math.max(0, Number.parseInt(String(ms), 10) || 0);
214
+ if (timeout <= 0) {
215
+ return;
216
+ }
217
+ const atomicsArray = new Int32Array(new SharedArrayBuffer(4));
218
+ Atomics.wait(atomicsArray, 0, 0, timeout);
219
+ }
220
+
221
+ export function ensureDirectory(dirPath) {
222
+ fs.mkdirSync(dirPath, { recursive: true });
223
+ }
224
+
225
+ export function shellQuote(value) {
226
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
227
+ }
228
+
229
+ export function writeTextAtomic(filePath, text) {
230
+ ensureDirectory(path.dirname(filePath));
231
+ const tmpPath = `${filePath}.tmp.${process.pid}.${crypto.randomBytes(3).toString("hex")}`;
232
+ fs.writeFileSync(tmpPath, text, "utf8");
233
+ fs.renameSync(tmpPath, filePath);
234
+ }
235
+
236
+ export function writeJsonAtomic(filePath, payload) {
237
+ writeTextAtomic(filePath, `${JSON.stringify(payload, null, 2)}\n`);
238
+ }
239
+
240
+ export function readJsonOrNull(filePath) {
241
+ try {
242
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ export function toIsoTimestamp() {
249
+ return new Date().toISOString();
250
+ }
251
+
252
+ export function readFileTail(filePath, maxChars = 12000) {
253
+ if (!fs.existsSync(filePath)) {
254
+ return "";
255
+ }
256
+ const raw = fs.readFileSync(filePath, "utf8");
257
+ return raw.length <= maxChars ? raw : raw.slice(-maxChars);
258
+ }
259
+
260
+ export function hashText(value) {
261
+ return crypto
262
+ .createHash("sha256")
263
+ .update(String(value || ""))
264
+ .digest("hex");
265
+ }
266
+
267
+ export function normalizeWaveVerdict(verdict) {
268
+ const normalized = String(verdict || "")
269
+ .trim()
270
+ .toLowerCase();
271
+ if (normalized === "hold") {
272
+ return "concerns";
273
+ }
274
+ if (normalized === "fail") {
275
+ return "blocked";
276
+ }
277
+ return normalized;
278
+ }
279
+
280
+ export function parseVerdictFromText(text, regex) {
281
+ if (!text) {
282
+ return { verdict: null, detail: "" };
283
+ }
284
+ regex.lastIndex = 0;
285
+ let match = regex.exec(text);
286
+ let verdict = null;
287
+ let detail = "";
288
+ while (match !== null) {
289
+ verdict = normalizeWaveVerdict(match[1]);
290
+ detail = String(match[2] || "")
291
+ .trim()
292
+ .replace(/^detail=/i, "")
293
+ .trim();
294
+ match = regex.exec(text);
295
+ }
296
+ return { verdict, detail };
297
+ }
298
+
299
+ export function readStatusRecordIfPresent(statusPath) {
300
+ if (!fs.existsSync(statusPath)) {
301
+ return null;
302
+ }
303
+ const raw = fs.readFileSync(statusPath, "utf8").trim();
304
+ if (!raw) {
305
+ return null;
306
+ }
307
+ const parsedJson = readJsonOrNull(statusPath);
308
+ if (parsedJson && typeof parsedJson === "object") {
309
+ const code = Number.parseInt(String(parsedJson.code ?? ""), 10);
310
+ return Number.isFinite(code)
311
+ ? {
312
+ code,
313
+ promptHash: typeof parsedJson.promptHash === "string" ? parsedJson.promptHash : null,
314
+ orchestratorId:
315
+ typeof parsedJson.orchestratorId === "string" ? parsedJson.orchestratorId : null,
316
+ completedAt: typeof parsedJson.completedAt === "string" ? parsedJson.completedAt : null,
317
+ }
318
+ : null;
319
+ }
320
+ const code = Number.parseInt(raw, 10);
321
+ return Number.isFinite(code)
322
+ ? {
323
+ code,
324
+ promptHash: null,
325
+ orchestratorId: null,
326
+ completedAt: null,
327
+ }
328
+ : null;
329
+ }
330
+
331
+ export function walkFiles(dirPath) {
332
+ const output = [];
333
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
334
+ const fullPath = path.join(dirPath, entry.name);
335
+ if (entry.isDirectory()) {
336
+ output.push(...walkFiles(fullPath));
337
+ } else if (entry.isFile()) {
338
+ output.push(fullPath);
339
+ }
340
+ }
341
+ return output;
342
+ }
343
+
344
+ export function truncate(value, width) {
345
+ const text = String(value ?? "");
346
+ if (text.length <= width) {
347
+ return text;
348
+ }
349
+ if (width <= 1) {
350
+ return text.slice(0, width);
351
+ }
352
+ return `${text.slice(0, width - 1)}…`;
353
+ }
354
+
355
+ export function pad(value, width) {
356
+ return truncate(value, width).padEnd(width, " ");
357
+ }
358
+
359
+ export function compactSingleLine(value, maxChars = 220) {
360
+ const text = String(value || "")
361
+ .replace(/\s+/g, " ")
362
+ .trim();
363
+ if (!text) {
364
+ return "";
365
+ }
366
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
367
+ }
368
+
369
+ export function formatAgeFromTimestamp(timestampMs) {
370
+ if (!Number.isFinite(timestampMs)) {
371
+ return "n/a";
372
+ }
373
+ const diffSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000));
374
+ if (diffSeconds < 60) {
375
+ return `${diffSeconds}s ago`;
376
+ }
377
+ const minutes = Math.floor(diffSeconds / 60);
378
+ if (minutes < 60) {
379
+ return `${minutes}m ago`;
380
+ }
381
+ const hours = Math.floor(minutes / 60);
382
+ return `${hours}h ${minutes % 60}m ago`;
383
+ }
384
+
385
+ export function formatElapsed(startIso, endIso = null) {
386
+ if (!startIso) {
387
+ return "n/a";
388
+ }
389
+ const start = Date.parse(startIso);
390
+ const end = endIso ? Date.parse(endIso) : Date.now();
391
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
392
+ return "n/a";
393
+ }
394
+ const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
395
+ const hours = Math.floor(totalSeconds / 3600);
396
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
397
+ const seconds = totalSeconds % 60;
398
+ if (hours > 0) {
399
+ return `${hours}h ${minutes}m ${seconds}s`;
400
+ }
401
+ if (minutes > 0) {
402
+ return `${minutes}m ${seconds}s`;
403
+ }
404
+ return `${seconds}s`;
405
+ }
@@ -0,0 +1,209 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ DASHBOARD_TERMINAL_COLOR,
6
+ DASHBOARD_TERMINAL_ICON,
7
+ REPO_ROOT,
8
+ TERMINAL_COLOR,
9
+ TERMINAL_ICON,
10
+ TMUX_COMMAND_TIMEOUT_MS,
11
+ ensureDirectory,
12
+ writeJsonAtomic,
13
+ } from "./shared.mjs";
14
+
15
+ function defaultTerminalsConfig() {
16
+ return {
17
+ terminals: [],
18
+ autorun: true,
19
+ env: {},
20
+ };
21
+ }
22
+
23
+ export function readTerminalsConfig(filePath) {
24
+ ensureDirectory(path.dirname(filePath));
25
+ if (!fs.existsSync(filePath)) {
26
+ writeJsonAtomic(filePath, defaultTerminalsConfig());
27
+ }
28
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
29
+ if (!Array.isArray(parsed.terminals)) {
30
+ parsed.terminals = [];
31
+ }
32
+ if (typeof parsed.autorun !== "boolean") {
33
+ parsed.autorun = true;
34
+ }
35
+ if (!parsed.env || typeof parsed.env !== "object") {
36
+ parsed.env = {};
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ export function writeTerminalsConfig(filePath, config) {
42
+ writeJsonAtomic(filePath, config);
43
+ }
44
+
45
+ function isLaneTemporaryTerminalName(name, lanePaths) {
46
+ return (
47
+ name === lanePaths.globalDashboardTerminalName ||
48
+ name.startsWith(lanePaths.terminalNamePrefix) ||
49
+ name.startsWith(lanePaths.dashboardTerminalNamePrefix)
50
+ );
51
+ }
52
+
53
+ function extractTmuxSessionName(command, socketName) {
54
+ const text = String(command || "").trim();
55
+ const marker = `tmux -L ${socketName} new -As `;
56
+ const index = text.indexOf(marker);
57
+ if (index === -1) {
58
+ return null;
59
+ }
60
+ const sessionName = text.slice(index + marker.length).trim();
61
+ return sessionName || null;
62
+ }
63
+
64
+ export function createTemporaryTerminalEntries(
65
+ lanePaths,
66
+ wave,
67
+ agents,
68
+ runTag,
69
+ includeDashboard = false,
70
+ ) {
71
+ const agentEntries = agents.map((agent) => {
72
+ const terminalName = `${lanePaths.terminalNamePrefix}${wave}-${agent.slug}`;
73
+ const sessionName = `${lanePaths.tmuxSessionPrefix}${wave}_${agent.slug}_${runTag}`.replace(
74
+ /[^a-zA-Z0-9_-]/g,
75
+ "_",
76
+ );
77
+ return {
78
+ terminalName,
79
+ sessionName,
80
+ config: {
81
+ name: terminalName,
82
+ icon: TERMINAL_ICON,
83
+ color: TERMINAL_COLOR,
84
+ command: `TMUX= tmux -L ${lanePaths.tmuxSocketName} new -As ${sessionName}`,
85
+ },
86
+ };
87
+ });
88
+ if (!includeDashboard) {
89
+ return agentEntries;
90
+ }
91
+ const dashboardSessionName = `${lanePaths.tmuxDashboardSessionPrefix}${wave}_${runTag}`.replace(
92
+ /[^a-zA-Z0-9_-]/g,
93
+ "_",
94
+ );
95
+ agentEntries.push({
96
+ terminalName: `${lanePaths.dashboardTerminalNamePrefix}${wave}`,
97
+ sessionName: dashboardSessionName,
98
+ config: {
99
+ name: `${lanePaths.dashboardTerminalNamePrefix}${wave}`,
100
+ icon: DASHBOARD_TERMINAL_ICON,
101
+ color: DASHBOARD_TERMINAL_COLOR,
102
+ command: `TMUX= tmux -L ${lanePaths.tmuxSocketName} new -As ${dashboardSessionName}`,
103
+ },
104
+ });
105
+ return agentEntries;
106
+ }
107
+
108
+ export function createGlobalDashboardTerminalEntry(lanePaths, runTag) {
109
+ const sessionName = `${lanePaths.tmuxGlobalDashboardSessionPrefix}_${runTag}`.replace(
110
+ /[^a-zA-Z0-9:_-]/g,
111
+ "_",
112
+ );
113
+ return {
114
+ terminalName: lanePaths.globalDashboardTerminalName,
115
+ sessionName,
116
+ config: {
117
+ name: lanePaths.globalDashboardTerminalName,
118
+ icon: DASHBOARD_TERMINAL_ICON,
119
+ color: DASHBOARD_TERMINAL_COLOR,
120
+ command: `TMUX= tmux -L ${lanePaths.tmuxSocketName} new -As ${sessionName}`,
121
+ },
122
+ };
123
+ }
124
+
125
+ export function appendTerminalEntries(terminalsPath, entries) {
126
+ const config = readTerminalsConfig(terminalsPath);
127
+ const namesToReplace = new Set(entries.map((entry) => entry.terminalName));
128
+ config.terminals = config.terminals.filter((terminal) => !namesToReplace.has(terminal?.name));
129
+ config.terminals.push(...entries.map((entry) => entry.config));
130
+ writeTerminalsConfig(terminalsPath, config);
131
+ }
132
+
133
+ export function removeTerminalEntries(terminalsPath, entries) {
134
+ const config = readTerminalsConfig(terminalsPath);
135
+ const namesToRemove = new Set(entries.map((entry) => entry.terminalName));
136
+ config.terminals = config.terminals.filter((terminal) => !namesToRemove.has(terminal?.name));
137
+ writeTerminalsConfig(terminalsPath, config);
138
+ }
139
+
140
+ export function removeLaneTemporaryTerminalEntries(terminalsPath, lanePaths) {
141
+ const config = readTerminalsConfig(terminalsPath);
142
+ const before = config.terminals.length;
143
+ config.terminals = config.terminals.filter(
144
+ (terminal) => !isLaneTemporaryTerminalName(String(terminal?.name || ""), lanePaths),
145
+ );
146
+ const removed = before - config.terminals.length;
147
+ if (removed > 0) {
148
+ writeTerminalsConfig(terminalsPath, config);
149
+ }
150
+ return removed;
151
+ }
152
+
153
+ export function pruneOrphanLaneTemporaryTerminalEntries(
154
+ terminalsPath,
155
+ lanePaths,
156
+ activeSessionNames = [],
157
+ ) {
158
+ const activeSessions =
159
+ activeSessionNames instanceof Set ? activeSessionNames : new Set(activeSessionNames || []);
160
+ const config = readTerminalsConfig(terminalsPath);
161
+ const removedNames = [];
162
+ config.terminals = config.terminals.filter((terminal) => {
163
+ const name = String(terminal?.name || "");
164
+ if (!isLaneTemporaryTerminalName(name, lanePaths)) {
165
+ return true;
166
+ }
167
+ const sessionName = extractTmuxSessionName(terminal?.command, lanePaths.tmuxSocketName);
168
+ if (sessionName && activeSessions.has(sessionName)) {
169
+ return true;
170
+ }
171
+ removedNames.push(name);
172
+ return false;
173
+ });
174
+ if (removedNames.length > 0) {
175
+ writeTerminalsConfig(terminalsPath, config);
176
+ }
177
+ return {
178
+ removed: removedNames.length,
179
+ removedNames,
180
+ };
181
+ }
182
+
183
+ export function killTmuxSessionIfExists(socketName, sessionName) {
184
+ const result = spawnSync("tmux", ["-L", socketName, "kill-session", "-t", sessionName], {
185
+ cwd: REPO_ROOT,
186
+ encoding: "utf8",
187
+ env: { ...process.env, TMUX: "" },
188
+ timeout: TMUX_COMMAND_TIMEOUT_MS,
189
+ });
190
+ if (result.error) {
191
+ if (result.error.code === "ETIMEDOUT") {
192
+ throw new Error(`kill existing session ${sessionName} failed: tmux command timed out`);
193
+ }
194
+ throw new Error(`kill existing session ${sessionName} failed: ${result.error.message}`);
195
+ }
196
+ if (result.status === 0) {
197
+ return;
198
+ }
199
+ const combined = `${String(result.stderr || "").toLowerCase()}\n${String(result.stdout || "").toLowerCase()}`;
200
+ if (
201
+ combined.includes("can't find session") ||
202
+ combined.includes("no server running") ||
203
+ combined.includes("no current target") ||
204
+ combined.includes("error connecting")
205
+ ) {
206
+ return;
207
+ }
208
+ throw new Error(`kill existing session ${sessionName} failed: ${(result.stderr || "").trim()}`);
209
+ }