@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.
- package/README.md +110 -11
- package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
- package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
- package/dist/sdk/define-workflow.d.ts +1 -1
- package/dist/sdk/index.js +1 -1
- package/dist/sdk/runtime/discovery.d.ts +57 -3
- package/dist/sdk/runtime/executor.d.ts +15 -2
- package/dist/sdk/runtime/tmux.d.ts +9 -0
- package/dist/sdk/types.d.ts +63 -4
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
- package/dist/sdk/workflows/index.d.ts +4 -4
- package/dist/sdk/workflows/index.js +7 -1
- package/package.json +1 -1
- package/src/cli.ts +25 -3
- package/src/commands/cli/chat/index.ts +5 -5
- package/src/commands/cli/init/index.ts +79 -77
- package/src/commands/cli/workflow-command.test.ts +757 -0
- package/src/commands/cli/workflow.test.ts +310 -0
- package/src/commands/cli/workflow.ts +445 -105
- package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
- package/src/sdk/define-workflow.test.ts +101 -0
- package/src/sdk/define-workflow.ts +62 -2
- package/src/sdk/runtime/discovery.ts +111 -8
- package/src/sdk/runtime/executor.ts +89 -32
- package/src/sdk/runtime/tmux.conf +55 -0
- package/src/sdk/runtime/tmux.ts +34 -10
- package/src/sdk/types.ts +67 -4
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
- package/src/sdk/workflows/index.ts +9 -1
- package/src/services/system/auto-sync.ts +1 -1
- package/src/services/system/install-ui.ts +109 -39
- 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 {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
254
|
+
for (const wf of filteredGlobal) {
|
|
196
255
|
byKey.set(`${wf.agent}/${wf.name}`, wf);
|
|
197
256
|
}
|
|
198
|
-
for (const wf of
|
|
257
|
+
for (const wf of filteredLocal) {
|
|
199
258
|
byKey.set(`${wf.agent}/${wf.name}`, wf);
|
|
200
259
|
}
|
|
201
|
-
for (const wf of
|
|
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 {
|
|
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
|
-
/**
|
|
94
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
356
|
+
// Attach or spawn depending on whether we're already inside tmux
|
|
324
357
|
if (tmux.isInsideTmux()) {
|
|
325
|
-
// Inside tmux:
|
|
326
|
-
//
|
|
327
|
-
|
|
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:
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|