@bastani/atomic 0.5.0-3 → 0.5.0-5

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 (42) hide show
  1. package/.atomic/workflows/hello/claude/index.ts +22 -25
  2. package/.atomic/workflows/hello/copilot/index.ts +41 -31
  3. package/.atomic/workflows/hello/opencode/index.ts +40 -40
  4. package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
  5. package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
  6. package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
  7. package/.atomic/workflows/ralph/claude/index.ts +128 -93
  8. package/.atomic/workflows/ralph/copilot/index.ts +212 -112
  9. package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
  10. package/.atomic/workflows/ralph/opencode/index.ts +174 -111
  11. package/README.md +138 -59
  12. package/package.json +1 -1
  13. package/src/cli.ts +0 -2
  14. package/src/commands/cli/chat/index.ts +28 -8
  15. package/src/commands/cli/init/index.ts +7 -10
  16. package/src/commands/cli/init/scm.ts +27 -10
  17. package/src/sdk/components/connectors.test.ts +45 -0
  18. package/src/sdk/components/layout.test.ts +321 -0
  19. package/src/sdk/components/layout.ts +51 -15
  20. package/src/sdk/components/orchestrator-panel-contexts.ts +13 -4
  21. package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
  22. package/src/sdk/components/orchestrator-panel-store.ts +24 -0
  23. package/src/sdk/components/orchestrator-panel.tsx +21 -0
  24. package/src/sdk/components/session-graph-panel.tsx +8 -15
  25. package/src/sdk/components/statusline.tsx +4 -6
  26. package/src/sdk/define-workflow.test.ts +71 -0
  27. package/src/sdk/define-workflow.ts +42 -39
  28. package/src/sdk/errors.ts +1 -1
  29. package/src/sdk/index.ts +4 -1
  30. package/src/sdk/providers/claude.ts +1 -1
  31. package/src/sdk/providers/copilot.ts +5 -3
  32. package/src/sdk/providers/opencode.ts +5 -3
  33. package/src/sdk/runtime/executor.ts +512 -301
  34. package/src/sdk/runtime/loader.ts +2 -2
  35. package/src/sdk/runtime/tmux.ts +31 -2
  36. package/src/sdk/types.ts +93 -20
  37. package/src/sdk/workflows.ts +7 -4
  38. package/src/services/config/definitions.ts +39 -2
  39. package/src/services/config/settings.ts +0 -6
  40. package/src/services/system/skills.ts +3 -7
  41. package/.atomic/workflows/package-lock.json +0 -31
  42. package/.atomic/workflows/package.json +0 -8
@@ -6,15 +6,24 @@
6
6
  * 2. It creates a tmux session with an orchestrator pane that runs
7
7
  * `bun run executor.ts --run <args>`
8
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
9
+ * 4. The orchestrator pane calls `definition.run(workflowCtx)` the
10
+ * user's callback uses `ctx.session()` to spawn agent sessions
10
11
  */
11
12
 
12
13
  import { join, resolve } from "path";
13
14
  import { homedir } from "os";
14
15
  import { mkdir, writeFile, readFile } from "fs/promises";
15
16
  import type {
16
- WorkflowDefinition, SessionOptions, SessionContext, AgentType, Transcript,
17
- SavedMessage, SaveTranscript,
17
+ WorkflowDefinition,
18
+ WorkflowContext,
19
+ SessionContext,
20
+ SessionRunOptions,
21
+ SessionHandle,
22
+ SessionRef,
23
+ AgentType,
24
+ Transcript,
25
+ SavedMessage,
26
+ SaveTranscript,
18
27
  } from "../types.ts";
19
28
  import type { SessionEvent } from "@github/copilot-sdk";
20
29
  import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
@@ -23,18 +32,48 @@ import * as tmux from "./tmux.ts";
23
32
  import { getMuxBinary } from "./tmux.ts";
24
33
  import { WorkflowLoader } from "./loader.ts";
25
34
  import { clearClaudeSession } from "../providers/claude.ts";
26
- import { OrchestratorPanel, type PanelSession } from "./panel.tsx";
35
+ import { OrchestratorPanel } from "./panel.tsx";
27
36
 
28
37
  /** Maximum time (ms) to wait for an agent's server to become reachable. */
