@bastani/atomic 0.5.3 → 0.5.4-0

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 (48) hide show
  1. package/README.md +110 -11
  2. package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
  3. package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
  4. package/dist/sdk/define-workflow.d.ts +1 -1
  5. package/dist/sdk/index.js +1 -1
  6. package/dist/sdk/runtime/discovery.d.ts +57 -3
  7. package/dist/sdk/runtime/executor.d.ts +15 -2
  8. package/dist/sdk/runtime/tmux.d.ts +9 -0
  9. package/dist/sdk/types.d.ts +63 -4
  10. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
  11. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
  12. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
  13. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
  14. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
  15. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
  16. package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
  17. package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
  18. package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
  19. package/dist/sdk/workflows/index.d.ts +4 -4
  20. package/dist/sdk/workflows/index.js +7 -1
  21. package/package.json +1 -1
  22. package/src/cli.ts +25 -3
  23. package/src/commands/cli/chat/index.ts +5 -5
  24. package/src/commands/cli/init/index.ts +79 -77
  25. package/src/commands/cli/workflow-command.test.ts +757 -0
  26. package/src/commands/cli/workflow.test.ts +310 -0
  27. package/src/commands/cli/workflow.ts +445 -105
  28. package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
  29. package/src/sdk/define-workflow.test.ts +101 -0
  30. package/src/sdk/define-workflow.ts +62 -2
  31. package/src/sdk/runtime/discovery.ts +111 -8
  32. package/src/sdk/runtime/executor.ts +89 -32
  33. package/src/sdk/runtime/tmux.conf +55 -0
  34. package/src/sdk/runtime/tmux.ts +34 -10
  35. package/src/sdk/types.ts +67 -4
  36. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
  37. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
  38. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
  39. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
  40. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
  41. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
  42. package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
  43. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
  44. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
  45. package/src/sdk/workflows/index.ts +9 -1
  46. package/src/services/system/auto-sync.ts +1 -1
  47. package/src/services/system/install-ui.ts +109 -39
  48. package/src/theme/colors.ts +65 -1
@@ -1,5 +1,6 @@
1
1
  import { test, expect, describe } from "bun:test";
2
2
  import { defineWorkflow, WorkflowBuilder } from "./define-workflow.ts";
3
+ import type { WorkflowInput } from "./types.ts";
3
4
 
