@bastani/atomic 0.5.0-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/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * Workflow runtime executor.
3
+ *
4
+ * Architecture:
5
+ * 1. `executeWorkflow()` is called by the CLI command
6
+ * 2. It creates a tmux session with an orchestrator pane that runs
7
+ * `bun run executor.ts --run <args>`
8
+ * 3. The CLI then attaches to the tmux session (user sees it live)
9
+ * 4. The orchestrator pane spawns agent windows and drives the SDK calls
10
+ */
11
+
12
+ import { join, resolve } from "path";
13
+ import { homedir } from "os";
14
+ import { mkdir, writeFile, readFile } from "fs/promises";
15
+ import type {
16
+ WorkflowDefinition, SessionOptions, SessionContext, AgentType, Transcript,
17
+ SavedMessage, SaveTranscript,
18
+ } from "../types.ts";
19
+ import type { SessionEvent } from "@github/copilot-sdk";
20
+ import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
21
+ import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
22
+ import * as tmux from "./tmux.ts";
23
+ import { getMuxBinary } from "./tmux.ts";
24
+ import { WorkflowLoader } from "./loader.ts";
25
+ import { clearClaudeSession } from "../providers/claude.ts";
26
+ import { OrchestratorPanel, type PanelSession } from "./panel.tsx";
27
+
28
+ /** Maximum time (ms) to wait for an agent's server to become reachable. */
29
+ const SERVER_WAIT_TIMEOUT_MS = 60_000;
30
+
31
+ /** Agent CLI configuration for spawning in tmux panes. */
32
+ const AGENT_CLI: Record<AgentType, { cmd: string; chatFlags: string[] }> = {
33
+ copilot: { cmd: "copilot", chatFlags: ["--add-dir", ".", "--yolo", "--experimental"] },
34
+ opencode: { cmd: "opencode", chatFlags: [] },
35
+ claude: { cmd: "claude", chatFlags: ["--allow-dangerously-skip-permissions", "--dangerously-skip-permissions"] },
36
+ };
37
+
38
+ export interface WorkflowRunOptions {
39
+ /** The compiled workflow definition */
40
+ definition: WorkflowDefinition;
41
+ /** Agent type */
42
+ agent: AgentType;
43
+ /** The user's prompt */
44
+ prompt: string;
45
+ /** Absolute path to the workflow's index.ts file (from discovery) */
46
+ workflowFile: string;
47
+ /** Project root (defaults to cwd) */
48
+ projectRoot?: string;
49
+ }
50
+
51
+ interface SessionResult {
52
+ name: string;
53
+ sessionId: string;
54
+ sessionDir: string;
55
+ paneId: string;
56
+ }
57
+
58
+ function generateId(): string {
59
+ return crypto.randomUUID().slice(0, 8);
60
+ }
61
+
62
+ function getSessionsBaseDir(): string {
63
+ return join(homedir(), ".atomic", "sessions");
64
+ }
65
+
66
+ async function getRandomPort(): Promise<number> {
67
+ const net = await import("node:net");
68
+
69
+ const MAX_RETRIES = 3;
70
+ let lastPort = 0;
71
+
72
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
73
+ const port = await new Promise<number>((resolve, reject) => {
74
+ const server = net.createServer();
75
+ server.listen(0, () => {
76
+ const addr = server.address();
77
+ const p = typeof addr === "object" && addr ? addr.port : 0;
78
+ server.close(() => resolve(p));
79
+ });
80
+ server.on("error", reject);
81
+ });
82
+
83
+ if (port > 0) return port;
84
+ lastPort = port;
85
+ await Bun.sleep(50);
86
+ }
87
+
88
+ throw new Error(`Failed to acquire a random port after ${MAX_RETRIES} attempts (last: ${lastPort})`);
89
+ }
90
+
91
+ function buildPaneCommand(agent: AgentType, port: number): string {
92
+ const { cmd, chatFlags } = AGENT_CLI[agent];
93
+
94
+ switch (agent) {
95
+ case "copilot":
96
+ return [cmd, "--ui-server", "--port", String(port), ...chatFlags].join(" ");
97
+ case "opencode":
98
+ return [cmd, "--port", String(port), ...chatFlags].join(" ");
99
+ case "claude":
100
+ // Claude is started via createClaudeSession() in the workflow's run()
101
+ return process.env.SHELL || (process.platform === "win32" ? "pwsh" : "sh");
102
+ default:
103
+ return [cmd, ...chatFlags].join(" ");
104
+ }
105
+ }
106
+
107
+ async function waitForServer(agent: AgentType, port: number, paneId: string): Promise<string> {
108
+ if (agent === "claude") return "";
109
+
110
+ const serverUrl = `localhost:${port}`;
111
+ const deadline = Date.now() + SERVER_WAIT_TIMEOUT_MS;
112
+
113
+ // Wait for the TUI to render first
114
+ while (Date.now() < deadline) {
115
+ const content = tmux.capturePane(paneId);
116
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
117
+ if (lines.length >= 3) break;
118
+ await Bun.sleep(1_000);
119
+ }
120
+
121
+ // Then verify the SDK can actually connect and list sessions
122
+ if (agent === "copilot") {
123
+ const { CopilotClient } = await import("@github/copilot-sdk");
124
+ while (Date.now() < deadline) {
125
+ try {
126
+ const probe = new CopilotClient({ cliUrl: serverUrl });
127
+ await probe.start();
128
+ await probe.listSessions();
129
+ await probe.stop();
130
+ return serverUrl;
131
+ } catch {
132
+ await Bun.sleep(1_000);
133
+ }
134
+ }
135
+ }
136
+
137
+ // For OpenCode, give it extra time after TUI renders
138
+ await Bun.sleep(3_000);
139
+ return serverUrl;
140
+ }
141
+
142
+ async function ensureDir(dir: string): Promise<void> {
143
+ await mkdir(dir, { recursive: true });
144
+ }
145
+
146
+ /**
147
+ * Escape a string for safe interpolation inside a bash double-quoted string.
148
+ *
149
+ * In bash `"..."` strings only `$`, `` ` ``, `\`, `"`, and `!` are special.
150
+ * Single quotes are literal inside double quotes and need no escaping.
151
+ * Null bytes are stripped because bash strings cannot contain them.
152
+ */
153
+ export function escBash(s: string): string {
154
+ return s
155
+ .replace(/\x00/g, "")
156
+ .replace(/[\n\r]+/g, " ")
157
+ .replace(/[\\"$`!]/g, "\\$&");
158
+ }
159
+
160
+ /**
161
+ * Escape a string for safe interpolation inside a PowerShell double-quoted string.
162
+ *
163
+ * In PowerShell `"..."` strings, backtick is the escape character and `$` triggers
164
+ * variable expansion. Null bytes are stripped for safety.
165
+ */
166
+ export function escPwsh(s: string): string {
167
+ return s
168
+ .replace(/\x00/g, "")
169
+ .replace(/[`"$]/g, "`$&")
170
+ .replace(/\n/g, "`n")
171
+ .replace(/\r/g, "`r");
172
+ }
173
+
174
+ // ============================================================================
175
+ // Entry point called by the CLI command
176
+ // ============================================================================
177
+
178
+ /**
179
+ * Called by `atomic workflow -n <name> -a <agent> <prompt>`.
180
+ *
181
+ * Creates a tmux session with the orchestrator as the initial pane,
182
+ * then attaches so the user sees everything live.
183
+ */
184
+ export async function executeWorkflow(options: WorkflowRunOptions): Promise<void> {
185
+ const { definition, agent, prompt, workflowFile, projectRoot = process.cwd() } = options;
186
+
187
+ const workflowRunId = generateId();
188
+ const tmuxSessionName = `atomic-wf-${definition.name}-${workflowRunId}`;
189
+ const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
190
+ await ensureDir(sessionsBaseDir);
191
+
192
+ // Write a launcher script for the orchestrator pane
193
+ const thisFile = resolve(import.meta.dir, "executor.ts");
194
+ const isWin = process.platform === "win32";
195
+ const launcherExt = isWin ? "ps1" : "sh";
196
+ const launcherPath = join(sessionsBaseDir, `orchestrator.${launcherExt}`);
197
+ const logPath = join(sessionsBaseDir, "orchestrator.log");
198
+
199
+ const launcherScript = isWin
200
+ ? [
201
+ `Set-Location "${escPwsh(projectRoot)}"`,
202
+ `$env:ATOMIC_WF_ID = "${escPwsh(workflowRunId)}"`,
203
+ `$env:ATOMIC_WF_TMUX = "${escPwsh(tmuxSessionName)}"`,
204
+ `$env:ATOMIC_WF_AGENT = "${escPwsh(agent)}"`,
205
+ `$env:ATOMIC_WF_PROMPT = "${escPwsh(Buffer.from(prompt).toString("base64"))}"`,
206
+ `$env:ATOMIC_WF_FILE = "${escPwsh(workflowFile)}"`,
207
+ `$env:ATOMIC_WF_CWD = "${escPwsh(projectRoot)}"`,
208
+ `bun run "${escPwsh(thisFile)}" --run 2>"${escPwsh(logPath)}"`,
209
+ ].join("\n")
210
+ : [
211
+ "#!/bin/bash",
212
+ `cd "${escBash(projectRoot)}"`,
213
+ `export ATOMIC_WF_ID="${escBash(workflowRunId)}"`,
214
+ `export ATOMIC_WF_TMUX="${escBash(tmuxSessionName)}"`,
215
+ `export ATOMIC_WF_AGENT="${escBash(agent)}"`,
216
+ `export ATOMIC_WF_PROMPT="${escBash(Buffer.from(prompt).toString("base64"))}"`,
217
+ `export ATOMIC_WF_FILE="${escBash(workflowFile)}"`,
218
+ `export ATOMIC_WF_CWD="${escBash(projectRoot)}"`,
219
+ `bun run "${escBash(thisFile)}" --run 2>"${escBash(logPath)}"`,
220
+ ].join("\n");
221
+
222
+ await writeFile(launcherPath, launcherScript, { mode: 0o755 });
223
+
224
+ // Create tmux session with orchestrator as the initial window
225
+ const shellCmd = isWin
226
+ ? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
227
+ : `bash "${escBash(launcherPath)}"`;
228
+ tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
229
+
230
+ // Attach or switch depending on whether we're already inside tmux
231
+ if (tmux.isInsideTmux()) {
232
+ // Inside tmux: switch the current client to the workflow session
233
+ // to avoid creating a nested tmux client
234
+ tmux.switchClient(tmuxSessionName);
235
+ } else {
236
+ // Outside tmux: attach normally (blocks until session ends)
237
+ const muxBinary = getMuxBinary() ?? "tmux";
238
+ const attachProc = Bun.spawn([muxBinary, "attach-session", "-t", tmuxSessionName], {
239
+ stdio: ["inherit", "inherit", "inherit"],
240
+ });
241
+ await attachProc.exited;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Throw immediately if the abort signal has already been triggered.
247
+ * Consolidates the repeated abort-check pattern used throughout session execution.
248
+ */
249
+ function throwIfAborted(signal?: AbortSignal): void {
250
+ if (signal?.aborted) throw new Error("Cancelled: a sibling session failed");
251
+ }
252
+
253
+ /**
254
+ * Small buffer (ms) subtracted from `Date.now()` when recording the Claude
255
+ * session start timestamp. Protects against fast sequential runs where
256
+ * the system clock granularity could cause a just-created session's
257
+ * `lastModified` to fall slightly before our recorded timestamp.
258
+ */
259
+ const CLAUDE_SESSION_TIMESTAMP_BUFFER_MS = 100;
260
+
261
+ // ============================================================================
262
+ // Session execution helpers
263
+ // ============================================================================
264
+
265
+ /** Type guard for objects with a string `content` property (Copilot assistant.message data). */
266
+ export function hasContent(value: unknown): value is { content: string } {
267
+ return typeof value === "object" && value !== null && "content" in value && typeof (value as { content: unknown }).content === "string";
268
+ }
269
+
270
+ /** Type guard for Claude message objects whose `content` is an array of text blocks. */
271
+ export function isTextBlockArray(value: unknown): value is Array<{ type: "text"; text: string }> {
272
+ return Array.isArray(value) && value.every(
273
+ (b) => typeof b === "object" && b !== null && b.type === "text" && typeof b.text === "string",
274
+ );
275
+ }
276
+
277
+ export function renderMessagesToText(messages: SavedMessage[]): string {
278
+ return messages
279
+ .map((m) => {
280
+ switch (m.provider) {
281
+ case "copilot": {
282
+ if (m.data.type !== "assistant.message") return "";
283
+ // SessionEvent["data"] for assistant.message has a typed `content: string`
284
+ return hasContent(m.data.data) ? m.data.data.content : "";
285
+ }
286
+ case "opencode": {
287
+ // Part is a discriminated union; filter to TextPart which has { type: "text", text: string }
288
+ return m.data.parts
289
+ .filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text")
290
+ .map((p) => p.text)
291
+ .join("\n");
292
+ }
293
+ case "claude": {
294
+ if (m.data.type !== "assistant") return "";
295
+ const msg = m.data.message;
296
+ if (typeof msg === "string") return msg;
297
+ if (msg && typeof msg === "object" && "content" in msg) {
298
+ const { content } = msg as { content: unknown };
299
+ if (typeof content === "string") return content;
300
+ if (isTextBlockArray(content)) {
301
+ return content.map((b) => b.text).join("\n");
302
+ }
303
+ }
304
+ return JSON.stringify(msg);
305
+ }
306
+ }
307
+ })
308
+ .filter((txt): txt is string => typeof txt === "string" && txt.length > 0)
309
+ .join("\n\n");
310
+ }
311
+
312
+ interface RunSessionOptions {
313
+ sessionDef: SessionOptions;
314
+ tmuxSessionName: string;
315
+ sessionsBaseDir: string;
316
+ agent: AgentType;
317
+ prompt: string;
318
+ completedSessions: SessionResult[];
319
+ panel: OrchestratorPanel;
320
+ signal?: AbortSignal;
321
+ siblingNames: Set<string>;
322
+ }
323
+
324
+ /**
325
+ * Run a single session from start to finish.
326
+ * On success: calls panel.sessionSuccess, returns SessionResult.
327
+ * On failure: writes error.txt, calls panel.sessionError, rethrows.
328
+ */
329
+ async function runSingleSession(opts: RunSessionOptions): Promise<SessionResult> {
330
+ const {
331
+ sessionDef, tmuxSessionName, sessionsBaseDir, agent,
332
+ prompt, completedSessions, panel, signal, siblingNames,
333
+ } = opts;
334
+
335
+ panel.sessionStart(sessionDef.name);
336
+
337
+ throwIfAborted(signal);
338
+
339
+ const port = await getRandomPort();
340
+ const paneCmd = buildPaneCommand(agent, port);
341
+ const paneId = tmux.createWindow(tmuxSessionName, sessionDef.name, paneCmd);
342
+
343
+ throwIfAborted(signal);
344
+
345
+ const serverUrl = await waitForServer(agent, port, paneId);
346
+
347
+ throwIfAborted(signal);
348
+
349
+ const sessionId = generateId();
350
+ const sessionDirName = `${sessionDef.name}-${sessionId}`;
351
+ const sessionDir = join(sessionsBaseDir, sessionDirName);
352
+ await ensureDir(sessionDir);
353
+
354
+ const messagesPath = join(sessionDir, "messages.json");
355
+ const inboxPath = join(sessionDir, "inbox.md");
356
+
357
+ // Snapshot existing Claude session IDs before the run so we can identify
358
+ // which session was created during this execution — robust against concurrent
359
+ // workflows creating sessions in the same working directory.
360
+ let knownClaudeSessionIds: Set<string> | undefined;
361
+ if (agent === "claude") {
362
+ const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
363
+ const existing = await listSessions({ dir: process.cwd() });
364
+ knownClaudeSessionIds = new Set(existing.map((s) => s.sessionId));
365
+ }
366
+
367
+ // Timestamp fallback for when the snapshot is unavailable.
368
+ // A small buffer is subtracted to handle clock granularity in fast sequential runs.
369
+ const claudeSessionStartedAfter = agent === "claude"
370
+ ? Date.now() - CLAUDE_SESSION_TIMESTAMP_BUFFER_MS
371
+ : 0;
372
+
373
+ async function wrapMessages(arg: SessionEvent[] | SessionPromptResponse | string): Promise<SavedMessage[]> {
374
+ if (typeof arg === "string") {
375
+ const { getSessionMessages, listSessions } = await import("@anthropic-ai/claude-agent-sdk");
376
+ const dir = process.cwd();
377
+ const sessions = await listSessions({ dir });
378
+
379
+ // Primary: filter to sessions not in the pre-run snapshot (new sessions only).
380
+ // Fallback: use timestamp if snapshot is unavailable.
381
+ const newSessions = knownClaudeSessionIds
382
+ ? sessions.filter((s) => !knownClaudeSessionIds!.has(s.sessionId))
383
+ : sessions.filter((s) => s.lastModified >= claudeSessionStartedAfter);
384
+
385
+ const candidates = newSessions.sort((a, b) => b.lastModified - a.lastModified);
386
+
387
+ const candidate = candidates[0];
388
+ if (!candidate) {
389
+ throw new Error(`wrapMessages: no new Claude session found for ${dir}`);
390
+ }
391
+
392
+ const msgs: SessionMessage[] = await getSessionMessages(candidate.sessionId, { dir });
393
+ return msgs.map((m) => ({ provider: "claude" as const, data: m }));
394
+ }
395
+
396
+ if (!Array.isArray(arg) && "info" in arg && "parts" in arg) {
397
+ return [{ provider: "opencode" as const, data: arg as SessionPromptResponse }];
398
+ }
399
+
400
+ if (Array.isArray(arg)) {
401
+ return (arg as SessionEvent[]).map((m) => ({
402
+ provider: "copilot" as const,
403
+ data: m,
404
+ }));
405
+ }
406
+
407
+ return [];
408
+ }
409
+
410
+ const pendingSaves: Promise<void>[] = [];
411
+
412
+ const save: SaveTranscript = ((arg: SessionEvent[] | SessionPromptResponse | string) => {
413
+ const p = (async () => {
414
+ const wrapped = await wrapMessages(arg);
415
+ await Bun.write(messagesPath, JSON.stringify(wrapped, null, 2));
416
+ const text = renderMessagesToText(wrapped);
417
+ await Bun.write(inboxPath, text);
418
+ })();
419
+ pendingSaves.push(p);
420
+ return p;
421
+ }) as SaveTranscript;
422
+
423
+ const ctx: SessionContext = {
424
+ serverUrl,
425
+ userPrompt: prompt,
426
+ agent,
427
+ sessionDir,
428
+ paneId,
429
+ sessionId,
430
+ save,
431
+ transcript: async (name: string): Promise<Transcript> => {
432
+ if (siblingNames.has(name)) {
433
+ throw new Error(
434
+ `Cannot read transcript for "${name}" \u2014 it is running in parallel. ` +
435
+ `Only sessions from prior steps are available.`
436
+ );
437
+ }
438
+ const prev = completedSessions.find((s) => s.name === name);
439
+ if (!prev) {
440
+ throw new Error(
441
+ `No transcript for "${name}". Available: ${completedSessions.map((s) => s.name).join(", ") || "(none)"}`
442
+ );
443
+ }
444
+ const filePath = join(prev.sessionDir, "inbox.md");
445
+ const content = await readFile(filePath, "utf-8");
446
+ return { path: filePath, content };
447
+ },
448
+ getMessages: async (name: string): Promise<SavedMessage[]> => {
449
+ if (siblingNames.has(name)) {
450
+ throw new Error(
451
+ `Cannot read messages for "${name}" \u2014 it is running in parallel. ` +
452
+ `Only sessions from prior steps are available.`
453
+ );
454
+ }
455
+ const prev = completedSessions.find((s) => s.name === name);
456
+ if (!prev) {
457
+ throw new Error(
458
+ `No messages for "${name}". Available: ${completedSessions.map((s) => s.name).join(", ") || "(none)"}`
459
+ );
460
+ }
461
+ const filePath = join(prev.sessionDir, "messages.json");
462
+ const raw = await readFile(filePath, "utf-8");
463
+ return JSON.parse(raw) as SavedMessage[];
464
+ },
465
+ };
466
+
467
+ await writeFile(
468
+ join(sessionDir, "metadata.json"),
469
+ JSON.stringify({
470
+ name: sessionDef.name,
471
+ description: sessionDef.description ?? "",
472
+ agent, paneId, serverUrl, port,
473
+ startedAt: new Date().toISOString(),
474
+ }, null, 2)
475
+ );
476
+
477
+ try {
478
+ await sessionDef.run(ctx);
479
+ if (pendingSaves.length > 0) await Promise.all(pendingSaves);
480
+ } catch (error) {
481
+ const isCancelled = signal?.aborted;
482
+ const message = isCancelled
483
+ ? "Cancelled: a sibling session failed"
484
+ : (error instanceof Error ? error.message : String(error));
485
+ await writeFile(join(sessionDir, "error.txt"), message).catch(() => {});
486
+ panel.sessionError(sessionDef.name, message);
487
+ throw error;
488
+ }
489
+
490
+ panel.sessionSuccess(sessionDef.name);
491
+ return { name: sessionDef.name, sessionId, sessionDir, paneId };
492
+ }
493
+
494
+ /** Like Promise.all() but aborts on first rejection and calls a cleanup callback. */
495
+ async function promiseAllFailFast<T>(
496
+ promises: Promise<T>[],
497
+ controller: AbortController,
498
+ onFirstFailure: () => void,
499
+ ): Promise<T[]> {
500
+ if (promises.length === 0) return Promise.resolve([]);
501
+
502
+ return new Promise<T[]>((resolve, reject) => {
503
+ const results = Array.from<T>({ length: promises.length });
504
+ let remaining = promises.length;
505
+ let rejected = false;
506
+
507
+ promises.forEach((promise, index) => {
508
+ promise.then(
509
+ (value) => {
510
+ if (rejected) return;
511
+ results[index] = value;
512
+ remaining--;
513
+ if (remaining === 0) resolve(results);
514
+ },
515
+ (error) => {
516
+ if (rejected) return;
517
+ rejected = true;
518
+ controller.abort();
519
+ onFirstFailure();
520
+ reject(error);
521
+ },
522
+ );
523
+ });
524
+ });
525
+ }
526
+
527
+ // ============================================================================
528
+ // Orchestrator logic — runs inside a tmux pane
529
+ // ============================================================================
530
+
531
+ async function runOrchestrator(): Promise<void> {
532
+ const requiredEnvVars = [
533
+ "ATOMIC_WF_ID", "ATOMIC_WF_TMUX", "ATOMIC_WF_AGENT",
534
+ "ATOMIC_WF_PROMPT", "ATOMIC_WF_FILE", "ATOMIC_WF_CWD",
535
+ ] as const;
536
+ for (const key of requiredEnvVars) {
537
+ if (!process.env[key]) {
538
+ throw new Error(`Missing required environment variable: ${key}`);
539
+ }
540
+ }
541
+
542
+ const workflowRunId = process.env.ATOMIC_WF_ID!;
543
+ const tmuxSessionName = process.env.ATOMIC_WF_TMUX!;
544
+ const agent = process.env.ATOMIC_WF_AGENT! as AgentType;
545
+ const prompt = Buffer.from(process.env.ATOMIC_WF_PROMPT!, "base64").toString("utf-8");
546
+ const workflowFile = process.env.ATOMIC_WF_FILE!;
547
+ const cwd = process.env.ATOMIC_WF_CWD!;
548
+
549
+ process.chdir(cwd);
550
+
551
+ const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
552
+ await ensureDir(sessionsBaseDir);
553
+
554
+ const panel = await OrchestratorPanel.create({ tmuxSession: tmuxSessionName });
555
+
556
+ // Idempotent shutdown guard
557
+ let shutdownCalled = false;
558
+ const shutdown = (exitCode = 0) => {
559
+ if (shutdownCalled) return;
560
+ shutdownCalled = true;
561
+ panel.destroy();
562
+ try { tmux.killSession(tmuxSessionName); } catch {}
563
+ process.exitCode = exitCode;
564
+ };
565
+
566
+ // Wire SIGINT so the terminal is always restored.
567
+ // SIGTERM and other signals are handled by OpenTUI's exitSignals.
568
+ const signalHandler = () => shutdown(1);
569
+ process.on("SIGINT", signalHandler);
570
+
571
+ try {
572
+ const plan: WorkflowLoader.Plan = {
573
+ name: workflowFile.split("/").at(-3) ?? "unknown",
574
+ agent,
575
+ path: workflowFile,
576
+ source: "local",
577
+ };
578
+
579
+ const loaded = await WorkflowLoader.loadWorkflow(plan, {
580
+ warn(warnings) {
581
+ for (const w of warnings) {
582
+ console.warn(`⚠ [${w.rule}] ${w.message}`);
583
+ }
584
+ },
585
+ });
586
+ if (!loaded.ok) {
587
+ throw new Error(loaded.message);
588
+ }
589
+ const definition = loaded.value.definition;
590
+
591
+ await writeFile(
592
+ join(sessionsBaseDir, "metadata.json"),
593
+ JSON.stringify({
594
+ workflowName: definition.name,
595
+ agent,
596
+ prompt,
597
+ projectRoot: cwd,
598
+ startedAt: new Date().toISOString(),
599
+ }, null, 2)
600
+ );
601
+
602
+ // Build panel sessions from steps — track all parent names for fan-in edges
603
+ const panelSessions: PanelSession[] = [];
604
+ let prevStepNames = ["orchestrator"];
605
+ for (const step of definition.steps) {
606
+ for (const s of step) {
607
+ panelSessions.push({ name: s.name, parents: prevStepNames });
608
+ }
609
+ prevStepNames = step.map((s) => s.name);
610
+ }
611
+
612
+ panel.showWorkflowInfo(definition.name, agent, panelSessions, prompt);
613
+
614
+ const completedSessions: SessionResult[] = [];
615
+
616
+ for (const step of definition.steps) {
617
+ if (step.length === 1) {
618
+ // Sequential: single session in this step
619
+ try {
620
+ const result = await runSingleSession({
621
+ sessionDef: step[0]!,
622
+ tmuxSessionName, sessionsBaseDir, agent, prompt,
623
+ completedSessions, panel, siblingNames: new Set(),
624
+ });
625
+ completedSessions.push(result);
626
+ if (agent === "claude") clearClaudeSession(result.paneId);
627
+ } catch (error) {
628
+ const message = error instanceof Error ? error.message : String(error);
629
+ panel.showFatalError(message);
630
+ await panel.waitForExit();
631
+ shutdown(1);
632
+ return;
633
+ }
634
+ } else {
635
+ // Parallel: multiple sessions run concurrently with fail-fast
636
+ const controller = new AbortController();
637
+ const allNames = new Set(step.map((s) => s.name));
638
+
639
+ const promises = step.map((sessionDef) => {
640
+ const mySiblings = new Set(allNames);
641
+ mySiblings.delete(sessionDef.name);
642
+ return runSingleSession({
643
+ sessionDef,
644
+ tmuxSessionName, sessionsBaseDir, agent, prompt,
645
+ completedSessions, panel,
646
+ signal: controller.signal,
647
+ siblingNames: mySiblings,
648
+ });
649
+ });
650
+
651
+ try {
652
+ const results = await promiseAllFailFast(promises, controller, () => {
653
+ for (const s of step) tmux.killWindow(tmuxSessionName, s.name);
654
+ });
655
+ completedSessions.push(...results);
656
+ if (agent === "claude") {
657
+ for (const r of results) clearClaudeSession(r.paneId);
658
+ }
659
+ } catch (error) {
660
+ // Wait for all cancelled siblings to settle
661
+ await Promise.allSettled(promises);
662
+ const message = error instanceof Error ? error.message : String(error);
663
+ panel.showFatalError(message);
664
+ await panel.waitForExit();
665
+ shutdown(1);
666
+ return;
667
+ }
668
+ }
669
+ }
670
+
671
+ panel.showCompletion(definition.name, sessionsBaseDir);
672
+ await panel.waitForExit();
673
+ shutdown(0);
674
+ } catch (error) {
675
+ const message = error instanceof Error ? error.message : String(error);
676
+ try {
677
+ panel.showFatalError(message);
678
+ await panel.waitForExit();
679
+ } catch {}
680
+ shutdown(1);
681
+ } finally {
682
+ process.off("SIGINT", signalHandler);
683
+ }
684
+ }
685
+
686
+ // ============================================================================
687
+ // Direct invocation: `bun run executor.ts --run`
688
+ // ============================================================================
689
+
690
+ if (process.argv.includes("--run")) {
691
+ runOrchestrator().catch((err) => {
692
+ console.error("Fatal:", err);
693
+ process.exitCode = 1;
694
+ });
695
+ }