29
38
  const SERVER_WAIT_TIMEOUT_MS = 60_000;
30
39
 
31
40
  /** 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"] },
41
+ const AGENT_CLI: Record<
42
+ AgentType,
43
+ { cmd: string; chatFlags: string[]; envVars: Record<string, string> }
44
+ > = {
45
+ copilot: {
46
+ cmd: "copilot",
47
+ chatFlags: [
48
+ "--add-dir",
49
+ ".",
50
+ "--yolo",
51
+ "--experimental",
52
+ "--no-auto-update",
53
+ ],
54
+ envVars: {
55
+ COPILOT_ALLOW_ALL: "true",
56
+ },
57
+ },
58
+ opencode: { cmd: "opencode", chatFlags: [], envVars: {} },
59
+ claude: {
60
+ cmd: "claude",
61
+ chatFlags: [
62
+ "--allow-dangerously-skip-permissions",
63
+ "--dangerously-skip-permissions",
64
+ ],
65
+ envVars: {},
66
+ },
36
67
  };
37
68
 
69
+ /** Thrown when the user aborts a running workflow via `q` or `Ctrl+C`. */
70
+ class WorkflowAbortError extends Error {
71
+ constructor() {
72
+ super("Workflow aborted by user");
73
+ this.name = "WorkflowAbortError";
74
+ }
75
+ }
76
+
38
77
  export interface WorkflowRunOptions {
39
78
  /** The compiled workflow definition */
40
79
  definition: WorkflowDefinition;
@@ -55,6 +94,18 @@ interface SessionResult {
55
94
  paneId: string;
56
95
  }
57
96
 
97
+ /** A session that has been spawned but may not have completed yet. */
98
+ interface ActiveSession {
99
+ name: string;
100
+ paneId: string;
101
+ /**
102
+ * Settles when the session finishes. Resolves on success, rejects with the
103
+ * callback's error on failure. Dependent sessions awaiting via `dependsOn`
104
+ * block on this promise.
105
+ */
106
+ done: Promise<void>;
107
+ }
108
+
58
109
  function generateId(): string {
59
110
  return crypto.randomUUID().slice(0, 8);
60
111
  }
@@ -85,26 +136,50 @@ async function getRandomPort(): Promise<number> {
85
136
  await Bun.sleep(50);
86
137
  }
87
138
 
88
- throw new Error(`Failed to acquire a random port after ${MAX_RETRIES} attempts (last: ${lastPort})`);
139
+ throw new Error(
140
+ `Failed to acquire a random port after ${MAX_RETRIES} attempts (last: ${lastPort})`,
141
+ );
89
142
  }
90
143
 