4
5
  describe("defineWorkflow", () => {
5
6
  test("returns a WorkflowBuilder", () => {
@@ -43,6 +44,106 @@ describe("WorkflowBuilder.compile()", () => {
43
44
  expect(def.__brand).toBe("WorkflowDefinition");
44
45
  });
45
46
 
47
+ test("defaults inputs to an empty array when none are declared", () => {
48
+ const def = defineWorkflow({ name: "test" })
49
+ .run(async () => {})
50
+ .compile();
51
+ expect(def.inputs).toEqual([]);
52
+ });
53
+
54
+ test("preserves declared inputs in order", () => {
55
+ const def = defineWorkflow({
56
+ name: "gen-spec",
57
+ inputs: [
58
+ {
59
+ name: "research_doc",
60
+ type: "string",
61
+ required: true,
62
+ description: "path",
63
+ },
64
+ {
65
+ name: "focus",
66
+ type: "enum",
67
+ required: true,
68
+ values: ["minimal", "standard", "exhaustive"],
69
+ default: "standard",
70
+ },
71
+ ],
72
+ })
73
+ .run(async () => {})
74
+ .compile();
75
+ expect(def.inputs).toHaveLength(2);
76
+ expect(def.inputs[0]?.name).toBe("research_doc");
77
+ expect(def.inputs[1]?.name).toBe("focus");
78
+ expect(def.inputs[1]?.type).toBe("enum");
79
+ });
80
+
81
+ test("freezes declared inputs to prevent downstream mutation", () => {
82
+ const def = defineWorkflow({
83
+ name: "test",
84
+ inputs: [{ name: "foo", type: "string" }],
85
+ })
86
+ .run(async () => {})
87
+ .compile();
88
+ expect(() => {
89
+ (def.inputs as unknown as WorkflowInput[])[0]!.name = "bar";
90
+ }).toThrow();
91
+ });
92
+
93
+ test("rejects enum inputs with no values", () => {
94
+ expect(() =>
95
+ defineWorkflow({
96
+ name: "bad",
97
+ inputs: [{ name: "mode", type: "enum" }],
98
+ })
99
+ .run(async () => {})
100
+ .compile(),
101
+ ).toThrow("declares no `values`");
102
+ });
103
+
104
+ test("rejects enum defaults outside the allowed values", () => {
105
+ expect(() =>
106
+ defineWorkflow({
107
+ name: "bad",
108
+ inputs: [
109
+ {
110
+ name: "mode",
111
+ type: "enum",
112
+ values: ["a", "b"],
113
+ default: "c",
114
+ },
115
+ ],
116
+ })
117
+ .run(async () => {})
118
+ .compile(),
119
+ ).toThrow(/not one of its declared values/);
120
+ });
121
+
122
+ test("rejects input names that are not valid CLI flag tails", () => {
123
+ expect(() =>
124
+ defineWorkflow({
125
+ name: "bad",
126
+ inputs: [{ name: "1bad", type: "string" }],
127
+ })
128
+ .run(async () => {})
129
+ .compile(),
130
+ ).toThrow(/invalid/);
131
+ });
132
+
133
+ test("rejects duplicate input names", () => {
134
+ expect(() =>
135
+ defineWorkflow({
136
+ name: "bad",
137
+ inputs: [
138
+ { name: "foo", type: "string" },
139
+ { name: "foo", type: "string" },
140
+ ],
141
+ })
142
+ .run(async () => {})
143
+ .compile(),
144
+ ).toThrow(/duplicate input name/);
145
+ });
146
+
46
147
  test("preserves name and description", () => {
47
148
  const def = defineWorkflow({ name: "my-wf", description: "A description" })
48
149
  .run(async () => {})
@@ -10,7 +10,49 @@
10
10
  * .compile()
11
11
  */
12
12
 
13
- import type { AgentType, WorkflowOptions, WorkflowContext, WorkflowDefinition } from "./types.ts";
13
+ import type {
14
+ AgentType,
15
+ WorkflowOptions,
16
+ WorkflowContext,
17
+ WorkflowDefinition,
18
+ WorkflowInput,
19
+ } from "./types.ts";
20
+
21
+ /**
22
+ * Validate a single declared workflow input, throwing on authoring
23
+ * mistakes that would otherwise surface as confusing runtime errors
24
+ * inside the picker or the flag parser.
25
+ */
26
+ function validateWorkflowInput(input: WorkflowInput, workflowName: string): void {
27
+ if (!input.name || input.name.trim() === "") {
28
+ throw new Error(
29
+ `Workflow "${workflowName}" has an input with an empty name.`,
30
+ );
31
+ }
32
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input.name)) {
33
+ throw new Error(
34
+ `Workflow "${workflowName}" input "${input.name}" has an invalid ` +
35
+ `name — must start with a letter and contain only letters, ` +
36
+ `digits, underscores, and dashes (so it can be used as a ` +
37
+ `\`--${input.name}\` CLI flag).`,
38
+ );
39
+ }
40
+ if (input.type === "enum") {
41
+ if (!Array.isArray(input.values) || input.values.length === 0) {
42
+ throw new Error(
43
+ `Workflow "${workflowName}" input "${input.name}" is an enum but ` +
44
+ `declares no \`values\`.`,
45
+ );
46
+ }
47
+ if (input.default !== undefined && !input.values.includes(input.default)) {
48
+ throw new Error(
49
+ `Workflow "${workflowName}" input "${input.name}" has a default ` +
50
+ `"${input.default}" that is not one of its declared values: ` +
51
+ `${input.values.join(", ")}.`,
52
+ );
53
+ }
54
+ }
55
+ }
14
56
 
15
57
  /**
16
58
  * Chainable workflow builder. Records the run callback,
@@ -61,10 +103,28 @@ export class WorkflowBuilder<A extends AgentType = AgentType> {
61
103
 
62
104
  const runFn = this.runFn;
63
105
 
106
+ // Freeze the declared inputs so consumers can read the schema without
107
+ // worrying that picker or executor code has mutated it upstream.
108
+ const declaredInputs = this.options.inputs ?? [];
109
+ const seen = new Set<string>();
110
+ for (const input of declaredInputs) {
111
+ validateWorkflowInput(input, this.options.name);
112
+ if (seen.has(input.name)) {
113
+ throw new Error(
114
+ `Workflow "${this.options.name}" has duplicate input name "${input.name}".`,
115
+ );
116
+ }
117
+ seen.add(input.name);
118
+ }
119
+ const inputs = Object.freeze(
120
+ declaredInputs.map((i) => Object.freeze({ ...i })),
121
+ ) as readonly WorkflowInput[];
122
+
64
123
  return {
65
124
  __brand: "WorkflowDefinition" as const,
66
125
  name: this.options.name,
67
126
  description: this.options.description ?? "",
127
+ inputs,
68
128
  run: runFn,
69
129
  };
70
130
  }
@@ -90,7 +150,7 @@ export class WorkflowBuilder<A extends AgentType = AgentType> {
90
150
  * {},
91
151
  * async (s) => {
92
152
  * // s.client: CopilotClient, s.session: CopilotSession
93
- * await s.session.sendAndWait({ prompt: s.userPrompt });
153
+ * await s.session.sendAndWait({ prompt: s.inputs.prompt ?? "" });
94
154
  * s.save(await s.session.getMessages());
95
155
  * },
96
156
  * );
@@ -13,7 +13,8 @@ import { readdir, writeFile } from "fs/promises";
13
13
  import { existsSync, readdirSync } from "fs";
14
14
  import { homedir } from "os";
15
15
  import ignore from "ignore";
16
- import type { AgentType } from "../types.ts";
16
+ import type { AgentType, WorkflowInput } from "../types.ts";
17
+ import { WorkflowLoader } from "./loader.ts";
17
18
 
18
19
  export interface DiscoveredWorkflow {
19
20
  name: string;
@@ -175,30 +176,88 @@ function discoverBuiltinWorkflows(
175
176
 
176
177
  /**
177
178
  * Discover all available workflows from built-in, global, and local sources.
178
- * Optionally filter by agent. Precedence: local > global > builtin.
179
+ * Optionally filter by agent.
180
+ *
181
+ * **Merge precedence:** `builtin > local > global`.
182
+ *
183
+ * Builtin names are **strictly reserved** — a user-defined local or
184
+ * global workflow whose name matches any built-in workflow is dropped
185
+ * entirely from discovery. It will not be registered, returned from
186
+ * `findWorkflow`, appear in the interactive picker, or show up in
187
+ * `atomic workflow -l`. This protects SDK-shipped workflows (e.g.
188
+ * `ralph`) from being silently overridden or even visibly "competing
189
+ * with" a user's own definition, which would otherwise be confusing
190
+ * when someone tries to run the canonical version.
191
+ *
192
+ * Reservation is by **name only**, across all agents: if a builtin
193
+ * defines `ralph` for any agent, a local `ralph` for any other agent is
194
+ * also dropped. Local still overrides global for every non-builtin
195
+ * name, so project-scoped customisation of user-scoped workflows
196
+ * continues to work.
197
+ *
198
+ * By default, the result is **merged by precedence** — if a workflow is
199
+ * defined in both local and global sources, only the higher-precedence
200
+ * entry is returned. This is the right shape for `findWorkflow`, which
201
+ * needs the single resolved entry per (name, agent) pair.
202
+ *
203
+ * Pass `{ merge: false }` to get the **unmerged** result — local and
204
+ * global contribute their entries independently, so `--list` can show
205
+ * both a local and a global copy of the same workflow when they coexist
206
+ * on disk. (Builtin reservation still applies in both modes.)
179
207
  */
180
208
  export async function discoverWorkflows(
181
209
  projectRoot: string = process.cwd(),
182
- agentFilter?: AgentType
210
+ agentFilter?: AgentType,
211
+ options: { merge?: boolean } = {},
183
212
  ): Promise<DiscoveredWorkflow[]> {
213
+ const { merge = true } = options;
214
+
184
215
  const localDir = getLocalWorkflowsDir(projectRoot);
185
216
  const globalDir = getGlobalWorkflowsDir();
186
217
 
187
- const builtinResults = discoverBuiltinWorkflows(agentFilter);
218
+ // Collect ALL builtin names (ignoring agentFilter) so reservation is
219
+ // name-based across every agent: a local `ralph` for copilot is still
220
+ // reserved by a builtin `ralph` for claude, even when the discovery
221
+ // call was filtered to copilot.
222
+ const allBuiltins = discoverBuiltinWorkflows();
223
+ const reservedNames = new Set<string>(allBuiltins.map((w) => w.name));
224
+ const builtinResults = agentFilter
225
+ ? allBuiltins.filter((w) => w.agent === agentFilter)
226
+ : allBuiltins;
227
+
188
228
  const [globalResults, localResults] = await Promise.all([
189
229
  discoverFromBaseDir(globalDir, "global", agentFilter),
190
230
  discoverFromBaseDir(localDir, "local", agentFilter),
191
231
  ]);
192
232
 
193
- // Merge with precedence: builtin (lowest) global local (highest)
233
+ // Drop any local/global workflow whose name matches a reserved
234
+ // builtin. This happens BEFORE both merge and unmerged code paths so
235
+ // reserved names never leak into `findWorkflow`, the picker, or
236
+ // `--list` — there is exactly one canonical entry per reserved name,
237
+ // the SDK-shipped one.
238
+ const filteredGlobal = globalResults.filter((w) => !reservedNames.has(w.name));
239
+ const filteredLocal = localResults.filter((w) => !reservedNames.has(w.name));
240
+
241
+ if (!merge) {
242
+ // Unmerged: keep local and global independent so `--list` can show
243
+ // both copies of a non-reserved name when they coexist. Order lowest
244
+ // → highest precedence so callers that want the winning entry can
245
+ // take the last one by (agent, name).
246
+ return [...filteredGlobal, ...filteredLocal, ...builtinResults];
247
+ }
248
+
249
+ // Merge with precedence: global (lowest) → local → builtin (highest).
250
+ // Builtin is layered last as a belt-and-braces guarantee — though
251
+ // reserved-name filtering above already makes this overwrite
252
+ // impossible in practice.
194
253
  const byKey = new Map<string, DiscoveredWorkflow>();
195
- for (const wf of builtinResults) {
254
+ for (const wf of filteredGlobal) {
196
255
  byKey.set(`${wf.agent}/${wf.name}`, wf);
197
256
  }
198
- for (const wf of globalResults) {
257
+ for (const wf of filteredLocal) {
199
258
  byKey.set(`${wf.agent}/${wf.name}`, wf);
200
259
  }
201
- for (const wf of localResults) {
260
+ for (const wf of builtinResults) {
202
261
  byKey.set(`${wf.agent}/${wf.name}`, wf);
203
262
  }
204
263
 
@@ -217,4 +276,48 @@ export async function findWorkflow(
217
276
  return all.find((w) => w.name === name) ?? null;
218
277
  }
219
278
 
279
+ /**
280
+ * A discovered workflow enriched with the metadata the picker needs to
281
+ * render it: the human description and the declared input schema.
282
+ *
283
+ * Populated by {@link loadWorkflowsMetadata}, which runs each discovered
284
+ * workflow through {@link WorkflowLoader.loadWorkflow} and extracts just
285
+ * the display-relevant fields — the full compiled definition is
286
+ * discarded after extraction so re-imports during execution are cheap.
287
+ */
288
+ export interface WorkflowWithMetadata extends DiscoveredWorkflow {
289
+ /** Workflow description, empty string when none was declared. */
290
+ description: string;
291
+ /** Declared input schema, empty array for free-form workflows. */
292
+ inputs: readonly WorkflowInput[];
293
+ }
294
+
295
+ /**
296
+ * Load metadata (description + inputs) for a batch of discovered workflows.
297
+ *
298
+ * Workflows that fail to import are **skipped silently** so one broken
299
+ * entry can never prevent the picker from rendering. Callers that need
300
+ * to surface load errors (e.g. `atomic workflow -n broken`) should use
301
+ * {@link WorkflowLoader.loadWorkflow} directly — that path produces
302
+ * structured error reports.
303
+ */
304
+ export async function loadWorkflowsMetadata(
305
+ discovered: DiscoveredWorkflow[],
306
+ ): Promise<WorkflowWithMetadata[]> {
307
+ const results = await Promise.all(
308
+ discovered.map(async (wf): Promise<WorkflowWithMetadata | null> => {
309
+ const loaded = await WorkflowLoader.loadWorkflow(wf);
310
+ if (!loaded.ok) return null;
311
+ return {
312
+ ...wf,
313
+ description: loaded.value.definition.description,
314
+ inputs: loaded.value.definition.inputs,
315
+ };
316
+ }),
317
+ );
318
+ return results.filter(
319
+ (r): r is WorkflowWithMetadata => r !== null,
320
+ );
321
+ }
322
+
220
323
 
@@ -35,7 +35,7 @@ import type { SessionEvent } from "@github/copilot-sdk";
35
35
  import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
36
36
  import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
37
37
  import * as tmux from "./tmux.ts";
38
- import { getMuxBinary } from "./tmux.ts";
38
+ import { spawnMuxAttach, SOCKET_NAME } from "./tmux.ts";
39
39
  import { WorkflowLoader } from "./loader.ts";
40
40
  import {
41
41
  clearClaudeSession,
@@ -90,8 +90,13 @@ export interface WorkflowRunOptions {
90
90
  definition: WorkflowDefinition;
91
91
  /** Agent type */
92
92
  agent: AgentType;
93
- /** The user's prompt */
94
- prompt: string;
93
+ /**
94
+ * Structured inputs for this run. Free-form workflows model their
95
+ * single positional prompt as `{ prompt: "..." }` so workflow
96
+ * authors can read `ctx.inputs.prompt` uniformly regardless of
97
+ * whether the workflow declares a schema. Empty record is valid.
98
+ */
99
+ inputs?: Record<string, string>;
95
100
  /** Absolute path to the workflow's index.ts file (from discovery) */
96
101
  workflowFile: string;
97
102
  /** Project root (defaults to cwd) */
@@ -253,6 +258,31 @@ export function escPwsh(s: string): string {
253
258
  .replace(/\r/g, "`r");
254
259
  }
255
260
 
261
+ /**
262
+ * Decode the ATOMIC_WF_INPUTS env var (base64-encoded JSON) into a
263
+ * `Record<string, string>`. Returns an empty record when the variable
264
+ * is missing, malformed, or does not decode to a string-map object —
265
+ * structured inputs are optional, so a corrupt payload must never
266
+ * prevent free-form workflows from running.
267
+ */
268
+ export function parseInputsEnv(raw: string | undefined): Record<string, string> {
269
+ if (!raw) return {};
270
+ try {
271
+ const decoded = Buffer.from(raw, "base64").toString("utf-8");
272
+ const parsed: unknown = JSON.parse(decoded);
273
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
274
+ return {};
275
+ }
276
+ const out: Record<string, string> = {};
277
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
278
+ if (typeof v === "string") out[k] = v;
279
+ }
280
+ return out;
281
+ } catch {
282
+ return {};
283
+ }
284
+ }
285
+
256
286
  // ============================================================================
257
287
  // Entry point called by the CLI command
258
288
  // ============================================================================
@@ -269,7 +299,7 @@ export async function executeWorkflow(
269
299
  const {
270
300
  definition,
271
301
  agent,
272
- prompt,
302
+ inputs = {},
273
303
  workflowFile,
274
304
  projectRoot = process.cwd(),
275
305
  } = options;
@@ -289,13 +319,20 @@ export async function executeWorkflow(
289
319
  const launcherPath = join(sessionsBaseDir, `orchestrator.${launcherExt}`);
290
320
  const logPath = join(sessionsBaseDir, "orchestrator.log");
291
321
 
322
+ // Inputs are passed through as base64-encoded JSON so long multiline
323
+ // text values survive shell quoting without any further escaping.
324
+ // Free-form workflows ride the same pipe — their single positional
325
+ // prompt is stored under the `prompt` key so workflow authors always
326
+ // read the user's prompt via `ctx.inputs.prompt`.
327
+ const inputsB64 = Buffer.from(JSON.stringify(inputs)).toString("base64");
328
+
292
329
  const launcherScript = isWin
293
330
  ? [
294
331
  `Set-Location "${escPwsh(projectRoot)}"`,
295
332
  `$env:ATOMIC_WF_ID = "${escPwsh(workflowRunId)}"`,
296
333
  `$env:ATOMIC_WF_TMUX = "${escPwsh(tmuxSessionName)}"`,
297
334
  `$env:ATOMIC_WF_AGENT = "${escPwsh(agent)}"`,
298
- `$env:ATOMIC_WF_PROMPT = "${escPwsh(Buffer.from(prompt).toString("base64"))}"`,
335
+ `$env:ATOMIC_WF_INPUTS = "${escPwsh(inputsB64)}"`,
299
336
  `$env:ATOMIC_WF_FILE = "${escPwsh(workflowFile)}"`,
300
337
  `$env:ATOMIC_WF_CWD = "${escPwsh(projectRoot)}"`,
301
338
  `bun run "${escPwsh(thisFile)}" 2>"${escPwsh(logPath)}"`,
@@ -306,7 +343,7 @@ export async function executeWorkflow(
306
343
  `export ATOMIC_WF_ID="${escBash(workflowRunId)}"`,
307
344
  `export ATOMIC_WF_TMUX="${escBash(tmuxSessionName)}"`,
308
345
  `export ATOMIC_WF_AGENT="${escBash(agent)}"`,
309
- `export ATOMIC_WF_PROMPT="${escBash(Buffer.from(prompt).toString("base64"))}"`,
346
+ `export ATOMIC_WF_INPUTS="${escBash(inputsB64)}"`,
310
347
  `export ATOMIC_WF_FILE="${escBash(workflowFile)}"`,
311
348
  `export ATOMIC_WF_CWD="${escBash(projectRoot)}"`,
312
349
  `bun run "${escBash(thisFile)}" 2>"${escBash(logPath)}"`,
@@ -314,26 +351,31 @@ export async function executeWorkflow(
314
351
 
315
352
  await writeFile(launcherPath, launcherScript, { mode: 0o755 });
316
353
 
317
- // Create tmux session with orchestrator as the initial window
318
- const shellCmd = isWin
319
- ? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
320
- : `bash "${escBash(launcherPath)}"`;
321
- tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
354
+ console.log(`[atomic] Session: ${tmuxSessionName} (FYI all atomic sessions run on tmux -L ${SOCKET_NAME})`);
322
355
 
323
- // Attach or switch depending on whether we're already inside tmux
356
+ // Attach or spawn depending on whether we're already inside tmux
324
357
  if (tmux.isInsideTmux()) {
325
- // Inside tmux: switch the current client to the workflow session
326
- // to avoid creating a nested tmux client
327
- tmux.switchClient(tmuxSessionName);
358
+ // Inside tmux: create the session with just a shell (agent windows live here),
359
+ // then run the orchestrator directly in the user's current pane.
360
+ const defaultShell = process.env.SHELL || (isWin ? "pwsh" : "sh");
361
+ tmux.createSession(tmuxSessionName, defaultShell, "orchestrator");
362
+
363
+ const launcherCmd = isWin
364
+ ? ["pwsh", "-NoProfile", "-File", launcherPath]
365
+ : ["bash", launcherPath];
366
+ const proc = Bun.spawn(launcherCmd, {
367
+ stdio: ["inherit", "inherit", "inherit"],
368
+ cwd: projectRoot,
369
+ });
370
+ await proc.exited;
328
371
  } else {
329
- // Outside tmux: attach normally (blocks until session ends)
330
- const muxBinary = getMuxBinary() ?? "tmux";
331
- const attachProc = Bun.spawn(
332
- [muxBinary, "attach-session", "-t", tmuxSessionName],
333
- {
334
- stdio: ["inherit", "inherit", "inherit"],
335
- },
336
- );
372
+ // Outside tmux: create session with the orchestrator and attach to it
373
+ const shellCmd = isWin
374
+ ? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
375
+ : `bash "${escBash(launcherPath)}"`;
376
+ tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
377
+
378
+ const attachProc = spawnMuxAttach(tmuxSessionName);
337
379
  await attachProc.exited;
338
380
  }
339
381
  }
@@ -428,7 +470,13 @@ interface SharedRunnerState {
428
470
  tmuxSessionName: string;
429
471
  sessionsBaseDir: string;
430
472
  agent: AgentType;
431
- prompt: string;
473
+ /**
474
+ * Structured inputs for this workflow run. Free-form workflows use
475
+ * `{ prompt: "..." }`; structured workflows use their declared field
476
+ * names. Workflow authors read both shapes via `ctx.inputs` — and
477
+ * specifically via `ctx.inputs.prompt` for the free-form case.
478
+ */
479
+ inputs: Record<string, string>;
432
480
  panel: OrchestratorPanel;
433
481
  /** Sessions that have been spawned (for name uniqueness + cleanup). */
434
482
  activeRegistry: Map<string, ActiveSession>;
@@ -748,10 +796,14 @@ function createSessionRunner(
748
796
  );
749
797
 
750
798
  // ── 13. Construct SessionContext ──
799
+ // Free-form workflows read their prompt via `s.inputs.prompt`;
800
+ // structured workflows read their declared fields the same way.
801
+ // A single uniform access pattern means workflow code never has
802
+ // to branch on "is this workflow structured or free-form".
751
803
  const ctx: SessionContext = {
752
804
  client: providerClient as SessionContext["client"],
753
805
  session: providerSession as SessionContext["session"],
754
- userPrompt: shared.prompt,
806
+ inputs: shared.inputs,
755
807
  agent: shared.agent,
756
808
  sessionDir,
757
809
  paneId,
@@ -837,12 +889,11 @@ export async function runOrchestrator(): Promise<void> {
837
889
  "ATOMIC_WF_ID",
838
890
  "ATOMIC_WF_TMUX",
839
891
  "ATOMIC_WF_AGENT",
840
- "ATOMIC_WF_PROMPT",
841
892
  "ATOMIC_WF_FILE",
842
893
  "ATOMIC_WF_CWD",
843
894
  ] as const;
844
895
  for (const key of requiredEnvVars) {
845
- if (!process.env[key]) {
896
+ if (process.env[key] === undefined) {
846
897
  throw new Error(`Missing required environment variable: ${key}`);
847
898
  }
848
899
  }
@@ -850,9 +901,15 @@ export async function runOrchestrator(): Promise<void> {
850
901
  const workflowRunId = process.env.ATOMIC_WF_ID!;
851
902
  const tmuxSessionName = process.env.ATOMIC_WF_TMUX!;
852
903
  const agent = process.env.ATOMIC_WF_AGENT! as AgentType;
853
- const prompt = Buffer.from(process.env.ATOMIC_WF_PROMPT!, "base64").toString(
854
- "utf-8",
855
- );
904
+ // ATOMIC_WF_INPUTS carries the full input payload. Free-form
905
+ // workflows store their single positional prompt under the `prompt`
906
+ // key so workflow authors always read it via `ctx.inputs.prompt`.
907
+ // An unset, missing, or malformed payload falls back to an empty
908
+ // record so `ctx.inputs.prompt` gracefully becomes `undefined`.
909
+ const inputs = parseInputsEnv(process.env.ATOMIC_WF_INPUTS);
910
+ // A bare prompt string is still useful for the panel header and the
911
+ // session-dir metadata.json — both just want something displayable.
912
+ const prompt = inputs.prompt ?? "";
856
913
  const workflowFile = process.env.ATOMIC_WF_FILE!;
857
914
  const cwd = process.env.ATOMIC_WF_CWD!;
858
915
 
@@ -887,7 +944,7 @@ export async function runOrchestrator(): Promise<void> {
887
944
  tmuxSessionName,
888
945
  sessionsBaseDir,
889
946
  agent,
890
- prompt,
947
+ inputs,
891
948
  panel,
892
949
  activeRegistry: new Map(),
893
950
  completedRegistry: new Map(),
@@ -936,7 +993,7 @@ export async function runOrchestrator(): Promise<void> {
936
993
  const sessionRunner = createSessionRunner(shared, "orchestrator");
937
994
 
938
995
  const workflowCtx: WorkflowContext = {
939
- userPrompt: prompt,
996
+ inputs,
940
997
  agent,
941
998
  stage: sessionRunner as WorkflowContext["stage"],
942
999
  transcript: async (ref: SessionRef): Promise<Transcript> => {
@@ -0,0 +1,55 @@
1
+ # Atomic tmux configuration — injected automatically via -f
2
+ # Shared by tmux (macOS/Linux) and psmux (Windows)
3
+ #
4
+ # True color + passthrough for rich TUI rendering
5
+ set-option -sa terminal-overrides ",xterm*:Tc"
6
+ set -g set-clipboard on
7
+ set -g allow-passthrough on
8
+
9
+ # Mouse mode — scroll works out of the box
10
+ set-option -g mouse on
11
+
12
+ # Prevent processes from overwriting window titles
13
+ set-option -g allow-rename off
14
+
15
+ # Sane defaults (inlined from tmux-sensible)
16
+ set -g escape-time 0
17
+ set -g history-limit 50000
18
+ set -g display-time 4000
19
+ set -g status-interval 5
20
+ set -g focus-events on
21
+ setw -g aggressive-resize on
22
+
23
+ # Status bar — minimal
24
+ set -g status-left " "
25
+ set -g status-right " #{session_name} | %H:%M "
26
+ set -g status-right-length 60
27
+ set -g status-style "bg=#1e1e2e,fg=#cdd6f4"
28
+ set -g status-right-style "fg=#6c7086"
29
+
30
+ # Pane splitting
31
+ bind - split-window -v -c "#{pane_current_path}"
32
+ bind | split-window -h -c "#{pane_current_path}"
33
+
34
+ # Pane resizing
35
+ bind -r l resize-pane -R 5
36
+ bind -r h resize-pane -L 5
37
+ bind -r k resize-pane -U 5
38
+ bind -r j resize-pane -D 5
39
+
40
+ # Vi-mode
41
+ set-window-option -g mode-keys vi
42
+ bind-key -T copy-mode-vi v send-keys -X begin-selection
43
+ bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
44
+ bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
45
+
46
+ # Escape exits copy-mode (clear selection first if one exists, otherwise cancel)
47
+ bind-key -T copy-mode-vi Escape if-shell -F "#{selection_present}" "send-keys -X clear-selection" "send-keys -X cancel"
48
+
49
+ # Copy-mode mouse bindings:
50
+ # - Drag: begin selection + disable scroll-exit (prevents losing selection if dragged to bottom)
51
+ # - Drag end: copy to system clipboard (via OSC52) and exit copy-mode
52
+ # - Click: clear any selection (but stay in copy-mode for continued scrolling)
53
+ bind -T copy-mode-vi MouseDrag1Pane select-pane \; send-keys -X begin-selection \; send-keys -X scroll-exit-off
54
+ bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel
55
+ bind -T copy-mode-vi MouseDown1Pane send-keys -X clear-selection \; select-pane