@bastani/atomic 0.6.4 → 0.6.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 (120) hide show
  1. package/.agents/skills/create-spec/SKILL.md +6 -3
  2. package/.agents/skills/tdd/SKILL.md +107 -0
  3. package/.agents/skills/tdd/deep-modules.md +33 -0
  4. package/.agents/skills/tdd/interface-design.md +31 -0
  5. package/.agents/skills/tdd/mocking.md +59 -0
  6. package/.agents/skills/tdd/refactoring.md +10 -0
  7. package/.agents/skills/tdd/tests.md +61 -0
  8. package/.agents/skills/workflow-creator/SKILL.md +550 -0
  9. package/.agents/skills/workflow-creator/references/agent-sessions.md +891 -0
  10. package/.agents/skills/workflow-creator/references/agent-setup-recipe.md +266 -0
  11. package/.agents/skills/workflow-creator/references/computation-and-validation.md +201 -0
  12. package/.agents/skills/workflow-creator/references/control-flow.md +470 -0
  13. package/.agents/skills/workflow-creator/references/failure-modes.md +1014 -0
  14. package/.agents/skills/workflow-creator/references/getting-started.md +392 -0
  15. package/.agents/skills/workflow-creator/references/registry-and-validation.md +141 -0
  16. package/.agents/skills/workflow-creator/references/running-workflows.md +418 -0
  17. package/.agents/skills/workflow-creator/references/session-config.md +384 -0
  18. package/.agents/skills/workflow-creator/references/state-and-data-flow.md +356 -0
  19. package/.agents/skills/workflow-creator/references/user-input.md +234 -0
  20. package/.agents/skills/workflow-creator/references/workflow-inputs.md +392 -0
  21. package/.claude/agents/debugger.md +2 -2
  22. package/.claude/agents/reviewer.md +1 -1
  23. package/.claude/agents/worker.md +2 -2
  24. package/.github/agents/debugger.md +1 -1
  25. package/.github/agents/worker.md +1 -1
  26. package/.mcp.json +5 -1
  27. package/.opencode/agents/debugger.md +1 -1
  28. package/.opencode/agents/worker.md +1 -1
  29. package/README.md +236 -201
  30. package/dist/sdk/define-workflow.d.ts +11 -6
  31. package/dist/sdk/define-workflow.d.ts.map +1 -1
  32. package/dist/sdk/errors.d.ts +10 -0
  33. package/dist/sdk/errors.d.ts.map +1 -1
  34. package/dist/sdk/index.d.ts +21 -9
  35. package/dist/sdk/index.d.ts.map +1 -1
  36. package/dist/sdk/primitives/inputs.d.ts +36 -0
  37. package/dist/sdk/primitives/inputs.d.ts.map +1 -0
  38. package/dist/sdk/primitives/metadata.d.ts +40 -0
  39. package/dist/sdk/primitives/metadata.d.ts.map +1 -0
  40. package/dist/sdk/primitives/run.d.ts +57 -0
  41. package/dist/sdk/primitives/run.d.ts.map +1 -0
  42. package/dist/sdk/primitives/sessions.d.ts +128 -0
  43. package/dist/sdk/primitives/sessions.d.ts.map +1 -0
  44. package/dist/sdk/runtime/executor.d.ts +24 -56
  45. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  46. package/dist/sdk/runtime/orchestrator-entry.d.ts +26 -0
  47. package/dist/sdk/runtime/orchestrator-entry.d.ts.map +1 -0
  48. package/dist/sdk/runtime/tmux.d.ts +20 -0
  49. package/dist/sdk/runtime/tmux.d.ts.map +1 -1
  50. package/dist/sdk/types.d.ts +26 -86
  51. package/dist/sdk/types.d.ts.map +1 -1
  52. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
  53. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
  54. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
  55. package/dist/sdk/workflows/builtin/open-claude-design/claude/index.d.ts.map +1 -1
  56. package/dist/sdk/workflows/builtin/open-claude-design/copilot/index.d.ts.map +1 -1
  57. package/dist/sdk/workflows/builtin/open-claude-design/opencode/index.d.ts.map +1 -1
  58. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -1
  59. package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts.map +1 -1
  60. package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts.map +1 -1
  61. package/dist/sdk/workflows/index.d.ts +20 -12
  62. package/dist/sdk/workflows/index.d.ts.map +1 -1
  63. package/dist/services/config/additional-instructions.d.ts +1 -1
  64. package/dist/services/config/additional-instructions.d.ts.map +1 -1
  65. package/package.json +4 -4
  66. package/src/cli.ts +39 -56
  67. package/src/commands/builtin-registry.ts +37 -0
  68. package/src/commands/cli/chat/index.ts +1 -3
  69. package/src/{sdk → commands/cli}/management-commands.ts +15 -55
  70. package/src/commands/cli/session.ts +1 -1
  71. package/src/commands/cli/workflow-command.test.ts +250 -16
  72. package/src/commands/cli/workflow-inputs.test.ts +1 -0
  73. package/src/commands/cli/workflow-inputs.ts +13 -3
  74. package/src/commands/cli/workflow-list.test.ts +1 -0
  75. package/src/commands/cli/workflow-list.ts +0 -0
  76. package/src/commands/cli/workflow-status.ts +1 -1
  77. package/src/commands/cli/workflow.ts +191 -11
  78. package/src/sdk/define-workflow.test.ts +47 -16
  79. package/src/sdk/define-workflow.ts +24 -6
  80. package/src/sdk/errors.test.ts +11 -0
  81. package/src/sdk/errors.ts +13 -0
  82. package/src/sdk/index.test.ts +92 -0
  83. package/src/sdk/index.ts +71 -15
  84. package/src/sdk/primitives/inputs.ts +48 -0
  85. package/src/sdk/primitives/metadata.ts +63 -0
  86. package/src/sdk/primitives/run.ts +81 -0
  87. package/src/sdk/primitives/sessions.test.ts +594 -0
  88. package/src/sdk/primitives/sessions.ts +328 -0
  89. package/src/sdk/runtime/executor.ts +36 -115
  90. package/src/sdk/runtime/orchestrator-entry.ts +110 -0
  91. package/src/sdk/runtime/tmux.ts +33 -0
  92. package/src/sdk/types.ts +26 -91
  93. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -0
  94. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -0
  95. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -0
  96. package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -0
  97. package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -0
  98. package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -0
  99. package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -0
  100. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -0
  101. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -0
  102. package/src/sdk/workflows/index.ts +68 -51
  103. package/src/services/config/additional-instructions.ts +1 -1
  104. package/.agents/skills/test-driven-development/SKILL.md +0 -371
  105. package/.agents/skills/test-driven-development/testing-anti-patterns.md +0 -299
  106. package/dist/commands/cli/session.d.ts +0 -67
  107. package/dist/commands/cli/session.d.ts.map +0 -1
  108. package/dist/commands/cli/workflow-status.d.ts +0 -63
  109. package/dist/commands/cli/workflow-status.d.ts.map +0 -1
  110. package/dist/sdk/commander.d.ts +0 -74
  111. package/dist/sdk/commander.d.ts.map +0 -1
  112. package/dist/sdk/management-commands.d.ts +0 -42
  113. package/dist/sdk/management-commands.d.ts.map +0 -1
  114. package/dist/sdk/workflow-cli.d.ts +0 -103
  115. package/dist/sdk/workflow-cli.d.ts.map +0 -1
  116. package/dist/sdk/workflows/builtin-registry.d.ts +0 -113
  117. package/dist/sdk/workflows/builtin-registry.d.ts.map +0 -1
  118. package/src/sdk/commander.ts +0 -161
  119. package/src/sdk/workflow-cli.ts +0 -409
  120. package/src/sdk/workflows/builtin-registry.ts +0 -23
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Session-management primitives.
3
+ *
4
+ * Thin wrappers around the tmux runtime utilities and the on-disk
5
+ * `~/.atomic/sessions/<workflowRunId>/` layout. Consumers (atomic CLI,
6
+ * third-party CLIs, embedding TUIs) call these instead of touching tmux
7
+ * commands or the status-writer schema directly.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import {
13
+ attachSession as tmuxAttach,
14
+ detachClients as tmuxDetachClients,
15
+ isTmuxInstalled,
16
+ killSession,
17
+ listSessions as listAllTmuxSessions,
18
+ nextWindow as tmuxNextWindow,
19
+ previousWindow as tmuxPreviousWindow,
20
+ selectWindow as tmuxSelectWindow,
21
+ type SessionType,
22
+ type TmuxSession,
23
+ } from "../runtime/tmux.ts";
24
+ import {
25
+ readSnapshot,
26
+ workflowRunIdFromTmuxName,
27
+ type WorkflowStatusSnapshot,
28
+ } from "../runtime/status-writer.ts";
29
+ import { MissingDependencyError, SessionNotFoundError } from "../errors.ts";
30
+ import type { AgentType, SavedMessage } from "../types.ts";
31
+
32
+ /** Scope filter for session listings — chat sessions, workflow sessions, or both. */
33
+ export type SessionScope = "chat" | "workflow" | "all";
34
+
35
+ /** Single session entry returned by `listSessions` / `getSession`. */
36
+ export interface SessionInfo {
37
+ /** Tmux session name (e.g. `atomic-wf-claude-ralph-a1b2c3d4`). */
38
+ id: string;
39
+ /** Session type derived from the name prefix. */
40
+ type?: SessionType;
41
+ /** Agent backend that owns this session. */
42
+ agent?: string;
43
+ /** ISO 8601 creation timestamp. */
44
+ created: string;
45
+ /** Whether a tmux client is currently attached. */
46
+ attached: boolean;
47
+ }
48
+
49
+ /** Status snapshot persisted by the orchestrator at `~/.atomic/sessions/<id>/status.json`. */
50
+ export type StatusSnapshot = WorkflowStatusSnapshot;
51
+
52
+ /** Options for filtering `listSessions()`. */
53
+ export interface ListSessionsOptions {
54
+ /** Restrict to one or more agent backends. */
55
+ agent?: AgentType | readonly AgentType[];
56
+ /** Restrict by session kind. Defaults to `"all"`. */
57
+ scope?: SessionScope;
58
+ }
59
+
60
+ /**
61
+ * Injectable dependencies for the session primitives.
62
+ *
63
+ * Defaults wire through to the real tmux/status-writer implementations.
64
+ * Tests pass in mocks; embedding consumers can override the base directory
65
+ * or swap the tmux backend (e.g. for psmux on Windows) without monkey-
66
+ * patching the underlying modules.
67
+ */
68
+ export interface SessionPrimitiveDeps {
69
+ isTmuxInstalled: () => boolean;
70
+ listAllTmuxSessions: () => readonly TmuxSession[];
71
+ killSession: (id: string) => void;
72
+ attachSession: (id: string) => void;
73
+ detachClients: (id: string) => void;
74
+ nextWindow: (id: string) => void;
75
+ previousWindow: (id: string) => void;
76
+ /** `target` is a tmux window target like `<session>:<index>`. */
77
+ selectWindow: (target: string) => void;
78
+ readSnapshot: typeof readSnapshot;
79
+ /** Base directory for session artefacts. Defaults to `~/.atomic/sessions`. */
80
+ sessionsBaseDir: string;
81
+ }
82
+
83
+ /** Default deps object — wires through to the real implementations. */
84
+ const defaultDeps: SessionPrimitiveDeps = {
85
+ isTmuxInstalled,
86
+ listAllTmuxSessions,
87
+ killSession,
88
+ attachSession: tmuxAttach,
89
+ detachClients: tmuxDetachClients,
90
+ nextWindow: tmuxNextWindow,
91
+ previousWindow: tmuxPreviousWindow,
92
+ selectWindow: tmuxSelectWindow,
93
+ readSnapshot,
94
+ sessionsBaseDir: join(homedir(), ".atomic", "sessions"),
95
+ };
96
+
97
+ /** Convert a TmuxSession into the consumer-facing SessionInfo shape. */
98
+ function toSessionInfo(s: TmuxSession): SessionInfo {
99
+ return {
100
+ id: s.name,
101
+ type: s.type,
102
+ agent: s.agent,
103
+ created: s.created,
104
+ attached: s.attached,
105
+ };
106
+ }
107
+
108
+ /** Filter sessions by scope. */
109
+ function filterByScope(
110
+ sessions: readonly TmuxSession[],
111
+ scope: SessionScope,
112
+ ): TmuxSession[] {
113
+ if (scope === "all") return [...sessions];
114
+ return sessions.filter((s) => s.type === scope);
115
+ }
116
+
117
+ /** Filter sessions by an allow-list of agent backends. */
118
+ function filterByAgents(
119
+ sessions: readonly TmuxSession[],
120
+ agents: readonly AgentType[],
121
+ ): TmuxSession[] {
122
+ if (agents.length === 0) return [...sessions];
123
+ const allowed = new Set<string>(agents);
124
+ return sessions.filter((s) => s.agent !== undefined && allowed.has(s.agent));
125
+ }
126
+
127
+ /**
128
+ * List atomic-managed tmux sessions on the shared `atomic` socket.
129
+ *
130
+ * Returns an empty array when tmux is not installed or the server has no
131
+ * sessions — never throws on the cold-start path.
132
+ */
133
+ export function listSessions(
134
+ options: ListSessionsOptions = {},
135
+ deps: SessionPrimitiveDeps = defaultDeps,
136
+ ): SessionInfo[] {
137
+ if (!deps.isTmuxInstalled()) return [];
138
+ const scope = options.scope ?? "all";
139
+ const agents: readonly AgentType[] = options.agent === undefined
140
+ ? []
141
+ : Array.isArray(options.agent)
142
+ ? (options.agent as readonly AgentType[])
143
+ : [options.agent as AgentType];
144
+
145
+ const all = deps.listAllTmuxSessions();
146
+ const scoped = filterByScope(all, scope);
147
+ const filtered = filterByAgents(scoped, agents);
148
+ return filtered.map(toSessionInfo);
149
+ }
150
+
151
+ /** Look up a single session by id. Returns `undefined` when not found. */
152
+ export function getSession(
153
+ id: string,
154
+ deps: SessionPrimitiveDeps = defaultDeps,
155
+ ): SessionInfo | undefined {
156
+ if (!deps.isTmuxInstalled()) return undefined;
157
+ const match = deps.listAllTmuxSessions().find((s) => s.name === id);
158
+ return match ? toSessionInfo(match) : undefined;
159
+ }
160
+
161
+ /**
162
+ * Stop a running session. Best-effort: if the session is already gone
163
+ * the underlying `tmux kill-session` is a no-op-equivalent.
164
+ */
165
+ export async function stopSession(
166
+ id: string,
167
+ deps: SessionPrimitiveDeps = defaultDeps,
168
+ ): Promise<void> {
169
+ if (!deps.isTmuxInstalled()) return;
170
+ try {
171
+ deps.killSession(id);
172
+ } catch {
173
+ // tmux returns non-zero when the session has already been torn down —
174
+ // surface that as a successful stop rather than a hard failure.
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Attach to a running session interactively. Only valid when the host
180
+ * process has a TTY — otherwise the underlying tmux invocation will
181
+ * complain that it can't take over the terminal.
182
+ */
183
+ export async function attachSession(
184
+ id: string,
185
+ deps: SessionPrimitiveDeps = defaultDeps,
186
+ ): Promise<void> {
187
+ if (!deps.isTmuxInstalled()) {
188
+ throw new MissingDependencyError("tmux");
189
+ }
190
+ deps.attachSession(id);
191
+ }
192
+
193
+ /**
194
+ * Validate that tmux is installed and the session id exists on the
195
+ * atomic socket. Shared preamble for the navigation primitives.
196
+ */
197
+ function ensureSession(id: string, deps: SessionPrimitiveDeps): void {
198
+ if (!deps.isTmuxInstalled()) {
199
+ throw new MissingDependencyError("tmux");
200
+ }
201
+ const session = deps.listAllTmuxSessions().find((s) => s.name === id);
202
+ if (!session) {
203
+ throw new SessionNotFoundError(id);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Move the session's current-window pointer to the next window.
209
+ * Mirrors the `Ctrl+\` keybinding bound inside an attached client.
210
+ *
211
+ * Pure navigation: never attaches. An already-attached client sees the
212
+ * change live; if no client is watching, the session's current-window
213
+ * pointer is updated silently and a subsequent `attachSession` will
214
+ * land on the new window. Compose `nextWindow(id)` + `attachSession(id)`
215
+ * if you want navigate-then-attach.
216
+ */
217
+ export async function nextWindow(
218
+ id: string,
219
+ deps: SessionPrimitiveDeps = defaultDeps,
220
+ ): Promise<void> {
221
+ ensureSession(id, deps);
222
+ deps.nextWindow(id);
223
+ }
224
+
225
+ /**
226
+ * Move the session's current-window pointer to the previous window.
227
+ * Symmetrical counterpart to {@link nextWindow} — also pure navigation.
228
+ */
229
+ export async function previousWindow(
230
+ id: string,
231
+ deps: SessionPrimitiveDeps = defaultDeps,
232
+ ): Promise<void> {
233
+ ensureSession(id, deps);
234
+ deps.previousWindow(id);
235
+ }
236
+
237
+ /**
238
+ * Jump to the orchestrator window (window 0) of the target session.
239
+ * Mirrors the `Ctrl+G` keybinding bound inside an attached client.
240
+ *
241
+ * For workflow sessions, window 0 hosts the orchestrator graph view;
242
+ * for chat sessions, window 0 is the agent pane. Pure navigation —
243
+ * never attaches.
244
+ */
245
+ export async function gotoOrchestrator(
246
+ id: string,
247
+ deps: SessionPrimitiveDeps = defaultDeps,
248
+ ): Promise<void> {
249
+ ensureSession(id, deps);
250
+ deps.selectWindow(`${id}:0`);
251
+ }
252
+
253
+ /**
254
+ * Detach every client currently attached to a session. The session
255
+ * itself keeps running in the background — re-attach with
256
+ * {@link attachSession} or `tmux -L atomic attach -t <id>`.
257
+ *
258
+ * Best-effort, idempotent: returns silently when tmux is missing, the
259
+ * session is already gone, or no clients are attached.
260
+ */
261
+ export async function detachSession(
262
+ id: string,
263
+ deps: SessionPrimitiveDeps = defaultDeps,
264
+ ): Promise<void> {
265
+ if (!deps.isTmuxInstalled()) return;
266
+ try {
267
+ deps.detachClients(id);
268
+ } catch {
269
+ // tmux returns non-zero when the session is gone or no clients are
270
+ // attached — surface that as a successful detach rather than a hard
271
+ // failure, matching `stopSession`'s best-effort semantics.
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Read the on-disk status snapshot for a workflow session. Returns
277
+ * `null` when the orchestrator hasn't written one yet (the workflow
278
+ * is still very early) or when the directory doesn't exist.
279
+ */
280
+ export async function getSessionStatus(
281
+ id: string,
282
+ deps: SessionPrimitiveDeps = defaultDeps,
283
+ ): Promise<StatusSnapshot | null> {
284
+ const runId = workflowRunIdFromTmuxName(id);
285
+ if (!runId) return null;
286
+ return await deps.readSnapshot(join(deps.sessionsBaseDir, runId));
287
+ }
288
+
289
+ /**
290
+ * Read the saved native-message transcript for a single session inside
291
+ * a workflow run. `id` is the tmux session id (`atomic-wf-...`); the
292
+ * `sessionName` is the `name` passed to `ctx.stage({ name })` whose
293
+ * messages were saved via `s.save(...)`.
294
+ *
295
+ * Returns an empty array when no transcript was persisted (e.g. the
296
+ * workflow chose not to call `s.save`).
297
+ */
298
+ export async function getSessionTranscript(
299
+ id: string,
300
+ sessionName: string,
301
+ deps: SessionPrimitiveDeps = defaultDeps,
302
+ ): Promise<SavedMessage[]> {
303
+ const runId = workflowRunIdFromTmuxName(id);
304
+ if (!runId) return [];
305
+ const file = Bun.file(
306
+ join(deps.sessionsBaseDir, runId, sessionName, "messages.json"),
307
+ );
308
+ if (!(await file.exists())) return [];
309
+ let parsed: unknown;
310
+ try {
311
+ parsed = JSON.parse(await file.text());
312
+ } catch {
313
+ return [];
314
+ }
315
+ if (!Array.isArray(parsed)) return [];
316
+ return parsed.filter(isSavedMessage);
317
+ }
318
+
319
+ /** Runtime guard for deserialised SavedMessage objects. */
320
+ function isSavedMessage(value: unknown): value is SavedMessage {
321
+ if (!value || typeof value !== "object") return false;
322
+ const v = value as Record<string, unknown>;
323
+ return (
324
+ v.provider === "claude" ||
325
+ v.provider === "copilot" ||
326
+ v.provider === "opencode"
327
+ );
328
+ }
@@ -2,16 +2,20 @@
2
2
  * Workflow runtime executor.
3
3
  *
4
4
  * Architecture:
5
- * 1. `executeWorkflow()` is called by the CLI command or worker
6
- * 2. It creates a tmux session with an orchestrator pane that re-executes
7
- * the user's entrypoint file with `ATOMIC_ORCHESTRATOR_MODE=1`
5
+ * 1. `executeWorkflow()` is called by the CLI command (e.g. atomic) or by
6
+ * the SDK's `runWorkflow()` primitive
7
+ * 2. It creates a tmux session with an orchestrator pane that runs the
8
+ * SDK-owned `orchestrator-entry.ts` with three positional args:
9
+ * `<workflowSource> <agent> <inputsB64>`
8
10
  * 3. The CLI then attaches to the tmux session (user sees it live)
9
- * 4. The orchestrator pane calls `definition.run(workflowCtx)` the
10
- * user's callback uses `ctx.stage()` to spawn agent sessions
11
+ * 4. The orchestrator pane imports the workflow module by `source`,
12
+ * calls `runOrchestrator(definition, inputs)`, which then calls
13
+ * `definition.run(workflowCtx)` — the user's callback uses
14
+ * `ctx.stage()` to spawn agent sessions
11
15
  *
12
- * In the new model the user's own file (e.g. `src/worker.ts`) is re-executed
13
- * with env vars (`ATOMIC_ORCHESTRATOR_MODE`, `ATOMIC_WF_KEY`) that signal
14
- * re-entry. The worker detects those vars and calls `runOrchestrator(definition)`.
16
+ * The dev's CLI is never re-imported. The SDK orchestrator entry script
17
+ * is the only re-exec target, so there is no orchestrator-mode env var
18
+ * re-entry signal and no boilerplate in user code.
15
19
  */
16
20
 
17
21
  import { join } from "node:path";
@@ -132,19 +136,6 @@ export interface WorkflowRunOptions {
132
136
  * whether the workflow declares a schema. Empty record is valid.
133
137
  */
134
138
  inputs?: Record<string, string>;
135
- /**
136
- * Absolute path to the user's entrypoint file (e.g. `src/worker.ts`).
137
- * The launcher re-executes this file with `ATOMIC_ORCHESTRATOR_MODE=1`
138
- * so the worker can detect re-entry and call `runOrchestrator()`.
139
- * Defaults to `process.argv[1]` at the call site.
140
- */
141
- entrypointFile: string;
142
- /**
143
- * Registry key identifying this workflow, formatted as `"<agent>/<name>"`.
144
- * Passed via `ATOMIC_WF_KEY` so the orchestrator can resolve the
145
- * definition from the registry without a file-system scan.
146
- */
147
- workflowKey: string;
148
139
  /** Project root (defaults to cwd) */
149
140
  projectRoot?: string;
150
141
  /**
@@ -402,33 +393,6 @@ export function escPwsh(s: string): string {
402
393
  .replace(/\r/g, "`r");
403
394
  }
404
395
 
405
- /**
406
- * Decode the ATOMIC_WF_INPUTS env var (base64-encoded JSON) into a
407
- * `Record<string, string>`. Returns an empty record when the variable
408
- * is missing, malformed, or does not decode to a string-map object —
409
- * structured inputs are optional, so a corrupt payload must never
410
- * prevent free-form workflows from running.
411
- */
412
- export function parseInputsEnv(
413
- raw: string | undefined,
414
- ): Record<string, string> {
415
- if (!raw) return {};
416
- try {
417
- const decoded = Buffer.from(raw, "base64").toString("utf-8");
418
- const parsed: unknown = JSON.parse(decoded);
419
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
420
- return {};
421
- }
422
- const out: Record<string, string> = {};
423
- for (const [k, v] of Object.entries(parsed)) {
424
- if (typeof v === "string") out[k] = v;
425
- }
426
- return out;
427
- } catch {
428
- return {};
429
- }
430
- }
431
-
432
396
  /**
433
397
  * Coerce raw string inputs to their declared runtime types. Integer inputs
434
398
  * become `number`; every other declared type passes through as `string`.
@@ -471,13 +435,11 @@ export function coerceInputsBySchema(
471
435
  */
472
436
  export async function executeWorkflow(
473
437
  options: WorkflowRunOptions,
474
- ): Promise<void> {
438
+ ): Promise<{ id: string; tmuxSessionName: string }> {
475
439
  const {
476
440
  definition,
477
441
  agent,
478
442
  inputs = {},
479
- entrypointFile,
480
- workflowKey,
481
443
  projectRoot = process.cwd(),
482
444
  detach = false,
483
445
  } = options;
@@ -501,8 +463,9 @@ export async function executeWorkflow(
501
463
  await ensureDir(sessionsBaseDir);
502
464
 
503
465
  // Write a launcher script for the orchestrator pane.
504
- // Re-executes the user's entrypoint file with ATOMIC_ORCHESTRATOR_MODE=1
505
- // so the worker can detect re-entry and call runOrchestrator().
466
+ // Runs the SDK-owned orchestrator entry script with positional args:
467
+ // bun <orchestrator-entry.ts> <workflowSource> <agent> <inputsB64>
468
+ // The dev's own CLI is never re-execed.
506
469
  const isWin = process.platform === "win32";
507
470
  const launcherExt = isWin ? "ps1" : "sh";
508
471
  const launcherPath = join(sessionsBaseDir, `orchestrator.${launcherExt}`);
@@ -515,17 +478,18 @@ export async function executeWorkflow(
515
478
  // read the user's prompt via `ctx.inputs.prompt`.
516
479
  const inputsB64 = Buffer.from(JSON.stringify(inputs)).toString("base64");
517
480
 
481
+ // Resolve the SDK's orchestrator entry script (sibling of this file).
482
+ const orchestratorEntry = join(import.meta.dir, "orchestrator-entry.ts");
483
+ const workflowSource = definition.source;
484
+
518
485
  const launcherScript = isWin
519
486
  ? [
520
487
  `Set-Location "${escPwsh(projectRoot)}"`,
521
488
  `$env:ATOMIC_WF_ID = "${escPwsh(workflowRunId)}"`,
522
489
  `$env:ATOMIC_WF_TMUX = "${escPwsh(tmuxSessionName)}"`,
523
490
  `$env:ATOMIC_WF_AGENT = "${escPwsh(agent)}"`,
524
- `$env:ATOMIC_WF_INPUTS = "${escPwsh(inputsB64)}"`,
525
- `$env:ATOMIC_ORCHESTRATOR_MODE = "1"`,
526
- `$env:ATOMIC_WF_KEY = "${escPwsh(workflowKey)}"`,
527
491
  `$env:ATOMIC_WF_CWD = "${escPwsh(projectRoot)}"`,
528
- `bun run "${escPwsh(entrypointFile)}" 2>"${escPwsh(logPath)}"`,
492
+ `bun run "${escPwsh(orchestratorEntry)}" "${escPwsh(workflowSource)}" "${escPwsh(agent)}" "${escPwsh(inputsB64)}" 2>"${escPwsh(logPath)}"`,
529
493
  ].join("\n")
530
494
  : [
531
495
  "#!/bin/bash",
@@ -533,11 +497,8 @@ export async function executeWorkflow(
533
497
  `export ATOMIC_WF_ID="${escBash(workflowRunId)}"`,
534
498
  `export ATOMIC_WF_TMUX="${escBash(tmuxSessionName)}"`,
535
499
  `export ATOMIC_WF_AGENT="${escBash(agent)}"`,
536
- `export ATOMIC_WF_INPUTS="${escBash(inputsB64)}"`,
537
- `export ATOMIC_ORCHESTRATOR_MODE="1"`,
538
- `export ATOMIC_WF_KEY="${escBash(workflowKey)}"`,
539
500
  `export ATOMIC_WF_CWD="${escBash(projectRoot)}"`,
540
- `bun run "${escBash(entrypointFile)}" 2>"${escBash(logPath)}"`,
501
+ `bun run "${escBash(orchestratorEntry)}" "${escBash(workflowSource)}" "${escBash(agent)}" "${escBash(inputsB64)}" 2>"${escBash(logPath)}"`,
541
502
  ].join("\n");
542
503
 
543
504
  await writeFile(launcherPath, launcherScript, { mode: 0o755 });
@@ -553,7 +514,7 @@ export async function executeWorkflow(
553
514
  // new-session -d). Print connection hints and return so the caller
554
515
  // can exit cleanly without blocking on the orchestrator.
555
516
  printDetachedBanner(tmuxSessionName);
556
- return;
517
+ return { id: workflowRunId, tmuxSessionName };
557
518
  }
558
519
 
559
520
  if (tmux.isInsideAtomicSocket()) {
@@ -567,6 +528,8 @@ export async function executeWorkflow(
567
528
  const attachProc = spawnMuxAttach(tmuxSessionName);
568
529
  await attachProc.exited;
569
530
  }
531
+
532
+ return { id: workflowRunId, tmuxSessionName };
570
533
  }
571
534
 
572
535
  /**
@@ -1906,71 +1869,29 @@ function createSessionRunner(
1906
1869
  // Orchestrator logic — runs inside a tmux pane
1907
1870
  // ============================================================================
1908
1871
 
1909
- /**
1910
- * Run the orchestrator inside a tmux pane.
1911
- *
1912
- * Called by the worker entrypoint when `ATOMIC_ORCHESTRATOR_MODE=1` is set.
1913
- * The `definition` parameter is resolved by the caller (the worker) from the
1914
- * registry using `ATOMIC_WF_KEY` — this function no longer performs any
1915
- * file-path import or workflow discovery.
1916
- *
1917
- * @param definition - Resolved workflow definition from the registry.
1918
- */
1919
1872
  export { validateOrchestratorEnv } from "./executor-env.ts";
1920
1873
  import { validateOrchestratorEnv } from "./executor-env.ts";
1921
1874
 
1922
1875
  /**
1923
- * Orchestrator re-entry guard.
1924
- *
1925
- * When `executeWorkflow()` spawns a detached pane, it re-invokes the
1926
- * composition root with `ATOMIC_ORCHESTRATOR_MODE=1` +
1927
- * `ATOMIC_WF_KEY="<agent>/<name>"`. This helper detects that re-entry
1928
- * and hands off to `runOrchestrator()` with the resolved definition.
1876
+ * Run the orchestrator for a compiled workflow definition.
1929
1877
  *
1930
- * Returns `true` when re-entry was handled (caller should stop normal
1931
- * CLI flow). Returns `false` when `ATOMIC_ORCHESTRATOR_MODE` is unset
1932
- * the caller should proceed with argv parsing.
1933
- *
1934
- * The `resolve` callback lets embedded workers pass a trivial lookup
1935
- * (their single bound definition) while the dispatcher passes its
1936
- * registry. Throws on malformed or unknown keys so authoring mistakes
1937
- * surface loudly instead of silently hanging.
1878
+ * Called by the SDK's `orchestrator-entry.ts` after it imports the
1879
+ * workflow module by `source` and decodes the inputs payload from argv.
1880
+ * The runtime environment (`ATOMIC_WF_ID`, `ATOMIC_WF_TMUX`,
1881
+ * `ATOMIC_WF_AGENT`, `ATOMIC_WF_CWD`) is set by the launcher script that
1882
+ * `executeWorkflow()` writes those vars describe *where* this
1883
+ * orchestrator is running, not what to do.
1938
1884
  */
1939
- export async function handleOrchestratorReEntry(
1940
- resolve: (name: string, agent: AgentType) => WorkflowDefinition | undefined,
1941
- ): Promise<boolean> {
1942
- if (process.env.ATOMIC_ORCHESTRATOR_MODE !== "1") {
1943
- return false;
1944
- }
1945
- const key = process.env.ATOMIC_WF_KEY ?? "";
1946
- const slashIdx = key.indexOf("/");
1947
- if (slashIdx < 0) {
1948
- throw new Error(
1949
- `ATOMIC_ORCHESTRATOR_MODE=1 but ATOMIC_WF_KEY "${key}" is malformed — expected "<agent>/<name>"`,
1950
- );
1951
- }
1952
- const agent = key.slice(0, slashIdx) as AgentType;
1953
- const name = key.slice(slashIdx + 1);
1954
- const def = resolve(name, agent);
1955
- if (!def) {
1956
- throw new Error(`ATOMIC_WF_KEY "${key}" not found in registry`);
1957
- }
1958
- await runOrchestrator(def);
1959
- return true;
1960
- }
1961
-
1962
1885
  export async function runOrchestrator(
1963
1886
  definition: WorkflowDefinition,
1887
+ inputs: Record<string, string> = {},
1964
1888
  ): Promise<void> {
1965
1889
  const { workflowRunId, tmuxSessionName, agent, cwd } = validateOrchestratorEnv();
1966
- // ATOMIC_WF_INPUTS carries the full input payload. Free-form
1967
- // workflows store their single positional prompt under the `prompt`
1968
- // key so workflow authors always read it via `ctx.inputs.prompt`.
1969
- // An unset, missing, or malformed payload falls back to an empty
1970
- // record so `ctx.inputs.prompt` gracefully becomes `undefined`.
1971
- const inputs = parseInputsEnv(process.env.ATOMIC_WF_INPUTS);
1972
1890
  // A bare prompt string is still useful for the panel header and the
1973
1891
  // session-dir metadata.json — both just want something displayable.
1892
+ // Free-form workflows store their single positional prompt under the
1893
+ // `prompt` key so workflow authors always read it via
1894
+ // `ctx.inputs.prompt`.
1974
1895
  const prompt = inputs.prompt ?? "";
1975
1896
 
1976
1897
  process.chdir(cwd);
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * SDK-owned orchestrator entry script.
4
+ *
5
+ * Run as the tmux pane command for every workflow spawned by `runWorkflow`.
6
+ * Reads the workflow source path, agent, and base64-encoded inputs from
7
+ * positional argv, imports the workflow module, validates the default
8
+ * export, and hands off to `runOrchestrator()`.
9
+ *
10
+ * Argv layout (after `bun <this-file>`):
11
+ * argv[2] = absolute path to the workflow's source file
12
+ * (the `source` field from `defineWorkflow({ source: ... })`)
13
+ * argv[3] = agent — one of "claude" | "copilot" | "opencode"
14
+ * argv[4] = base64-encoded JSON record of structured inputs
15
+ *
16
+ * The dev's CLI never re-imports its own argv[1] — there's no
17
+ * `ATOMIC_ORCHESTRATOR_MODE` env var, no `handleOrchestratorReentry()`,
18
+ * no boilerplate. This file is the only re-exec target.
19
+ *
20
+ * The remaining ATOMIC_WF_* env vars (ID, TMUX, AGENT, CWD) are still set
21
+ * by the launcher script written by `executeWorkflow()` — they describe
22
+ * the runtime environment (which tmux session, which workflow run id,
23
+ * etc.) rather than acting as a re-entry signal.
24
+ */
25
+
26
+ import { runOrchestrator } from "./executor.ts";
27
+ import type { AgentType, WorkflowDefinition } from "../types.ts";
28
+ import { isValidAgent } from "../../services/config/definitions.ts";
29
+ import { InvalidWorkflowError } from "../errors.ts";
30
+
31
+ /** Runtime guard for the imported module's default export. */
32
+ function isWorkflowDefinition(value: unknown): value is WorkflowDefinition {
33
+ return (
34
+ typeof value === "object" &&
35
+ value !== null &&
36
+ (value as { __brand?: unknown }).__brand === "WorkflowDefinition"
37
+ );
38
+ }
39
+
40
+ /** Decode the base64 inputs payload into a string-keyed record. */
41
+ function decodeInputs(b64: string): Record<string, string> {
42
+ if (b64 === "") return {};
43
+ let decoded: string;
44
+ try {
45
+ decoded = Buffer.from(b64, "base64").toString("utf-8");
46
+ } catch {
47
+ return {};
48
+ }
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(decoded);
52
+ } catch {
53
+ return {};
54
+ }
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
56
+ return {};
57
+ }
58
+ const out: Record<string, string> = {};
59
+ for (const [k, v] of Object.entries(parsed)) {
60
+ if (typeof v === "string") out[k] = v;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ async function main(): Promise<void> {
66
+ const sourcePath = process.argv[2];
67
+ const agentRaw = process.argv[3];
68
+ const inputsB64 = process.argv[4] ?? "";
69
+
70
+ if (!sourcePath || !agentRaw) {
71
+ throw new Error(
72
+ "[atomic/orchestrator-entry] Missing positional arguments. " +
73
+ "Expected: <workflowSource> <agent> <inputsB64>",
74
+ );
75
+ }
76
+
77
+ if (!isValidAgent(agentRaw)) {
78
+ throw new Error(
79
+ `[atomic/orchestrator-entry] Invalid agent "${agentRaw}". ` +
80
+ `Expected one of: claude, copilot, opencode.`,
81
+ );
82
+ }
83
+ const agent: AgentType = agentRaw;
84
+
85
+ // Import the workflow module by its source path. The dev's `defineWorkflow`
86
+ // call passed `source: import.meta.path`, so this is the same path the SDK
87
+ // captured at build time.
88
+ const mod: unknown = await import(sourcePath);
89
+ const def = (mod as { default?: unknown }).default;
90
+
91
+ if (!isWorkflowDefinition(def)) {
92
+ throw new InvalidWorkflowError(sourcePath);
93
+ }
94
+
95
+ if (def.agent !== agent) {
96
+ throw new Error(
97
+ `[atomic/orchestrator-entry] Workflow at "${sourcePath}" targets ` +
98
+ `agent "${def.agent}" but the orchestrator was started for agent ` +
99
+ `"${agent}". This usually means the wrong workflow file was passed ` +
100
+ `to runWorkflow().`,
101
+ );
102
+ }
103
+
104
+ const inputs = decodeInputs(inputsB64);
105
+ await runOrchestrator(def, inputs);
106
+ }
107
+
108
+ if (import.meta.main) {
109
+ await main();
110
+ }