91
- function buildPaneCommand(agent: AgentType, port: number): string {
92
- const { cmd, chatFlags } = AGENT_CLI[agent];
144
+ function buildPaneCommand(
145
+ agent: AgentType,
146
+ port: number,
147
+ ): { command: string; envVars: Record<string, string> } {
148
+ const { cmd, chatFlags, envVars } = AGENT_CLI[agent];
93
149
 
94
150
  switch (agent) {
95
151
  case "copilot":
96
- return [cmd, "--ui-server", "--port", String(port), ...chatFlags].join(" ");
152
+ return {
153
+ command: [cmd, "--ui-server", "--port", String(port), ...chatFlags].join(
154
+ " ",
155
+ ),
156
+ envVars,
157
+ };
97
158
  case "opencode":
98
- return [cmd, "--port", String(port), ...chatFlags].join(" ");
159
+ return {
160
+ command: [cmd, "--port", String(port), ...chatFlags].join(" "),
161
+ envVars,
162
+ };
99
163
  case "claude":
100
164
  // Claude is started via createClaudeSession() in the workflow's run()
101
- return process.env.SHELL || (process.platform === "win32" ? "pwsh" : "sh");
165
+ return {
166
+ command:
167
+ process.env.SHELL || (process.platform === "win32" ? "pwsh" : "sh"),
168
+ envVars,
169
+ };
102
170
  default:
103
- return [cmd, ...chatFlags].join(" ");
171
+ return {
172
+ command: [cmd, ...chatFlags].join(" "),
173
+ envVars,
174
+ };
104
175
  }
105
176
  }
106
177
 
107
- async function waitForServer(agent: AgentType, port: number, paneId: string): Promise<string> {
178
+ async function waitForServer(
179
+ agent: AgentType,
180
+ port: number,
181
+ paneId: string,
182
+ ): Promise<string> {
108
183
  if (agent === "claude") return "";
109
184
 
110
185
  const serverUrl = `localhost:${port}`;
@@ -181,8 +256,16 @@ export function escPwsh(s: string): string {
181
256
  * Creates a tmux session with the orchestrator as the initial pane,
182
257
  * then attaches so the user sees everything live.
183
258
  */
184
- export async function executeWorkflow(options: WorkflowRunOptions): Promise<void> {
185
- const { definition, agent, prompt, workflowFile, projectRoot = process.cwd() } = options;
259
+ export async function executeWorkflow(
260
+ options: WorkflowRunOptions,
261
+ ): Promise<void> {
262
+ const {
263
+ definition,
264
+ agent,
265
+ prompt,
266
+ workflowFile,
267
+ projectRoot = process.cwd(),
268
+ } = options;
186
269
 
187
270
  const workflowRunId = generateId();
188
271
  const tmuxSessionName = `atomic-wf-${definition.name}-${workflowRunId}`;
@@ -235,21 +318,16 @@ export async function executeWorkflow(options: WorkflowRunOptions): Promise<void
235
318
  } else {
236
319
  // Outside tmux: attach normally (blocks until session ends)
237
320
  const muxBinary = getMuxBinary() ?? "tmux";
238
- const attachProc = Bun.spawn([muxBinary, "attach-session", "-t", tmuxSessionName], {
239
- stdio: ["inherit", "inherit", "inherit"],
240
- });
321
+ const attachProc = Bun.spawn(
322
+ [muxBinary, "attach-session", "-t", tmuxSessionName],
323
+ {
324
+ stdio: ["inherit", "inherit", "inherit"],
325
+ },
326
+ );
241
327
  await attachProc.exited;
242
328
  }
243
329
  }
244
330
 
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
331
  /**
254
332
  * Small buffer (ms) subtracted from `Date.now()` when recording the Claude
255
333
  * session start timestamp. Protects against fast sequential runs where
@@ -264,13 +342,27 @@ const CLAUDE_SESSION_TIMESTAMP_BUFFER_MS = 100;
264
342
 
265
343
  /** Type guard for objects with a string `content` property (Copilot assistant.message data). */
266
344
  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";
345
+ return (
346
+ typeof value === "object" &&
347
+ value !== null &&
348
+ "content" in value &&
349
+ typeof (value as { content: unknown }).content === "string"
350
+ );
268
351
  }
269
352
 
270
353
  /** 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",
354
+ export function isTextBlockArray(
355
+ value: unknown,
356
+ ): value is Array<{ type: "text"; text: string }> {
357
+ return (
358
+ Array.isArray(value) &&
359
+ value.every(
360
+ (b) =>
361
+ typeof b === "object" &&
362
+ b !== null &&
363
+ b.type === "text" &&
364
+ typeof b.text === "string",
365
+ )
274
366
  );
275
367
  }
276
368
 
@@ -286,7 +378,10 @@ export function renderMessagesToText(messages: SavedMessage[]): string {
286
378
  case "opencode": {
287
379
  // Part is a discriminated union; filter to TextPart which has { type: "text", text: string }
288
380
  return m.data.parts
289
- .filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text")
381
+ .filter(
382
+ (p): p is Extract<typeof p, { type: "text" }> =>
383
+ p.type === "text",
384
+ )
290
385
  .map((p) => p.text)
291
386
  .join("\n");
292
387
  }
@@ -309,219 +404,322 @@ export function renderMessagesToText(messages: SavedMessage[]): string {
309
404
  .join("\n\n");
310
405
  }
311
406
 
312
- interface RunSessionOptions {
313
- sessionDef: SessionOptions;
407
+ /** Resolve a SessionRef (string or SessionHandle) to the session name. */
408
+ function resolveRef(ref: SessionRef): string {
409
+ return typeof ref === "string" ? ref : ref.name;
410
+ }
411
+
412
+ // ============================================================================
413
+ // Session runner — implements ctx.session() lifecycle
414
+ // ============================================================================
415
+
416
+ /** Shared state passed to session runners by the orchestrator. */
417
+ interface SharedRunnerState {
314
418
  tmuxSessionName: string;
315
419
  sessionsBaseDir: string;
316
420
  agent: AgentType;
317
421
  prompt: string;
318
- completedSessions: SessionResult[];
319
422
  panel: OrchestratorPanel;
320
- signal?: AbortSignal;
321
- siblingNames: Set<string>;
423
+ /** Sessions that have been spawned (for name uniqueness + cleanup). */
424
+ activeRegistry: Map<string, ActiveSession>;
425
+ /** Sessions that completed successfully (for transcript reads). */
426
+ completedRegistry: Map<string, SessionResult>;
322
427
  }
323
428
 
324
429
  /**
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.
430
+ * Create a `ctx.session()` function bound to a parent name for graph edges.
431
+ * The returned function manages the full session lifecycle:
432
+ * spawn run callback → flush saves → complete/error cleanup.
328
433
  */
329
- async function runSingleSession(opts: RunSessionOptions): Promise<SessionResult> {
330
- const {
331
- sessionDef, tmuxSessionName, sessionsBaseDir, agent,
332
- prompt, completedSessions, panel, signal, siblingNames,
333
- } = opts;
434
+ function createSessionRunner(
435
+ shared: SharedRunnerState,
436
+ parentName: string,
437
+ ): <T = void>(
438
+ options: SessionRunOptions,
439
+ run: (ctx: SessionContext) => Promise<T>,
440
+ ) => Promise<SessionHandle<T>> {
441
+ return async <T = void>(
442
+ options: SessionRunOptions,
443
+ run: (ctx: SessionContext) => Promise<T>,
444
+ ): Promise<SessionHandle<T>> => {
445
+ const { name } = options;
446
+ const deps = options.dependsOn ?? [];
447
+
448
+ // ── 1. Validate name uniqueness (synchronous, before any await) ──
449
+ if (!name || name.trim() === "") {
450
+ throw new Error("Session name is required.");
451
+ }
452
+ if (shared.activeRegistry.has(name) || shared.completedRegistry.has(name)) {
453
+ throw new Error(`Duplicate session name: "${name}"`);
454
+ }
334
455
 
335
- panel.sessionStart(sessionDef.name);
456
+ // ── 2. Validate dependsOn (synchronous, before any await) ──
457
+ if (deps.length > 0) {
458
+ if (deps.includes(name)) {
459
+ throw new Error(`Session "${name}" cannot depend on itself.`);
460
+ }
461
+ const unknown = deps.filter(
462
+ (d) =>
463
+ !shared.activeRegistry.has(d) && !shared.completedRegistry.has(d),
464
+ );
465
+ if (unknown.length > 0) {
466
+ throw new Error(
467
+ `Session "${name}" dependsOn unknown session(s): ${unknown.join(
468
+ ", ",
469
+ )}. Dependencies must be spawned before the dependent session.`,
470
+ );
471
+ }
472
+ }
336
473
 
337
- throwIfAborted(signal);
474
+ // ── 3. Create done promise so dependent sessions can await this one ──
475
+ let resolveDone!: () => void;
476
+ let rejectDone!: (err: unknown) => void;
477
+ const donePromise = new Promise<void>((resolve, reject) => {
478
+ resolveDone = resolve;
479
+ rejectDone = reject;
480
+ });
481
+ // Prevent "unhandled rejection" noise when no dependent awaits us.
482
+ donePromise.catch(() => {});
338
483
 
339
- const port = await getRandomPort();
340
- const paneCmd = buildPaneCommand(agent, port);
341
- const paneId = tmux.createWindow(tmuxSessionName, sessionDef.name, paneCmd);
484
+ // ── 4. Register in active registry (synchronous) ──
485
+ // Placeholder paneId filled in after tmux window creation.
486
+ shared.activeRegistry.set(name, { name, paneId: "", done: donePromise });
342
487
 
343
- throwIfAborted(signal);
488
+ const sessionId = generateId();
489
+ let paneId = "";
490
+ // Graph parents: explicit deps if provided, otherwise the enclosing scope.
491
+ const graphParents = deps.length > 0 ? [...deps] : [parentName];
344
492
 
345
- const serverUrl = await waitForServer(agent, port, paneId);
493
+ try {
494
+ // ── 5. Wait for dependsOn sessions to finish ──
495
+ // Active deps block; completed deps resolve immediately. If a dep
496
+ // rejects (its session failed), this throws and aborts the dependent.
497
+ if (deps.length > 0) {
498
+ await Promise.all(
499
+ deps.map((d) => {
500
+ const active = shared.activeRegistry.get(d);
501
+ if (active) return active.done;
502
+ return Promise.resolve();
503
+ }),
504
+ );
505
+ }
346
506
 
347
- throwIfAborted(signal);
507
+ // ── 6. Allocate port ──
508
+ const port = await getRandomPort();
509
+ const { command: paneCmd, envVars: paneEnvVars } = buildPaneCommand(
510
+ shared.agent,
511
+ port,
512
+ );
348
513
 
349
- const sessionId = generateId();
350
- const sessionDirName = `${sessionDef.name}-${sessionId}`;
351
- const sessionDir = join(sessionsBaseDir, sessionDirName);
352
- await ensureDir(sessionDir);
514
+ // ── 7. Create tmux window ──
515
+ paneId = tmux.createWindow(
516
+ shared.tmuxSessionName,
517
+ name,
518
+ paneCmd,
519
+ undefined,
520
+ paneEnvVars,
521
+ );
522
+ shared.activeRegistry.set(name, { name, paneId, done: donePromise });
353
523
 
354
- const messagesPath = join(sessionDir, "messages.json");
355
- const inboxPath = join(sessionDir, "inbox.md");
524
+ // ── 8. Wait for server readiness ──
525
+ const serverUrl = await waitForServer(shared.agent, port, paneId);
356
526
 
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
- }
527
+ // ── 9. Create session directory ──
528
+ const sessionDirName = `${name}-${sessionId}`;
529
+ const sessionDir = join(shared.sessionsBaseDir, sessionDirName);
530
+ await ensureDir(sessionDir);
366
531
 
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
- }
532
+ const messagesPath = join(sessionDir, "messages.json");
533
+ const inboxPath = join(sessionDir, "inbox.md");
391
534
 
392
- const msgs: SessionMessage[] = await getSessionMessages(candidate.sessionId, { dir });
393
- return msgs.map((m) => ({ provider: "claude" as const, data: m }));
394
- }
535
+ // ── 10. Add node to graph panel ──
536
+ shared.panel.addSession(name, graphParents);
395
537
 
396
- if (!Array.isArray(arg) && "info" in arg && "parts" in arg) {
397
- return [{ provider: "opencode" as const, data: arg as SessionPromptResponse }];
398
- }
538
+ // ── 11. Claude session snapshot (for identifying new sessions later) ──
539
+ let knownClaudeSessionIds: Set<string> | undefined;
540
+ if (shared.agent === "claude") {
541
+ const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
542
+ const existing = await listSessions({ dir: process.cwd() });
543
+ knownClaudeSessionIds = new Set(existing.map((s) => s.sessionId));
544
+ }
545
+ const claudeSessionStartedAfter =
546
+ shared.agent === "claude"
547
+ ? Date.now() - CLAUDE_SESSION_TIMESTAMP_BUFFER_MS
548
+ : 0;
549
+
550
+ // ── Message wrapping (Claude/Copilot/OpenCode) ──
551
+ async function wrapMessages(
552
+ arg: SessionEvent[] | SessionPromptResponse | string,
553
+ ): Promise<SavedMessage[]> {
554
+ if (typeof arg === "string") {
555
+ const { getSessionMessages, listSessions } = await import(
556
+ "@anthropic-ai/claude-agent-sdk"
557
+ );
558
+ const dir = process.cwd();
559
+ const sessions = await listSessions({ dir });
560
+
561
+ const newSessions = knownClaudeSessionIds
562
+ ? sessions.filter(
563
+ (s) => !knownClaudeSessionIds!.has(s.sessionId),
564
+ )
565
+ : sessions.filter(
566
+ (s) => s.lastModified >= claudeSessionStartedAfter,
567
+ );
568
+
569
+ const candidates = newSessions.sort(
570
+ (a, b) => b.lastModified - a.lastModified,
571
+ );
572
+
573
+ const candidate = candidates[0];
574
+ if (!candidate) {
575
+ throw new Error(
576
+ `wrapMessages: no new Claude session found for ${dir}`,
577
+ );
578
+ }
399
579
 
400
- if (Array.isArray(arg)) {
401
- return (arg as SessionEvent[]).map((m) => ({
402
- provider: "copilot" as const,
403
- data: m,
404
- }));
405
- }
580
+ const msgs: SessionMessage[] = await getSessionMessages(
581
+ candidate.sessionId,
582
+ { dir },
583
+ );
584
+ return msgs.map((m) => ({ provider: "claude" as const, data: m }));
585
+ }
406
586
 
407
- return [];
408
- }
587
+ if (!Array.isArray(arg) && "info" in arg && "parts" in arg) {
588
+ return [
589
+ {
590
+ provider: "opencode" as const,
591
+ data: arg as SessionPromptResponse,
592
+ },
593
+ ];
594
+ }
409
595
 
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
- );
596
+ if (Array.isArray(arg)) {
597
+ return (arg as SessionEvent[]).map((m) => ({
598
+ provider: "copilot" as const,
599
+ data: m,
600
+ }));
601
+ }
602
+
603
+ return [];
443
604
  }
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.`
605
+
606
+ // ── Save function ──
607
+ const pendingSaves: Promise<void>[] = [];
608
+
609
+ const save: SaveTranscript = ((
610
+ arg: SessionEvent[] | SessionPromptResponse | string,
611
+ ) => {
612
+ const p = (async () => {
613
+ const wrapped = await wrapMessages(arg);
614
+ await Bun.write(messagesPath, JSON.stringify(wrapped, null, 2));
615
+ const text = renderMessagesToText(wrapped);
616
+ await Bun.write(inboxPath, text);
617
+ })();
618
+ pendingSaves.push(p);
619
+ return p;
620
+ }) as SaveTranscript;
621
+
622
+ // ── Transcript/messages access (reads only from completedRegistry) ──
623
+ const transcriptFn = async (ref: SessionRef): Promise<Transcript> => {
624
+ const refName = resolveRef(ref);
625
+ const prev = shared.completedRegistry.get(refName);
626
+ if (!prev) {
627
+ const available =
628
+ [...shared.completedRegistry.keys()].join(", ") || "(none)";
629
+ throw new Error(
630
+ `No transcript for "${refName}". Available: ${available}`,
631
+ );
632
+ }
633
+ const filePath = join(prev.sessionDir, "inbox.md");
634
+ const content = await readFile(filePath, "utf-8");
635
+ return { path: filePath, content };
636
+ };
637
+
638
+ const getMessagesFn = async (
639
+ ref: SessionRef,
640
+ ): Promise<SavedMessage[]> => {
641
+ const refName = resolveRef(ref);
642
+ const prev = shared.completedRegistry.get(refName);
643
+ if (!prev) {
644
+ const available =
645
+ [...shared.completedRegistry.keys()].join(", ") || "(none)";
646
+ throw new Error(
647
+ `No messages for "${refName}". Available: ${available}`,
648
+ );
649
+ }
650
+ const filePath = join(prev.sessionDir, "messages.json");
651
+ const raw = await readFile(filePath, "utf-8");
652
+ return JSON.parse(raw) as SavedMessage[];
653
+ };
654
+
655
+ // ── 12. Construct SessionContext ──
656
+ const ctx: SessionContext = {
657
+ serverUrl,
658
+ userPrompt: shared.prompt,
659
+ agent: shared.agent,
660
+ sessionDir,
661
+ paneId,
662
+ sessionId,
663
+ save,
664
+ transcript: transcriptFn,
665
+ getMessages: getMessagesFn,
666
+ session: createSessionRunner(shared, name),
667
+ };
668
+
669
+ // ── Write session metadata ──
670
+ await writeFile(
671
+ join(sessionDir, "metadata.json"),
672
+ JSON.stringify(
673
+ {
674
+ name,
675
+ description: options.description ?? "",
676
+ agent: shared.agent,
677
+ paneId,
678
+ serverUrl,
679
+ port,
680
+ startedAt: new Date().toISOString(),
681
+ },
682
+ null,
683
+ 2,
684
+ ),
685
+ );
686
+
687
+ // ── 13. Run user callback ──
688
+ let callbackResult: T;
689
+ try {
690
+ callbackResult = await run(ctx);
691
+ if (pendingSaves.length > 0) await Promise.all(pendingSaves);
692
+ } catch (error) {
693
+ const message =
694
+ error instanceof Error ? error.message : String(error);
695
+ await writeFile(join(sessionDir, "error.txt"), message).catch(
696
+ () => {},
453
697
  );
698
+ shared.panel.sessionError(name, message);
699
+ throw error;
454
700
  }
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
- );
701
+
702
+ // ── 14. Mark session complete ──
703
+ shared.panel.sessionSuccess(name);
704
+ const result: SessionResult = { name, sessionId, sessionDir, paneId };
705
+ shared.completedRegistry.set(name, result);
706
+ shared.activeRegistry.delete(name);
707
+ resolveDone();
708
+
709
+ return { name, id: sessionId, result: callbackResult! };
710
+ } catch (error) {
711
+ // Ensure the done promise settles and the active entry is cleared so
712
+ // dependents fail fast instead of hanging forever on a ghost dep.
713
+ shared.activeRegistry.delete(name);
714
+ rejectDone(error);
715
+ throw error;
716
+ } finally {
717
+ // ── 15. Cleanup (Claude session state) ──
718
+ if (shared.agent === "claude" && paneId) {
719
+ clearClaudeSession(paneId);
460
720
  }
461
- const filePath = join(prev.sessionDir, "messages.json");
462
- const raw = await readFile(filePath, "utf-8");
463
- return JSON.parse(raw) as SavedMessage[];
464
- },
721
+ }
465
722
  };
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
723
  }
526
724
 
527
725
  // ============================================================================
@@ -530,8 +728,12 @@ async function promiseAllFailFast<T>(
530
728
 
531
729
  async function runOrchestrator(): Promise<void> {
532
730
  const requiredEnvVars = [
533
- "ATOMIC_WF_ID", "ATOMIC_WF_TMUX", "ATOMIC_WF_AGENT",
534
- "ATOMIC_WF_PROMPT", "ATOMIC_WF_FILE", "ATOMIC_WF_CWD",
731
+ "ATOMIC_WF_ID",
732
+ "ATOMIC_WF_TMUX",
733
+ "ATOMIC_WF_AGENT",
734
+ "ATOMIC_WF_PROMPT",
735
+ "ATOMIC_WF_FILE",
736
+ "ATOMIC_WF_CWD",
535
737
  ] as const;
536
738
  for (const key of requiredEnvVars) {
537
739
  if (!process.env[key]) {
@@ -542,7 +744,9 @@ async function runOrchestrator(): Promise<void> {
542
744
  const workflowRunId = process.env.ATOMIC_WF_ID!;
543
745
  const tmuxSessionName = process.env.ATOMIC_WF_TMUX!;
544
746
  const agent = process.env.ATOMIC_WF_AGENT! as AgentType;
545
- const prompt = Buffer.from(process.env.ATOMIC_WF_PROMPT!, "base64").toString("utf-8");
747
+ const prompt = Buffer.from(process.env.ATOMIC_WF_PROMPT!, "base64").toString(
748
+ "utf-8",
749
+ );
546
750
  const workflowFile = process.env.ATOMIC_WF_FILE!;
547
751
  const cwd = process.env.ATOMIC_WF_CWD!;
548
752
 
@@ -551,7 +755,9 @@ async function runOrchestrator(): Promise<void> {
551
755
  const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
552
756
  await ensureDir(sessionsBaseDir);
553
757
 
554
- const panel = await OrchestratorPanel.create({ tmuxSession: tmuxSessionName });
758
+ const panel = await OrchestratorPanel.create({
759
+ tmuxSession: tmuxSessionName,
760
+ });
555
761
 
556
762
  // Idempotent shutdown guard
557
763
  let shutdownCalled = false;
@@ -559,7 +765,9 @@ async function runOrchestrator(): Promise<void> {
559
765
  if (shutdownCalled) return;
560
766
  shutdownCalled = true;
561
767
  panel.destroy();
562
- try { tmux.killSession(tmuxSessionName); } catch {}
768
+ try {
769
+ tmux.killSession(tmuxSessionName);
770
+ } catch {}
563
771
  process.exitCode = exitCode;
564
772
  };
565
773
 
@@ -568,6 +776,17 @@ async function runOrchestrator(): Promise<void> {
568
776
  const signalHandler = () => shutdown(1);
569
777
  process.on("SIGINT", signalHandler);
570
778
 
779
+ // Shared state for all session runners
780
+ const shared: SharedRunnerState = {
781
+ tmuxSessionName,
782
+ sessionsBaseDir,
783
+ agent,
784
+ prompt,
785
+ panel,
786
+ activeRegistry: new Map(),
787
+ completedRegistry: new Map(),
788
+ };
789
+
571
790
  try {
572
791
  const plan: WorkflowLoader.Plan = {
573
792
  name: workflowFile.split("/").at(-3) ?? "unknown",
@@ -590,94 +809,86 @@ async function runOrchestrator(): Promise<void> {
590
809
 
591
810
  await writeFile(
592
811
  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)
812
+ JSON.stringify(
813
+ {
814
+ workflowName: definition.name,
815
+ agent,
816
+ prompt,
817
+ projectRoot: cwd,
818
+ startedAt: new Date().toISOString(),
819
+ },
820
+ null,
821
+ 2,
822
+ ),
600
823
  );
601
824
 
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
- }
825
+ // Initialize panel with just the orchestrator node (sessions added dynamically)
826
+ panel.showWorkflowInfo(definition.name, agent, [], prompt);
827
+
828
+ // Build the WorkflowContext — top-level context for the .run() callback
829
+ const sessionRunner = createSessionRunner(shared, "orchestrator");
611
830
 
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;
831
+ const workflowCtx: WorkflowContext = {
832
+ userPrompt: prompt,
833
+ agent,
834
+ session: sessionRunner,
835
+ transcript: async (ref: SessionRef): Promise<Transcript> => {
836
+ const refName = resolveRef(ref);
837
+ const prev = shared.completedRegistry.get(refName);
838
+ if (!prev) {
839
+ const available =
840
+ [...shared.completedRegistry.keys()].join(", ") || "(none)";
841
+ throw new Error(
842
+ `No transcript for "${refName}". Available: ${available}`,
843
+ );
633
844
  }
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;
845
+ const filePath = join(prev.sessionDir, "inbox.md");
846
+ const content = await readFile(filePath, "utf-8");
847
+ return { path: filePath, content };
848
+ },
849
+ getMessages: async (ref: SessionRef): Promise<SavedMessage[]> => {
850
+ const refName = resolveRef(ref);
851
+ const prev = shared.completedRegistry.get(refName);
852
+ if (!prev) {
853
+ const available =
854
+ [...shared.completedRegistry.keys()].join(", ") || "(none)";
855
+ throw new Error(
856
+ `No messages for "${refName}". Available: ${available}`,
857
+ );
667
858
  }
668
- }
669
- }
859
+ const filePath = join(prev.sessionDir, "messages.json");
860
+ const raw = await readFile(filePath, "utf-8");
861
+ return JSON.parse(raw) as SavedMessage[];
862
+ },
863
+ };
864
+
865
+ // Run the workflow, racing against user abort (q / Ctrl+C)
866
+ const abortPromise = panel.waitForAbort().then(() => {
867
+ throw new WorkflowAbortError();
868
+ });
869
+ await Promise.race([definition.run(workflowCtx), abortPromise]);
670
870
 
671
871
  panel.showCompletion(definition.name, sessionsBaseDir);
672
872
  await panel.waitForExit();
673
873
  shutdown(0);
674
874
  } 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);
875
+ // Kill any active session tmux windows that didn't complete
876
+ for (const [, active] of shared.activeRegistry) {
877
+ try {
878
+ tmux.killWindow(tmuxSessionName, active.name);
879
+ } catch {}
880
+ }
881
+
882
+ if (error instanceof WorkflowAbortError) {
883
+ shutdown(0);
884
+ } else {
885
+ const message = error instanceof Error ? error.message : String(error);
886
+ try {
887
+ panel.showFatalError(message);
888
+ await panel.waitForExit();
889
+ } catch {}
890
+ shutdown(1);
891
+ }
681
892
  } finally {
682
893
  process.off("SIGINT", signalHandler);
683
894
  }