@bastani/atomic 0.5.0-3 → 0.5.0-4

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 (40) 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 +62 -53
  12. package/package.json +1 -1
  13. package/src/commands/cli/chat/index.ts +28 -8
  14. package/src/commands/cli/init/index.ts +6 -4
  15. package/src/commands/cli/init/scm.ts +27 -10
  16. package/src/sdk/components/connectors.test.ts +45 -0
  17. package/src/sdk/components/layout.test.ts +321 -0
  18. package/src/sdk/components/layout.ts +51 -15
  19. package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
  20. package/src/sdk/components/orchestrator-panel-store.ts +24 -0
  21. package/src/sdk/components/orchestrator-panel.tsx +21 -0
  22. package/src/sdk/components/session-graph-panel.tsx +3 -9
  23. package/src/sdk/components/statusline.tsx +4 -6
  24. package/src/sdk/define-workflow.test.ts +71 -0
  25. package/src/sdk/define-workflow.ts +42 -39
  26. package/src/sdk/errors.ts +1 -1
  27. package/src/sdk/index.ts +4 -1
  28. package/src/sdk/providers/claude.ts +1 -1
  29. package/src/sdk/providers/copilot.ts +5 -3
  30. package/src/sdk/providers/opencode.ts +5 -3
  31. package/src/sdk/runtime/executor.ts +512 -301
  32. package/src/sdk/runtime/loader.ts +2 -2
  33. package/src/sdk/runtime/tmux.ts +31 -2
  34. package/src/sdk/types.ts +93 -20
  35. package/src/sdk/workflows.ts +7 -4
  36. package/src/services/config/definitions.ts +39 -2
  37. package/src/services/config/settings.ts +0 -6
  38. package/src/services/system/skills.ts +3 -7
  39. package/.atomic/workflows/package-lock.json +0 -31
  40. package/.atomic/workflows/package.json +0 -8
@@ -1,26 +1,17 @@
1
1
  /**
2
2
  * Ralph workflow for OpenCode — plan → orchestrate → review → debug loop.
3
3
  *
4
- * One OpenCode client backs every iteration; each loop step creates a fresh
5
- * sub-session bound to the appropriate sub-agent (planner, orchestrator,
6
- * reviewer, debugger). The loop terminates when:
4
+ * Each sub-agent invocation spawns its own visible session in the graph,
5
+ * so users can see each iteration's progress in real time. The loop
6
+ * terminates when:
7
7
  * - {@link MAX_LOOPS} iterations have completed, OR
8
8
  * - Two consecutive reviewer passes return zero findings.
9
9
  *
10
- * A loop is one cycle of plan → orchestrate → review. When a review returns
11
- * zero findings on the FIRST pass we re-run only the reviewer (still inside
12
- * the same loop iteration) to confirm; if that confirmation pass is also
13
- * clean we stop. The debugger only runs when findings remain, and its
14
- * markdown report is fed back into the next iteration's planner.
15
- *
16
10
  * Run: atomic workflow -n ralph -a opencode "<your spec>"
17
11
  */
18
12
 
19
13
  import { defineWorkflow } from "@bastani/atomic/workflows";
20
- import {
21
- createOpencodeClient,
22
- type SessionPromptResponse,
23
- } from "@opencode-ai/sdk/v2";
14
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2";
24
15
 
25
16
  import {
26
17
  buildPlannerPrompt,
@@ -51,114 +42,186 @@ export default defineWorkflow({
51
42
  description:
52
43
  "Plan → orchestrate → review → debug loop with bounded iteration",
53
44
  })
54
- .session({
55
- name: "ralph-loop",
56
- description:
57
- "Drive plan/orchestrate/review/debug iterations until clean or capped",
58
- run: async (ctx) => {
59
- const client = createOpencodeClient({ baseUrl: ctx.serverUrl });
60
-
61
- let lastResultData: SessionPromptResponse | null = null;
62
-
63
- /** Run a sub-agent in a fresh session and return its concatenated text. */
64
- async function runAgent(
65
- title: string,
66
- agent: string,
67
- text: string,
68
- ): Promise<string> {
69
- const session = await client.session.create({ title });
70
- await client.tui.selectSession({ sessionID: session.data!.id });
71
- const result = await client.session.prompt({
72
- sessionID: session.data!.id,
73
- parts: [{ type: "text", text }],
74
- agent,
75
- });
76
- lastResultData = result.data ?? null;
77
- return extractResponseText(result.data!.parts);
78
- }
79
-
80
- let consecutiveClean = 0;
81
- let debuggerReport = "";
82
-
83
- for (let iteration = 1; iteration <= MAX_LOOPS; iteration++) {
84
- // ── Plan ────────────────────────────────────────────────────────────
85
- await runAgent(
86
- `planner-${iteration}`,
87
- "planner",
88
- buildPlannerPrompt(ctx.userPrompt, {
89
- iteration,
90
- debuggerReport: debuggerReport || undefined,
91
- }),
45
+ .run(async (ctx) => {
46
+ let consecutiveClean = 0;
47
+ let debuggerReport = "";
48
+ // Track the most recent session so the next stage can declare it as a
49
+ // dependency this chains planner → orchestrator → reviewer → [confirm]
50
+ // [debugger] next planner in the graph instead of showing every
51
+ // stage as an independent sibling under the root.
52
+ let prevStage: string | undefined;
53
+ const depsOn = (): string[] | undefined =>
54
+ prevStage ? [prevStage] : undefined;
55
+
56
+ for (let iteration = 1; iteration <= MAX_LOOPS; iteration++) {
57
+ // ── Plan ────────────────────────────────────────────────────────────
58
+ const plannerName = `planner-${iteration}`;
59
+ const planner = await ctx.session(
60
+ { name: plannerName, dependsOn: depsOn() },
61
+ async (s) => {
62
+ const client = createOpencodeClient({ baseUrl: s.serverUrl });
63
+ const session = await client.session.create({
64
+ title: `planner-${iteration}`,
65
+ });
66
+ await client.tui.selectSession({ sessionID: session.data!.id });
67
+ const result = await client.session.prompt({
68
+ sessionID: session.data!.id,
69
+ parts: [
70
+ {
71
+ type: "text",
72
+ text: buildPlannerPrompt(s.userPrompt, {
73
+ iteration,
74
+ debuggerReport: debuggerReport || undefined,
75
+ }),
76
+ },
77
+ ],
78
+ agent: "planner",
79
+ });
80
+ s.save(result.data!);
81
+ return extractResponseText(result.data!.parts);
82
+ },
83
+ );
84
+ prevStage = plannerName;
85
+
86
+ // ── Orchestrate ─────────────────────────────────────────────────────
87
+ const orchName = `orchestrator-${iteration}`;
88
+ await ctx.session(
89
+ { name: orchName, dependsOn: depsOn() },
90
+ async (s) => {
91
+ const client = createOpencodeClient({ baseUrl: s.serverUrl });
92
+ const session = await client.session.create({
93
+ title: `orchestrator-${iteration}`,
94
+ });
95
+ await client.tui.selectSession({ sessionID: session.data!.id });
96
+ const result = await client.session.prompt({
97
+ sessionID: session.data!.id,
98
+ parts: [
99
+ {
100
+ type: "text",
101
+ text: buildOrchestratorPrompt(s.userPrompt, {
102
+ plannerNotes: planner.result,
103
+ }),
104
+ },
105
+ ],
106
+ agent: "orchestrator",
107
+ });
108
+ s.save(result.data!);
109
+ },
110
+ );
111
+ prevStage = orchName;
112
+
113
+ // ── Review (first pass) ─────────────────────────────────────────────
114
+ let gitStatus = await safeGitStatusS();
115
+ const reviewerName = `reviewer-${iteration}`;
116
+ const review = await ctx.session(
117
+ { name: reviewerName, dependsOn: depsOn() },
118
+ async (s) => {
119
+ const client = createOpencodeClient({ baseUrl: s.serverUrl });
120
+ const session = await client.session.create({
121
+ title: `reviewer-${iteration}`,
122
+ });
123
+ await client.tui.selectSession({ sessionID: session.data!.id });
124
+ const result = await client.session.prompt({
125
+ sessionID: session.data!.id,
126
+ parts: [
127
+ {
128
+ type: "text",
129
+ text: buildReviewPrompt(s.userPrompt, {
130
+ gitStatus,
131
+ iteration,
132
+ }),
133
+ },
134
+ ],
135
+ agent: "reviewer",
136
+ });
137
+ s.save(result.data!);
138
+ return extractResponseText(result.data!.parts);
139
+ },
140
+ );
141
+ prevStage = reviewerName;
142
+
143
+ let reviewRaw = review.result;
144
+ let parsed = parseReviewResult(reviewRaw);
145
+
146
+ if (!hasActionableFindings(parsed, reviewRaw)) {
147
+ consecutiveClean += 1;
148
+ if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break;
149
+
150
+ // Confirmation pass — re-run reviewer only
151
+ gitStatus = await safeGitStatusS();
152
+ const confirmName = `reviewer-${iteration}-confirm`;
153
+ const confirm = await ctx.session(
154
+ { name: confirmName, dependsOn: depsOn() },
155
+ async (s) => {
156
+ const client = createOpencodeClient({ baseUrl: s.serverUrl });
157
+ const session = await client.session.create({
158
+ title: `reviewer-${iteration}-confirm`,
159
+ });
160
+ await client.tui.selectSession({ sessionID: session.data!.id });
161
+ const result = await client.session.prompt({
162
+ sessionID: session.data!.id,
163
+ parts: [
164
+ {
165
+ type: "text",
166
+ text: buildReviewPrompt(s.userPrompt, {
167
+ gitStatus,
168
+ iteration,
169
+ isConfirmationPass: true,
170
+ }),
171
+ },
172
+ ],
173
+ agent: "reviewer",
174
+ });
175
+ s.save(result.data!);
176
+ return extractResponseText(result.data!.parts);
177
+ },
92
178
  );
179
+ prevStage = confirmName;
93
180
 
94
- // ── Orchestrate ─────────────────────────────────────────────────────
95
- await runAgent(
96
- `orchestrator-${iteration}`,
97
- "orchestrator",
98
- buildOrchestratorPrompt(),
99
- );
100
-
101
- // ── Review (first pass) ─────────────────────────────────────────────
102
- let gitStatus = await safeGitStatusS();
103
- let reviewRaw = await runAgent(
104
- `reviewer-${iteration}-1`,
105
- "reviewer",
106
- buildReviewPrompt(ctx.userPrompt, { gitStatus, iteration }),
107
- );
108
- let parsed = parseReviewResult(reviewRaw);
181
+ reviewRaw = confirm.result;
182
+ parsed = parseReviewResult(reviewRaw);
109
183
 
110
184
  if (!hasActionableFindings(parsed, reviewRaw)) {
111
185
  consecutiveClean += 1;
112
- if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) {
113
- break;
114
- }
115
-
116
- // Confirmation pass — re-run reviewer only, NOT plan/orchestrate.
117
- gitStatus = await safeGitStatusS();
118
- reviewRaw = await runAgent(
119
- `reviewer-${iteration}-2`,
120
- "reviewer",
121
- buildReviewPrompt(ctx.userPrompt, {
122
- gitStatus,
123
- iteration,
124
- isConfirmationPass: true,
125
- }),
126
- );
127
- parsed = parseReviewResult(reviewRaw);
128
-
129
- if (!hasActionableFindings(parsed, reviewRaw)) {
130
- consecutiveClean += 1;
131
- if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) {
132
- break;
133
- }
134
- } else {
135
- consecutiveClean = 0;
136
- // fall through to debugger
137
- }
186
+ if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break;
138
187
  } else {
139
188
  consecutiveClean = 0;
140
189
  }
141
-
142
- // ── Debug (only if findings remain AND another iteration is allowed) ─
143
- if (
144
- hasActionableFindings(parsed, reviewRaw) &&
145
- iteration < MAX_LOOPS
146
- ) {
147
- const debuggerRaw = await runAgent(
148
- `debugger-${iteration}`,
149
- "debugger",
150
- buildDebuggerReportPrompt(parsed, reviewRaw, {
151
- iteration,
152
- gitStatus,
153
- }),
154
- );
155
- debuggerReport = extractMarkdownBlock(debuggerRaw);
156
- }
190
+ } else {
191
+ consecutiveClean = 0;
157
192
  }
158
193
 
159
- if (lastResultData !== null) {
160
- ctx.save(lastResultData);
194
+ // ── Debug (only if findings remain AND another iteration is allowed)
195
+ if (hasActionableFindings(parsed, reviewRaw) && iteration < MAX_LOOPS) {
196
+ const debuggerName = `debugger-${iteration}`;
197
+ const debugger_ = await ctx.session(
198
+ { name: debuggerName, dependsOn: depsOn() },
199
+ async (s) => {
200
+ const client = createOpencodeClient({ baseUrl: s.serverUrl });
201
+ const session = await client.session.create({
202
+ title: `debugger-${iteration}`,
203
+ });
204
+ await client.tui.selectSession({ sessionID: session.data!.id });
205
+ const result = await client.session.prompt({
206
+ sessionID: session.data!.id,
207
+ parts: [
208
+ {
209
+ type: "text",
210
+ text: buildDebuggerReportPrompt(parsed, reviewRaw, {
211
+ iteration,
212
+ gitStatus,
213
+ }),
214
+ },
215
+ ],
216
+ agent: "debugger",
217
+ });
218
+ s.save(result.data!);
219
+ return extractResponseText(result.data!.parts);
220
+ },
221
+ );
222
+ prevStage = debuggerName;
223
+ debuggerReport = extractMarkdownBlock(debugger_.result);
161
224
  }
162
- },
225
+ }
163
226
  })
164
227
  .compile();
package/README.md CHANGED
@@ -220,7 +220,7 @@ Each agent gets its own configuration directory (`.claude/`, `.opencode/`, `.git
220
220
 
221
221
  ### Workflow SDK — Build Your Own Harness
222
222
 
223
- Every team has a process — triage bugs this way, ship features that way, review PRs with these checks. Most of it lives in a wiki nobody reads or in one senior engineer's head. The **Workflow SDK** (`@bastani/atomic/workflows`) lets you encode that process as a chain of named sessions with raw provider SDK code then run it from the CLI.
223
+ Every team has a process — triage bugs this way, ship features that way, review PRs with these checks. Most of it lives in a wiki nobody reads or in one senior engineer's head. The **Workflow SDK** (`@bastani/atomic/workflows`) lets you encode that process as TypeScript spawn agent sessions dynamically with native control flow (`for`, `if`, `Promise.all()`), and watch them appear in a live graph as they execute.
224
224
 
225
225
  Drop a `.ts` file in `.atomic/workflows/<name>/<agent>/index.ts` and run it:
226
226
 
@@ -239,31 +239,28 @@ export default defineWorkflow({
239
239
  name: "hello",
240
240
  description: "Two-session Claude demo: describe → summarize",
241
241
  })
242
- .session({
243
- name: "describe",
244
- description: "Ask Claude to describe the project",
245
- run: async (ctx) => {
246
- await createClaudeSession({ paneId: ctx.paneId });
247
- await claudeQuery({
248
- paneId: ctx.paneId,
249
- prompt: ctx.userPrompt,
250
- });
251
- ctx.save(ctx.sessionId);
252
- },
253
- })
254
- .session({
255
- name: "summarize",
256
- description: "Summarize the previous session's output",
257
- run: async (ctx) => {
258
- await createClaudeSession({ paneId: ctx.paneId });
259
- const research = await ctx.transcript("describe");
260
-
261
- await claudeQuery({
262
- paneId: ctx.paneId,
263
- prompt: `Read ${research.path} and summarize it in 2-3 bullet points.`,
264
- });
265
- ctx.save(ctx.sessionId);
266
- },
242
+ .run(async (ctx) => {
243
+ const describe = await ctx.session(
244
+ { name: "describe", description: "Ask Claude to describe the project" },
245
+ async (s) => {
246
+ await createClaudeSession({ paneId: s.paneId });
247
+ await claudeQuery({ paneId: s.paneId, prompt: s.userPrompt });
248
+ s.save(s.sessionId);
249
+ },
250
+ );
251
+
252
+ await ctx.session(
253
+ { name: "summarize", description: "Summarize the previous session's output" },
254
+ async (s) => {
255
+ const research = await s.transcript(describe);
256
+ await createClaudeSession({ paneId: s.paneId });
257
+ await claudeQuery({
258
+ paneId: s.paneId,
259
+ prompt: `Read ${research.path} and summarize it in 2-3 bullet points.`,
260
+ });
261
+ s.save(s.sessionId);
262
+ },
263
+ );
267
264
  })
268
265
  .compile();
269
266
  ```
@@ -272,13 +269,14 @@ export default defineWorkflow({
272
269
 
273
270
  **Key capabilities:**
274
271
 
275
- | Capability | Description |
276
- | ------------------------ | ------------------------------------------------------------------------------------ |
277
- | **Sequential sessions** | Chain `.session()` calls that execute in order, each in its own tmux pane |
278
- | **Transcript passing** | Access previous session output via `ctx.transcript(name)` or `ctx.getMessages(name)` |
279
- | **Provider-agnostic** | Write raw SDK code for Claude, Copilot, or OpenCode inside each session's `run()` |
280
- | **tmux-based execution** | Each session runs in its own tmux pane for isolation and observability |
281
- | **Native SDK access** | Use `createClaudeSession`, `claudeQuery`, Copilot SDK, or OpenCode SDK directly |
272
+ | Capability | Description |
273
+ | ---------------------------- | ------------------------------------------------------------------------------------ |
274
+ | **Dynamic session spawning** | Call `ctx.session()` to spawn sessions at runtime each gets its own tmux window and graph node |
275
+ | **Native TypeScript control flow** | Use `for`, `if/else`, `Promise.all()`, `try/catch` — no framework DSL needed |
276
+ | **Session return values** | Session callbacks can return data: `const h = await ctx.session(...); h.result` |
277
+ | **Transcript passing** | Access prior session output via handle (`s.transcript(handle)`) or name (`s.transcript("name")`) |
278
+ | **Provider-agnostic** | Write raw SDK code for Claude, Copilot, or OpenCode inside each session callback |
279
+ | **Live graph visualization** | Sessions appear in the TUI graph as they're spawned — loops and conditionals are visible in real time |
282
280
 
283
281
  Drop a `.ts` file in `.atomic/workflows/<name>/<agent>/` (project-local) or `~/.atomic/workflows/` (global). You can also ask Atomic to create workflows for you:
284
282
 
@@ -294,22 +292,33 @@ Use your workflow-creator skill to create a workflow that plans, implements, and
294
292
  | Method | Purpose |
295
293
  | --------------------------------------- | ----------------------------------------------------------------- |
296
294
  | `defineWorkflow({ name, description })` | Entry point — returns a `WorkflowBuilder` |
297
- | `.session({ name, description?, run })` | Add a named session (or pass an array for parallel execution) |
295
+ | `.run(async (ctx) => { ... })` | Set the workflow's entry point `ctx` is a `WorkflowContext` |
298
296
  | `.compile()` | **Required** — terminal method that seals the workflow definition |
299
297
 
300
- #### Session Context (`ctx`)
298
+ #### WorkflowContext (`ctx`) — top-level orchestrator
301
299
 
302
300
  | Property | Type | Description |
303
301
  | ----------------------- | ------------------------- | -------------------------------------------------------------- |
304
302
  | `ctx.userPrompt` | `string` | Original user prompt from the CLI invocation |
305
303
  | `ctx.agent` | `AgentType` | Which agent is running (`"claude"`, `"copilot"`, `"opencode"`) |
306
- | `ctx.serverUrl` | `string` | The agent's server URL |
307
- | `ctx.paneId` | `string` | tmux pane ID for this session |
308
- | `ctx.sessionId` | `string` | Session UUID |
309
- | `ctx.sessionDir` | `string` | Path to this session's storage directory on disk |
310
- | `ctx.transcript(name)` | `Promise<Transcript>` | Get a previous session's transcript (`{ path, content }`) |
311
- | `ctx.getMessages(name)` | `Promise<SavedMessage[]>` | Get a previous session's raw native messages |
312
- | `ctx.save(messages)` | `SaveTranscript` | Save this session's output for subsequent sessions |
304
+ | `ctx.session(opts, fn)` | `Promise<SessionHandle<T>>` | Spawn a session — returns handle with `name`, `id`, `result` |
305
+ | `ctx.transcript(ref)` | `Promise<Transcript>` | Get a completed session's transcript (`{ path, content }`) |
306
+ | `ctx.getMessages(ref)` | `Promise<SavedMessage[]>` | Get a completed session's raw native messages |
307
+
308
+ #### SessionContext (`s`) inside each session callback
309
+
310
+ | Property | Type | Description |
311
+ | ----------------------- | ------------------------- | -------------------------------------------------------------- |
312
+ | `s.serverUrl` | `string` | The agent's server URL |
313
+ | `s.userPrompt` | `string` | Original user prompt from the CLI invocation |
314
+ | `s.agent` | `AgentType` | Which agent is running |
315
+ | `s.paneId` | `string` | tmux pane ID for this session |
316
+ | `s.sessionId` | `string` | Session UUID |
317
+ | `s.sessionDir` | `string` | Path to this session's storage directory on disk |
318
+ | `s.save(messages)` | `SaveTranscript` | Save this session's output for subsequent sessions |
319
+ | `s.transcript(ref)` | `Promise<Transcript>` | Get a completed session's transcript |
320
+ | `s.getMessages(ref)` | `Promise<SavedMessage[]>` | Get a completed session's raw native messages |
321
+ | `s.session(opts, fn)` | `Promise<SessionHandle<T>>` | Spawn a nested sub-session (child in the graph) |
313
322
 
314
323
  #### Saving Transcripts
315
324
 
@@ -317,9 +326,9 @@ Each provider saves transcripts differently:
317
326
 
318
327
  | Provider | How to Save |
319
328
  | ------------ | ------------------------------------------------------------------ |
320
- | **Claude** | `ctx.save(ctx.sessionId)` — auto-reads via `getSessionMessages()` |
321
- | **Copilot** | `ctx.save(await session.getMessages())` — pass `SessionEvent[]` |
322
- | **OpenCode** | `ctx.save(result.data)` — pass the full `{ info, parts }` response |
329
+ | **Claude** | `s.save(s.sessionId)` — auto-reads via `getSessionMessages()` |
330
+ | **Copilot** | `s.save(await session.getMessages())` — pass `SessionEvent[]` |
331
+ | **OpenCode** | `s.save(result.data!)` — pass the full `{ info, parts }` response |
323
332
 
324
333
  #### Provider Helpers
325
334
 
@@ -327,17 +336,17 @@ Each provider saves transcripts differently:
327
336
  | --------------------------------- | --------------------------------------------------- |
328
337
  | `createClaudeSession({ paneId })` | Start a Claude TUI in a tmux pane |
329
338
  | `claudeQuery({ paneId, prompt })` | Send a prompt to Claude and wait for the response |
330
- | `clearClaudeSession({ paneId })` | Clear the current Claude session |
331
- | `validateClaudeWorkflow()` | Validate a Claude workflow definition before run |
332
- | `validateCopilotWorkflow()` | Validate a Copilot workflow definition before run |
333
- | `validateOpenCodeWorkflow()` | Validate an OpenCode workflow definition before run |
339
+ | `clearClaudeSession(paneId)` | Clear the current Claude session |
340
+ | `validateClaudeWorkflow()` | Validate a Claude workflow source before run |
341
+ | `validateCopilotWorkflow()` | Validate a Copilot workflow source before run |
342
+ | `validateOpenCodeWorkflow()` | Validate an OpenCode workflow source before run |
334
343
 
335
344
  #### Key Rules
336
345
 
337
- 1. Every workflow file must use `export default` with `.compile()` at the end
338
- 2. Session names must be unique within a workflow
339
- 3. Sessions execute sequentially in the order they are defined
340
- 4. Each session runs in its own tmux pane with the chosen agent
346
+ 1. Every workflow file must use `export default` with `.run()` and `.compile()`
347
+ 2. Session names must be unique within a workflow run
348
+ 3. `transcript()` / `getMessages()` only access completed sessions (callback returned + saves flushed)
349
+ 4. Each session runs in its own tmux window with the chosen agent
341
350
  5. Workflows are organized per-workflow: `.atomic/workflows/<name>/<agent>/index.ts`
342
351
 
343
352
  Workflow files need no `package.json` or `node_modules` of their own — the Atomic loader rewrites `@bastani/atomic/*` and atomic's transitive deps (`@github/copilot-sdk`, `@opencode-ai/sdk`, `@anthropic-ai/claude-agent-sdk`, `zod`, etc.) to absolute paths inside the installed atomic package at load time. Drop a `.ts` file and it runs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/atomic",
3
- "version": "0.5.0-3",
3
+ "version": "0.5.0-4",
4
4
  "description": "Configuration management CLI and SDK for coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,14 +92,20 @@ function buildLauncherScript(
92
92
  cmd: string,
93
93
  args: string[],
94
94
  projectRoot: string,
95
+ envVars: Record<string, string> = {},
95
96
  ): { script: string; ext: string } {
96
97
  const isWin = process.platform === "win32";
98
+ const envEntries = Object.entries(envVars);
97
99
 
98
100
  if (isWin) {
99
101
  // PowerShell: use array splatting for safe arg passing
100
102
  const argList = args.map((a) => `"${escPwsh(a)}"`).join(", ");
103
+ const envLines = envEntries.map(
104
+ ([key, value]) => `$env:${key} = "${escPwsh(value)}"`,
105
+ );
101
106
  const script = [
102
107
  `Set-Location "${escPwsh(projectRoot)}"`,
108
+ ...envLines,
103
109
  argList.length > 0
104
110
  ? `& "${escPwsh(cmd)}" @(${argList})`
105
111
  : `& "${escPwsh(cmd)}"`,
@@ -111,9 +117,13 @@ function buildLauncherScript(
111
117
  const quotedArgs = args
112
118
  .map((a) => `"${escBash(a)}"`)
113
119
  .join(" ");
120
+ const envLines = envEntries.map(
121
+ ([key, value]) => `export ${key}="${escBash(value)}"`,
122
+ );
114
123
  const script = [
115
124
  "#!/bin/bash",
116
125
  `cd "${escBash(projectRoot)}"`,
126
+ ...envLines,
117
127
  `exec "${escBash(cmd)}" ${quotedArgs}`,
118
128
  ].join("\n");
119
129
  return { script, ext: "sh" };
@@ -162,15 +172,16 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
162
172
  // ── Build argv ──
163
173
  const args = buildAgentArgs(agentType, passthroughArgs);
164
174
  const cmd = [config.cmd, ...args];
175
+ const envVars = config.env_vars;
165
176
 
166
177
  // ── Inside tmux: spawn inline in the current pane ──
167
178
  if (isInsideTmux()) {
168
- return spawnDirect(cmd, projectRoot);
179
+ return spawnDirect(cmd, projectRoot, envVars);
169
180
  }
170
181
 
171
182
  // ── No TTY: tmux attach requires a real terminal ──
172
183
  if (!process.stdin.isTTY) {
173
- return spawnDirect(cmd, projectRoot);
184
+ return spawnDirect(cmd, projectRoot, envVars);
174
185
  }
175
186
 
176
187
  // ── Ensure tmux is available ──
@@ -184,7 +195,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
184
195
  }
185
196
  if (!isTmuxInstalled()) {
186
197
  // No tmux available — fall back to direct spawn
187
- return spawnDirect(cmd, projectRoot);
198
+ return spawnDirect(cmd, projectRoot, envVars);
188
199
  }
189
200
  }
190
201
 
@@ -194,7 +205,12 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
194
205
 
195
206
  const sessionsDir = join(homedir(), ".atomic", "sessions", "chat");
196
207
  await mkdir(sessionsDir, { recursive: true });
197
- const { script, ext } = buildLauncherScript(config.cmd, args, projectRoot);
208
+ const { script, ext } = buildLauncherScript(
209
+ config.cmd,
210
+ args,
211
+ projectRoot,
212
+ envVars,
213
+ );
198
214
  const launcherPath = join(sessionsDir, `${windowName}.${ext}`);
199
215
  await writeFile(launcherPath, script, { mode: 0o755 });
200
216
 
@@ -218,7 +234,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
218
234
  // If tmux attach itself failed (e.g. lost TTY), clean up and fall back
219
235
  if (exitCode !== 0) {
220
236
  try { killSession(windowName); } catch {}
221
- return spawnDirect(cmd, projectRoot);
237
+ return spawnDirect(cmd, projectRoot, envVars);
222
238
  }
223
239
 
224
240
  return exitCode;
@@ -228,7 +244,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
228
244
  console.error(
229
245
  `${COLORS.yellow}Warning: Failed to create tmux session (${message}). Falling back to direct spawn.${COLORS.reset}`
230
246
  );
231
- return spawnDirect(cmd, projectRoot);
247
+ return spawnDirect(cmd, projectRoot, envVars);
232
248
  }
233
249
  }
234
250
 
@@ -236,11 +252,15 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
236
252
  * Spawn the agent CLI directly with inherited stdio.
237
253
  * Used when not inside tmux.
238
254
  */
239
- async function spawnDirect(cmd: string[], projectRoot: string): Promise<number> {
255
+ async function spawnDirect(
256
+ cmd: string[],
257
+ projectRoot: string,
258
+ envVars: Record<string, string> = {},
259
+ ): Promise<number> {
240
260
  const proc = Bun.spawn(cmd, {
241
261
  stdio: ["inherit", "inherit", "inherit"],
242
262
  cwd: projectRoot,
243
- env: { ...process.env },
263
+ env: { ...process.env, ...envVars },
244
264
  });
245
265
 
246
266
  return await proc.exited;
@@ -21,6 +21,7 @@ import {
21
21
  getAgentKeys,
22
22
  isValidAgent,
23
23
  SCM_CONFIG,
24
+ SCM_SKILLS_BY_TYPE,
24
25
  type SourceControlType,
25
26
  getScmKeys,
26
27
  isValidScm,
@@ -35,7 +36,6 @@ import {
35
36
  getTemplateAgentFolder,
36
37
  } from "@/services/config/atomic-global-config.ts";
37
38
  import {
38
- getScmPrefix,
39
39
  installLocalScmSkills,
40
40
  reconcileScmVariants,
41
41
  syncProjectScmSkills,
@@ -404,9 +404,11 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
404
404
  // skip the network-backed skills CLI in that case to keep dev iteration
405
405
  // fast and offline-friendly.
406
406
  if (import.meta.dir.includes("node_modules")) {
407
+ const skillsToInstall = SCM_SKILLS_BY_TYPE[scmType];
408
+ const skillsLabel = skillsToInstall.join(", ");
407
409
  const skillsSpinner = spinner();
408
410
  skillsSpinner.start(
409
- `Installing ${getScmPrefix(scmType)}* skills locally for ${agent.name}...`,
411
+ `Installing ${skillsLabel} locally for ${agent.name}...`,
410
412
  );
411
413
  const skillsResult = await installLocalScmSkills({
412
414
  scmType,
@@ -415,11 +417,11 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
415
417
  });
416
418
  if (skillsResult.success) {
417
419
  skillsSpinner.stop(
418
- `Installed ${getScmPrefix(scmType)}* skills locally for ${agent.name}`,
420
+ `Installed ${skillsLabel} locally for ${agent.name}`,
419
421
  );
420
422
  } else {
421
423
  skillsSpinner.stop(
422
- `Skipped local ${getScmPrefix(scmType)}* skills install (${skillsResult.details})`,
424
+ `Skipped local ${skillsLabel} install (${skillsResult.details})`,
423
425
  );
424
426
  }
425
427
  }